Initial commit with .gitignore
This commit is contained in:
129
.gitignore
vendored
Normal file
129
.gitignore
vendored
Normal 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
BIN
backend/._schema.js
Executable file
Binary file not shown.
BIN
backend/._services
Executable file
BIN
backend/._services
Executable file
Binary file not shown.
36
backend/db.js
Executable file
36
backend/db.js
Executable 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
BIN
backend/routes/._attendance.js
Executable file
Binary file not shown.
BIN
backend/routes/._auth.js
Executable file
BIN
backend/routes/._auth.js
Executable file
Binary file not shown.
BIN
backend/routes/._leaveRequests.js
Executable file
BIN
backend/routes/._leaveRequests.js
Executable file
Binary file not shown.
BIN
backend/routes/._settings.js
Executable file
BIN
backend/routes/._settings.js
Executable file
Binary file not shown.
BIN
backend/routes/._staff.js
Executable file
BIN
backend/routes/._staff.js
Executable file
Binary file not shown.
BIN
backend/routes/._users.js
Executable file
BIN
backend/routes/._users.js
Executable file
Binary file not shown.
239
backend/routes/ai.js
Executable file
239
backend/routes/ai.js
Executable 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
443
backend/routes/attendance.js
Executable 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
140
backend/routes/auth.js
Executable 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
293
backend/routes/leaveRequests.js
Executable 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
62
backend/routes/registrations.js
Executable 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
87
backend/routes/settings.js
Executable 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
207
backend/routes/staff.js
Executable 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
528
backend/routes/users.js
Executable 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
266
backend/schema.js
Executable 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
BIN
backend/services/._fonnteQueue.js
Executable file
Binary file not shown.
258
backend/services/fonnteQueue.js
Executable file
258
backend/services/fonnteQueue.js
Executable 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 };
|
||||
BIN
database/._fix_alfa_rollback.sql
Normal file
BIN
database/._fix_alfa_rollback.sql
Normal file
Binary file not shown.
BIN
database/._migration_daily_index.sql
Executable file
BIN
database/._migration_daily_index.sql
Executable file
Binary file not shown.
BIN
database/._migration_leave_requests.sql
Executable file
BIN
database/._migration_leave_requests.sql
Executable file
Binary file not shown.
BIN
database/._migration_v2_compatible.sql
Executable file
BIN
database/._migration_v2_compatible.sql
Executable file
Binary file not shown.
48
database/fix_alfa_rollback.sql
Normal file
48
database/fix_alfa_rollback.sql
Normal 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;
|
||||
25
database/migration_daily_index.sql
Executable file
25
database/migration_daily_index.sql
Executable 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));
|
||||
82
database/migration_leave_requests.sql
Executable file
82
database/migration_leave_requests.sql
Executable 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
|
||||
-- =====================================================
|
||||
86
database/migration_v2_compatible.sql
Executable file
86
database/migration_v2_compatible.sql
Executable 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
5342
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Executable file
43
package.json
Executable 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
123
server.js
Executable 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
118
types.ts
Executable 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'
|
||||
};
|
||||
Reference in New Issue
Block a user