460 lines
14 KiB
JavaScript
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; |