// Users API Routes import express from 'express'; import pool from '../db.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); // ==================== OPTIMIZED ENDPOINTS ==================== // GET /api/users/simple - OPTIMIZED: Lightweight list without face data (10x faster) // Returns only essential fields for dropdown/list display router.get('/simple', async (req, res) => { try { const [rows] = await pool.query( `SELECT id, name, nis, class_name, shift, CASE WHEN registered_face IS NOT NULL AND registered_face != '' THEN 1 ELSE 0 END as has_face FROM users WHERE role = ? ORDER BY class_name, name`, ['STUDENT'] ); const users = rows.map(row => ({ id: row.id, name: row.name, nis: row.nis, className: row.class_name, shift: row.shift, hasFace: row.has_face === 1 })); res.json(users); } catch (error) { console.error('GET /api/users/simple Error:', error); res.status(500).json({ error: error.message }); } }); // GET /api/users/count - OPTIMIZED: Statistics only (fastest) router.get('/count', async (req, res) => { try { const [totalRows] = await pool.query( 'SELECT COUNT(*) as total FROM users WHERE role = ?', ['STUDENT'] ); const [registeredRows] = await pool.query( `SELECT COUNT(*) as registered FROM users WHERE role = ? AND registered_face IS NOT NULL AND registered_face != ''`, ['STUDENT'] ); const [classRows] = await pool.query( 'SELECT class_name, COUNT(*) as count FROM users WHERE role = ? GROUP BY class_name ORDER BY class_name', ['STUDENT'] ); res.json({ total: totalRows[0].total, registered: registeredRows[0].registered, unregistered: totalRows[0].total - registeredRows[0].registered, byClass: classRows.map(r => ({ className: r.class_name, count: r.count })) }); } catch (error) { console.error('GET /api/users/count Error:', error); res.status(500).json({ error: error.message }); } }); // GET /api/users/by-class/:className - OPTIMIZED: Get students by class (with pagination) router.get('/by-class/:className', async (req, res) => { try { const { className } = req.params; const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 50; const offset = (page - 1) * limit; const includeFace = req.query.includeFace === 'true'; // Build SELECT based on whether we need face data const selectFields = includeFace ? 'id, name, nis, class_name, shift, role, registered_face, created_at' : `id, name, nis, class_name, shift, role, CASE WHEN registered_face IS NOT NULL AND registered_face != '' THEN 1 ELSE 0 END as has_face, created_at`; const [rows] = await pool.query( `SELECT ${selectFields} FROM users WHERE role = ? AND class_name = ? ORDER BY name LIMIT ? OFFSET ?`, ['STUDENT', className, limit, offset] ); const [countRows] = await pool.query( 'SELECT COUNT(*) as total FROM users WHERE role = ? AND class_name = ?', ['STUDENT', className] ); const users = rows.map(row => includeFace ? ({ id: row.id, name: row.name, nis: row.nis, className: row.class_name, shift: row.shift, role: row.role, registeredFace: row.registered_face, createdAt: row.created_at }) : ({ id: row.id, name: row.name, nis: row.nis, className: row.class_name, shift: row.shift, role: row.role, hasFace: row.has_face === 1, createdAt: row.created_at })); res.json({ users, pagination: { page, limit, total: countRows[0].total, totalPages: Math.ceil(countRows[0].total / limit) } }); } catch (error) { console.error('GET /api/users/by-class Error:', error); res.status(500).json({ error: error.message }); } }); // GET /api/users/classes - Get list of available classes router.get('/classes', async (req, res) => { try { const [rows] = await pool.query( `SELECT class_name, COUNT(*) as count, SUM(CASE WHEN registered_face IS NOT NULL AND registered_face != '' THEN 1 ELSE 0 END) as registered FROM users WHERE role = ? AND class_name IS NOT NULL AND class_name != '' GROUP BY class_name ORDER BY class_name`, ['STUDENT'] ); res.json(rows.map(r => ({ className: r.class_name, total: r.count, registered: r.registered }))); } catch (error) { console.error('GET /api/users/classes Error:', error); res.status(500).json({ error: error.message }); } }); // ==================== ORIGINAL ENDPOINTS ==================== // GET /api/users - Get all students router.get('/', async (req, res) => { try { // Note: face_descriptor is excluded from this query for performance // It will be fetched separately when needed for face comparison const [rows] = await pool.query( 'SELECT id, name, nis, class_name, shift, role, registered_face, created_at FROM users WHERE role = ? ORDER BY class_name, name', ['STUDENT'] ); // Map to frontend format const users = rows.map(row => ({ id: row.id, name: row.name, nis: row.nis, className: row.class_name, shift: row.shift, role: row.role, registeredFace: row.registered_face, createdAt: row.created_at })); res.json(users); } catch (error) { console.error('GET /api/users Error:', error); res.status(500).json({ error: error.message }); } }); // GET /api/users/:id - Get single user with face_descriptor (for attendance verification) router.get('/:id', async (req, res) => { try { const { id } = req.params; let rows; let hasFaceDescriptor = true; try { [rows] = await pool.query( 'SELECT id, name, nis, class_name, shift, role, registered_face, face_descriptor, created_at FROM users WHERE id = ? OR nis = ?', [id, id] ); } catch (err) { // If face_descriptor column doesn't exist, query without it if (err.code === 'ER_BAD_FIELD_ERROR') { hasFaceDescriptor = false; [rows] = await pool.query( 'SELECT id, name, nis, class_name, shift, role, registered_face, created_at FROM users WHERE id = ? OR nis = ?', [id, id] ); } else { throw err; } } if (rows.length === 0) { return res.status(404).json({ error: 'User not found' }); } const row = rows[0]; res.json({ id: row.id, name: row.name, nis: row.nis, className: row.class_name, shift: row.shift, role: row.role, registeredFace: row.registered_face, faceDescriptor: hasFaceDescriptor ? row.face_descriptor : null, createdAt: row.created_at }); } catch (error) { console.error('GET /api/users/:id Error:', error); res.status(500).json({ error: error.message }); } }); // POST /api/users - Create or update user router.post('/', async (req, res) => { try { const { id, name, nis, className, shift, role } = req.body; const userId = id || uuidv4(); await pool.query( `INSERT INTO users (id, name, nis, class_name, shift, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = ?, class_name = ?, shift = ?`, [userId, name, nis, className, shift || 'PAGI', role || 'STUDENT', Date.now(), name, className, shift || 'PAGI'] ); res.json({ success: true, id: userId }); res.json({ success: true, id: userId }); } catch (error) { console.error('POST /api/users Error:', error); res.status(500).json({ error: error.message }); } }); // POST /api/users/bulk - Bulk import users router.post('/bulk', async (req, res) => { try { const { users } = req.body; let count = 0; for (const user of users) { const userId = user.nis || uuidv4(); await pool.query( `INSERT INTO users (id, name, nis, class_name, shift, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = ?, class_name = ?, shift = ?`, [userId, user.name, user.nis, user.className, user.shift || 'PAGI', 'STUDENT', Date.now(), user.name, user.className, user.shift || 'PAGI'] ); count++; } res.json({ success: true, count }); } catch (error) { console.error('POST /api/users/bulk Error:', error); res.status(500).json({ error: error.message }); } }); // PUT /api/users/:id/face - Update user's registered face router.put('/:id/face', async (req, res) => { try { const { id } = req.params; const { registeredFace, faceDescriptor } = req.body; // Check if already registered const [current] = await pool.query( 'SELECT registered_face FROM users WHERE id = ? OR nis = ?', [id, id] ); if (current.length > 0 && current[0].registered_face) { return res.status(400).json({ error: 'Wajah sudah terdaftar. Gunakan menu Reset Wajah untuk mengganti.' }); } // Try to update with face_descriptor first if (faceDescriptor) { try { await pool.query( 'UPDATE users SET registered_face = ?, face_descriptor = ? WHERE id = ? OR nis = ?', [registeredFace, faceDescriptor, id, id] ); } catch (err) { // If face_descriptor column doesn't exist, update only registered_face if (err.code === 'ER_BAD_FIELD_ERROR') { console.log('face_descriptor column not found, updating only registered_face'); await pool.query( 'UPDATE users SET registered_face = ? WHERE id = ? OR nis = ?', [registeredFace, id, id] ); } else { throw err; } } } else { await pool.query( 'UPDATE users SET registered_face = ? WHERE id = ? OR nis = ?', [registeredFace, id, id] ); } res.json({ success: true }); } catch (error) { console.error('PUT /api/users/:id/face Error:', error); res.status(500).json({ error: error.message }); } }); // DELETE /api/users/:id/face - Reset user's face router.delete('/:id/face', async (req, res) => { try { const { id } = req.params; try { await pool.query( 'UPDATE users SET registered_face = NULL, face_descriptor = NULL WHERE id = ? OR nis = ?', [id, id] ); } catch (err) { // If face_descriptor column doesn't exist, update only registered_face if (err.code === 'ER_BAD_FIELD_ERROR') { console.log('face_descriptor column not found, resetting only registered_face'); await pool.query( 'UPDATE users SET registered_face = NULL WHERE id = ? OR nis = ?', [id, id] ); } else { throw err; } } res.json({ success: true }); } catch (error) { console.error('DELETE /api/users/:id/face Error:', error); res.status(500).json({ error: error.message }); } }); // DELETE /api/users/:id - Delete user router.delete('/:id', async (req, res) => { try { const { id } = req.params; await pool.query('DELETE FROM users WHERE id = ? OR nis = ?', [id, id]); res.json({ success: true }); } catch (error) { console.error('DELETE /api/users/:id Error:', error); res.status(500).json({ error: error.message }); } }); // Helper: Parse CSV const parseCSV = (text) => { const lines = text.split('\n').filter(l => l.trim() !== ''); if (lines.length === 0) return []; // Simple CSV parser that handles quotes const splitLine = (line) => { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') inQuotes = !inQuotes; else if (char === ',' && !inQuotes) { result.push(current); current = ''; } else current += char; } result.push(current); return result.map(s => s.trim().replace(/^"|"$/g, '').trim()); }; const headers = splitLine(lines[0]); return lines.slice(1).map(line => { const values = splitLine(line); const obj = {}; headers.forEach((h, i) => obj[h] = values[i] || ''); return obj; }); }; // POST /api/users/migrate-legacy - Migrate from Google Sheet router.post('/migrate-legacy', async (req, res) => { try { const SHEET_ID = '1UKoY998Q161a3_dbBc6mAmMWKYMeZ8i0JoqcmrOZBo8'; // Hardcoded Legacy ID const SHEET_NAME = 'Siswa'; const url = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}&t=${Date.now()}`; console.log('Migrating from:', url); const response = await fetch(url); if (!response.ok) throw new Error(`Failed to fetch sheet: ${response.status}`); const csvText = await response.text(); const data = parseCSV(csvText); const usersToImport = data.map(row => { let shift = 'PAGI'; if (row['SHIFT'] && row['SHIFT'].toUpperCase() === 'SIANG') shift = 'SIANG'; return { id: row['NIS'] || uuidv4(), name: row['NAMA_LENGKAP'] || row['Nama'] || 'Tanpa Nama', nis: row['NIS'], className: row['KELAS'] || row['Kelas'], shift: shift }; }).filter(u => u.name !== 'Tanpa Nama' && u.className); if (usersToImport.length === 0) { return res.json({ success: false, message: 'No data found in sheet' }); } // Bulk Insert Logic let count = 0; for (const user of usersToImport) { const userId = user.nis || user.id; await pool.query( `INSERT INTO users (id, name, nis, class_name, shift, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = ?, class_name = ?, shift = ?`, [userId, user.name, user.nis, user.className, user.shift, 'STUDENT', Date.now(), user.name, user.className, user.shift] ); count++; } res.json({ success: true, count, message: `Successfully migrated ${count} users` }); } catch (error) { console.error('Migration Error:', error); res.status(500).json({ success: false, error: error.message }); } }); // POST /api/users/sync-faces-legacy - Sync faces from Legacy Google Sheet router.post('/sync-faces-legacy', async (req, res) => { try { const SHEET_ID = '1UKoY998Q161a3_dbBc6mAmMWKYMeZ8i0JoqcmrOZBo8'; const SHEET_NAME = 'Registrasi'; const url = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}&t=${Date.now()}`; console.log('Fetching Legacy Faces from:', url); const response = await fetch(url); if (!response.ok) throw new Error(`Failed to fetch sheet: ${response.status}`); const csvText = await response.text(); const data = parseCSV(csvText); if (data.length === 0) { return res.json({ success: false, message: 'No registration data found in sheet' }); } let updatedCount = 0; for (const row of data) { const nis = row['NIS']; const photoUrl = row['Foto URL'] || row['Foto']; // Only update if we have both NIS and Photo if (nis && photoUrl && photoUrl.startsWith('http')) { const [result] = await pool.query( 'UPDATE users SET registered_face = ? WHERE nis = ? AND (registered_face IS NULL OR registered_face = "")', [photoUrl, nis] ); if (result.changedRows > 0) updatedCount++; } } res.json({ success: true, count: updatedCount, message: `Successfully synced ${updatedCount} faces from legacy sheet` }); } catch (error) { console.error('Legacy Face Sync Error:', error); res.status(500).json({ success: false, error: error.message }); } }); // POST /api/users/promote - Bulk promote/graduate students router.post('/promote', async (req, res) => { try { console.log('🚀 Starting bulk promotion process...'); // Order is critical to avoid double promotion! // 1. XII -> LULUS const [resXII] = await pool.query( "UPDATE users SET class_name = 'LULUS' WHERE class_name LIKE 'XII%' AND role = 'STUDENT'" ); // 2. XI -> XII const [resXI] = await pool.query( "UPDATE users SET class_name = REPLACE(class_name, 'XI', 'XII') WHERE class_name LIKE 'XI%' AND class_name NOT LIKE 'XII%' AND role = 'STUDENT'" ); // 3. X -> XI const [resX] = await pool.query( "UPDATE users SET class_name = REPLACE(class_name, 'X', 'XI') WHERE class_name LIKE 'X%' AND class_name NOT LIKE 'XI%' AND class_name NOT LIKE 'XII%' AND role = 'STUDENT'" ); res.json({ success: true, message: 'Kenaikan kelas berhasil diproses.', details: { XII_to_Lulus: resXII.affectedRows, XI_to_XII: resXI.affectedRows, X_to_XI: resX.affectedRows } }); } catch (error) { console.error('POST /api/users/promote Error:', error); res.status(500).json({ error: error.message }); } }); export default router;