import express from 'express'; import path from 'path'; import cors from 'cors'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; import bcrypt from 'bcryptjs'; import pool, { initDatabase } from './server/db.js'; dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3006; // Middleware app.use(cors()); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); // Helper: Convert camelCase to snake_case const camelToSnake = (str) => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); const snakeToCamel = (str) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); // Helper: Convert object keys const convertKeysToSnake = (obj) => { const result = {}; for (const key in obj) { result[camelToSnake(key)] = obj[key]; } return result; }; const convertKeysToCamel = (obj) => { const result = {}; for (const key in obj) { result[snakeToCamel(key)] = obj[key]; } return result; }; // ==================== AUTH API ==================== // Login app.post('/api/auth/login', async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ success: false, message: 'Username dan password diperlukan' }); } const [users] = await pool.query('SELECT * FROM users WHERE username = ?', [username]); if (users.length === 0) { return res.status(401).json({ success: false, message: 'Username atau password salah' }); } const user = users[0]; const isValid = await bcrypt.compare(password, user.password); if (!isValid) { return res.status(401).json({ success: false, message: 'Username atau password salah' }); } res.json({ success: true, user: { id: user.id, username: user.username, fullName: user.full_name, role: user.role } }); } catch (error) { console.error('Login error:', error); res.status(500).json({ success: false, message: 'Terjadi kesalahan server' }); } }); // ==================== STUDENTS API ==================== // Get all students app.get('/api/students', async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM students ORDER BY nama ASC'); const students = rows.map(row => { const student = convertKeysToCamel(row); // Normalize field names for frontend compatibility return { ...student, id: String(student.id), fotoDiterimaUrl: student.fotoDiterimaUrl || '', fotoLulusUrl: student.fotoLulusUrl || '', }; }); res.json(students); } catch (error) { console.error('Error fetching students:', error); res.status(500).json({ success: false, message: 'Gagal mengambil data siswa' }); } }); // Get single student app.get('/api/students/:id', async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM students WHERE id = ?', [req.params.id]); if (rows.length === 0) { return res.status(404).json({ success: false, message: 'Siswa tidak ditemukan' }); } const student = convertKeysToCamel(rows[0]); student.id = String(student.id); res.json(student); } catch (error) { console.error('Error fetching student:', error); res.status(500).json({ success: false, message: 'Gagal mengambil data siswa' }); } }); // Create/Update student app.post('/api/students', async (req, res) => { try { const studentData = req.body; const { id, ...data } = studentData; // Convert keys to snake_case for database const dbData = convertKeysToSnake(data); // Remove undefined/null values and clean up data const cleanData = {}; for (const [key, value] of Object.entries(dbData)) { if (value !== undefined && value !== null) { cleanData[key] = value; } } if (id && id !== '' && !isNaN(parseInt(id))) { // Update existing student const fields = Object.keys(cleanData); const values = Object.values(cleanData); const setClause = fields.map(f => `${f} = ?`).join(', '); await pool.query(`UPDATE students SET ${setClause} WHERE id = ?`, [...values, parseInt(id)]); res.json({ success: true, message: 'Data siswa berhasil diupdate', id: parseInt(id) }); } else { // Insert new student const fields = Object.keys(cleanData); const values = Object.values(cleanData); const placeholders = fields.map(() => '?').join(', '); const [result] = await pool.query( `INSERT INTO students (${fields.join(', ')}) VALUES (${placeholders})`, values ); res.json({ success: true, message: 'Data siswa berhasil disimpan', id: result.insertId }); } } catch (error) { console.error('Error saving student:', error); res.status(500).json({ success: false, message: 'Gagal menyimpan data siswa: ' + error.message }); } }); // Delete student app.delete('/api/students/:id', async (req, res) => { try { const [result] = await pool.query('DELETE FROM students WHERE id = ?', [req.params.id]); if (result.affectedRows === 0) { return res.status(404).json({ success: false, message: 'Siswa tidak ditemukan' }); } res.json({ success: true, message: 'Data siswa berhasil dihapus' }); } catch (error) { console.error('Error deleting student:', error); res.status(500).json({ success: false, message: 'Gagal menghapus data siswa' }); } }); // ==================== SETTINGS API ==================== // Get settings app.get('/api/settings', async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM settings WHERE setting_key = ?', ['app_settings']); if (rows.length === 0) { res.json({ schoolName: 'SMA NEGERI 1 ABIANSEMAL', logoUrl: 'https://iili.io/KN7pUR2.png', faviconUrl: '', tahunAjaran: '2025/2026', margins: { top: 20, right: 20, bottom: 20, left: 20 } }); } else { res.json(JSON.parse(rows[0].setting_value)); } } catch (error) { console.error('Error fetching settings:', error); res.status(500).json({ success: false, message: 'Gagal mengambil pengaturan' }); } }); // Save settings app.post('/api/settings', async (req, res) => { try { const settings = req.body; const [existing] = await pool.query('SELECT * FROM settings WHERE setting_key = ?', ['app_settings']); if (existing.length === 0) { await pool.query('INSERT INTO settings (setting_key, setting_value) VALUES (?, ?)', ['app_settings', JSON.stringify(settings)]); } else { await pool.query('UPDATE settings SET setting_value = ? WHERE setting_key = ?', [JSON.stringify(settings), 'app_settings']); } res.json({ success: true, message: 'Pengaturan berhasil disimpan' }); } catch (error) { console.error('Error saving settings:', error); res.status(500).json({ success: false, message: 'Gagal menyimpan pengaturan' }); } }); // ==================== STATISTICS API ==================== // Get student statistics app.get('/api/stats', async (req, res) => { try { const [total] = await pool.query('SELECT COUNT(*) as count FROM students'); const [laki] = await pool.query("SELECT COUNT(*) as count FROM students WHERE jenis_kelamin = 'L'"); const [perempuan] = await pool.query("SELECT COUNT(*) as count FROM students WHERE jenis_kelamin = 'P'"); const [byTahun] = await pool.query( 'SELECT tahun_ajaran, COUNT(*) as count FROM students GROUP BY tahun_ajaran ORDER BY tahun_ajaran DESC' ); res.json({ total: total[0].count, laki: laki[0].count, perempuan: perempuan[0].count, byTahunAjaran: byTahun }); } catch (error) { console.error('Error fetching stats:', error); res.status(500).json({ success: false, message: 'Gagal mengambil statistik' }); } }); // ==================== LEGER API ==================== // Get all legers for a student app.get('/api/students/:id/legers', async (req, res) => { try { const studentId = req.params.id; const [rows] = await pool.query( 'SELECT id, student_id, nis, nama, semester, file_name, file_size, uploaded_at, updated_at FROM student_legers WHERE student_id = ? ORDER BY semester ASC', [studentId] ); res.json(rows); } catch (error) { console.error('Error fetching legers:', error); res.status(500).json({ success: false, message: 'Gagal mengambil data leger' }); } }); // Get single leger with file data (for download/view) app.get('/api/students/:id/legers/:semester', async (req, res) => { try { const { id, semester } = req.params; const [rows] = await pool.query( 'SELECT * FROM student_legers WHERE student_id = ? AND semester = ?', [id, semester] ); if (rows.length === 0) { return res.status(404).json({ success: false, message: 'Leger tidak ditemukan' }); } res.json(rows[0]); } catch (error) { console.error('Error fetching leger:', error); res.status(500).json({ success: false, message: 'Gagal mengambil data leger' }); } }); // Upload/Update leger app.post('/api/students/:id/legers', async (req, res) => { try { const studentId = req.params.id; const { semester, fileName, fileData, fileSize, nis, nama } = req.body; if (!semester || !fileName || !fileData) { return res.status(400).json({ success: false, message: 'Data tidak lengkap' }); } // Check if leger already exists for this student and semester const [existing] = await pool.query( 'SELECT id FROM student_legers WHERE student_id = ? AND semester = ?', [studentId, semester] ); if (existing.length > 0) { // Update existing await pool.query( 'UPDATE student_legers SET file_name = ?, file_data = ?, file_size = ?, nis = ?, nama = ?, updated_at = NOW() WHERE student_id = ? AND semester = ?', [fileName, fileData, fileSize, nis, nama, studentId, semester] ); res.json({ success: true, message: `Leger semester ${semester} berhasil diperbarui` }); } else { // Insert new await pool.query( 'INSERT INTO student_legers (student_id, nis, nama, semester, file_name, file_data, file_size) VALUES (?, ?, ?, ?, ?, ?, ?)', [studentId, nis, nama, semester, fileName, fileData, fileSize] ); res.json({ success: true, message: `Leger semester ${semester} berhasil diupload` }); } } catch (error) { console.error('Error uploading leger:', error); res.status(500).json({ success: false, message: 'Gagal mengupload leger: ' + error.message }); } }); // Delete leger app.delete('/api/students/:id/legers/:semester', async (req, res) => { try { const { id, semester } = req.params; const [result] = await pool.query( 'DELETE FROM student_legers WHERE student_id = ? AND semester = ?', [id, semester] ); if (result.affectedRows === 0) { return res.status(404).json({ success: false, message: 'Leger tidak ditemukan' }); } res.json({ success: true, message: `Leger semester ${semester} berhasil dihapus` }); } catch (error) { console.error('Error deleting leger:', error); res.status(500).json({ success: false, message: 'Gagal menghapus leger' }); } }); // Export all legers for a student as JSON (for backup) app.get('/api/students/:id/legers/export/all', async (req, res) => { try { const studentId = req.params.id; const [rows] = await pool.query( 'SELECT * FROM student_legers WHERE student_id = ? ORDER BY semester ASC', [studentId] ); res.json({ success: true, studentId, legers: rows }); } catch (error) { console.error('Error exporting legers:', error); res.status(500).json({ success: false, message: 'Gagal mengekspor leger' }); } }); // ==================== DOCUMENTS API ==================== // Get all documents for a student (optionally filter by type) app.get('/api/students/:id/documents', async (req, res) => { try { const studentId = req.params.id; const docType = req.query.type; let query = 'SELECT id, student_id, nis, nama, doc_type, doc_name, file_name, file_size, uploaded_at, updated_at FROM student_documents WHERE student_id = ?'; const params = [studentId]; if (docType) { query += ' AND doc_type = ?'; params.push(docType); } query += ' ORDER BY uploaded_at DESC'; const [rows] = await pool.query(query, params); res.json(rows); } catch (error) { console.error('Error fetching documents:', error); res.status(500).json({ success: false, message: 'Gagal mengambil data dokumen' }); } }); // Get single document with file data app.get('/api/students/:id/documents/:docId', async (req, res) => { try { const { id, docId } = req.params; const [rows] = await pool.query( 'SELECT * FROM student_documents WHERE student_id = ? AND id = ?', [id, docId] ); if (rows.length === 0) { return res.status(404).json({ success: false, message: 'Dokumen tidak ditemukan' }); } res.json(rows[0]); } catch (error) { console.error('Error fetching document:', error); res.status(500).json({ success: false, message: 'Gagal mengambil data dokumen' }); } }); // Upload document app.post('/api/students/:id/documents', async (req, res) => { try { const studentId = req.params.id; const { docType, docName, fileName, fileData, fileSize, nis, nama } = req.body; if (!docType || !fileName || !fileData) { return res.status(400).json({ success: false, message: 'Data tidak lengkap' }); } // For ijazah, check if one already exists (only allow 1 ijazah per student) if (docType === 'ijazah') { const [existing] = await pool.query( 'SELECT id FROM student_documents WHERE student_id = ? AND doc_type = ?', [studentId, 'ijazah'] ); if (existing.length > 0) { // Update existing ijazah await pool.query( 'UPDATE student_documents SET doc_name = ?, file_name = ?, file_data = ?, file_size = ?, nis = ?, nama = ?, updated_at = NOW() WHERE student_id = ? AND doc_type = ?', [docName || 'Ijazah', fileName, fileData, fileSize, nis, nama, studentId, 'ijazah'] ); return res.json({ success: true, message: 'Ijazah berhasil diperbarui' }); } } // Insert new document await pool.query( 'INSERT INTO student_documents (student_id, nis, nama, doc_type, doc_name, file_name, file_data, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [studentId, nis, nama, docType, docName || (docType === 'ijazah' ? 'Ijazah' : 'Sertifikat'), fileName, fileData, fileSize] ); const docLabel = docType === 'ijazah' ? 'Ijazah' : 'Sertifikat'; res.json({ success: true, message: `${docLabel} berhasil diupload` }); } catch (error) { console.error('Error uploading document:', error); res.status(500).json({ success: false, message: 'Gagal mengupload dokumen: ' + error.message }); } }); // Delete document app.delete('/api/students/:id/documents/:docId', async (req, res) => { try { const { id, docId } = req.params; const [result] = await pool.query( 'DELETE FROM student_documents WHERE student_id = ? AND id = ?', [id, docId] ); if (result.affectedRows === 0) { return res.status(404).json({ success: false, message: 'Dokumen tidak ditemukan' }); } res.json({ success: true, message: 'Dokumen berhasil dihapus' }); } catch (error) { console.error('Error deleting document:', error); res.status(500).json({ success: false, message: 'Gagal menghapus dokumen' }); } }); // ==================== PUBLIC STUDENT ACCESS API ==================== // Search student by name and birth date (Public) app.post('/api/public/find-student', async (req, res) => { try { const { nama, tanggalLahir } = req.body; if (!nama || !tanggalLahir) { return res.status(400).json({ success: false, message: 'Nama dan Tanggal Lahir harus diisi' }); } console.log(`Searching for: Name="${nama}", DOB="${tanggalLahir}"`); // Helper to format date from YYYY-MM-DD to DD/MM/YYYY const formatDateDMY = (dateStr) => { if (!dateStr) return ''; const parts = dateStr.split('-'); if (parts.length === 3) { return `${parts[2]}/${parts[1]}/${parts[0]}`; } return dateStr; }; // Helper to format date from YYYY-MM-DD to DD-MM-YYYY const formatDateDMYHyphen = (dateStr) => { if (!dateStr) return ''; const parts = dateStr.split('-'); if (parts.length === 3) { return `${parts[2]}-${parts[1]}-${parts[0]}`; } return dateStr; }; const tanggalLahirDMY = formatDateDMY(tanggalLahir); const tanggalLahirDMYHyphen = formatDateDMYHyphen(tanggalLahir); // Try multiple date formats // Robust search: Remove all spaces for name comparison to handle typo/spacing issues // AND try multiple date formats let query = ` SELECT id, nis, nama, tahun_ajaran, tanggal_lahir FROM students WHERE LOWER(REPLACE(nama, ' ', '')) = LOWER(REPLACE(?, ' ', '')) AND ( tanggal_lahir = ? OR tanggal_lahir = ? OR tanggal_lahir = ? ) `; let params = [nama, tanggalLahir, tanggalLahirDMY, tanggalLahirDMYHyphen]; const [rows] = await pool.query(query, params); if (rows.length === 0) { console.log('Search returned no results'); // Check for partial matches for debugging // Name match (ignoring spaces) const [nameCheck] = await pool.query( "SELECT count(*) as count FROM students WHERE LOWER(REPLACE(nama, ' ', '')) = LOWER(REPLACE(?, ' ', ''))", [nama] ); // Date match (any format) const [dateCheck] = await pool.query( "SELECT count(*) as count FROM students WHERE tanggal_lahir = ? OR tanggal_lahir = ? OR tanggal_lahir = ?", [tanggalLahir, tanggalLahirDMY, tanggalLahirDMYHyphen] ); const debugInfo = { nameMatches: nameCheck[0].count, dateMatches: dateCheck[0].count, searchedName: nama, searchedDate: tanggalLahir, searchedDateDMY: tanggalLahirDMY, searchedDateDMYHyphen: tanggalLahirDMYHyphen }; console.log('Debug info:', debugInfo); let message = 'Data tidak ditemukan.'; if (nameCheck[0].count > 0 && dateCheck[0].count === 0) { message = 'Nama siswa ditemukan, tetapi tanggal lahir tidak cocok. Pastikan tanggal lahir sesuai data sekolah.'; } else if (nameCheck[0].count === 0 && dateCheck[0].count > 0) { message = 'Tanggal lahir valid, tetapi nama tidak ditemukan. Periksa penulisan nama (sesuai Ijazah/Akta).'; } else { message = 'Data siswa tidak ditemukan. Periksa Nama dan Tanggal Lahir.'; } return res.status(404).json({ success: false, message, debug: debugInfo }); } // Jika ditemukan, kembalikan data minimal (NIS & ID) untuk akses selanjutnya res.json({ success: true, student: convertKeysToCamel(rows[0]) }); } catch (error) { console.error('Public search error:', error); res.status(500).json({ success: false, message: 'Terjadi kesalahan pada server' }); } }); // Health Check app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), env: process.env.NODE_ENV, db_name: process.env.DB_NAME }); }); // Get student detail for public view (requires student ID) app.get('/api/public/students/:id', async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM students WHERE id = ?', [req.params.id]); if (rows.length === 0) return res.status(404).json({ message: 'Siswa tidak ditemukan' }); res.json(convertKeysToCamel(rows[0])); } catch (error) { res.status(500).json({ message: error.message }); } }); // ==================== ACADEMIC YEARS API ==================== // Get all years app.get('/api/years', async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM academic_years ORDER BY year_name DESC'); res.json(rows.map(convertKeysToCamel)); } catch (error) { console.error('Error fetching years:', error); res.status(500).json({ success: false, message: 'Gagal mengambil data tahun ajaran' }); } }); // Add year app.post('/api/years', async (req, res) => { try { const { yearName } = req.body; await pool.query('INSERT INTO academic_years (year_name) VALUES (?)', [yearName]); res.json({ success: true, message: 'Tahun ajaran berhasil ditambah' }); } catch (error) { console.error('Error adding year:', error); res.status(500).json({ success: false, message: 'Gagal menambah tahun ajaran' }); } }); // Set active year app.post('/api/years/:id/active', async (req, res) => { try { const yearId = req.params.id; // Set all to false await pool.query('UPDATE academic_years SET is_active = FALSE'); // Set target to true await pool.query('UPDATE academic_years SET is_active = TRUE WHERE id = ?', [yearId]); // Also sync to global settings for backward compatibility/quick access const [yearRows] = await pool.query('SELECT year_name FROM academic_years WHERE id = ?', [yearId]); if (yearRows.length > 0) { const newYear = yearRows[0].year_name; const [settingsRows] = await pool.query('SELECT * FROM settings WHERE setting_key = ?', ['app_settings']); if (settingsRows.length > 0) { const currentSettings = JSON.parse(settingsRows[0].setting_value); currentSettings.tahunAjaran = newYear; await pool.query('UPDATE settings SET setting_value = ? WHERE setting_key = ?', [JSON.stringify(currentSettings), 'app_settings']); } } res.json({ success: true, message: 'Tahun ajaran aktif berhasil diubah' }); } catch (error) { console.error('Error setting active year:', error); res.status(500).json({ success: false, message: 'Gagal mengubah tahun ajaran aktif' }); } }); // Delete year app.delete('/api/years/:id', async (req, res) => { try { const yearId = req.params.id; const [year] = await pool.query('SELECT is_active FROM academic_years WHERE id = ?', [yearId]); if (year.length > 0 && year[0].is_active) { return res.status(400).json({ success: false, message: 'Tidak dapat menghapus tahun ajaran yang sedang aktif' }); } await pool.query('DELETE FROM academic_years WHERE id = ?', [yearId]); res.json({ success: true, message: 'Tahun ajaran berhasil dihapus' }); } catch (error) { console.error('Error deleting year:', error); res.status(500).json({ success: false, message: 'Gagal menghapus tahun ajaran' }); } }); // ==================== USER MANAGEMENT API ==================== // Get all users app.get('/api/users', async (req, res) => { try { const [rows] = await pool.query('SELECT id, username, full_name, role, created_at FROM users ORDER BY full_name ASC'); res.json(rows.map(convertKeysToCamel)); } catch (error) { console.error('Error fetching users:', error); res.status(500).json({ success: false, message: 'Gagal mengambil data pengguna' }); } }); // Add new user app.post('/api/users', async (req, res) => { try { const { username, password, fullName, role } = req.body; if (!username || !password || !fullName || !role) { return res.status(400).json({ success: false, message: 'Semua data harus diisi' }); } const hashedPassword = await bcrypt.hash(password, 10); await pool.query( 'INSERT INTO users (username, password, full_name, role) VALUES (?, ?, ?, ?)', [username, hashedPassword, fullName, role] ); res.json({ success: true, message: 'Pengguna berhasil ditambahkan' }); } catch (error) { console.error('Error adding user:', error); if (error.code === 'ER_DUP_ENTRY') { res.status(400).json({ success: false, message: 'Username sudah digunakan' }); } else { res.status(500).json({ success: false, message: 'Gagal menambah pengguna' }); } } }); // Update user app.put('/api/users/:id', async (req, res) => { try { const userId = req.params.id; const { username, password, fullName, role } = req.body; if (!username || !fullName || !role) { return res.status(400).json({ success: false, message: 'Data nama, username dan role harus diisi' }); } // Check if username exists for OTHER users const [existing] = await pool.query('SELECT id FROM users WHERE username = ? AND id != ?', [username, userId]); if (existing.length > 0) { return res.status(400).json({ success: false, message: 'Username sudah digunakan oleh pengguna lain' }); } if (password && password.trim() !== '') { // Update with password const hashedPassword = await bcrypt.hash(password, 10); await pool.query( 'UPDATE users SET username = ?, password = ?, full_name = ?, role = ? WHERE id = ?', [username, hashedPassword, fullName, role, userId] ); } else { // Update without password await pool.query( 'UPDATE users SET username = ?, full_name = ?, role = ? WHERE id = ?', [username, fullName, role, userId] ); } res.json({ success: true, message: 'Data pengguna berhasil diperbarui' }); } catch (error) { console.error('Error updating user:', error); res.status(500).json({ success: false, message: 'Gagal memperbarui pengguna' }); } }); // Delete user app.delete('/api/users/:id', async (req, res) => { try { const userId = req.params.id; // Prevent deleting the last admin or the currently logged in user (would need a better check but for now simpler) const [userRows] = await pool.query('SELECT username FROM users WHERE id = ?', [userId]); if (userRows.length > 0 && userRows[0].username === 'Kesiswaan') { return res.status(400).json({ success: false, message: 'Account utama tidak dapat dihapus' }); } await pool.query('DELETE FROM users WHERE id = ?', [userId]); res.json({ success: true, message: 'Pengguna berhasil dihapus' }); } catch (error) { console.error('Error deleting user:', error); res.status(500).json({ success: false, message: 'Gagal menghapus pengguna' }); } }); // ==================== STATIC FILES ==================== // Serve static files from the dist directory app.use(express.static(path.join(__dirname, 'dist'))); // Handle SPA routing app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); // ==================== START SERVER ==================== const startServer = async () => { try { // Initialize database await initDatabase(); app.listen(PORT, '0.0.0.0', () => { console.log(`\nšŸš€ Server is running on http://localhost:${PORT}`); console.log(`šŸ“¦ Database: ${process.env.DB_NAME || 'db_bukuiduk'}`); console.log(`🌐 Environment: Node.js ${process.version}`); }); } catch (error) { console.error('āŒ Failed to start server:', error); process.exit(1); } }; startServer();