/** * Attendance Controller * Student and staff attendance tracking */ const express = require('express'); const router = express.Router(); const { v4: uuidv4 } = require('uuid'); // Database path const dbPath = process.env.DB_PATH || require('path').join(__dirname, '../../data/school.db'); const Database = require('better-sqlite3'); let db; const getDb = () => { if (!db) { db = new Database(dbPath); db.pragma('foreign_keys = ON'); } return db; }; // Auth middleware const auth = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token provided' }); try { const jwt = require('jsonwebtoken'); const JWT_SECRET = process.env.JWT_SECRET || 'africa-alert-secret-key-2024'; req.user = jwt.verify(token, JWT_SECRET); next(); } catch (err) { res.status(401).json({ error: 'Invalid token' }); } }; // Admin or teacher only const adminOrTeacher = (req, res, next) => { if (req.user.role !== 'admin' && req.user.role !== 'teacher') { return res.status(403).json({ error: 'Admin or Teacher access required' }); } next(); }; // Admin-only middleware const adminOnly = (req, res, next) => { if (req.user.role !== 'admin') { return res.status(403).json({ error: 'Admin access required' }); } next(); }; // ============ ATTENDANCE CRUD ============ // List attendance records router.get('/', auth, (req, res) => { try { const { date, class_id, student_id, status, start_date, end_date } = req.query; let query = ` SELECT a.*, u.first_name || ' ' || u.last_name as student_name, c.name as class_name, s.name as subject_name FROM attendance a JOIN users u ON a.student_id = u.id LEFT JOIN classes c ON a.class_id = c.id LEFT JOIN subjects s ON a.subject_id = s.id WHERE a.is_deleted = 0 `; const params = []; // Filter by user role if (req.user.role === 'student') { query += ' AND a.student_id = ?'; params.push(req.user.id); } else if (req.user.role === 'teacher') { // Teachers see attendance for their classes query += ' AND (a.class_id IN (SELECT id FROM classes WHERE class_teacher_id = ?) OR a.subject_id IN (SELECT id FROM subjects WHERE teacher_id = ?))'; params.push(req.user.id, req.user.id); } if (date) { query += ' AND a.date = ?'; params.push(date); } if (start_date) { query += ' AND a.date >= ?'; params.push(start_date); } if (end_date) { query += ' AND a.date <= ?'; params.push(end_date); } if (class_id) { query += ' AND a.class_id = ?'; params.push(class_id); } if (student_id && req.user.role !== 'student') { query += ' AND a.student_id = ?'; params.push(student_id); } if (status) { query += ' AND a.status = ?'; params.push(status); } query += ' ORDER BY a.date DESC, u.last_name'; const records = getDb().prepare(query).all(...params); res.json(records); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get single attendance record router.get('/:id', auth, (req, res) => { try { const record = getDb().prepare(` SELECT a.*, u.first_name || ' ' || u.last_name as student_name, c.name as class_name, s.name as subject_name FROM attendance a JOIN users u ON a.student_id = u.id LEFT JOIN classes c ON a.class_id = c.id LEFT JOIN subjects s ON a.subject_id = s.id WHERE a.id = ? AND a.is_deleted = 0 `).get(req.params.id); if (!record) { return res.status(404).json({ error: 'Attendance record not found' }); } res.json(record); } catch (error) { res.status(500).json({ error: error.message }); } }); // Mark attendance router.post('/', auth, adminOrTeacher, (req, res) => { try { const { student_id, class_id, subject_id, date, status, remarks } = req.body; if (!student_id || !date || !status) { return res.status(400).json({ error: 'student_id, date, and status are required' }); } // Validate status const validStatuses = ['present', 'absent', 'late', 'excused']; if (!validStatuses.includes(status)) { return res.status(400).json({ error: `Status must be one of: ${validStatuses.join(', ')}` }); } // Check if already exists (upsert) const existing = getDb().prepare('SELECT id FROM attendance WHERE student_id = ? AND date = ? AND subject_id IS ?').get(student_id, date, subject_id || null); if (existing) { getDb().prepare(` UPDATE attendance SET status = ?, remarks = ?, marked_by = ?, class_id = COALESCE(?, class_id), updated_at = CURRENT_TIMESTAMP, sync_status = 'pending' WHERE id = ? `).run(status, remarks, req.user.id, class_id, existing.id); const updated = getDb().prepare('SELECT * FROM attendance WHERE id = ?').get(existing.id); return res.json(updated); } const uid = uuidv4(); const result = getDb().prepare(` INSERT INTO attendance (uid, student_id, class_id, subject_id, date, status, remarks, marked_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run(uid, student_id, class_id, subject_id, date, status, remarks, req.user.id); const record = getDb().prepare(` SELECT a.*, u.first_name || ' ' || u.last_name as student_name, c.name as class_name FROM attendance a JOIN users u ON a.student_id = u.id LEFT JOIN classes c ON a.class_id = c.id WHERE a.id = ? `).get(result.lastInsertRowid); res.status(201).json(record); } catch (error) { res.status(500).json({ error: error.message }); } }); // Bulk mark attendance router.post('/bulk', auth, adminOrTeacher, (req, res) => { try { const { records } = req.body; if (!Array.isArray(records) || records.length === 0) { return res.status(400).json({ error: 'Records array required' }); } // Validate all records first for (const record of records) { if (!record.student_id || !record.date || !record.status) { return res.status(400).json({ error: 'Each record must have student_id, date, and status' }); } } const upsert = getDb().prepare(` INSERT INTO attendance (uid, student_id, class_id, subject_id, date, status, remarks, marked_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); const upsertMany = getDb().transaction((records) => { let inserted = 0; let updated = 0; for (const record of records) { const existing = getDb().prepare('SELECT id FROM attendance WHERE student_id = ? AND date = ?').get(record.student_id, record.date); if (existing) { getDb().prepare(` UPDATE attendance SET status = ?, remarks = ?, marked_by = ?, updated_at = CURRENT_TIMESTAMP, sync_status = 'pending' WHERE id = ? `).run(record.status, record.remarks, req.user.id, existing.id); updated++; } else { const uid = uuidv4(); upsert.run(uid, record.student_id, record.class_id, record.subject_id, record.date, record.status, record.remarks, req.user.id); inserted++; } } return { inserted, updated }; }); const result = upsertMany(records); res.status(201).json({ success: true, ...result }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Update attendance router.put('/:id', auth, adminOrTeacher, (req, res) => { try { const { status, remarks } = req.body; if (!status) { return res.status(400).json({ error: 'Status is required' }); } getDb().prepare(` UPDATE attendance SET status = ?, remarks = COALESCE(?, remarks), marked_by = ?, updated_at = CURRENT_TIMESTAMP, sync_status = 'pending' WHERE id = ? AND is_deleted = 0 `).run(status, remarks, req.user.id, req.params.id); const updated = getDb().prepare(` SELECT a.*, u.first_name || ' ' || u.last_name as student_name FROM attendance a JOIN users u ON a.student_id = u.id WHERE a.id = ? `).get(req.params.id); if (!updated) { return res.status(404).json({ error: 'Attendance record not found' }); } res.json(updated); } catch (error) { res.status(500).json({ error: error.message }); } }); // ============ DAILY ATTENDANCE ============ // Get class attendance for a date router.get('/class/:classId/date/:date', auth, (req, res) => { try { const { class_id, date } = req.params; // Get all enrolled students const students = getDb().prepare(` SELECT u.id, u.first_name, u.last_name, e.roll_number FROM enrollments e JOIN users u ON e.student_id = u.id WHERE e.class_id = ? AND e.status = 'active' AND e.is_deleted = 0 AND u.is_deleted = 0 ORDER BY e.roll_number, u.last_name `).all(class_id); // Get attendance for that date const attendance = getDb().prepare(` SELECT * FROM attendance WHERE class_id = ? AND date = ? AND is_deleted = 0 `).all(class_id, date); // Map attendance to students const attendanceMap = {}; attendance.forEach(a => { attendanceMap[a.student_id] = a; }); const result = students.map(s => ({ ...s, attendance: attendanceMap[s.id] || null })); res.json({ date, class_id: parseInt(class_id), total_students: students.length, marked: attendance.length, records: result }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ============ ATTENDANCE REPORTS ============ // Get attendance summary router.get('/reports/summary', auth, adminOrTeacher, (req, res) => { try { const { class_id, start_date, end_date } = req.query; let whereClause = 'a.is_deleted = 0'; const params = []; if (class_id) { whereClause += ' AND a.class_id = ?'; params.push(class_id); } if (start_date) { whereClause += ' AND a.date >= ?'; params.push(start_date); } if (end_date) { whereClause += ' AND a.date <= ?'; params.push(end_date); } const summary = getDb().prepare(` SELECT COUNT(*) as total_records, SUM(CASE WHEN a.status = 'present' THEN 1 ELSE 0 END) as present, SUM(CASE WHEN a.status = 'absent' THEN 1 ELSE 0 END) as absent, SUM(CASE WHEN a.status = 'late' THEN 1 ELSE 0 END) as late, SUM(CASE WHEN a.status = 'excused' THEN 1 ELSE 0 END) as excused FROM attendance a WHERE ${whereClause} `).get(...params); res.json({ ...summary, present_percentage: summary.total_records > 0 ? ((summary.present / summary.total_records) * 100).toFixed(1) : 0, absent_percentage: summary.total_records > 0 ? ((summary.absent / summary.total_records) * 100).toFixed(1) : 0 }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get student attendance report router.get('/reports/student/:studentId', auth, (req, res) => { try { const studentId = parseInt(req.params.studentId); // Check access if (req.user.role === 'student' && req.user.id !== studentId) { return res.status(403).json({ error: 'Access denied' }); } const { start_date, end_date } = req.query; let query = ` SELECT a.*, c.name as class_name, s.name as subject_name FROM attendance a LEFT JOIN classes c ON a.class_id = c.id LEFT JOIN subjects s ON a.subject_id = s.id WHERE a.student_id = ? AND a.is_deleted = 0 `; const params = [studentId]; if (start_date) { query += ' AND a.date >= ?'; params.push(start_date); } if (end_date) { query += ' AND a.date <= ?'; params.push(end_date); } query += ' ORDER BY a.date DESC'; const records = getDb().prepare(query).all(...params); // Calculate summary const summary = { total: records.length, present: records.filter(r => r.status === 'present').length, absent: records.filter(r => r.status === 'absent').length, late: records.filter(r => r.status === 'late').length, excused: records.filter(r => r.status === 'excused').length }; res.json({ records, summary }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ============ MY ATTENDANCE (Student) ============ router.get('/my', auth, (req, res) => { try { const { start_date, end_date } = req.query; let query = ` SELECT a.*, c.name as class_name FROM attendance a LEFT JOIN classes c ON a.class_id = c.id WHERE a.student_id = ? AND a.is_deleted = 0 `; const params = [req.user.id]; if (start_date) { query += ' AND a.date >= ?'; params.push(start_date); } if (end_date) { query += ' AND a.date <= ?'; params.push(end_date); } query += ' ORDER BY a.date DESC'; const records = getDb().prepare(query).all(...params); const summary = { total: records.length, present: records.filter(r => r.status === 'present').length, absent: records.filter(r => r.status === 'absent').length, late: records.filter(r => r.status === 'late').length, excused: records.filter(r => r.status === 'excused').length }; res.json({ records, summary }); } catch (error) { res.status(500).json({ error: error.message }); } }); module.exports = router;