Initial commit with .gitignore

This commit is contained in:
smanab
2026-02-22 14:54:55 +08:00
commit ef146d99b9
33 changed files with 8555 additions and 0 deletions

129
.gitignore vendored Normal file
View File

@@ -0,0 +1,129 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.test
.env.production
.env.local
.env.development.local
.env.test.local
.env.production.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# OS metadata
.DS_Store
Thumbs.db
# Project specific backups
*.tar.gz

BIN
backend/._schema.js Executable file

Binary file not shown.

BIN
backend/._services Executable file

Binary file not shown.

36
backend/db.js Executable file
View File

@@ -0,0 +1,36 @@
// Database Connection Module
// Handles MySQL connection with connection pooling and auto-reconnect
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
connectTimeout: 60000,
enableKeepAlive: true,
keepAliveInitialDelay: 10000
});
// Test connection on startup
export const testConnection = async () => {
try {
const connection = await pool.getConnection();
console.log('✅ MySQL Connected Successfully to:', process.env.DB_HOST);
connection.release();
return true;
} catch (error) {
console.error('❌ MySQL Connection Failed:', error.message);
throw error;
}
};
export default pool;

BIN
backend/routes/._attendance.js Executable file

Binary file not shown.

BIN
backend/routes/._auth.js Executable file

Binary file not shown.

BIN
backend/routes/._leaveRequests.js Executable file

Binary file not shown.

BIN
backend/routes/._settings.js Executable file

Binary file not shown.

BIN
backend/routes/._staff.js Executable file

Binary file not shown.

BIN
backend/routes/._users.js Executable file

Binary file not shown.

239
backend/routes/ai.js Executable file
View File

