geocrop-platform./apps/nextgen/server/src/controllers/attendance.controller.js

460 lines
14 KiB
JavaScript

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