444 lines
16 KiB
JavaScript
Executable File
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;
|
|
|