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

444 lines
16 KiB
JavaScript
Executable File

// Attendance API Routes with Fonnte WhatsApp Integration
import express from 'express';
import pool from '../db.js';
import crypto from 'crypto';
import { addToQueue } from '../services/fonnteQueue.js';
const router = express.Router();
// Send WhatsApp notification via queue (prevents mass sending)
// Token diambil dari database settings (bukan environment variable) agar sesuai dengan konfigurasi di halaman Pengaturan
const sendWhatsAppNotification = async (phone, message) => {
try {
// Get Fonnte token from settings table
const [settings] = await pool.query(
"SELECT setting_value FROM settings WHERE setting_key = 'FONNTE_TOKEN'"
);
const token = settings.length > 0 && settings[0].setting_value
? settings[0].setting_value
: process.env.FONNTE_TOKEN;
if (!token) {
console.log('📱 [Fonnte] Token tidak dikonfigurasi! Periksa Pengaturan > Token Fonnte');
return;
}
addToQueue(phone, message, token);
} catch (error) {
console.error('📱 [Fonnte] Error getting token:', error.message);
}
};
// ==================== OPTIMIZED PAGINATED ENDPOINTS ====================
// GET /api/attendance/daily - OPTIMIZED: Get daily attendance with pagination (for Reports)
// This endpoint is optimized for large datasets with server-side pagination
router.get('/daily', async (req, res) => {
try {
const { date, className, search, status, page = 1, limit = 36 } = req.query;
const pageNum = Math.max(1, parseInt(page));
const limitNum = Math.min(100, Math.max(1, parseInt(limit))); // Cap at 100
const offset = (pageNum - 1) * limitNum;
// Build WHERE conditions
const conditions = [];
const params = [];
// Filter by date (required for daily report)
if (date) {
conditions.push('date_str = ?');
params.push(date);
}
// Exclude REGISTRATION status by default for attendance report
conditions.push("status != 'REGISTRATION'");
// Filter by class
if (className) {
conditions.push('class_name = ?');
params.push(className);
}
// Filter by status
if (status) {
conditions.push('status = ?');
params.push(status);
}
// Search by name (case-insensitive)
if (search) {
conditions.push('user_name LIKE ?');
params.push(`%${search}%`);
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
// Get total count for pagination info
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM attendance ${whereClause}`,
params
);
const total = countResult[0].total;
const totalPages = Math.ceil(total / limitNum);
// Fetch paginated data (exclude photo_evidence for speed, include only what's needed)
const [rows] = await pool.query(
`SELECT id, user_id, user_name, nis, class_name, date_str, time_str,
lat, lng, distance, status, parent_phone, timestamp
FROM attendance ${whereClause}
ORDER BY class_name ASC, user_name ASC
LIMIT ? OFFSET ?`,
[...params, limitNum, offset]
);
// Map to frontend format (lightweight - no photo)
const records = rows.map(row => ({
id: row.id,
userId: row.user_id,
userName: row.user_name,
nis: row.nis,
className: row.class_name,
dateStr: row.date_str,
timeStr: row.time_str,
timestamp: row.timestamp,
location: {
lat: parseFloat(row.lat) || 0,
lng: parseFloat(row.lng) || 0,
distance: parseFloat(row.distance) || 0
},
status: row.status,
parentPhone: row.parent_phone
}));
// Return with pagination metadata
res.json({
data: records,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages,
hasNext: pageNum < totalPages,
hasPrev: pageNum > 1
}
});
} catch (error) {
console.error('GET /api/attendance/daily Error:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/attendance/daily-summary - FAST: Get daily attendance summary stats
router.get('/daily-summary', async (req, res) => {
try {
const { date } = req.query;
if (!date) {
return res.status(400).json({ error: 'Date parameter required' });
}
const [stats] = await pool.query(
`SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'PRESENT' THEN 1 ELSE 0 END) as present,
SUM(CASE WHEN status = 'LATE' THEN 1 ELSE 0 END) as late,
SUM(CASE WHEN status = 'ALFA' THEN 1 ELSE 0 END) as alfa,
SUM(CASE WHEN status = 'SAKIT' THEN 1 ELSE 0 END) as sakit,
SUM(CASE WHEN status = 'IZIN' THEN 1 ELSE 0 END) as izin,
SUM(CASE WHEN status = 'DISPEN' THEN 1 ELSE 0 END) as dispen
FROM attendance
WHERE date_str = ? AND status != 'REGISTRATION' group by date_str`,
[date]
);
// Get by class breakdown
const [byClass] = await pool.query(
`SELECT class_name, COUNT(*) as count, status
FROM attendance
WHERE date_str = ? AND status != 'REGISTRATION'
GROUP BY class_name, status
ORDER BY class_name`,
[date]
);
res.json({
date,
summary: stats[0],
byClass
});
} catch (error) {
console.error('GET /api/attendance/daily-summary Error:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/attendance - Get attendance records
router.get('/', async (req, res) => {
try {
const { date, status, limit = 500 } = req.query;
let query = 'SELECT * FROM attendance';
const params = [];
const conditions = [];
if (date) {
conditions.push('date_str = ?');
params.push(date);
}
if (req.query.month) {
conditions.push('date_str LIKE ?');
params.push(`${req.query.month}%`);
}
if (status) {
conditions.push('status = ?');
params.push(status);
}
if (req.query.excludeStatus) {
conditions.push('status != ?');
params.push(req.query.excludeStatus);
}
if (req.query.userId) {
conditions.push('user_id = ?');
params.push(req.query.userId);
}
// NEW: Filter by className (for optimized monthly/weekly reports)
if (req.query.className) {
conditions.push('class_name = ?');
params.push(req.query.className);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY timestamp DESC LIMIT ?';
params.push(parseInt(limit));
const [rows] = await pool.query(query, params);
// Map to frontend format
const records = rows.map(row => ({
id: row.id,
userId: row.user_id,
userName: row.user_name,
nis: row.nis,
className: row.class_name,
timestamp: row.timestamp,
dateStr: row.date_str,
timeStr: row.time_str,
location: {
lat: parseFloat(row.lat) || 0,
lng: parseFloat(row.lng) || 0,
distance: parseFloat(row.distance) || 0
},
photoEvidence: row.photo_evidence,
status: row.status,
aiVerification: row.ai_verification,
parentPhone: row.parent_phone
}));
res.json(records);
} catch (error) {
console.error('GET /api/attendance Error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/attendance - Submit attendance
router.post('/', async (req, res) => {
try {
const record = req.body;
// Check if user exists and has a registered face (unless this IS a registration)
if (record.status !== 'REGISTRATION') {
const [userRows] = await pool.query(
'SELECT registered_face FROM users WHERE id = ? OR nis = ?',
[record.userId, record.nis]
);
if (userRows.length === 0) {
return res.status(404).json({ error: 'Siswa tidak ditemukan.' });
}
if (!userRows[0].registered_face) {
return res.status(400).json({ error: 'Siswa belum melakukan registrasi wajah.' });
}
}
await pool.query(
`INSERT INTO attendance
(id, user_id, user_name, nis, class_name, timestamp, date_str, time_str, lat, lng, distance, photo_evidence, status, ai_verification, parent_phone)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
record.id,
record.userId,
record.userName,
record.nis,
record.className,
record.timestamp,
record.dateStr,
record.timeStr,
record.location?.lat || 0,
record.location?.lng || 0,
record.location?.distance || 0,
record.photoEvidence,
record.status,
record.aiVerification,
record.parentPhone
]
);
// Update user's face if this is a registration
if (record.status === 'REGISTRATION' && record.photoEvidence) {
await pool.query(
'UPDATE users SET registered_face = ? WHERE nis = ? OR id = ?',
[record.photoEvidence, record.nis, record.userId]
);
// Also insert to registrations table
await pool.query(
`INSERT INTO registrations (id, user_id, nis, user_name, class_name, photo_url, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[record.id, record.userId, record.nis, record.userName, record.className, record.photoEvidence, record.timestamp]
);
}
// Send WhatsApp notification for attendance (not registration)
if (record.status !== 'REGISTRATION' && record.parentPhone) {
const statusText = record.status === 'PRESENT' ? 'HADIR (Tepat Waktu)' : 'TERLAMBAT';
const distance = record.location?.distance ? Math.round(record.location.distance) : 0;
const message = `*Notifikasi Absensi Sekolah*\n` +
`SMA Negeri 1 Abiansemal\n` +
`Halo Orang Tua/Wali dari siswa:\n` +
`Nama: *${record.userName}*\n` +
`NIS: ${record.nis || '-'}\n` +
`Kelas: ${record.className || '-'}\n` +
`Waktu: ${record.dateStr}, ${record.timeStr}\n` +
`Status: ${statusText}\n` +
`Lokasi: Sekolah (Jarak ${distance}m)\n` +
`Terima kasih.`;
sendWhatsAppNotification(record.parentPhone, message);
}
res.json({ success: true, photoUrl: record.photoEvidence });
} catch (error) {
console.error('POST /api/attendance Error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/attendance/batch-alfa - Batch submit ALFA records
router.post('/batch-alfa', async (req, res) => {
try {
const { records } = req.body;
let count = 0;
for (const record of records) {
await pool.query(
`INSERT INTO attendance
(id, user_id, user_name, nis, class_name, timestamp, date_str, time_str, status, ai_verification)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'ALFA', 'Otomatis dari Sistem')
ON DUPLICATE KEY UPDATE status = 'ALFA'`,
[
record.id,
record.userId,
record.userName,
record.nis,
record.className,
record.timestamp || Date.now(),
record.dateStr,
record.timeStr || '00:00',
]
);
count++;
}
res.json({ success: true, count });
} catch (error) {
console.error('POST /api/attendance/batch-alfa Error:', error);
res.status(500).json({ error: error.message });
}
});
// PUT /api/attendance/:id - Update attendance status (ALFA -> SAKIT/IZIN)
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { status, note } = req.body;
// Validate status
const validStatuses = ['PRESENT', 'LATE', 'ALFA', 'SAKIT', 'IZIN', 'DISPEN'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: 'Status tidak valid.' });
}
// Update the record
const updateNote = note ? `Diubah: ${note}` : `Diubah ke ${status}`;
await pool.query(
'UPDATE attendance SET status = ?, ai_verification = ? WHERE id = ?',
[status, updateNote, id]
);
console.log(`📝 Attendance ${id} updated to ${status}`);
res.json({ success: true, message: `Status berhasil diubah ke ${status}.` });
} catch (error) {
console.error('PUT /api/attendance Error:', error);
res.status(500).json({ error: error.message });
}
});
// Internal function for automated Alfa rekap
export const runAutoRekapAlfa = async () => {
try {
console.log(`🕒 [Auto-Rekap] Starting automated Alfa rekap at ${new Date().toLocaleString()}`);
const today = new Date().toISOString().split('T')[0];
// 1. Get ALL students
const [students] = await pool.query('SELECT id, name, nis, class_name, shift FROM users WHERE role = "STUDENT"');
// 2. Get today's attendance records (any status)
const [todayLogs] = await pool.query('SELECT user_id FROM attendance WHERE date_str = ?', [today]);
const processedUserIds = new Set(todayLogs.map(l => l.user_id));
// 3. Find students who have NO record today
const missingStudents = students.filter(s => !processedUserIds.has(s.id));
if (missingStudents.length === 0) {
console.log('🕒 [Auto-Rekap] No missing students found today.');
return { success: true, count: 0 };
}
console.log(`🕒 [Auto-Rekap] Found ${missingStudents.length} students missing. Creating ALFA records...`);
// 4. Batch Insert ALFA records
for (const student of missingStudents) {
const id = crypto.randomUUID();
await pool.query(
`INSERT INTO attendance (id, user_id, user_name, nis, class_name, timestamp, date_str, time_str, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, student.id, student.name, student.nis, student.class_name, Date.now(), today, '19:00', 'ALFA']
);
}
console.log(`✅ [Auto-Rekap] Successfully created ${missingStudents.length} ALFA records.`);
return { success: true, count: missingStudents.length };
} catch (error) {
console.error('❌ [Auto-Rekap] Error:', error);
return { success: false, error: error.message };
}
};
// POST /api/attendance/auto-rekap-alfa - Trigger rekap manually
router.post('/auto-rekap-alfa', async (req, res) => {
const result = await runAutoRekapAlfa();
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
});
export default router;