@@ -0,0 +1,239 @@
import express from 'express';
import dotenv from 'dotenv';
dotenv.config();
const router = express.Router();
// Gemini API Base URL - Using REST API directly to avoid SDK issues
const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
const getApiKey = () => {
const key = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
if (!key) throw new Error("Server GOOGLE_API_KEY or GEMINI_API_KEY is missing.");
return key;
};
// POST /api/ai/analyze - Analyze Face Quality
router.post('/analyze', async (req, res) => {
try {
const { image } = req.body;
if (!image) throw new Error("Image data missing");
const apiKey = getApiKey();
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
// Detect image type from data URI
let mimeType = 'image/jpeg';
if (image.startsWith('data:image/png')) {
mimeType = 'image/png';
} else if (image.startsWith('data:image/webp')) {
mimeType = 'image/webp';
}
const prompt = `Anda adalah sistem keamanan biometrik sekolah yang SANGAT KETAT.
Tugas: Analisis keaslian foto untuk absensi.
KRITERIA TOLAK MUTLAK (SPOOFING):
1. Terdeteksi sebagai foto dari layar HP/Laptop (Cari pola Moire/Pixel grid/Glare layar).
2. Terdeteksi sebagai foto cetak (kertas) atau pas foto yang difoto ulang.
3. Terdeteksi bingkai HP atau jari yang memegang HP lain di dalam foto.
4. Wajah tidak menghadap kamera secara langsung (Terlalu miring/profil).
5. Wajah tertutup masker, helm, atau kacamata hitam gelap.
PENTING: Berikan respons HANYA dalam format JSON yang valid, tanpa markdown atau backticks:
{"hasFace": true/false, "confidence": 0-100, "description": "penjelasan singkat"}
hasFace harus TRUE hanya jika foto selfie ASLI, LIVE, JELAS, dan WAJAH TUNGGAL.
Jika false, jelaskan alasannya di description dalam Bahasa Indonesia.`;
console.log("Calling Gemini REST API for face analysis...");
console.log("Using MIME type:", mimeType);
console.log("Base64 length:", base64Data.length);
const requestBody = {
contents: [{
parts: [
{
inline_data: {
mime_type: mimeType,
data: base64Data
}
},
{ text: prompt }
]
}]
};
const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error("Gemini API HTTP Error:", response.status, errorText);
throw new Error(`Gemini API Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
console.log("Raw Gemini response:", JSON.stringify(data, null, 2));
// Extract text from response
const textContent = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textContent) {
console.error("No text content in response:", data);
throw new Error("No response text from AI");
}
// Parse JSON from the response (remove any markdown formatting if present)
let resultText = textContent.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
// Fix common JSON issues
resultText = resultText.replace(/'/g, '"'); // Replace single quotes with double quotes
console.log("Cleaned response text:", resultText);
const result = JSON.parse(resultText);
console.log("AI Analyze Result:", result);
res.json(result);
} catch (error) {
console.error("AI Analyze Error:", error);
res.status(500).json({
hasFace: false,
confidence: 0,
description: `Layanan AI Error: ${error.message}`
});
}
});
// POST /api/ai/compare - Compare Faces
router.post('/compare', async (req, res) => {
try {
const { registeredFace, currentImage } = req.body;
if (!registeredFace || !currentImage) throw new Error("Images missing");
const apiKey = getApiKey();
// Handle Registered Face (could be URL or Base64)
let regBase64 = registeredFace;
let regMimeType = 'image/jpeg';
if (registeredFace.startsWith('http')) {
try {
console.log("Fetching registered face from URL:", registeredFace);
const fetchResp = await fetch(registeredFace);
if (!fetchResp.ok) throw new Error(`Failed to fetch registered face: ${fetchResp.status}`);
const contentType = fetchResp.headers.get('content-type');
if (contentType) {
regMimeType = contentType.split(';')[0];
}
const arrayBuffer = await fetchResp.arrayBuffer();
regBase64 = Buffer.from(arrayBuffer).toString('base64');
console.log("Successfully fetched and converted to base64");
} catch (fetchError) {
console.error("Error fetching registered face:", fetchError);
throw new Error(`Failed to fetch registered face image: ${fetchError.message}`);
}
} else {
if (registeredFace.startsWith('data:image/png')) {
regMimeType = 'image/png';
} else if (registeredFace.startsWith('data:image/webp')) {
regMimeType = 'image/webp';
}
regBase64 = registeredFace.replace(/^data:image\/\w+;base64,/, "");
}
// Handle current image
let currMimeType = 'image/jpeg';
if (currentImage.startsWith('data:image/png')) {
currMimeType = 'image/png';
} else if (currentImage.startsWith('data:image/webp')) {
currMimeType = 'image/webp';
}
const currBase64 = currentImage.replace(/^data:image\/\w+;base64,/, "");
const prompt = `Anda adalah ahli forensik digital. Verifikasi apakah dua foto ini menampilkan orang yang sama untuk absensi sekolah.
INSTRUKSI KEAMANAN TINGGI:
1. Bandingkan fitur biometrik wajah (mata, hidung, mulut, struktur tulang).
2. PERHATIKAN UMUR: Pastikan Candidate Image adalah foto TERBARU dari orang yang sama.
3. ANTI-MANIPULASI: Jika Candidate Image terlihat seperti foto hasil crop, foto layar, atau foto kertas, TOLAK dengan tegas.
4. Pastikan ekspresi wajah wajar (bukan sedang tidur atau dipaksa).
Gambar pertama adalah REFERENCE (wajah terdaftar). Gambar kedua adalah CANDIDATE (foto absensi sekarang).
PENTING: Berikan respons HANYA dalam format JSON yang valid, tanpa markdown atau backticks:
{"match": true/false, "confidence": 0-100, "reason": "alasan teknis singkat dalam Bahasa Indonesia"}
match harus true jika 95%+ yakin orang yang sama DAN foto asli.`;
console.log("Calling Gemini REST API for face comparison...");
const requestBody = {
contents: [{
parts: [
{ text: "REFERENCE IMAGE (Wajah Terdaftar):" },
{
inline_data: {
mime_type: regMimeType,
data: regBase64
}
},
{ text: "CANDIDATE IMAGE (Foto Absensi Sekarang):" },
{
inline_data: {
mime_type: currMimeType,
data: currBase64
}
},
{ text: prompt }
]
}]
};
const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error("Gemini API HTTP Error:", response.status, errorText);
throw new Error(`Gemini API Error: ${response.status}`);
}
const data = await response.json();
console.log("Raw Gemini response:", JSON.stringify(data, null, 2));
const textContent = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textContent) {
console.error("No text content in response:", data);
throw new Error("No comparison response text");
}
let resultText = textContent.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
resultText = resultText.replace(/'/g, '"');
const result = JSON.parse(resultText);
console.log("AI Compare Result:", result);
res.json(result);
} catch (error) {
console.error("AI Compare Error:", error);
res.status(500).json({
match: false,
confidence: 0,
reason: `Server AI Verify Error: ${error.message}`
});
}
});
export default router;

443
backend/routes/attendance.js Executable file
View File

@@ -0,0 +1,443 @@
// 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;

140
backend/routes/auth.js Executable file
View File

@@ -0,0 +1,140 @@
// Authentication API Routes - with Dynamic Staff Users
import express from 'express';
import pool from '../db.js';
import crypto from 'crypto';
const router = express.Router();
// Password hashing (same as staff.js)
const hashPassword = (password) => {
return crypto.createHash('sha256').update(password).digest('hex');
};
// Fallback hardcoded credentials (for initial setup before any staff exists)
const FALLBACK_ADMIN = {
username: 'Adminabsensiswa',
password: 'Smanab100%'
};
const STUDENT_PASSWORD = 'Smanab#1';
// POST /api/auth/login - User login
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
// 1. Check Staff Users from Database First
const hashedPassword = hashPassword(password);
const [staffRows] = await pool.query(
'SELECT id, username, name, role FROM staff_users WHERE username = ? AND password = ? AND is_active = TRUE',
[username, hashedPassword]
);
if (staffRows.length > 0) {
const staff = staffRows[0];
return res.json({
success: true,
user: {
id: staff.id,
name: staff.name,
role: staff.role,
createdAt: Date.now()
}
});
}
// 2. Fallback: Check hardcoded Admin (for first-time setup)
if (username === FALLBACK_ADMIN.username && password === FALLBACK_ADMIN.password) {
// Check if any admin exists in database
const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM staff_users WHERE role = 'ADMIN'"
);
// If no admin in DB yet, allow fallback login
if (adminCount[0].count === 0) {
return res.json({
success: true,
user: {
id: 'admin-fallback',
name: 'Administrator (Setup)',
role: 'ADMIN',
createdAt: Date.now(),
isSetup: true // Flag to indicate first-time setup
}
});
}
}
// 3. Check Student (username = NIS, password = STUDENT_PASSWORD)
if (password === STUDENT_PASSWORD) {
const [rows] = await pool.query(
'SELECT id, name, nis, class_name, shift, role, registered_face, face_descriptor, created_at FROM users WHERE nis = ?',
[username]
);
if (rows.length > 0) {
const student = rows[0];
return res.json({
success: true,
user: {
id: student.id,
name: student.name,
nis: student.nis,
className: student.class_name,
shift: student.shift,
role: student.role || 'STUDENT',
registeredFace: student.registered_face,
faceDescriptor: student.face_descriptor,
createdAt: student.created_at
}
});
} else {
return res.json({
success: false,
message: 'NIS tidak ditemukan. Hubungi Admin.'
});
}
}
// Invalid credentials
res.json({
success: false,
message: 'Username atau Password salah.'
});
} catch (error) {
console.error('POST /api/auth/login Error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// POST /api/auth/change-password - Change password for staff
router.post('/change-password', async (req, res) => {
try {
const { userId, oldPassword, newPassword } = req.body;
if (!userId || !oldPassword || !newPassword) {
return res.status(400).json({ error: 'Data tidak lengkap.' });
}
const hashedOld = hashPassword(oldPassword);
const [user] = await pool.query(
'SELECT id FROM staff_users WHERE id = ? AND password = ?',
[userId, hashedOld]
);
if (user.length === 0) {
return res.json({ success: false, message: 'Password lama salah.' });
}
const hashedNew = hashPassword(newPassword);
await pool.query('UPDATE staff_users SET password = ? WHERE id = ?', [hashedNew, userId]);
res.json({ success: true, message: 'Password berhasil diubah.' });
} catch (error) {
console.error('POST /api/auth/change-password Error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

293
backend/routes/leaveRequests.js Executable file
View File

@@ -0,0 +1,293 @@
// Leave Requests API Routes - Pengajuan Izin/Sakit Siswa
import express from 'express';
import pool from '../db.js';
import crypto from 'crypto';
import { addToQueuePriority } from '../services/fonnteQueue.js';
const router = express.Router();
// Send WhatsApp Notification to Guru BK via Queue (prevents mass sending)
const sendWhatsAppToGuruBK = async (message) => {
try {
// Get all active Guru BK with phone numbers
const [guruBKList] = await pool.query(
"SELECT name, phone FROM staff_users WHERE role = 'GURU_BK' AND is_active = TRUE AND phone IS NOT NULL AND phone != ''"
);
if (guruBKList.length === 0) {
console.log('📱 No Guru BK with phone number found');
return;
}
// Get Fonnte token from settings
const [settings] = await pool.query(
"SELECT setting_value FROM settings WHERE setting_key = 'FONNTE_TOKEN'"
);
const fonnteToken = settings.length > 0 ? settings[0].setting_value : process.env.FONNTE_TOKEN;
if (!fonnteToken) {
console.log('📱 Fonnte token not configured');
return;
}
// Add all messages to queue with HIGH priority (queue handles the delays)
for (const guru of guruBKList) {
addToQueuePriority(guru.phone, message, fonnteToken);
console.log(`📱 ⚡ HIGH PRIORITY: Queued WhatsApp for Guru BK ${guru.name}: ${guru.phone}`);
}
} catch (error) {
console.error('📱 WhatsApp notification error:', error.message);
}
};
// GET /api/leave-requests - Get leave requests
router.get('/', async (req, res) => {
try {
const { status, studentId } = req.query;
let query = 'SELECT * FROM leave_requests';
const params = [];
const conditions = [];
if (status) {
conditions.push('status = ?');
params.push(status);
}
if (studentId) {
conditions.push('student_id = ?');
params.push(studentId);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY created_at DESC';
const [rows] = await pool.query(query, params);
// Map to frontend format
const requests = rows.map(row => ({
id: row.id,
studentId: row.student_id,
studentName: row.student_name,
studentNis: row.student_nis,
studentClass: row.student_class,
requestType: row.request_type,
requestDate: row.request_date,
reason: row.reason,
photoEvidence: row.photo_evidence,
status: row.status,
reviewedBy: row.reviewed_by,
reviewedByName: row.reviewed_by_name,
reviewedAt: row.reviewed_at,
rejectionReason: row.rejection_reason,
createdAt: row.created_at
}));
res.json(requests);
} catch (error) {
console.error('GET /api/leave-requests Error:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/leave-requests/pending-count - Get pending count for notification badge
router.get('/pending-count', async (req, res) => {
try {
const [rows] = await pool.query(
"SELECT COUNT(*) as count FROM leave_requests WHERE status = 'PENDING'"
);
res.json({ count: rows[0].count });
} catch (error) {
console.error('GET /api/leave-requests/pending-count Error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/leave-requests - Submit new leave request (by student)
router.post('/', async (req, res) => {
try {
const { studentId, studentName, studentNis, studentClass, requestType, requestDate, reason, photoEvidence } = req.body;
if (!studentId || !requestType || !requestDate || !reason) {
return res.status(400).json({ error: 'Data tidak lengkap. Wajib: tipe, tanggal, alasan.' });
}
const id = crypto.randomUUID();
await pool.query(
`INSERT INTO leave_requests
(id, student_id, student_name, student_nis, student_class, request_type, request_date, reason, photo_evidence)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, studentId, studentName, studentNis, studentClass, requestType, requestDate, reason, photoEvidence || null]
);
console.log(`📝 New leave request from ${studentName}: ${requestType} for ${requestDate}`);
// Send WhatsApp notification to all Guru BK
const message = `*🔔 Pengajuan ${requestType} Baru*\n\n` +
`Siswa: *${studentName}*\n` +
`NIS: ${studentNis || '-'}\n` +
`Kelas: ${studentClass || '-'}\n` +
`Tanggal: ${requestDate}\n` +
`Alasan: ${reason}\n\n` +
`Silakan buka aplikasi Absensi untuk menyetujui atau menolak pengajuan ini.`;
sendWhatsAppToGuruBK(message);
res.json({ success: true, id, message: 'Pengajuan berhasil dikirim.' });
} catch (error) {
console.error('POST /api/leave-requests Error:', error);
res.status(500).json({ error: error.message });
}
});
// PUT /api/leave-requests/:id/approve - Approve leave request (by Guru BK)
router.put('/:id/approve', async (req, res) => {
try {
const { id } = req.params;
const { reviewerId, reviewerName } = req.body;
// Get the request first
const [rows] = await pool.query('SELECT * FROM leave_requests WHERE id = ?', [id]);
if (rows.length === 0) {
return res.status(404).json({ error: 'Pengajuan tidak ditemukan.' });
}
const request = rows[0];
// Update leave request status
await pool.query(
`UPDATE leave_requests SET status = 'APPROVED', reviewed_by = ?, reviewed_by_name = ?, reviewed_at = NOW() WHERE id = ?`,
[reviewerId, reviewerName, id]
);
// Also update attendance record if exists for this date
await pool.query(
`UPDATE attendance SET status = ? WHERE user_id = ? AND date_str = ? AND status = 'ALFA'`,
[request.request_type, request.student_id, request.request_date]
);
// If no attendance record, create one
const [existingAttendance] = await pool.query(
`SELECT id FROM attendance WHERE user_id = ? AND date_str = ?`,
[request.student_id, request.request_date]
);
if (existingAttendance.length === 0) {
const attendanceId = crypto.randomUUID();
await pool.query(
`INSERT INTO attendance (id, user_id, user_name, nis, class_name, timestamp, date_str, time_str, status, ai_verification)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[attendanceId, request.student_id, request.student_name, request.student_nis, request.student_class,
Date.now(), request.request_date, '00:00', request.request_type, `Disetujui oleh ${reviewerName}`]
);
}
console.log(`✅ Leave request ${id} APPROVED by ${reviewerName}`);
// Send WhatsApp notification to student/parent
try {
const [attendanceRows] = await pool.query(
"SELECT parent_phone FROM attendance WHERE user_id = ? AND parent_phone IS NOT NULL AND parent_phone != '' ORDER BY timestamp DESC LIMIT 1",
[request.student_id]
);
const studentPhone = attendanceRows.length > 0 ? attendanceRows[0].parent_phone : null;
if (studentPhone) {
const [settings] = await pool.query("SELECT setting_value FROM settings WHERE setting_key = 'FONNTE_TOKEN'");
const fonnteToken = settings.length > 0 ? settings[0].setting_value : process.env.FONNTE_TOKEN;
if (fonnteToken) {
const message = `*✅ Pengajuan ${request.request_type} Disetujui*\n\n` +
`Halo *${request.student_name}*,\n` +
`Pengajuan ${request.request_type} Anda untuk tanggal *${request.request_date}* telah *DISETUJUI* oleh ${reviewerName}.\n\n` +
`Terima kasih.`;
addToQueuePriority(studentPhone, message, fonnteToken);
console.log(`📱 Notified student ${request.student_name} about approval`);
}
}
} catch (waErr) {
console.error('📱 Approval WA notification error:', waErr.message);
}
res.json({ success: true, message: 'Pengajuan disetujui.' });
} catch (error) {
console.error('PUT /api/leave-requests/:id/approve Error:', error);
res.status(500).json({ error: error.message });
}
});
// PUT /api/leave-requests/:id/reject - Reject leave request (by Guru BK)
router.put('/:id/reject', async (req, res) => {
try {
const { id } = req.params;
const { reviewerId, reviewerName, rejectionReason } = req.body;
if (!rejectionReason) {
return res.status(400).json({ error: 'Alasan penolakan wajib diisi.' });
}
await pool.query(
`UPDATE leave_requests SET status = 'REJECTED', reviewed_by = ?, reviewed_by_name = ?, reviewed_at = NOW(), rejection_reason = ? WHERE id = ?`,
[reviewerId, reviewerName, rejectionReason, id]
);
console.log(`❌ Leave request ${id} REJECTED by ${reviewerName}: ${rejectionReason}`);
// Send WhatsApp notification to student/parent
try {
// Get the request again to get student info
const [reqRows] = await pool.query('SELECT * FROM leave_requests WHERE id = ?', [id]);
if (reqRows.length > 0) {
const request = reqRows[0];
const [attendanceRows] = await pool.query(
"SELECT parent_phone FROM attendance WHERE user_id = ? AND parent_phone IS NOT NULL AND parent_phone != '' ORDER BY timestamp DESC LIMIT 1",
[request.student_id]
);
const studentPhone = attendanceRows.length > 0 ? attendanceRows[0].parent_phone : null;
if (studentPhone) {
const [settings] = await pool.query("SELECT setting_value FROM settings WHERE setting_key = 'FONNTE_TOKEN'");
const fonnteToken = settings.length > 0 ? settings[0].setting_value : process.env.FONNTE_TOKEN;
if (fonnteToken) {
const message = `*❌ Pengajuan ${request.request_type} Ditolak*\n\n` +
`Halo *${request.student_name}*,\n` +
`Mohon maaf, pengajuan ${request.request_type} Anda untuk tanggal *${request.request_date}* telah *DITOLAK* oleh ${reviewerName}.\n\n` +
`*Alasan:* ${rejectionReason}\n\n` +
`Silakan hubungi Guru BK jika ada pertanyaan.`;
addToQueuePriority(studentPhone, message, fonnteToken);
console.log(`📱 Notified student ${request.student_name} about rejection`);
}
}
}
} catch (waErr) {
console.error('📱 Rejection WA notification error:', waErr.message);
}
res.json({ success: true, message: 'Pengajuan ditolak.' });
} catch (error) {
console.error('PUT /api/leave-requests/:id/reject Error:', error);
res.status(500).json({ error: error.message });
}
});
// DELETE /api/leave-requests/:id - Delete leave request
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
await pool.query('DELETE FROM leave_requests WHERE id = ?', [id]);
res.json({ success: true, message: 'Pengajuan dihapus.' });
} catch (error) {
console.error('DELETE /api/leave-requests Error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

62
backend/routes/registrations.js Executable file
View File

@@ -0,0 +1,62 @@
// Registrations API Routes
import express from 'express';
import pool from '../db.js';
const router = express.Router();
// GET /api/registrations - Get all face registrations
router.get('/', async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT * FROM registrations ORDER BY timestamp DESC'
);
// Map to frontend format (AttendanceRecord style)
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.timestamp ? new Date(parseInt(row.timestamp)).toISOString().split('T')[0] : '',
timeStr: row.timestamp ? new Date(parseInt(row.timestamp)).toTimeString().slice(0, 5) : '',
location: { lat: 0, lng: 0, distance: 0 },
photoEvidence: row.photo_url,
status: 'REGISTRATION',
aiVerification: 'Registrasi Cloud',
parentPhone: ''
}));
res.json(records);
} catch (error) {
console.error('GET /api/registrations Error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/registrations - Submit new face registration
router.post('/', async (req, res) => {
try {
const { id, userId, nis, userName, className, photoUrl, timestamp } = req.body;
await pool.query(
`INSERT INTO registrations (id, user_id, nis, user_name, class_name, photo_url, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[id, userId, nis, userName, className, photoUrl, timestamp]
);
// Also update the user's registered face
await pool.query(
'UPDATE users SET registered_face = ? WHERE nis = ? OR id = ?',
[photoUrl, nis, userId]
);
res.json({ success: true, photoUrl });
} catch (error) {
console.error('POST /api/registrations Error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

87
backend/routes/settings.js Executable file
View File

@@ -0,0 +1,87 @@
// Settings API Routes
import express from 'express';
import pool from '../db.js';
const router = express.Router();
// GET /api/settings - Get all settings
router.get('/', async (req, res) => {
try {
const [rows] = await pool.query('SELECT setting_key, setting_value FROM settings');
// Convert to object format
const settings = {};
for (const row of rows) {
settings[row.setting_key] = row.setting_value;
}
// Map to frontend AppSettings format
const appSettings = {
schoolName: settings['NAMA_SEKOLAH'] || 'SMA Negeri 1 Abiansemal',
schoolLat: parseFloat(settings['LATITUDE']) || -8.5107893,
schoolLng: parseFloat(settings['LONGITUDE']) || 115.2142912,
allowedRadiusMeters: parseInt(settings['RADIUS_METER']) || 100,
morningStart: settings['JAM_MASUK_PAGI'] || '07:00',
morningEnd: settings['JAM_PULANG_PAGI'] || '12:00',
afternoonStart: settings['JAM_MASUK_SIANG'] || '12:30',
afternoonEnd: settings['JAM_PULANG_SIANG'] || '16:00',
allowedDays: settings['HARI_AKTIF'] ? settings['HARI_AKTIF'].split(',').map(d => parseInt(d.trim())) : [1, 2, 3, 4, 5, 6],
activeDates: settings['TANGGAL_AKTIF'] ? settings['TANGGAL_AKTIF'].split(',').filter(d => d.trim()) : [],
fonnteToken: settings['FONNTE_TOKEN'] || '',
availableClasses: settings['DAFTAR_KELAS'] ? settings['DAFTAR_KELAS'].split(',').map(c => c.trim()) : [],
semester: settings['SEMESTER'] || 'Ganjil',
academicYear: settings['TAHUN_AJARAN'] || '',
faceMatchThreshold: parseFloat(settings['AMBANG_WAJAH']) || 0.45,
autoRekapAlfaTime: settings['AUTO_REKAP_ALFA_TIME'] || '19:00'
};
res.json(appSettings);
} catch (error) {
console.error('GET /api/settings Error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/settings - Save settings
router.post('/', async (req, res) => {
try {
const settings = req.body;
// Map frontend format to database key-value pairs
const settingsMap = {
'NAMA_SEKOLAH': settings.schoolName,
'LATITUDE': settings.schoolLat?.toString(),
'LONGITUDE': settings.schoolLng?.toString(),
'RADIUS_METER': settings.allowedRadiusMeters?.toString(),
'JAM_MASUK_PAGI': settings.morningStart,
'JAM_PULANG_PAGI': settings.morningEnd,
'JAM_MASUK_SIANG': settings.afternoonStart,
'JAM_PULANG_SIANG': settings.afternoonEnd,
'HARI_AKTIF': Array.isArray(settings.allowedDays) ? settings.allowedDays.join(',') : settings.allowedDays,
'TANGGAL_AKTIF': Array.isArray(settings.activeDates) ? settings.activeDates.join(',') : settings.activeDates,
'FONNTE_TOKEN': settings.fonnteToken,
'DAFTAR_KELAS': Array.isArray(settings.availableClasses) ? settings.availableClasses.join(',') : settings.availableClasses,
'SEMESTER': settings.semester,
'TAHUN_AJARAN': settings.academicYear,
'AMBANG_WAJAH': settings.faceMatchThreshold?.toString(),
'AUTO_REKAP_ALFA_TIME': settings.autoRekapAlfaTime
};
for (const [key, value] of Object.entries(settingsMap)) {
if (value !== undefined && value !== null) {
await pool.query(
`INSERT INTO settings (setting_key, setting_value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = ?`,
[key, value, value]
);
}
}
res.json({ success: true });
} catch (error) {
console.error('POST /api/settings Error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

207
backend/routes/staff.js Executable file
View File

@@ -0,0 +1,207 @@
// Staff Users API Routes - Dynamic User Management
import express from 'express';
import pool from '../db.js';
import crypto from 'crypto';
const router = express.Router();
// Simple password hashing (for demo - in production use bcrypt)
const hashPassword = (password) => {
return crypto.createHash('sha256').update(password).digest('hex');
};
// Helper to parse assigned_classes JSON
const parseAssignedClasses = (row) => {
try {
return row.assigned_classes ? JSON.parse(row.assigned_classes) : [];
} catch {
return [];
}
};
// GET /api/staff - Get all staff users
router.get('/', async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT id, username, name, role, phone, assigned_classes, is_active, created_at FROM staff_users ORDER BY created_at DESC'
);
// Parse assigned_classes for each staff
const result = rows.map(row => ({
...row,
assignedClasses: parseAssignedClasses(row),
assigned_classes: undefined // Remove raw field
}));
res.json(result);
} catch (error) {
console.error('GET /api/staff Error:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/staff/guru-bk/by-class/:className - Find Guru BK by class
router.get('/guru-bk/by-class/:className', async (req, res) => {
try {
const { className } = req.params;
// Find all GURU_BK who have this class in their assigned_classes
const [rows] = await pool.query(
`SELECT id, username, name, role, phone, assigned_classes, is_active
FROM staff_users
WHERE role = 'GURU_BK' AND is_active = TRUE`
);
// Filter to find Guru BK with matching class
const matchingGuruBK = rows.filter(row => {
const assignedClasses = parseAssignedClasses(row);
return assignedClasses.includes(className);
}).map(row => ({
id: row.id,
name: row.name,
phone: row.phone,
assignedClasses: parseAssignedClasses(row)
}));
if (matchingGuruBK.length > 0) {
// Return the first matching Guru BK
res.json({ found: true, guruBK: matchingGuruBK[0] });
} else {
// No matching Guru BK found, return first active GURU_BK as fallback
const [fallback] = await pool.query(
`SELECT id, name, phone, assigned_classes
FROM staff_users
WHERE role = 'GURU_BK' AND is_active = TRUE
LIMIT 1`
);
if (fallback.length > 0) {
res.json({
found: false,
fallback: true,
guruBK: {
id: fallback[0].id,
name: fallback[0].name,
phone: fallback[0].phone,
assignedClasses: parseAssignedClasses(fallback[0])
}
});
} else {
res.json({ found: false, guruBK: null });
}
}
} catch (error) {
console.error('GET /api/staff/guru-bk/by-class Error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/staff - Create new staff user
router.post('/', async (req, res) => {
try {
const { username, password, name, role, phone, assignedClasses } = req.body;
if (!username || !password || !name || !role) {
return res.status(400).json({ error: 'Username, password, name, dan role wajib diisi.' });
}
// Check if username already exists
const [existing] = await pool.query('SELECT id FROM staff_users WHERE username = ?', [username]);
if (existing.length > 0) {
return res.status(400).json({ error: 'Username sudah digunakan.' });
}
const id = crypto.randomUUID();
const hashedPassword = hashPassword(password);
const assignedClassesJson = assignedClasses && Array.isArray(assignedClasses)
? JSON.stringify(assignedClasses)
: null;
await pool.query(
'INSERT INTO staff_users (id, username, password, name, role, phone, assigned_classes) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, username, hashedPassword, name, role, phone || null, assignedClassesJson]
);
res.json({ success: true, id, message: 'Staff berhasil ditambahkan.' });
} catch (error) {
console.error('POST /api/staff Error:', error);
res.status(500).json({ error: error.message });
}
});
// PUT /api/staff/:id - Update staff user
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { username, password, name, role, phone, assignedClasses, is_active } = req.body;
// Build dynamic update query
const updates = [];
const params = [];
if (username) {
updates.push('username = ?');
params.push(username);
}
if (password) {
updates.push('password = ?');
params.push(hashPassword(password));
}
if (name) {
updates.push('name = ?');
params.push(name);
}
if (role) {
updates.push('role = ?');
params.push(role);
}
if (typeof is_active === 'boolean') {
updates.push('is_active = ?');
params.push(is_active);
}
if (phone !== undefined) {
updates.push('phone = ?');
params.push(phone || null);
}
if (assignedClasses !== undefined) {
updates.push('assigned_classes = ?');
params.push(Array.isArray(assignedClasses) ? JSON.stringify(assignedClasses) : null);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'Tidak ada data yang diubah.' });
}
params.push(id);
await pool.query(
`UPDATE staff_users SET ${updates.join(', ')} WHERE id = ?`,
params
);
res.json({ success: true, message: 'Staff berhasil diupdate.' });
} catch (error) {
console.error('PUT /api/staff Error:', error);
res.status(500).json({ error: error.message });
}
});
// DELETE /api/staff/:id - Delete staff user
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
// Prevent deleting the last admin
const [admins] = await pool.query("SELECT COUNT(*) as count FROM staff_users WHERE role = 'ADMIN'");
const [targetUser] = await pool.query('SELECT role FROM staff_users WHERE id = ?', [id]);
if (targetUser.length > 0 && targetUser[0].role === 'ADMIN' && admins[0].count <= 1) {
return res.status(400).json({ error: 'Tidak dapat menghapus admin terakhir.' });
}
await pool.query('DELETE FROM staff_users WHERE id = ?', [id]);
res.json({ success: true, message: 'Staff berhasil dihapus.' });
} catch (error) {
console.error('DELETE /api/staff Error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

528
backend/routes/users.js Executable file
View File

@@ -0,0 +1,528 @@
// Users API Routes
import express from 'express';
import pool from '../db.js';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
// ==================== OPTIMIZED ENDPOINTS ====================
// GET /api/users/simple - OPTIMIZED: Lightweight list without face data (10x faster)
// Returns only essential fields for dropdown/list display
router.get('/simple', async (req, res) => {
try {
const [rows] = await pool.query(
`SELECT id, name, nis, class_name, shift,
CASE WHEN registered_face IS NOT NULL AND registered_face != '' THEN 1 ELSE 0 END as has_face
FROM users WHERE role = ? ORDER BY class_name, name`,
['STUDENT']
);
const users = rows.map(row => ({
id: row.id,
name: row.name,
nis: row.nis,
className: row.class_name,
shift: row.shift,
hasFace: row.has_face === 1
}));
res.json(users);
} catch (error) {
console.error('GET /api/users/simple Error:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/users/count - OPTIMIZED: Statistics only (fastest)
router.get('/count', async (req, res) => {
try {
const [totalRows] = await pool.query(
'SELECT COUNT(*) as total FROM users WHERE role = ?',
['STUDENT']
);
const [registeredRows] = await pool.query(
`SELECT COUNT(*) as registered FROM users
WHERE role = ? AND registered_face IS NOT NULL AND registered_face != ''`,
['STUDENT']
);
const [classRows] = await pool.query(
'SELECT class_name, COUNT(*) as count FROM users WHERE role = ? GROUP BY class_name ORDER BY class_name',
['STUDENT']
);
res.json({
total: totalRows[0].total,
registered: registeredRows[0].registered,
unregistered: totalRows[0].total - registeredRows[0].registered,
byClass: classRows.map(r => ({ className: r.class_name, count: r.count }))
});
} catch (error) {
console.error('GET /api/users/count Error:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/users/by-class/:className - OPTIMIZED: Get students by class (with pagination)
router.get('/by-class/:className', async (req, res) => {
try {
const { className } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
const includeFace = req.query.includeFace === 'true';
// Build SELECT based on whether we need face data
const selectFields = includeFace
? 'id, name, nis, class_name, shift, role, registered_face, created_at'
: `id, name, nis, class_name, shift, role,
CASE WHEN registered_face IS NOT NULL AND registered_face != '' THEN 1 ELSE 0 END as has_face,
created_at`;
const [rows] = await pool.query(
`SELECT ${selectFields} FROM users WHERE role = ? AND class_name = ? ORDER BY name LIMIT ? OFFSET ?`,
['STUDENT', className, limit, offset]
);
const [countRows] = await pool.query(
'SELECT COUNT(*) as total FROM users WHERE role = ? AND class_name = ?',
['STUDENT', className]
);
const users = rows.map(row => includeFace ? ({
id: row.id,
name: row.name,
nis: row.nis,
className: row.class_name,
shift: row.shift,
role: row.role,
registeredFace: row.registered_face,
createdAt: row.created_at
}) : ({
id: row.id,
name: row.name,
nis: row.nis,
className: row.class_name,
shift: row.shift,
role: row.role,
hasFace: row.has_face === 1,
createdAt: row.created_at
}));
res.json({
users,
pagination: {
page,
limit,
total: countRows[0].total,
totalPages: Math.ceil(countRows[0].total / limit)
}
});
} catch (error) {
console.error('GET /api/users/by-class Error:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/users/classes - Get list of available classes
router.get('/classes', async (req, res) => {
try {
const [rows] = await pool.query(
`SELECT class_name, COUNT(*) as count,
SUM(CASE WHEN registered_face IS NOT NULL AND registered_face != '' THEN 1 ELSE 0 END) as registered
FROM users WHERE role = ? AND class_name IS NOT NULL AND class_name != ''
GROUP BY class_name ORDER BY class_name`,
['STUDENT']
);
res.json(rows.map(r => ({
className: r.class_name,
total: r.count,
registered: r.registered
})));
} catch (error) {
console.error('GET /api/users/classes Error:', error);
res.status(500).json({ error: error.message });
}
});
// ==================== ORIGINAL ENDPOINTS ====================
// GET /api/users - Get all students
router.get('/', async (req, res) => {
try {
// Note: face_descriptor is excluded from this query for performance
// It will be fetched separately when needed for face comparison
const [rows] = await pool.query(
'SELECT id, name, nis, class_name, shift, role, registered_face, created_at FROM users WHERE role = ? ORDER BY class_name, name',
['STUDENT']
);
// Map to frontend format
const users = rows.map(row => ({
id: row.id,
name: row.name,
nis: row.nis,
className: row.class_name,
shift: row.shift,
role: row.role,
registeredFace: row.registered_face,
createdAt: row.created_at
}));
res.json(users);
} catch (error) {
console.error('GET /api/users Error:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/users/:id - Get single user with face_descriptor (for attendance verification)
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
let rows;
let hasFaceDescriptor = true;
try {
[rows] = await pool.query(
'SELECT id, name, nis, class_name, shift, role, registered_face, face_descriptor, created_at FROM users WHERE id = ? OR nis = ?',
[id, id]
);
} catch (err) {
// If face_descriptor column doesn't exist, query without it
if (err.code === 'ER_BAD_FIELD_ERROR') {
hasFaceDescriptor = false;
[rows] = await pool.query(
'SELECT id, name, nis, class_name, shift, role, registered_face, created_at FROM users WHERE id = ? OR nis = ?',
[id, id]
);
} else {
throw err;
}
}
if (rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
const row = rows[0];
res.json({
id: row.id,
name: row.name,
nis: row.nis,
className: row.class_name,
shift: row.shift,
role: row.role,
registeredFace: row.registered_face,
faceDescriptor: hasFaceDescriptor ? row.face_descriptor : null,
createdAt: row.created_at
});
} catch (error) {
console.error('GET /api/users/:id Error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/users - Create or update user
router.post('/', async (req, res) => {
try {
const { id, name, nis, className, shift, role } = req.body;
const userId = id || uuidv4();
await pool.query(
`INSERT INTO users (id, name, nis, class_name, shift, role, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = ?, class_name = ?, shift = ?`,
[userId, name, nis, className, shift || 'PAGI', role || 'STUDENT', Date.now(),
name, className, shift || 'PAGI']
);
res.json({ success: true, id: userId });
res.json({ success: true, id: userId });
} catch (error) {
console.error('POST /api/users Error:', error);
res.status(500).json({ error: error.message });
}
});
// POST /api/users/bulk - Bulk import users
router.post('/bulk', async (req, res) => {
try {
const { users } = req.body;
let count = 0;
for (const user of users) {
const userId = user.nis || uuidv4();
await pool.query(
`INSERT INTO users (id, name, nis, class_name, shift, role, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = ?, class_name = ?, shift = ?`,
[userId, user.name, user.nis, user.className, user.shift || 'PAGI', 'STUDENT', Date.now(),
user.name, user.className, user.shift || 'PAGI']
);
count++;
}
res.json({ success: true, count });
} catch (error) {
console.error('POST /api/users/bulk Error:', error);
res.status(500).json({ error: error.message });
}
});
// PUT /api/users/:id/face - Update user's registered face
router.put('/:id/face', async (req, res) => {
try {
const { id } = req.params;
const { registeredFace, faceDescriptor } = req.body;
// Check if already registered
const [current] = await pool.query(
'SELECT registered_face FROM users WHERE id = ? OR nis = ?',
[id, id]
);
if (current.length > 0 && current[0].registered_face) {
return res.status(400).json({ error: 'Wajah sudah terdaftar. Gunakan menu Reset Wajah untuk mengganti.' });
}
// Try to update with face_descriptor first
if (faceDescriptor) {
try {
await pool.query(
'UPDATE users SET registered_face = ?, face_descriptor = ? WHERE id = ? OR nis = ?',
[registeredFace, faceDescriptor, id, id]
);
} catch (err) {
// If face_descriptor column doesn't exist, update only registered_face
if (err.code === 'ER_BAD_FIELD_ERROR') {
console.log('face_descriptor column not found, updating only registered_face');
await pool.query(
'UPDATE users SET registered_face = ? WHERE id = ? OR nis = ?',
[registeredFace, id, id]
);
} else {
throw err;
}
}
} else {
await pool.query(
'UPDATE users SET registered_face = ? WHERE id = ? OR nis = ?',
[registeredFace, id, id]
);
}
res.json({ success: true });
} catch (error) {
console.error('PUT /api/users/:id/face Error:', error);
res.status(500).json({ error: error.message });
}
});
// DELETE /api/users/:id/face - Reset user's face
router.delete('/:id/face', async (req, res) => {
try {
const { id } = req.params;
try {
await pool.query(
'UPDATE users SET registered_face = NULL, face_descriptor = NULL WHERE id = ? OR nis = ?',
[id, id]
);
} catch (err) {
// If face_descriptor column doesn't exist, update only registered_face
if (err.code === 'ER_BAD_FIELD_ERROR') {
console.log('face_descriptor column not found, resetting only registered_face');
await pool.query(
'UPDATE users SET registered_face = NULL WHERE id = ? OR nis = ?',
[id, id]
);
} else {
throw err;
}
}
res.json({ success: true });
} catch (error) {
console.error('DELETE /api/users/:id/face Error:', error);
res.status(500).json({ error: error.message });
}
});
// DELETE /api/users/:id - Delete user
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
await pool.query('DELETE FROM users WHERE id = ? OR nis = ?', [id, id]);
res.json({ success: true });
} catch (error) {
console.error('DELETE /api/users/:id Error:', error);
res.status(500).json({ error: error.message });
}
});
// Helper: Parse CSV
const parseCSV = (text) => {
const lines = text.split('\n').filter(l => l.trim() !== '');
if (lines.length === 0) return [];
// Simple CSV parser that handles quotes
const splitLine = (line) => {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') inQuotes = !inQuotes;
else if (char === ',' && !inQuotes) { result.push(current); current = ''; }
else current += char;
}
result.push(current);
return result.map(s => s.trim().replace(/^"|"$/g, '').trim());
};
const headers = splitLine(lines[0]);
return lines.slice(1).map(line => {
const values = splitLine(line);
const obj = {};
headers.forEach((h, i) => obj[h] = values[i] || '');
return obj;
});
};
// POST /api/users/migrate-legacy - Migrate from Google Sheet
router.post('/migrate-legacy', async (req, res) => {
try {
const SHEET_ID = '1UKoY998Q161a3_dbBc6mAmMWKYMeZ8i0JoqcmrOZBo8'; // Hardcoded Legacy ID
const SHEET_NAME = 'Siswa';
const url = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}&t=${Date.now()}`;
console.log('Migrating from:', url);
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch sheet: ${response.status}`);
const csvText = await response.text();
const data = parseCSV(csvText);
const usersToImport = data.map(row => {
let shift = 'PAGI';
if (row['SHIFT'] && row['SHIFT'].toUpperCase() === 'SIANG') shift = 'SIANG';
return {
id: row['NIS'] || uuidv4(),
name: row['NAMA_LENGKAP'] || row['Nama'] || 'Tanpa Nama',
nis: row['NIS'],
className: row['KELAS'] || row['Kelas'],
shift: shift
};
}).filter(u => u.name !== 'Tanpa Nama' && u.className);
if (usersToImport.length === 0) {
return res.json({ success: false, message: 'No data found in sheet' });
}
// Bulk Insert Logic
let count = 0;
for (const user of usersToImport) {
const userId = user.nis || user.id;
await pool.query(
`INSERT INTO users (id, name, nis, class_name, shift, role, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = ?, class_name = ?, shift = ?`,
[userId, user.name, user.nis, user.className, user.shift, 'STUDENT', Date.now(),
user.name, user.className, user.shift]
);
count++;
}
res.json({ success: true, count, message: `Successfully migrated ${count} users` });
} catch (error) {
console.error('Migration Error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// POST /api/users/sync-faces-legacy - Sync faces from Legacy Google Sheet
router.post('/sync-faces-legacy', async (req, res) => {
try {
const SHEET_ID = '1UKoY998Q161a3_dbBc6mAmMWKYMeZ8i0JoqcmrOZBo8';
const SHEET_NAME = 'Registrasi';
const url = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}&t=${Date.now()}`;
console.log('Fetching Legacy Faces from:', url);
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch sheet: ${response.status}`);
const csvText = await response.text();
const data = parseCSV(csvText);
if (data.length === 0) {
return res.json({ success: false, message: 'No registration data found in sheet' });
}
let updatedCount = 0;
for (const row of data) {
const nis = row['NIS'];
const photoUrl = row['Foto URL'] || row['Foto'];
// Only update if we have both NIS and Photo
if (nis && photoUrl && photoUrl.startsWith('http')) {
const [result] = await pool.query(
'UPDATE users SET registered_face = ? WHERE nis = ? AND (registered_face IS NULL OR registered_face = "")',
[photoUrl, nis]
);
if (result.changedRows > 0) updatedCount++;
}
}
res.json({ success: true, count: updatedCount, message: `Successfully synced ${updatedCount} faces from legacy sheet` });
} catch (error) {
console.error('Legacy Face Sync Error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// POST /api/users/promote - Bulk promote/graduate students
router.post('/promote', async (req, res) => {
try {
console.log('🚀 Starting bulk promotion process...');
// Order is critical to avoid double promotion!
// 1. XII -> LULUS
const [resXII] = await pool.query(
"UPDATE users SET class_name = 'LULUS' WHERE class_name LIKE 'XII%' AND role = 'STUDENT'"
);
// 2. XI -> XII
const [resXI] = await pool.query(
"UPDATE users SET class_name = REPLACE(class_name, 'XI', 'XII') WHERE class_name LIKE 'XI%' AND class_name NOT LIKE 'XII%' AND role = 'STUDENT'"
);
// 3. X -> XI
const [resX] = await pool.query(
"UPDATE users SET class_name = REPLACE(class_name, 'X', 'XI') WHERE class_name LIKE 'X%' AND class_name NOT LIKE 'XI%' AND class_name NOT LIKE 'XII%' AND role = 'STUDENT'"
);
res.json({
success: true,
message: 'Kenaikan kelas berhasil diproses.',
details: {
XII_to_Lulus: resXII.affectedRows,
XI_to_XII: resXI.affectedRows,
X_to_XI: resX.affectedRows
}
});
} catch (error) {
console.error('POST /api/users/promote Error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

266
backend/schema.js Executable file
View File

@@ -0,0 +1,266 @@
// Database Schema Initialization
// Auto-creates all required tables on application startup
import pool from './db.js';
const createTables = async () => {
const connection = await pool.getConnection();
try {
console.log('🔧 Initializing Database Schema...');
// 1. Users Table
await connection.query(`
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
nis VARCHAR(50) UNIQUE,
class_name VARCHAR(100),
shift ENUM('PAGI', 'SIANG') DEFAULT 'PAGI',
role ENUM('ADMIN', 'STUDENT', 'TEACHER') DEFAULT 'STUDENT',
registered_face LONGTEXT,
face_descriptor LONGTEXT,
created_at BIGINT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_nis (nis),
INDEX idx_class (class_name),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// 1.1 Add face_descriptor column if not exists (Migration)
try {
await connection.query(`
SELECT count(*) FROM information_schema.COLUMNS
WHERE (TABLE_SCHEMA = '${process.env.DB_NAME}' OR TABLE_SCHEMA = DATABASE())
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'face_descriptor'
`).then(async ([rows]) => {
// @ts-ignore
if (rows[0]['count(*)'] === 0 && rows[0]['COUNT(*)'] === 0) {
console.log(' ⚠️ Migrating table "users": adding face_descriptor column...');
await connection.query('ALTER TABLE users ADD COLUMN face_descriptor LONGTEXT AFTER registered_face');
console.log(' ✓ Migration successful');
}
});
} catch (migErr) {
// Ignore error if column check fails, it might be already created by CREATE TABLE
console.log(' Table check skipped or passed');
}
console.log(' ✓ Table "users" ready');
// 2. Attendance Table
await connection.query(`
CREATE TABLE IF NOT EXISTS attendance (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36),
user_name VARCHAR(255),
nis VARCHAR(50),
class_name VARCHAR(100),
timestamp BIGINT,
date_str VARCHAR(20),
time_str VARCHAR(10),
lat DECIMAL(10, 7),
lng DECIMAL(10, 7),
distance DECIMAL(10, 2),
photo_evidence LONGTEXT,
status ENUM('PRESENT', 'LATE', 'REGISTRATION', 'ALFA') DEFAULT 'PRESENT',
ai_verification TEXT,
parent_phone VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_date (date_str),
INDEX idx_user (user_id),
INDEX idx_nis (nis),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log(' ✓ Table "attendance" ready');
// 3. Registrations Table (Face Registration History)
await connection.query(`
CREATE TABLE IF NOT EXISTS registrations (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36),
nis VARCHAR(50),
user_name VARCHAR(255),
class_name VARCHAR(100),
photo_url LONGTEXT,
timestamp BIGINT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_nis (nis),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log(' ✓ Table "registrations" ready');
// 4. Settings Table (Key-Value Store)
await connection.query(`
CREATE TABLE IF NOT EXISTS settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) UNIQUE NOT NULL,
setting_value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_key (setting_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log(' ✓ Table "settings" ready');
// 5. Staff Users Table (Admin, Guru, Guru BK) - Dynamic User Management
await connection.query(`
CREATE TABLE IF NOT EXISTS staff_users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role ENUM('ADMIN', 'TEACHER', 'GURU_BK') NOT NULL,
phone VARCHAR(20),
assigned_classes TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log(' ✓ Table "staff_users" ready');
// 5.1 Add phone column to staff_users if not exists (Migration)
try {
await connection.query(`ALTER TABLE staff_users ADD COLUMN phone VARCHAR(20) AFTER role`);
console.log(' ✓ Added phone column to staff_users');
} catch (migErr) {
// Column already exists
}
// 5.2 Add assigned_classes column to staff_users if not exists (Migration)
try {
await connection.query(`ALTER TABLE staff_users ADD COLUMN assigned_classes TEXT AFTER phone`);
console.log(' ✓ Added assigned_classes column to staff_users');
} catch (migErr) {
// Column already exists
}
// 5.2 Add SAKIT, IZIN, and DISPEN to attendance status (Migration)
try {
await connection.query(`
ALTER TABLE attendance
MODIFY COLUMN status ENUM('PRESENT', 'LATE', 'REGISTRATION', 'ALFA', 'SAKIT', 'IZIN', 'DISPEN') DEFAULT 'PRESENT'
`);
console.log(' ✓ Attendance status updated (added SAKIT, IZIN, DISPEN)');
} catch (migErr) {
// Ignore if already updated
}
// 6. Leave Requests Table (Pengajuan Izin/Sakit/Dispensasi oleh Siswa)
await connection.query(`
CREATE TABLE IF NOT EXISTS leave_requests (
id VARCHAR(36) PRIMARY KEY,
student_id VARCHAR(36) NOT NULL,
student_name VARCHAR(255) NOT NULL,
student_nis VARCHAR(50),
student_class VARCHAR(100),
request_type ENUM('SAKIT', 'IZIN', 'DISPEN') NOT NULL,
request_date VARCHAR(20) NOT NULL,
reason TEXT NOT NULL,
photo_evidence LONGTEXT,
status ENUM('PENDING', 'APPROVED', 'REJECTED') DEFAULT 'PENDING',
reviewed_by VARCHAR(36),
reviewed_by_name VARCHAR(255),
reviewed_at TIMESTAMP NULL,
rejection_reason TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_student (student_id),
INDEX idx_status (status),
INDEX idx_date (request_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log(' ✓ Table "leave_requests" ready');
// 6.1 Add DISPEN to leave_requests request_type (Migration)
try {
await connection.query(`
ALTER TABLE leave_requests
MODIFY COLUMN request_type ENUM('SAKIT', 'IZIN', 'DISPEN') NOT NULL
`);
console.log(' ✓ Leave requests type updated (added DISPEN)');
} catch (migErr) {
// Ignore if already updated
}
// 5. Insert Default Settings if empty
const [settingsRows] = await connection.query('SELECT COUNT(*) as count FROM settings');
if (settingsRows[0].count === 0) {
const defaultSettings = [
['NAMA_SEKOLAH', 'SMA Negeri 1 Abiansemal'],
['LATITUDE', '-8.5107893'],
['LONGITUDE', '115.2142912'],
['RADIUS_METER', '100'],
['JAM_MASUK_PAGI', '07:00'],
['JAM_PULANG_PAGI', '12:00'],
['JAM_MASUK_SIANG', '12:30'],
['JAM_PULANG_SIANG', '16:00'],
['HARI_AKTIF', '1,2,3,4,5,6'],
['DAFTAR_KELAS', 'X-1,X-2,XI-1,XI-2,XII-1,XII-2'],
['SEMESTER', 'Ganjil'],
['TAHUN_AJARAN', '2023/2024'],
['AMBANG_WAJAH', '0.45'],
['AUTO_REKAP_ALFA_TIME', '19:00']
];
for (const [key, value] of defaultSettings) {
await connection.query(
'INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = ?',
[key, value, value]
);
}
console.log(' ✓ Default settings inserted');
}
// 5.1 Ensure FONNTE_TOKEN exists
try {
await connection.query(
"INSERT INTO settings (setting_key, setting_value) VALUES ('FONNTE_TOKEN', '') ON DUPLICATE KEY UPDATE setting_key = setting_key"
);
} catch (err) {
// Ignore
}
// 6. AUTO-MIGRATION: Create optimized indexes for daily attendance queries
console.log(' 📊 Optimizing database indexes...');
const optimizedIndexes = [
// Composite indexes for filtered daily queries
{ name: 'idx_attendance_date_class', table: 'attendance', columns: 'date_str, class_name' },
{ name: 'idx_attendance_date_status', table: 'attendance', columns: 'date_str, status' },
{ name: 'idx_attendance_date_class_status', table: 'attendance', columns: 'date_str, class_name, status' },
// Index for user name search (LIKE queries)
{ name: 'idx_attendance_username', table: 'attendance', columns: 'user_name' },
// Index for pagination ordering
{ name: 'idx_attendance_classname_username', table: 'attendance', columns: 'class_name, user_name' },
];
for (const idx of optimizedIndexes) {
try {
await connection.query(`CREATE INDEX ${idx.name} ON ${idx.table}(${idx.columns})`);
console.log(` ✓ Index "${idx.name}" created`);
} catch (indexErr) {
// Index already exists - this is fine
if (indexErr.code === 'ER_DUP_KEYNAME') {
// Silent - already exists
} else {
console.log(` Index "${idx.name}" skipped: ${indexErr.message}`);
}
}
}
console.log('✅ Database Schema Initialized Successfully!');
} catch (error) {
console.error('❌ Schema Initialization Error:', error.message);
throw error;
} finally {
connection.release();
}
};
export default createTables;

BIN
backend/services/._fonnteQueue.js Executable file

Binary file not shown.

258
backend/services/fonnteQueue.js Executable file
View File

@@ -0,0 +1,258 @@
// Fonnte WhatsApp Message Queue Service
// Mengirim pesan satu per satu dengan delay acak dan simulasi mengetik (typing indicator)
// Untuk menghindari deteksi spam dan pembatasan akun WhatsApp
// Optimized untuk 1000+ siswa - OPSI B (Moderat)
import axios from 'axios';
// ==================== CONFIGURATION - OPSI B (MODERAT) ====================
// Konfigurasi optimal untuk ~656-1000 siswa dengan 1 device
// Estimasi: ~140 pesan/jam, 656 siswa selesai dalam ~5 jam
// Delay Configuration (dalam milidetik)
const MIN_DELAY_BETWEEN_MESSAGES = 4000; // Minimum 4 detik
const MAX_DELAY_BETWEEN_MESSAGES = 9000; // Maximum 9 detik
// Rate Limiting
const MAX_MESSAGES_PER_HOUR = 140; // ~140 pesan per jam
// Typing configuration - dalam detik (sesuai Fonnte API)
const MIN_TYPING_DURATION = 2; // Minimum 2 detik mengetik
const MAX_TYPING_DURATION = 4; // Maximum 4 detik mengetik
// Batch configuration - istirahat setiap X pesan untuk menghindari deteksi pola
const BATCH_SIZE = 20; // Setiap 20 pesan
const BATCH_REST_DURATION = 45000; // Istirahat 45 detik
// Message Queue
const messageQueue = [];
let isProcessing = false;
let messagesSentThisHour = 0;
let hourStartTime = Date.now();
// Variasi salam dengan penutup yang berpasangan
const GREETING_PAIRS = [
{ greeting: 'Om Swastyastu', closing: 'Om Shanti, Shanti, Shanti, Om' },
{ greeting: 'Selamat pagi', closing: 'Terima kasih.' },
{ greeting: 'Selamat siang', closing: 'Terima kasih.' },
{ greeting: 'Halo Bapak/Ibu', closing: 'Salam hormat.' },
{ greeting: 'Salam sejahtera', closing: 'Hormat kami.' },
{ greeting: 'Kepada Yth. Orang Tua/Wali', closing: 'Matur suksma.' }
];
// Get random pair
const getRandomPair = () => GREETING_PAIRS[Math.floor(Math.random() * GREETING_PAIRS.length)];
// Add variation to message
export const addMessageVariation = (message) => {
const pair = getRandomPair();
let variedMessage = message;
// Add greeting at start
variedMessage = pair.greeting + ',\n\n' + variedMessage;
// Replace closing
variedMessage = variedMessage.replace(/Terima kasih\./gi, pair.closing);
return variedMessage;
};
// Sleep helper
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Random delay helper - memberikan jeda acak untuk lebih natural
const getRandomDelay = () => {
const delay = Math.floor(Math.random() * (MAX_DELAY_BETWEEN_MESSAGES - MIN_DELAY_BETWEEN_MESSAGES + 1)) + MIN_DELAY_BETWEEN_MESSAGES;
return delay;
};
// Random typing duration - dalam detik
const getRandomTypingDuration = () => {
return Math.floor(Math.random() * (MAX_TYPING_DURATION - MIN_TYPING_DURATION + 1)) + MIN_TYPING_DURATION;
};
// Reset hourly counter
const checkHourlyReset = () => {
if (Date.now() - hourStartTime > 3600000) {
messagesSentThisHour = 0;
hourStartTime = Date.now();
console.log('📱 [Fonnte] Hourly counter reset');
}
};
// Add message to queue (normal priority)
export const addToQueue = (phone, message, token, priority = 'normal') => {
if (!token || !phone || phone.length < 5) {
console.log('📱 [Fonnte] Invalid phone or token, skipping');
return false;
}
checkHourlyReset();
// Check hourly limit
if (messagesSentThisHour >= MAX_MESSAGES_PER_HOUR) {
console.log(`📱 [Fonnte] Hourly limit reached (${MAX_MESSAGES_PER_HOUR}). Skipping.`);
return false;
}
// Clean phone number
const cleanPhone = phone.replace(/[^\d]/g, '');
// Check duplicate
const existingIndex = messageQueue.findIndex(m => m.phone === cleanPhone);
if (existingIndex >= 0) {
console.log(`📱 [Fonnte] Phone ${cleanPhone} already in queue, updating`);
messageQueue[existingIndex].message = message;
return true;
}
// Add variation to message before queueing
const variedMessage = addMessageVariation(message);
const queueItem = {
phone: cleanPhone,
message: variedMessage,
token,
priority,
addedAt: Date.now()
};
// Priority handling: HIGH priority messages go to front of queue
if (priority === 'high') {
// Find the position after all existing high priority messages
const lastHighPriorityIndex = messageQueue.findLastIndex(m => m.priority === 'high');
if (lastHighPriorityIndex >= 0) {
messageQueue.splice(lastHighPriorityIndex + 1, 0, queueItem);
} else {
messageQueue.unshift(queueItem); // Add to front if no high priority exists
}
console.log(`📱 [Fonnte] ⚡ HIGH PRIORITY Queued (Guru BK). Size: ${messageQueue.length}`);
} else {
messageQueue.push(queueItem);
console.log(`📱 [Fonnte] Queued. Size: ${messageQueue.length}, This hour: ${messagesSentThisHour}/${MAX_MESSAGES_PER_HOUR}`);
}
if (!isProcessing) {
processQueue();
}
return true;
};
// Add message to queue with HIGH priority (for Guru BK notifications)
export const addToQueuePriority = (phone, message, token) => {
return addToQueue(phone, message, token, 'high');
};
// Process queue
const processQueue = async () => {
if (isProcessing) return;
isProcessing = true;
console.log('📱 [Fonnte] Starting queue processor...');
let messageCount = 0;
let batchCount = 0; // Counter untuk batch rest
while (messageQueue.length > 0) {
checkHourlyReset();
if (messagesSentThisHour >= MAX_MESSAGES_PER_HOUR) {
console.log(`📱 [Fonnte] Hourly limit. Waiting...`);
await sleep(60000); // Wait 1 minute then check again
continue;
}
const item = messageQueue.shift();
messageCount++;
batchCount++;
// Skip expired (> 2 hours old - diperpanjang untuk handling antrian besar)
if (Date.now() - item.addedAt > 7200000) {
console.log(`📱 [Fonnte] Expired: ${item.phone}`);
continue;
}
try {
// Generate random typing duration untuk simulasi mengetik
const typingDuration = getRandomTypingDuration();
console.log(`📱 [Fonnte] Sending #${messageCount} to ${item.phone} (typing: ${typingDuration}s)...`);
// Kirim pesan dengan typing indicator
// Parameter 'typing': menampilkan status "sedang mengetik" sebelum pesan terkirim
// Ini membuat pesan terlihat seperti diketik manual oleh manusia
// IMPORTANT: Fonnte API membutuhkan format form-urlencoded, bukan JSON
const formData = new URLSearchParams();
formData.append('target', item.phone);
formData.append('message', item.message);
formData.append('typing', 'true');
formData.append('delay', typingDuration.toString());
formData.append('countryCode', '62'); // Indonesia country code
const response = await axios.post('https://api.fonnte.com/send',
formData.toString(),
{
headers: {
'Authorization': item.token,
'Content-Type': 'application/x-www-form-urlencoded'
},
timeout: 60000
}
);
// Check Fonnte API response
const fonnteResponse = response.data;
if (fonnteResponse.status === true || fonnteResponse.status === 'true') {
messagesSentThisHour++;
console.log(`📱 [Fonnte] ✓ Sent #${messageCount} (${messagesSentThisHour}/${MAX_MESSAGES_PER_HOUR} this hour)`);
} else {
// Fonnte returned error - log the actual reason
console.error(`📱 [Fonnte] ✗ REJECTED #${messageCount} to ${item.phone}`);
console.error(`📱 [Fonnte] ⚠️ Reason: ${fonnteResponse.reason || fonnteResponse.message || JSON.stringify(fonnteResponse)}`);
console.error(`📱 [Fonnte] 🔑 Token used (first 10 chars): ${item.token?.substring(0, 10)}...`);
}
} catch (error) {
console.error(`📱 [Fonnte] ✗ Failed: ${item.phone} - ${error.message}`);
}
// Check if we need batch rest (istirahat setelah X pesan)
if (batchCount >= BATCH_SIZE && messageQueue.length > 0) {
const restSeconds = Math.round(BATCH_REST_DURATION / 1000);
console.log(`📱 [Fonnte] 🛑 Batch rest (${batchCount} messages sent). Resting ${restSeconds}s...`);
await sleep(BATCH_REST_DURATION);
batchCount = 0; // Reset batch counter
}
// Random delay sebelum pesan berikutnya untuk menghindari pola yang terdeteksi sebagai bot
else if (messageQueue.length > 0) {
const nextDelay = getRandomDelay();
const delaySeconds = Math.round(nextDelay / 1000);
console.log(`📱 [Fonnte] Waiting ${delaySeconds}s before next message... Remaining: ${messageQueue.length}`);
await sleep(nextDelay);
}
}
isProcessing = false;
console.log(`📱 [Fonnte] ✅ Done. Total: ${messageCount}`);
};
// Status
export const getQueueStatus = () => {
checkHourlyReset();
return {
queueSize: messageQueue.length,
isProcessing,
messagesSentThisHour,
maxPerHour: MAX_MESSAGES_PER_HOUR
};
};
// Clear
export const clearQueue = () => {
const count = messageQueue.length;
messageQueue.length = 0;
return count;
};
export default { addToQueue, addToQueuePriority, getQueueStatus, clearQueue, addMessageVariation };

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,48 @@
-- =====================================================
-- FIX ALFA ROLLBACK SCRIPT
-- Tanggal: 2026-01-12
-- Tujuan: Menghapus record ALFA yang salah terekap
-- =====================================================
--
-- INSTRUKSI:
-- 1. Jalankan STEP 1 dulu untuk melihat data yang akan dihapus
-- 2. Verifikasi outputnya benar (hanya record bukan XII-B)
-- 3. Jalankan STEP 2 untuk menghapus data
-- 4. Jalankan STEP 3 untuk verifikasi hasil
-- =====================================================
-- =====================================================
-- STEP 1: PREVIEW - Lihat data yang akan dihapus
-- Jalankan query ini PERTAMA untuk memastikan data benar
-- =====================================================
SELECT
class_name AS 'Kelas',
COUNT(*) AS 'Jumlah Siswa'
FROM attendance
WHERE date_str = '2026-01-12'
AND status = 'ALFA'
AND ai_verification = 'Otomatis dari Sistem'
AND class_name != 'XII-B'
GROUP BY class_name
ORDER BY class_name;
-- =====================================================
-- STEP 2: HAPUS DATA - Jalankan setelah preview benar
-- =====================================================
DELETE FROM attendance
WHERE date_str = '2026-01-12'
AND status = 'ALFA'
AND ai_verification = 'Otomatis dari Sistem'
AND class_name != 'XII-B';
-- =====================================================
-- STEP 3: VERIFIKASI - Pastikan hanya XII-B tersisa
-- =====================================================
SELECT
class_name AS 'Kelas',
COUNT(*) AS 'Jumlah ALFA Tersisa'
FROM attendance
WHERE date_str = '2026-01-12'
AND status = 'ALFA'
GROUP BY class_name
ORDER BY class_name;

View File

@@ -0,0 +1,25 @@
-- Migration: Add indexes for optimized daily attendance queries
-- Run this on your MySQL database to improve performance for large datasets
-- Index for daily attendance lookup (most common query pattern)
CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date_str);
-- Composite index for filtered daily queries (date + class + status)
CREATE INDEX IF NOT EXISTS idx_attendance_date_class ON attendance(date_str, class_name);
CREATE INDEX IF NOT EXISTS idx_attendance_date_status ON attendance(date_str, status);
CREATE INDEX IF NOT EXISTS idx_attendance_date_class_status ON attendance(date_str, class_name, status);
-- Index for user name search (LIKE queries)
CREATE INDEX IF NOT EXISTS idx_attendance_username ON attendance(user_name);
-- Index for pagination ordering (used with ORDER BY)
CREATE INDEX IF NOT EXISTS idx_attendance_classname_username ON attendance(class_name, user_name);
-- For faster COUNT queries
CREATE INDEX IF NOT EXISTS idx_attendance_status ON attendance(status);
-- Verify indexes were created
SHOW INDEX FROM attendance;
-- Performance tip: For very large tables (>100k rows), consider partitioning by month:
-- ALTER TABLE attendance PARTITION BY RANGE (YEAR(date_str) * 100 + MONTH(date_str));

View File

@@ -0,0 +1,82 @@
-- =====================================================
-- MIGRATION SCRIPT: Pengajuan Izin/Sakit Feature
-- Run this script on your MySQL database server
-- =====================================================
-- 1. Add phone column to staff_users table (for Guru BK WhatsApp notification)
ALTER TABLE staff_users
ADD COLUMN IF NOT EXISTS phone VARCHAR(20) AFTER role;
-- Alternative syntax if your MySQL version doesn't support IF NOT EXISTS:
-- First check if column exists, if not add it
-- SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'staff_users' AND COLUMN_NAME = 'phone');
-- SET @sql := IF(@exist = 0, 'ALTER TABLE staff_users ADD COLUMN phone VARCHAR(20) AFTER role', 'SELECT "Column already exists"');
-- PREPARE stmt FROM @sql;
-- EXECUTE stmt;
-- 2. Update attendance status ENUM to include SAKIT and IZIN
ALTER TABLE attendance
MODIFY COLUMN status ENUM('PRESENT', 'LATE', 'REGISTRATION', 'ALFA', 'SAKIT', 'IZIN') DEFAULT 'PRESENT';
-- 3. Create leave_requests table (Pengajuan Izin/Sakit dari Siswa)
CREATE TABLE IF NOT EXISTS leave_requests (
id VARCHAR(36) PRIMARY KEY,
student_id VARCHAR(36) NOT NULL COMMENT 'ID siswa yang mengajukan',
student_name VARCHAR(255) NOT NULL COMMENT 'Nama siswa',
student_nis VARCHAR(50) COMMENT 'NIS siswa',
student_class VARCHAR(100) COMMENT 'Kelas siswa',
request_type ENUM('SAKIT', 'IZIN') NOT NULL COMMENT 'Jenis pengajuan',
request_date VARCHAR(20) NOT NULL COMMENT 'Tanggal izin (YYYY-MM-DD)',
reason TEXT NOT NULL COMMENT 'Alasan pengajuan',
photo_evidence LONGTEXT COMMENT 'Bukti foto (base64 atau URL)',
status ENUM('PENDING', 'APPROVED', 'REJECTED') DEFAULT 'PENDING' COMMENT 'Status pengajuan',
reviewed_by VARCHAR(36) COMMENT 'ID Guru BK yang mereview',
reviewed_by_name VARCHAR(255) COMMENT 'Nama Guru BK yang mereview',
reviewed_at TIMESTAMP NULL COMMENT 'Waktu review',
rejection_reason TEXT COMMENT 'Alasan penolakan (wajib jika status REJECTED)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Waktu pengajuan dibuat',
INDEX idx_student (student_id),
INDEX idx_status (status),
INDEX idx_date (request_date),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. Add Fonnte Token setting if not exists
INSERT IGNORE INTO settings (setting_key, setting_value)
VALUES ('FONNTE_TOKEN', '');
-- =====================================================
-- VERIFICATION QUERIES (Run to check if migration success)
-- =====================================================
-- Check staff_users has phone column:
-- DESCRIBE staff_users;
-- Check leave_requests table exists:
-- SHOW TABLES LIKE 'leave_requests';
-- Check attendance status enum:
-- SHOW COLUMNS FROM attendance LIKE 'status';
-- =====================================================
-- SAMPLE DATA FOR TESTING (Optional)
-- =====================================================
-- Create a sample Guru BK account with phone (change password hash as needed)
-- INSERT INTO staff_users (id, username, password, name, role, phone, is_active)
-- VALUES (
-- UUID(),
-- 'gurubk1',
-- SHA2('password123', 256), -- Password: password123
-- 'Guru BK Test',
-- 'GURU_BK',
-- '628123456789', -- WhatsApp number
-- TRUE
-- );
-- =====================================================
-- NOTES:
-- - Nomor WhatsApp harus format: 628xxxxxxxxxx (tanpa + atau 0)
-- - Fonnte Token didapat dari dashboard fonnte.com
-- - Guru BK bisa lebih dari 1, semua akan menerima notifikasi WA
-- =====================================================

View File

@@ -0,0 +1,86 @@
-- =====================================================
-- MIGRATION SCRIPT v2: Compatible Version
-- For MySQL 5.7+ / MariaDB 10.2+
-- =====================================================
-- =====================================================
-- STEP 1: Add phone column to staff_users
-- =====================================================
-- Check and add phone column (run this separately if error)
SET @dbname = DATABASE();
SET @tablename = 'staff_users';
SET @columnname = 'phone';
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
'SELECT "Column phone already exists in staff_users"',
'ALTER TABLE staff_users ADD COLUMN phone VARCHAR(20) AFTER role'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- =====================================================
-- STEP 2: Update attendance status ENUM
-- =====================================================
ALTER TABLE attendance
MODIFY COLUMN status ENUM('PRESENT', 'LATE', 'REGISTRATION', 'ALFA', 'SAKIT', 'IZIN') DEFAULT 'PRESENT';
-- =====================================================
-- STEP 3: Create leave_requests table
-- =====================================================
CREATE TABLE IF NOT EXISTS leave_requests (
id VARCHAR(36) NOT NULL,
student_id VARCHAR(36) NOT NULL,
student_name VARCHAR(255) NOT NULL,
student_nis VARCHAR(50) DEFAULT NULL,
student_class VARCHAR(100) DEFAULT NULL,
request_type ENUM('SAKIT', 'IZIN') NOT NULL,
request_date VARCHAR(20) NOT NULL,
reason TEXT NOT NULL,
photo_evidence LONGTEXT,
status ENUM('PENDING', 'APPROVED', 'REJECTED') DEFAULT 'PENDING',
reviewed_by VARCHAR(36) DEFAULT NULL,
reviewed_by_name VARCHAR(255) DEFAULT NULL,
reviewed_at TIMESTAMP NULL DEFAULT NULL,
rejection_reason TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_student (student_id),
KEY idx_status (status),
KEY idx_date (request_date),
KEY idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =====================================================
-- STEP 4: Ensure Fonnte Token setting exists
-- =====================================================
INSERT INTO settings (setting_key, setting_value)
SELECT 'FONNTE_TOKEN', ''
WHERE NOT EXISTS (
SELECT 1 FROM settings WHERE setting_key = 'FONNTE_TOKEN'
);
-- =====================================================
-- VERIFICATION - Run these to confirm success
-- =====================================================
SELECT 'Checking staff_users phone column...' AS step;
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'staff_users'
AND COLUMN_NAME = 'phone';
SELECT 'Checking leave_requests table...' AS step;
SELECT COUNT(*) as table_exists FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'leave_requests';
SELECT 'Checking attendance status enum...' AS step;
SHOW COLUMNS FROM attendance LIKE 'status';
SELECT 'Migration completed successfully!' AS result;

5342
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Executable file
View File

@@ -0,0 +1,43 @@
{
"name": "absensi-siswa",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"start": "node server.js"
},
"dependencies": {
"@google/genai": "*",
"axios": "^1.6.7",
"cors": "^2.8.5",
"date-fns": "^3.3.1",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"lucide-react": "^0.344.0",
"mysql2": "^3.9.2",
"node-cron": "^4.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"react-webcam": "^7.2.0",
"recharts": "^2.12.2",
"uuid": "^9.0.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^20.11.24",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.4"
}
}

123
server.js Executable file
View File

@@ -0,0 +1,123 @@
// Main Server - MySQL Backend for Absensi Siswa
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
// Import database and schema
import pool, { testConnection } from './backend/db.js';
import createTables from './backend/schema.js';
// Import routes
import usersRoutes from './backend/routes/users.js';
import attendanceRoutes from './backend/routes/attendance.js';
import registrationsRoutes from './backend/routes/registrations.js';
import settingsRoutes from './backend/routes/settings.js';
import authRoutes from './backend/routes/auth.js';
import staffRoutes from './backend/routes/staff.js';
import leaveRequestsRoutes from './backend/routes/leaveRequests.js';
import cron from 'node-cron';
import { runAutoRekapAlfa } from './backend/routes/attendance.js';
// Define __dirname for ES Modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const port = process.env.PORT || 3010;
// Middleware
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// API Routes
app.use('/api/users', usersRoutes);
app.use('/api/attendance', attendanceRoutes);
app.use('/api/registrations', registrationsRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/staff', staffRoutes);
app.use('/api/leave-requests', leaveRequestsRoutes);
// Health check endpoint
app.get('/api/health', async (req, res) => {
try {
// Ping database
await pool.query('SELECT 1');
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
database: 'connected',
dbName: process.env.DB_NAME,
port: port
});
} catch (error) {
console.error('Health Check Failed:', error);
res.status(500).json({
status: 'error',
database: 'disconnected',
error: error.message
});
}
});
// Serve static files from the 'dist' directory (Vite build output)
app.use(express.static(path.join(__dirname, 'dist')));
// Handle SPA routing: return index.html for any unknown routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
// Initialize database and start server
const startServer = async () => {
try {
// 1. Test database connection
await testConnection();
// 2. Initialize database schema
await createTables();
// 3. Start server
app.listen(port, async () => {
console.log(`\n🚀 Server is running on port ${port}`);
console.log(`📍 Local: http://localhost:${port}`);
console.log(`📊 API: http://localhost:${port}/api/health`);
console.log(`🗃️ Database: ${process.env.DB_NAME}@${process.env.DB_HOST}`);
// 4. Initialize Scheduler (Cron Job) - Read time from database
try {
const pool = (await import('./backend/db.js')).default;
const [timeRow] = await pool.query("SELECT setting_value FROM settings WHERE setting_key = 'AUTO_REKAP_ALFA_TIME'");
const rekapTime = timeRow.length > 0 ? timeRow[0].setting_value : '19:00';
const [hour, minute] = rekapTime.split(':');
cron.schedule(`${minute} ${hour} * * *`, async () => {
console.log(`⏰ [Cron] Running scheduled Alfa rekap at ${rekapTime}...`);
await runAutoRekapAlfa();
}, {
timezone: "Asia/Makassar" // WITA
});
console.log(`⏰ Scheduler initialized (Auto-Rekap Alfa at ${rekapTime} WITA)`);
} catch (cronError) {
console.error('⏰ Scheduler initialization failed:', cronError.message);
}
});
} catch (error) {
console.error('❌ Server startup failed:', error.message);
process.exit(1);
}
};
startServer();

118
types.ts Executable file
View File

@@ -0,0 +1,118 @@
export enum UserRole {
ADMIN = 'ADMIN',
STUDENT = 'STUDENT',
TEACHER = 'TEACHER',
GURU_BK = 'GURU_BK'
}
export interface User {
id: string;
name: string;
nis?: string; // Student ID
className?: string; // Class/Grade e.g. "XII RPL 1"
shift?: 'PAGI' | 'SIANG'; // Shift assignment
role: UserRole;
registeredFace?: string; // Base64 or URL of registered face photo
faceDescriptor?: string; // JSON serialized 128-d face descriptor for local comparison
createdAt: number;
isSetup?: boolean; // Flag for first-time admin setup
}
export interface StaffUser {
id: string;
username: string;
name: string;
role: 'ADMIN' | 'TEACHER' | 'GURU_BK';
phone?: string;
assignedClasses?: string[];
is_active: boolean;
created_at?: string;
}
export interface LeaveRequest {
id: string;
studentId: string;
studentName: string;
studentNis?: string;
studentClass?: string;
requestType: 'SAKIT' | 'IZIN' | 'DISPEN';
requestDate: string;
reason: string;
photoEvidence?: string;
status: 'PENDING' | 'APPROVED' | 'REJECTED';
reviewedBy?: string;
reviewedByName?: string;
reviewedAt?: string;
rejectionReason?: string;
createdAt?: string;
}
export interface AttendanceRecord {
id: string;
userId: string;
userName: string;
nis?: string;
className?: string;
timestamp: number;
dateStr: string; // YYYY-MM-DD
timeStr: string; // HH:MM
location: {
lat: number;
lng: number;
distance: number;
};
photoEvidence: string; // Base64
status: 'PRESENT' | 'LATE' | 'REGISTRATION' | 'ALFA' | 'SAKIT' | 'IZIN' | 'DISPEN'; // Added DISPEN
aiVerification?: string;
parentPhone?: string; // Added field for Parent/Guardian Phone
}
export interface AppSettings {
schoolName: string;
schoolLat: number;
schoolLng: number;
allowedRadiusMeters: number;
// Double Shift Configuration (Now using HH:mm strings)
morningStart: string; // e.g., "07:00"
morningEnd: string; // e.g., "12:00"
afternoonStart: string; // e.g., "12:30"
afternoonEnd: string; // e.g., "16:00"
allowedDays: number[]; // Legacy support (0=Sunday, etc)
activeDates: string[]; // Specific dates e.g., ['2024-03-01', '2024-03-02']
googleScriptUrl?: string; // URL for Google Apps Script Web App
fonnteToken?: string; // Fonnte WhatsApp API Token
// New Periodical Settings
availableClasses?: string[]; // List of classes e.g. ["X-1", "X-2"]
semester?: 'Ganjil' | 'Genap';
academicYear?: string; // e.g. "2023/2024"
// Security & Accuracy
faceMatchThreshold?: number; // 0.1 - 0.7 (Lower = Stricter, Default 0.45)
// Auto-Rekap Alfa Schedule
autoRekapAlfaTime?: string; // e.g., "19:00"
}
export const DEFAULT_SETTINGS: AppSettings = {
schoolName: 'SMA Negeri 1 Abiansemal',
schoolLat: -8.5107893,
schoolLng: 115.2142912,
allowedRadiusMeters: 100,
// Default Shifts (Updated format from screenshot)
morningStart: "07:00",
morningEnd: "12:00",
afternoonStart: "12:30",
afternoonEnd: "16:00",
allowedDays: [1, 2, 3, 4, 5, 6], // Mon-Sat
activeDates: [],
academicYear: '2023/2024',
semester: 'Ganjil',
googleScriptUrl: 'https://script.google.com/macros/s/AKfycbzYZbBdDzfXX80EM-1fTl35r0xWJCXeDROk0tjZYd-TtpH74uVTjMYLY1qy7QUkTM9_aw/exec',
faceMatchThreshold: 0.45,
autoRekapAlfaTime: '19:00'
};