529 lines
19 KiB
JavaScript
Executable File
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;
|