Files
smanab/backend/routes/users.js
2026-02-22 14:54:55 +08:00

529 lines
19 KiB
JavaScript
Executable File

// 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;