diff --git a/backend/managerRoutes.js b/backend/managerRoutes.js new file mode 100644 index 0000000..bd331be --- /dev/null +++ b/backend/managerRoutes.js @@ -0,0 +1,635 @@ +import express from 'express'; +import { Parser } from 'json2csv'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { v4 as uuidv4 } from 'uuid'; + +export default function(db) { + const router = express.Router(); + + // Middleware to authenticate and authorize managers + const authenticateJWT = (req, res, next) => { + const authHeader = req.headers.authorization; + if (authHeader) { + const token = authHeader.split(' ')[1]; + jwt.verify(token, process.env.JWT_SECRET, (err, user) => { + if (err || user.role !== 'manager') { + return res.status(403).json({ message: 'Forbidden' }); + } + req.user = user; + next(); + }); + } else { + res.status(401).json({ message: 'Unauthorized' }); + } + }; + router.use(authenticateJWT); + + // --- START: Date Management Routes --- + router.get('/enabled-dates', async (req, res) => { + try { + const [rows] = await db.execute('SELECT YEAR(enabled_date) as year, MONTH(enabled_date) as month, DAY(enabled_date) as day FROM enabled_dates'); + // Format date safely using components from the database to avoid timezone shifts + const dates = rows.map(r => `${r.year}-${String(r.month).padStart(2, '0')}-${String(r.day).padStart(2, '0')}`); + res.json(dates); + } catch (error) { + console.error('Error fetching enabled dates:', error); + res.status(500).json({ message: 'Database error fetching enabled dates.' }); + } + }); + +// Definitive version using a dedicated database connection + router.post('/enabled-dates/update', async (req, res) => { + let connection; // Define connection here to ensure it's accessible in the 'finally' block + try { + const { datesToEnable, datesToDisable } = req.body; + + if (!Array.isArray(datesToEnable) || !Array.isArray(datesToDisable)) { + return res.status(400).json({ message: 'Invalid input format.' }); + } + + // 1. Get a single, dedicated connection from the pool + connection = await db.getConnection(); + + // 2. Process all deletions sequentially on the dedicated connection + for (const date of datesToDisable) { + await connection.execute('DELETE FROM enabled_dates WHERE enabled_date = ?', [date]); + } + + // 3. Process all insertions sequentially on the dedicated connection + for (const date of datesToEnable) { + await connection.execute('INSERT IGNORE INTO enabled_dates (enabled_date) VALUES (?)', [date]); + } + + res.status(200).json({ message: 'Work schedule updated successfully.' }); + + } catch (error) { + console.error('Error updating work schedule:', error); + res.status(500).json({ message: 'Database error during schedule update.' }); + } finally { + // 4. Ensure the dedicated connection is always released back to the pool + if (connection) { + connection.release(); + } + } + }); + // --- END: Date Management Routes --- + + // --- ATTENDANCE & REPORTING --- + + router.get('/failed-records', async (req, res) => { + try { + const { search = '', startDate, endDate } = req.query; + if (!startDate || !endDate) { + return res.status(400).json({ message: 'Start date and end date are required.' }); + } + + const searchTerm = `%${search}%`; + const params = [startDate, `${endDate} 23:59:59`]; + + let searchQuery = ''; + if (search) { + searchQuery = `AND (w.full_name LIKE ? OR w.department LIKE ?)`; + params.push(searchTerm, searchTerm); + } + + const query = ` + SELECT cr.worker_id, w.full_name, COUNT(*) as count + FROM clock_records cr + JOIN workers w ON cr.worker_id = w.id + WHERE cr.event_type = 'failed' + AND cr.timestamp BETWEEN ? AND ? + ${searchQuery} + GROUP BY cr.worker_id, w.full_name + ORDER BY count DESC + `; + + const [rows] = await db.execute(query, params); + res.json(rows); + } catch (error) { + console.error('Failed records summary error:', error); + res.status(500).json({ message: 'Database error fetching failed records summary.', details: error.message }); + } + }); + + router.get('/failed-records/details', async (req, res) => { + try { + const { workerId, startDate, endDate } = req.query; + if (!workerId || !startDate || !endDate) { + return res.status(400).json({ message: 'Worker ID, start date, and end date are required.' }); + } + + const query = ` + SELECT cr.id, cr.timestamp, cr.event_type, COALESCE(qc.name, 'N/A') as qrCodeUsedName, cr.notes + FROM clock_records cr + LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id + WHERE cr.worker_id = ? + AND cr.event_type = 'failed' + AND cr.timestamp BETWEEN ? AND ? + ORDER BY cr.timestamp DESC + `; + + const params = [workerId, startDate, `${endDate} 23:59:59`]; + const [rows] = await db.execute(query, params); + res.json(rows); + } catch (error) { + console.error('Failed records details error:', error); + res.status(500).json({ message: 'Database error fetching failed records details.', details: error.message }); + } + }); + + // GET attendance records with a modified query to avoid the MySQL 5.7 bug + router.get('/attendance-records/export-raw', async (req, res) => { + try { + const { workerIds, startDate, endDate } = req.query; + if (!startDate || !endDate) { + return res.status(400).json({ message: 'Start date and end date are required.' }); + } + + let workerIdClause = ''; + const params = [startDate, `${endDate} 23:59:59`]; + + if (workerIds) { + const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id)); + if (idsArray.length > 0) { + workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`; + } + } + + const query = ` + SELECT w.username, w.full_name, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qr_code_name, cr.notes + FROM clock_records cr + JOIN workers w ON cr.worker_id = w.id + LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id + WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause} + ORDER BY cr.timestamp DESC + `; + + const [rows] = await db.execute(query, params); + + const json2csvParser = new Parser({ fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] }); + const csv = json2csvParser.parse(rows); + res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_to_${endDate}.csv`).send(csv); + + } catch (error) { + console.error('Raw attendance export error:', error); + res.status(500).json({ message: 'Database error exporting raw attendance.', details: error.message }); + } + }); + + router.post('/add-record', authenticateJWT, async (req, res) => { + try { + const { workerId, eventType, timestamp, notes } = req.body + + if (!workerId || !eventType || !timestamp) { + return res + .status(400) + .json({ message: 'Worker ID, event type, and timestamp are required.' }) + } + + // Check last event to prevent adding a duplicate event type + const [lastEventRows] = await db.execute( + 'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', + [workerId], + ) + + if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) { + const status = eventType === 'clock_in' ? 'in' : 'out' + return res.status(409).json({ message: `Worker is already clocked ${status}.` }) + } + // --- THIS IS THE FIX --- + const sanitizedTimestamp = timestamp.replace('T', ' ') + + await db.execute( + 'INSERT INTO clock_records (worker_id, event_type, timestamp, notes, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, NULL, NULL, NULL)', + [workerId, eventType, sanitizedTimestamp, notes], + ) + + res.status(201).json({ message: 'Manual record added successfully.' }) + } catch (error) { + console.error('Add manual record error:', error) + res.status(500).json({ message: 'Database error adding manual record.' }) + } + }) + + router.get('/attendance-records/export', async (req, res) => { + try { + const { workerIds, startDate, endDate } = req.query; + if (!startDate || !endDate) { + return res.status(400).json({ message: 'Start date and end date are required.' }); + } + + let workerIdClause = ''; + const params = [startDate, `${endDate} 23:59:59`]; + + if (workerIds) { + const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id)); + if (idsArray.length > 0) { + workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`; + } + } + + const query = ` + SELECT cr.worker_id, w.username, w.full_name, cr.event_type, cr.timestamp + FROM clock_records cr + JOIN workers w ON cr.worker_id = w.id + WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause} + ORDER BY cr.worker_id, cr.timestamp ASC + `; + + const [rows] = await db.execute(query, params); + + const workHoursByWorkerAndDay = {}; + + rows.forEach(row => { + const day = new Date(row.timestamp).toISOString().split('T')[0]; + if (!workHoursByWorkerAndDay[row.worker_id]) { + workHoursByWorkerAndDay[row.worker_id] = { + username: row.username, + full_name: row.full_name, + days: {} + }; + } + if (!workHoursByWorkerAndDay[row.worker_id].days[day]) { + workHoursByWorkerAndDay[row.worker_id].days[day] = []; + } + workHoursByWorkerAndDay[row.worker_id].days[day].push({ + type: row.event_type, + time: new Date(row.timestamp) + }); + }); + + const csvData = []; + for (const workerId in workHoursByWorkerAndDay) { + const workerData = workHoursByWorkerAndDay[workerId]; + for (const day in workerData.days) { + const events = workerData.days[day]; + let dailyTotalSeconds = 0; + let lastClockIn = null; + + events.forEach(event => { + if (event.type === 'clock_in') { + lastClockIn = event.time; + } else if (event.type === 'clock_out' && lastClockIn) { + dailyTotalSeconds += (event.time - lastClockIn) / 1000; + lastClockIn = null; + } + }); + + csvData.push({ + username: workerData.username, + full_name: workerData.full_name, + date: day, + work_hours: (dailyTotalSeconds / 3600).toFixed(2) + }); + } + } + + const json2csvParser = new Parser({ fields: ['username', 'full_name', 'date', 'work_hours'] }); + const csv = json2csvParser.parse(csvData); + res.header('Content-Type', 'text/csv').attachment(`work_hours_${startDate}_to_${endDate}.csv`).send(csv); + + } catch (error) { + console.error('Work hours export error:', error); + res.status(500).json({ message: 'Database error exporting work hours.', details: error.message }); + } + }); + + router.get('/attendance-records', async (req, res) => { + try { + const { workerIds, startDate, endDate, format } = req.query; + if (!workerIds) { + return res.status(400).json({ message: 'Worker IDs are required.' }); + } + + // Ensure all IDs are numbers to prevent SQL injection. + const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id)); + if (idsArray.length === 0) { + return res.json([]); + } + + // --- MODIFICATION START --- + // Instead of using a '?' placeholder for the IN clause, we build it directly. + // This is safe because we have already sanitized idsArray to be only numbers. + // This change is intended to bypass the specific bug in your MySQL version. + const inClause = idsArray.join(','); + let query = ` + SELECT cr.id, w.full_name, cr.event_type, cr.timestamp, + COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName, + cr.latitude, cr.longitude, cr.notes + FROM clock_records cr + JOIN workers w ON cr.worker_id = w.id + LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id + WHERE cr.worker_id IN (${inClause})`; // Placeholder is replaced here + + const params = []; + // --- MODIFICATION END --- + + if (startDate && endDate) { + query += ' AND cr.timestamp BETWEEN ? AND ?'; + const endOfDay = new Date(endDate); + endOfDay.setHours(23, 59, 59, 999); + params.push(startDate, endOfDay); + } + query += ' ORDER BY w.full_name, cr.timestamp DESC'; + + const [rows] = await db.execute(query, params); + + if (format === 'csv') { + const json2csvParser = new Parser({ fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName', 'notes'] }); + const csv = json2csvParser.parse(rows); + res.header('Content-Type', 'text/csv').attachment('attendance.csv').send(csv); + } else { + res.json(rows); + } + } catch (error) { + console.error('Attendance records error:', error); + res.status(500).json({ message: 'Database error fetching attendance records.', details: error.message }); + } + }); + + + // --- All other manager routes remain the same --- + + // GET all workers with filtering and pagination + router.get('/workers', async (req, res) => { + try { + const { search = '', page = 1, limit = 20 } = req.query; + const offset = (parseInt(page) - 1) * parseInt(limit); + const searchTerm = `%${search}%`; + + let baseQuery = ` + SELECT w.id, w.username, w.full_name, w.department, w.position, w.created_at + FROM workers w + `; + let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`; + + const params = []; + const countParams = []; + let whereClauses = ["w.role = 'worker'"]; + + if (search) { + whereClauses.push(`(w.full_name LIKE ? OR w.department LIKE ?)`); + params.push(searchTerm, searchTerm); + countParams.push(searchTerm, searchTerm); + } + + if (whereClauses.length > 0) { + const whereString = ` WHERE ${whereClauses.join(' AND ')}`; + baseQuery += whereString; + countQuery += whereString; + } + + baseQuery += ` ORDER BY w.created_at DESC LIMIT ? OFFSET ?`; + params.push(parseInt(limit), offset); + + const [workers] = await db.execute(baseQuery, params); + const [[{ totalCount }]] = await db.execute(countQuery, countParams); + + res.json({ workers, totalCount }); + } catch (error) { + console.error('Get workers error:', error); + res.status(500).json({ message: 'Database error fetching workers.', details: error.message }); + } + }); + + // POST (add) a new worker + router.post('/workers', async (req, res) => { + try { + const { username, password, fullName, department, position, role = 'worker' } = req.body; + if (!username || !password || !fullName) { + return res.status(400).json({ message: 'Username, password, and full name are required.' }); + } + const hashedPassword = await bcrypt.hash(password, 10); + const [result] = await db.execute( + 'INSERT INTO workers (username, password_hash, full_name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)', + [username, hashedPassword, fullName, role, department, position] + ); + res.status(201).json({ id: result.insertId, username, fullName, role, department, position }); + } catch (error) { + console.error('Add worker error:', error); + if (error.code === 'ER_DUP_ENTRY') { + return res.status(409).json({ message: 'Username already exists.' }); + } + res.status(500).json({ message: 'Database error adding worker.', details: error.message }); + } + }); + + // DELETE a worker + router.delete('/workers/:id', async (req, res) => { + try { + const { id } = req.params; + const [result] = await db.execute("DELETE FROM workers WHERE id = ? AND role = 'worker'", [id]); + if (result.affectedRows === 0) { + return res.status(404).json({ message: 'Worker not found.' }); + } + res.status(204).send(); + } catch (error) { + console.error('Delete worker error:', error); + res.status(500).json({ message: 'Database error deleting worker.', details: error.message }); + } + }); + + // PUT (update) a worker's password + router.put('/workers/:workerId/password', async (req, res) => { + try { + const { workerId } = req.params; + const { newPassword } = req.body; + if (!newPassword || newPassword.length < 6) { + return res.status(400).json({ message: 'Password must be at least 6 characters long.' }); + } + const hashedPassword = await bcrypt.hash(newPassword, 10); + const [result] = await db.execute("UPDATE workers SET password_hash = ? WHERE id = ? AND role = 'worker'", [hashedPassword, workerId]); + if (result.affectedRows === 0) { + return res.status(404).json({ message: 'Worker not found.' }); + } + res.status(200).json({ message: 'Password updated successfully.' }); + } catch (error) { + console.error('Update password error:', error); + res.status(500).json({ message: 'Database error updating password.', details: error.message }); + } + }); + + // PUT (clear) a worker's device UUID + router.put('/workers/:workerId/reset-device', async (req, res) => { + try { + const { workerId } = req.params; + const [result] = await db.execute("UPDATE workers SET device_uuid = NULL WHERE id = ?", [workerId]); + if (result.affectedRows === 0) { + return res.status(404).json({ message: 'Worker not found.' }); + } + res.status(200).json({ message: 'Device registration cleared.' }); + } catch (error) { + console.error('Reset device error:', error); + res.status(500).json({ message: 'Database error resetting device.', details: error.message }); + } + }); + + + // Geofence Management Routes + router.get('/geofences', async (req, res) => { + try { + const [rows] = await db.execute( + 'SELECT id, name, coordinates, is_active, created_at FROM geofences ORDER BY created_at DESC' + ); + const geofences = rows.map(row => ({ + ...row, + coordinates: JSON.parse(row.coordinates || '[]') + })); + res.json(geofences); + } catch (error) { + console.error('Get geofences error:', error); + res.status(500).json({ message: 'Database error fetching geofences.', details: error.message }); + } + }); + + router.post('/geofences', async (req, res) => { + try { + const { name, coordinates } = req.body; + if (!name || !coordinates) { + return res.status(400).json({ message: 'Geofence name and coordinates are required.' }); + } + + const [result] = await db.execute( + 'INSERT INTO geofences (name, coordinates, is_active) VALUES (?, ?, ?)', + [name, JSON.stringify(coordinates), true] + ); + + const newGeofence = { + id: result.insertId, + name, + coordinates, + is_active: true, + }; + res.status(201).json(newGeofence); + } catch (error) { + console.error('Add geofence error:', error); + res.status(500).json({ message: 'Database error adding geofence.', details: error.message }); + } + }); + + router.put('/geofences/:id', async (req, res) => { + try { + const { id } = req.params; + const { is_active } = req.body; + + if (typeof is_active !== 'boolean') { + return res.status(400).json({ message: 'is_active must be a boolean.' }); + } + + const [result] = await db.execute( + 'UPDATE geofences SET is_active = ? WHERE id = ?', + [is_active, id] + ); + + if (result.affectedRows === 0) { + return res.status(404).json({ message: 'Geofence not found.' }); + } + + res.json({ id, is_active }); + } catch (error) { + console.error('Update geofence error:', error); + res.status(500).json({ message: 'Database error updating geofence.', details: error.message }); + } + }); + + router.delete('/geofences/:id', async (req, res) => { + try { + const { id } = req.params; + const [result] = await db.execute('DELETE FROM geofences WHERE id = ?', [id]); + + if (result.affectedRows === 0) { + return res.status(404).json({ message: 'Geofence not found.' }); + } + + res.status(204).send(); + } catch (error) { + console.error('Delete geofence error:', error); + res.status(500).json({ message: 'Database error deleting geofence.', details: error.message }); + } + }); + + + // QR Code Management Routes + router.get('/qr-codes', authenticateJWT, async (req, res) => { + try { + const [rows] = await db.execute( + 'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC' + ); + res.json(rows); + } catch (error) { + console.error('Get QR codes error:', error); + res.status(500).json({ message: 'Database error fetching QR codes.' }); + } + }); + + router.post('/qr-codes', authenticateJWT, async (req, res) => { + try { + const { name } = req.body; + if (!name) return res.status(400).json({ message: 'QR Code name is required.' }); + + const newQrCode = { + id: uuidv4(), + name, + is_active: true + }; + + await db.execute( + 'INSERT INTO qr_codes (id, name, is_active) VALUES (?, ?, ?)', + [newQrCode.id, newQrCode.name, newQrCode.is_active] + ); + + res.status(201).json(newQrCode); + } catch (error) { + console.error('Add QR code error:', error); + res.status(500).json({ message: 'Database error adding QR code.' }); + } + }); + + router.put('/qr-codes/:id', authenticateJWT, async (req, res) => { + try { + const { id } = req.params; + // Handle both isActive (camelCase) and is_active (snake_case) + const is_active = req.body.is_active ?? req.body.isActive; + + if (typeof is_active !== 'boolean') { + return res.status(400).json({ message: 'Status must be a boolean value.' }); + } + + const [result] = await db.execute( + 'UPDATE qr_codes SET is_active = ? WHERE id = ?', + [is_active, id] + ); + + if (result.affectedRows === 0) { + return res.status(404).json({ message: 'QR Code not found.' }); + } + + res.json({ id, is_active }); + } catch (error) { + console.error('Update QR code error:', error); + res.status(500).json({ message: 'Database error updating QR code.' }); + } + }); + + router.delete('/qr-codes/:id', authenticateJWT, async (req, res) => { + try { + const { id } = req.params; + const [result] = await db.execute( + 'DELETE FROM qr_codes WHERE id = ?', + [id] + ); + + if (result.affectedRows === 0) { + return res.status(404).json({ message: 'QR Code not found.' }); + } + + res.status(204).send(); + } catch (error) { + console.error('Delete QR code error:', error); + res.status(500).json({ message: 'Database error deleting QR code.' }); + } + }); + + return router; +} diff --git a/backend/server.js b/backend/server.js index 144271b..a8cfbd2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,168 +1,21 @@ -import express from 'express' -import cors from 'cors' -import https from 'https' -import http from 'http' -import fs from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' +// server.js +import express from 'express'; +import cors from 'cors'; +import https from 'https'; +import http from 'http'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; +import mysql from 'mysql2/promise'; +import managerRoutes from './managerRoutes.js'; +import workerRoutes from './workerRoutes.js'; -import { Parser } from 'json2csv' -import { v4 as uuidv4 } from 'uuid' -import mysql from 'mysql2/promise' -import dotenv from 'dotenv' -import bcrypt from 'bcrypt' -import jwt from 'jsonwebtoken' - -import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf' - - -async function validateDeviceForUser(userId, deviceUuid, db) { - try { - // Step 1: Get user's current registered device UUID from workers table - const [userRows] = await db.execute( - 'SELECT device_uuid, role, username FROM workers WHERE id = ?', - [userId] - ) - - if (userRows.length === 0) { - return { - valid: false, - message: 'User not found' - } - } - - const user = userRows[0] - const registeredDeviceUuid = user.device_uuid - - // Step 2: If no device is registered for this user (NULL device_uuid) - if (!registeredDeviceUuid) { - // Check if this device UUID is already registered to another user - const [otherUserRows] = await db.execute( - 'SELECT id, username FROM workers WHERE device_uuid = ? AND id != ?', - [deviceUuid, userId] - ) - - if (otherUserRows.length > 0) { - // Log security alert for device conflict - await logSecurityAlert(userId, 'device_conflict', { - attempted_device_uuid: deviceUuid, - conflicting_user: otherUserRows[0].username, - message: 'Attempted to register device already assigned to another user' - }, db) - - return { - valid: false, - message: 'Device is already registered to another account' - } - } - - // Step 3: Auto-register this device for the user (initial setup) - await db.execute( - 'UPDATE workers SET device_uuid = ? WHERE id = ?', - [deviceUuid, userId] - ) - - console.log(`Auto-registered device ${deviceUuid} for user ${userId} (${user.username}) - initial setup`) - return { - valid: true, - message: 'Device registered successfully for first-time login' - } - } - - // Step 4: User has a registered device - check if current device matches - if (registeredDeviceUuid === deviceUuid) { - // Device UUID matches - allow login - return { - valid: true, - message: 'Device validated successfully' - } - } - - // Step 5: Device UUID mismatch - log security alert and reject - await logSecurityAlert(userId, 'unauthorized_device_attempt', { - registered_device_uuid: registeredDeviceUuid, - attempted_device_uuid: deviceUuid, - username: user.username, - message: 'Login attempt from unauthorized device' - }, db) - - return { - valid: false, - message: 'This device is not authorized for your account. Please use your registered device.' - } - } catch (error) { - console.error('Device validation error:', error) - return { - valid: false, - message: 'Device validation failed' - } - } -} - -// Helper function to log security alerts for device validation issues -async function logSecurityAlert(userId, alertType, alertData, db) { - try { - await db.execute( - 'INSERT INTO security_alerts (user_id, alert_type, alert_data, severity, created_at) VALUES (?, ?, ?, ?, NOW())', - [userId, alertType, JSON.stringify(alertData), 'medium'] - ) - console.log(`Security alert logged: ${alertType} for user ${userId}`) - } catch (error) { - console.error('Failed to log security alert:', error) - } -} - -// Helper function to register a new device for user (simplified for workers table) -async function registerDeviceForUser(userId, deviceUuid, db) { - try { - // Check if device is already registered to another user - const [otherUserRows] = await db.execute( - 'SELECT id, username FROM workers WHERE device_uuid = ? AND id != ?', - [deviceUuid, userId] - ) - - if (otherUserRows.length > 0) { - // Log security alert for device conflict - await logSecurityAlert(userId, 'device_registration_conflict', { - attempted_device_uuid: deviceUuid, - conflicting_user: otherUserRows[0].username, - message: 'Attempted to register device already assigned to another user' - }, db) - - return { - success: false, - message: 'Device is already registered to another account' - } - } - - // Register device by updating workers table - await db.execute( - 'UPDATE workers SET device_uuid = ? WHERE id = ?', - [deviceUuid, userId] - ) - - console.log(`Device ${deviceUuid} registered for user ${userId}`) - return { - success: true, - message: 'Device registered successfully' - } - } catch (error) { - console.error('Device registration error:', error) - return { - success: false, - message: 'Database error during device registration' - } - } -} - -// Main function to start the server async function startServer() { - dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') }) + dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') }); + const app = express(); - const app = express() - - // --- Database Connection --- const db = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, @@ -172,1052 +25,65 @@ async function startServer() { waitForConnections: true, connectionLimit: 10, queueLimit: 0, - }) + }); try { - const connection = await db.getConnection() - console.log('Database connected successfully!') - connection.release() + const connection = await db.getConnection(); + console.log('Database connected successfully!'); + connection.release(); } catch (error) { - console.error('!!! DATABASE CONNECTION FAILED !!!') - console.error('Error:', error.message) - process.exit(1) + console.error('!!! DATABASE CONNECTION FAILED !!!'); + console.error('Error:', error.message); + process.exit(1); } - // Define the geofence polygon by calling the 'polygon' function directly - // const geofence = polygon([ - // [ - // [101.80827335908509, 2.8350045747358337], - // [101.80822799653066, 2.8340134829130363], - // [101.80827902940462, 2.8335264317641418], - // [101.80941309326164, 2.8332772427247335], - // [101.81144873788423, 2.834596811345506], - // [101.81166988033686, 2.8345911479647157], - // [101.81199875885511, 2.83593336858695], - // [101.80827335908509, 2.8350045747358337], - // ], - // ]) - -const geofence = polygon([ - [ - [113.35311466293217, 23.161344441258407], - [113.28591534444001, 23.161344441258407], - [113.28591534444001, 23.091366234233973], - [113.35311466293217, 23.091366234233973], - [113.35311466293217, 23.161344441258407] - ] -]) - - - // Enhanced CORS configuration for HTTPS and mobile development const corsOptions = { - origin: function (origin, callback) { - // Allow requests with no origin (mobile apps, Postman, etc.) - if (!origin) return callback(null, true) - - // Define allowed origins - const allowedOrigins = [ - 'http://localhost:5173', - 'https://localhost:5173', - 'capacitor://localhost', - 'ionic://localhost', - 'http://localhost', - 'https://localhost' - ] - - // Add environment-specific origins - if (process.env.CORS_ORIGIN) { - const envOrigins = process.env.CORS_ORIGIN.split(',') - allowedOrigins.push(...envOrigins) - } - - // Add local IP origins for mobile testing - if (process.env.SERVER_IP) { - const serverIP = process.env.SERVER_IP - allowedOrigins.push( - `http://${serverIP}:5173`, - `https://${serverIP}:5173`, - `http://${serverIP}:3000`, - `https://${serverIP}:3443` - ) - } - - // Check if origin is allowed - if (allowedOrigins.indexOf(origin) !== -1 || origin.startsWith('capacitor://') || origin.startsWith('ionic://')) { - callback(null, true) + origin: (origin, callback) => { + if (!origin || ['http://localhost:5173', 'https://localhost:5173', 'capacitor://localhost', 'ionic://localhost', 'http://localhost', 'https://localhost'].includes(origin) || origin.startsWith('capacitor://') || origin.startsWith('ionic://')) { + callback(null, true); } else { - console.log('CORS blocked origin:', origin) - callback(null, true) // Allow all origins in development + console.log('CORS blocked origin:', origin); + callback(new Error('Not allowed by CORS')); } }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning'], - exposedHeaders: ['Content-Range', 'X-Content-Range'] - } + exposedHeaders: ['Content-Range', 'X-Content-Range'], + }; - app.use(cors(corsOptions)) - app.use(express.json()) + app.use(cors(corsOptions)); + app.use(express.json()); - // --- API Endpoints --- + app.use('/api/managers', managerRoutes(db)); + app.use('/api', workerRoutes(db)); - // Auth Endpoint with Device UUID validation - app.post('/api/auth/login', async (req, res) => { - try { - const { username, password, deviceUuid } = req.body - const [rows] = await db.execute( - 'SELECT id, role, password_hash FROM workers WHERE username = ?', - [username], - ) - if (rows.length > 0) { - const user = rows[0] - const passwordMatch = await bcrypt.compare(password, user.password_hash) - if (passwordMatch) { - // Check device UUID validation if provided, but skip for managers - if (deviceUuid && user.role !== 'manager') { - const deviceValidation = await validateDeviceForUser(user.id, deviceUuid, db) - if (!deviceValidation.valid) { - return res.status(403).json({ - message: 'Device not authorized for this account', - deviceError: deviceValidation.message - }) - } - } - - const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { - expiresIn: '1h', - }) - res.json({ message: 'Login successful', token }) - } else { - res.status(401).json({ message: 'Invalid credentials' }) - } - } else { - res.status(401).json({ message: 'Invalid credentials' }) - } - } catch (error) { - console.error('Login error:', error) - res.status(500).json({ message: 'Database error during login.' }) - } - }) - - // Middleware to verify JWT - const authenticateJWT = (req, res, next) => { - const authHeader = req.headers.authorization - - if (authHeader) { - const token = authHeader.split(' ')[1] - - jwt.verify(token, process.env.JWT_SECRET, (err, user) => { - if (err) { - return res.status(403).json({ message: 'Forbidden' }) - } - - req.user = user - next() - }) - } else { - res.status(401).json({ message: 'Unauthorized' }) - } - } - - // Worker Clock In/Out Endpoint - Optimized Version - app.post('/api/clock', authenticateJWT, async (req, res) => { - try { - const { userId, eventType, qrCodeValue, latitude, longitude, notes } = req.body; - const connection = await db.getConnection(); // Get connection from pool first - - try { - // Start transaction - await connection.beginTransaction(); - - // Bypass checks for special cases - if (qrCodeValue !== 'FORCE_CLOCK_OUT') { - // Parallelize geofence and QR code checks - const [geofenceCheck, qrCheck] = await Promise.all([ - booleanPointInPolygon(point([longitude, latitude]), geofence), - connection.execute('SELECT name, is_active FROM qr_codes WHERE id = ?', [qrCodeValue]) - ]); - - if (!geofenceCheck) { - const distance = pointToLineDistance(point([longitude, latitude]), - geofence.geometry.coordinates[0], { units: 'meters' }); - const notes = `Clock-in outside of the zone: ${distance.toFixed(2)} meters.`; - - await connection.execute( - 'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', - [userId, 'failed', new Date(), qrCodeValue, latitude, longitude, notes] - ); - - await connection.commit(); - return res.status(403).json({ message: `You are not within the allowed work area.` }); - } - - if (qrCheck[0].length === 0) { - await connection.rollback(); - return res.status(400).json({ message: 'Invalid QR Code scanned.' }); - } - - if (!qrCheck[0][0].is_active) { - await connection.rollback(); - return res.status(400).json({ message: 'This QR Code has expired and is no longer active.' }); - } - } - - // Check last event - const [lastEvent] = await connection.execute( - 'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', - [userId] - ); - - if (qrCodeValue === 'FORCE_CLOCK_OUT') { - // If it's a forced clock-out, log it as a failed event regardless of previous state - await connection.execute( - 'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', - [userId, 'failed', new Date(), qrCodeValue, latitude, longitude, `FAKE GPS APP Detected.`] - ); - await connection.commit(); - return res.status(200).json({ message: 'Forced clock-out attempt was logged.' }); -} - -if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) { - await connection.rollback(); - return res.status(400).json({ message: `You are already clocked ${eventType === 'clock_in' ? 'in' : 'out'}.` }); -} - - // Insert new record - const timestamp = new Date(); - await connection.execute( - 'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', - [userId, eventType, timestamp, qrCodeValue || null, latitude || null, longitude || null, notes || null] - ); - - await connection.commit(); - res.status(201).json({ message: 'Clock event recorded successfully' }); - } catch (err) { - await connection.rollback(); - throw err; - } finally { - connection.release(); - } - } catch (error) { - console.error('Clock event error:', error); - res.status(500).json({ message: 'Database error during clock event.' }); - } - }) - - // Fetch worker details endpoint - app.get('/api/workers/:id', authenticateJWT, async (req, res) => { - try { - const { id } = req.params - const [rows] = await db.execute( - 'SELECT full_name FROM workers WHERE id = ? AND role = "worker"', - [id], - ) - if (rows.length > 0) { - res.json({ full_name: rows[0].full_name }) - } else { - res.status(404).json({ message: 'Worker not found.' }) - } - } catch (error) { - console.error('Get worker details error:', error) - res.status(500).json({ message: 'Database error fetching worker details.' }) - } - }) - - // Worker Status Endpoint - app.get('/api/worker/status/:userId', authenticateJWT, async (req, res) => { - try { - const { userId } = req.params - const [rows] = await db.execute( - 'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', - [userId], - ) - if (rows.length > 0) { - res.json({ eventType: rows[0].event_type }) - } else { - res.json({ eventType: 'clock_out' }) // Default to clocked out - } - } catch (error) { - console.error('Worker status error:', error) - res.status(500).json({ message: 'Database error fetching status.' }) - } - }) - - // Worker History Endpoint - app.get('/api/worker/clock-history/:userId', authenticateJWT, async (req, res) => { - try { - const { userId } = req.params - // MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries - const [rows] = await db.execute( - `SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC`, - [userId], - ) - res.json(rows) - } catch (error) { - console.error('Worker history error:', error) - res.status(500).json({ message: 'Database error fetching history.' }) - } - }) - - app.put('/api/worker/change-password', authenticateJWT, async (req, res) => { - try { - const { userId } = req.user // Get user ID from JWT - const { currentPassword, newPassword } = req.body - - if (!currentPassword || !newPassword) { - return res.status(400).json({ message: 'Current password and new password are required.' }) - } - if (newPassword.length < 6) { - return res.status(400).json({ message: 'New password must be at least 6 characters long.' }) - } - - // Get user's current password hash - const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId]) - - if (rows.length === 0) { - return res.status(404).json({ message: 'User not found.' }) - } - - const user = rows[0] - - // Verify current password - const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash) - if (!passwordMatch) { - return res.status(401).json({ message: 'Incorrect current password.' }) - } - - // Hash new password - const saltRounds = 10 - const newHashedPassword = await bcrypt.hash(newPassword, saltRounds) - - // Update password in DB - await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [ - newHashedPassword, - userId, - ]) - - res.json({ message: 'Password updated successfully.' }) - } catch (error) { - console.error('Change password error:', error) - res.status(500).json({ message: 'Database error during password change.' }) - } - }) - - // Manager: PUT (Update) a Worker's Password - app.put('/api/managers/workers/:workerId/password', authenticateJWT, async (req, res) => { - try { - // Ensure the user performing the action is a manager - if (req.user.role !== 'manager') { - return res - .status(403) - .json({ message: 'Forbidden: You do not have permission to perform this action.' }) - } - - const { workerId } = req.params - const { newPassword } = req.body - - if (!newPassword || newPassword.length < 6) { - return res.status(400).json({ message: 'Password must be at least 6 characters long.' }) - } - - const saltRounds = 10 - const hashedPassword = await bcrypt.hash(newPassword, saltRounds) - - const [result] = await db.execute( - "UPDATE workers SET password_hash = ? WHERE id = ? AND role = 'worker'", - [hashedPassword, workerId], - ) - - if (result.affectedRows === 0) { - return res - .status(404) - .json({ message: 'Worker not found or you cannot change the password for this user.' }) - } - - res.status(200).json({ message: 'Password updated successfully.' }) - } catch (error) { - console.error('Update password error:', error) - res.status(500).json({ message: 'Database error while updating password.' }) - } - }) - // GET all tags - app.get('/api/managers/tags', authenticateJWT, async (req, res) => { - try { - const [tags] = await db.execute('SELECT * FROM tags ORDER BY tag_name ASC') - res.json(tags) - } catch (error) { - console.error('Get tags error:', error) - res.status(500).json({ message: 'Database error fetching tags.' }) - } - }) - - // POST a new tag - app.post('/api/managers/tags', authenticateJWT, async (req, res) => { - try { - const { tag_name } = req.body - if (!tag_name) { - return res.status(400).json({ message: 'Tag name is required.' }) - } - const [result] = await db.execute('INSERT INTO tags (tag_name) VALUES (?)', [tag_name]) - res.status(201).json({ id: result.insertId, tag_name }) - } catch (error) { - if (error.code === 'ER_DUP_ENTRY') { - return res.status(409).json({ message: 'This tag already exists.' }) - } - console.error('Add tag error:', error) - res.status(500).json({ message: 'Database error adding tag.' }) - } - }) - - // NEW: DELETE a tag - app.delete('/api/managers/tags/:id', authenticateJWT, async (req, res) => { - try { - const { id } = req.params - - // Optional: Check if the user is a manager before allowing deletion - if (req.user.role !== 'manager') { - return res.status(403).json({ message: 'Forbidden: Only managers can delete tags.' }) - } - - // Delete the tag from the 'tags' table. - // If 'worker_tags' table has ON DELETE CASCADE for tag_id, - // related entries in 'worker_tags' will automatically be removed. - const [result] = await db.execute('DELETE FROM tags WHERE id = ?', [id]) - - if (result.affectedRows === 0) { - return res.status(404).json({ message: 'Tag not found.' }) - } - - res.status(204).send() // 204 No Content for successful deletion - } catch (error) { - console.error('Delete tag error:', error) - res.status(500).json({ message: 'Database error deleting tag.' }) - } - }) - - // POST to assign a tag to a worker - app.post('/api/managers/workers/:workerId/tags', authenticateJWT, async (req, res) => { - try { - const { workerId } = req.params - const { tagId } = req.body // Expects a single tag ID - - if (!tagId) { - return res.status(400).json({ message: 'Tag ID is required.' }) - } - - // INSERT IGNORE prevents errors if the tag is already assigned to the worker - await db.query('INSERT IGNORE INTO worker_tags (worker_id, tag_id) VALUES (?, ?)', [ - workerId, - tagId, - ]) - - res.status(200).json({ message: 'Tag assigned successfully.' }) - } catch (error) { - console.error('Assign tag error:', error) - res.status(500).json({ message: 'Database error assigning tag.' }) - } - }) - - // DELETE to remove a tag from a worker - app.delete('/api/managers/workers/:workerId/tags/:tagId', authenticateJWT, async (req, res) => { - try { - const { workerId, tagId } = req.params - await db.query('DELETE FROM worker_tags WHERE worker_id = ? AND tag_id = ?', [ - workerId, - tagId, - ]) - res.status(204).send() // 204 No Content for successful deletion - } catch (error) { - console.error('Remove tag error:', error) - res.status(500).json({ message: 'Database error removing tag.' }) - } - }) - - // Find this endpoint in your server.js and replace it with the code below. - - // Manager: GET All Workers (FIXED for older MySQL versions) - app.get('/api/managers/workers', authenticateJWT, async (req, res) => { - try { - const { search = '', page = 1, limit = 20, tags = '' } = req.query - const offset = (parseInt(page) - 1) * parseInt(limit) - const searchTerm = `%${search}%` - - const tagIds = tags - .split(',') - .filter((id) => id) - .map(Number) - const hasTagFilter = tagIds.length > 0 - - // Base queries - let baseQuery = ` - SELECT - w.id, w.username, w.full_name, w.created_at, - (SELECT GROUP_CONCAT(t.tag_name SEPARATOR ', ') - FROM worker_tags wt_sub - JOIN tags t ON wt_sub.tag_id = t.id - WHERE wt_sub.worker_id = w.id) as tags - FROM workers w - ` - let countQuery = `SELECT COUNT(DISTINCT w.id) as totalCount FROM workers w` - - // Parameters for the queries - const params = [] - const countParams = [] - - // Join with worker_tags if filtering - if (hasTagFilter) { - const joinClause = ` JOIN worker_tags wt ON w.id = wt.worker_id` - baseQuery += joinClause - countQuery += joinClause - } - - // Common WHERE clause - const whereClause = ` WHERE w.role = 'worker' AND (w.full_name LIKE ? OR w.username LIKE ?)` - baseQuery += whereClause - countQuery += whereClause - params.push(searchTerm, searchTerm) - countParams.push(searchTerm, searchTerm) - - // Add tag filtering logic - if (hasTagFilter) { - const tagPlaceholders = tagIds.map(() => '?').join(',') - - const tagFilterClause = ` AND wt.tag_id IN (${tagPlaceholders})` - baseQuery += tagFilterClause - countQuery += tagFilterClause - - // Add the tag IDs to the parameters individually - params.push(...tagIds) - countParams.push(...tagIds) - // --- FIX END --- - } - - // Grouping and pagination for the main query - if (hasTagFilter) { - baseQuery += ` GROUP BY w.id HAVING COUNT(DISTINCT wt.tag_id) = ?` - params.push(tagIds.length) - } - - baseQuery += ` ORDER BY w.created_at DESC LIMIT ? OFFSET ?` - params.push(parseInt(limit), offset) - - // Execute queries - const [workers] = await db.execute(baseQuery, params) - const [[{ totalCount }]] = await db.execute(countQuery, countParams) - - res.json({ workers, totalCount }) - } catch (error) { - // This is the error you are seeing - console.error('Get workers error:', error) - res.status(500).json({ message: 'Database error fetching workers.' }) - } - }) - - // Manager: POST (Add new) Worker - - app.post('/api/managers/workers', authenticateJWT, async (req, res) => { - try { - const { username, password, fullName, role = 'worker' } = req.body - if (!username || !password || !fullName) { - return res.status(400).json({ message: 'Username, password, and full name are required.' }) - } - - if (!['worker', 'manager'].includes(role)) { - return res.status(400).json({ message: 'Invalid role specified.' }) - } - const saltRounds = 10 - const hashedPassword = await bcrypt.hash(password, saltRounds) - - const [result] = await db.execute( - 'INSERT INTO workers (username, password_hash, full_name, role) VALUES (?, ?, ?, ?)', - [username, hashedPassword, fullName, role], // Pass role to query - ) - res.status(201).json({ id: result.insertId, username, fullName, role }) - } catch (error) { - if (error.code === 'ER_DUP_ENTRY') { - return res.status(409).json({ message: 'Username already exists.' }) - } - console.error('Add worker error:', error) - res.status(500).json({ message: 'Database error adding worker.' }) - } - }) - - // Manager: DELETE Worker - app.delete('/api/managers/workers/:id', authenticateJWT, async (req, res) => { - try { - const { id } = req.params - const [result] = await db.execute("DELETE FROM workers WHERE id = ? AND role = 'worker'", [ - id, - ]) - if (result.affectedRows === 0) { - return res.status(404).json({ message: 'Worker not found or user is not a worker.' }) - } - res.status(204).send() - } catch (error) { - console.error('Delete worker error:', error) - res.status(500).json({ message: 'Database error deleting worker.' }) - } - }) - - // --- NEW --- Manager: POST (Add Manual Attendance Record) - // Note: For this to work, you may need to alter your database table: - // ALTER TABLE clock_records ADD COLUMN notes TEXT; - app.post('/api/managers/add-record', authenticateJWT, async (req, res) => { - try { - const { workerId, eventType, timestamp, notes } = req.body - - if (!workerId || !eventType || !timestamp) { - return res - .status(400) - .json({ message: 'Worker ID, event type, and timestamp are required.' }) - } - - // Check last event to prevent adding a duplicate event type - const [lastEventRows] = await db.execute( - 'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', - [workerId], - ) - - if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) { - const status = eventType === 'clock_in' ? 'in' : 'out' - return res.status(409).json({ message: `Worker is already clocked ${status}.` }) - } - // --- THIS IS THE FIX --- - const sanitizedTimestamp = timestamp.replace('T', ' ') - - await db.execute( - 'INSERT INTO clock_records (worker_id, event_type, timestamp, notes, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, NULL, NULL, NULL)', - [workerId, eventType, sanitizedTimestamp, notes], - ) - - res.status(201).json({ message: 'Manual record added successfully.' }) - } catch (error) { - console.error('Add manual record error:', error) - res.status(500).json({ message: 'Database error adding manual record.' }) - } - }) - - // Manager: GET Attendance Records - app.get('/api/managers/attendance-records', authenticateJWT, async (req, res) => { - try { - const { workerIds, startDate, endDate, format } = req.query - if (!workerIds) { - return res.status(400).json({ message: 'Worker IDs are required.' }) - } - const idsArray = workerIds.split(',').map(Number) - if (idsArray.length === 0) return res.json([]) - const placeholders = idsArray.map(() => '?').join(',') - - // MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries, and select `notes` - let query = `SELECT cr.id, w.full_name, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName, cr.latitude, cr.longitude, cr.notes FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id JOIN workers w ON cr.worker_id = w.id WHERE cr.worker_id IN (${placeholders})`; - - const params = [...idsArray] - if (startDate && endDate) { - const endOfDay = new Date(endDate) - endOfDay.setHours(23, 59, 59, 999) - query += ' AND cr.timestamp BETWEEN ? AND ?' - params.push(startDate, endOfDay) - } - query += ' ORDER BY w.full_name, cr.timestamp DESC' - - const [rows] = await db.execute(query, params) - - if (format === 'csv') { - // MODIFIED: Add 'notes' to CSV export - const json2csvParser = new Parser({ - fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName', 'notes'], - }) - const csv = json2csvParser.parse(rows) - res.header('Content-Type', 'text/csv') - res.attachment(`attendance-report-${new Date().toISOString().split('T')[0]}.csv`) - return res.send(csv) - } - res.json(rows) - } catch (error) { - console.error('Attendance records error:', error) - res.status(500).json({ message: 'Database error fetching attendance records.' }) - } - }) - - // Manager: GET QR Codes - app.get('/api/managers/qr-codes', authenticateJWT, async (req, res) => { - try { - const [rows] = await db.execute( - 'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC', - ) - res.json(rows) - } catch (error) { - console.error('Get QR codes error:', error) - res.status(500).json({ message: 'Database error fetching QR codes.' }) - } - }) - - // Manager: POST QR Code - app.post('/api/managers/qr-codes', authenticateJWT, async (req, res) => { - try { - const { name } = req.body - if (!name) return res.status(400).json({ message: 'QR Code name is required.' }) - const newQrCode = { id: uuidv4(), name, isActive: true } - await db.execute('INSERT INTO qr_codes (id, name, is_active) VALUES (?, ?, ?)', [ - newQrCode.id, - newQrCode.name, - newQrCode.isActive, - ]) - res - .status(201) - .json({ id: newQrCode.id, name: newQrCode.name, is_active: newQrCode.isActive }) - } catch (error) { - console.error('Add QR code error:', error) - res.status(500).json({ message: 'Database error adding QR code.' }) - } - }) - - // Manager: PUT QR Code - app.put('/api/managers/qr-codes/:id', authenticateJWT, async (req, res) => { - try { - const { id } = req.params - const { isActive } = req.body - if (typeof isActive !== 'boolean') - return res.status(400).json({ message: 'isActive must be a boolean.' }) - const [result] = await db.execute('UPDATE qr_codes SET is_active = ? WHERE id = ?', [ - isActive, - id, - ]) - if (result.affectedRows === 0) return res.status(404).json({ message: 'QR Code not found.' }) - res.json({ id, isActive }) - } catch (error) { - console.error('Update QR code error:', error) - res.status(500).json({ message: 'Database error updating QR code.' }) - } - }) - - // Manager: DELETE QR Code - app.delete('/api/managers/qr-codes/:id', authenticateJWT, async (req, res) => { - try { - const { id } = req.params - const [result] = await db.execute('DELETE FROM qr_codes WHERE id = ?', [id]) - if (result.affectedRows === 0) return res.status(404).json({ message: 'QR Code not found.' }) - res.status(204).send() - } catch (error) { - console.error('Delete QR code error:', error) - res.status(500).json({ message: 'Database error deleting QR code.' }) - } - }) - - // Manager: GET single worker's details - app.get('/api/managers/worker/:id', authenticateJWT, async (req, res) => { - try { - const { id } = req.params - const [rows] = await db.execute( - "SELECT full_name FROM workers WHERE id = ? AND role = 'worker'", - [id], - ) - if (rows.length > 0) { - res.json(rows[0]) - } else { - res.status(404).json({ message: 'Worker not found.' }) - } - } catch (error) { - console.error('Get single worker error:', error) - res.status(500).json({ message: 'Database error fetching worker details.' }) - } - }) - - // --- NEW NATIVE FEATURES API ENDPOINTS --- - - // Location Update Endpoint - OPTIMIZED: Removed continuous geofence checking - app.post('/api/location/update', authenticateJWT, async (req, res) => { - try { - const { userId, latitude, longitude, checkGeofence } = req.body - - if (!userId || !latitude || !longitude) { - return res.status(400).json({ message: 'User ID, latitude, and longitude are required.' }) - } - - // OPTIMIZATION: Only check geofence when explicitly requested - // This reduces unnecessary processing on every location update - if (checkGeofence === true) { - try { - const userLocation = point([longitude, latitude]); - const isWithinGeofence = booleanPointInPolygon(userLocation, geofence); - - if (!isWithinGeofence) { - // User is outside the geofence - log security alert silently - const distance = pointToLineDistance(userLocation, geofence.geometry.coordinates[0], { units: 'meters' }); - - const alertData = { - latitude: latitude, - longitude: longitude, - timestamp: new Date().toISOString(), - distance_from_geofence: distance.toFixed(2), - check_type: 'scheduled_update' // Indicate this was a scheduled check - }; - - // Log geofence violation to security_alerts table - await logSecurityAlert(userId, 'geofence_violation', alertData, db); - - console.log(`OPTIMIZED: Geofence violation detected for user ${userId}: ${distance.toFixed(2)} meters outside boundary`); - } else { - console.log(`OPTIMIZED: User ${userId} within geofence during scheduled check`); - } - } catch (geofenceError) { - console.error('OPTIMIZED: Error checking geofence:', geofenceError); - // Continue with location update even if geofence check fails - } - } - - // OPTIMIZED: Simplified location update - only essential fields - // No need for timestamp conversion as we use created_at with NOW() - await db.execute( - 'INSERT INTO location_updates (user_id, longitude, latitude, created_at) VALUES (?, ?, ?, NOW())', - [userId, longitude, latitude] - ) - - res.json({ message: 'Location updated successfully' }) - } catch (error) { - console.error('Location update error:', error) - res.status(500).json({ message: 'Database error during location update.' }) - } - }) - - // Device Registration Endpoint - app.post('/api/device/register', authenticateJWT, async (req, res) => { - try { - const { userId, deviceUuid, deviceInfo } = req.body - - if (!userId || !deviceUuid) { - return res.status(400).json({ message: 'User ID and device UUID are required.' }) - } - - const result = await registerDeviceForUser(userId, deviceUuid, deviceInfo, db) - - if (result.success) { - res.json({ message: result.message, success: true }) - } else { - res.status(409).json({ message: result.message, success: false }) - } - } catch (error) { - console.error('Device registration error:', error) - res.status(500).json({ message: 'Database error during device registration.', success: false }) - } - }) - - // Device Validation Endpoint - app.post('/api/device/validate', authenticateJWT, async (req, res) => { - try { - const { userId, deviceUuid } = req.body - - if (!userId || !deviceUuid) { - return res.status(400).json({ message: 'User ID and device UUID are required.' }) - } - - const validation = await validateDeviceForUser(userId, deviceUuid, db) - - res.json({ - valid: validation.valid, - message: validation.message - }) - } catch (error) { - console.error('Device validation error:', error) - res.status(500).json({ message: 'Database error during device validation.', valid: false }) - } - }) - - - // Security Check Endpoint - COMMENTED OUT FOR SERVER-SIDE SECURITY PREFERENCE - // Client-side security computation removed per user preference for server-side security - /* - app.post('/api/security/check', authenticateJWT, async (req, res) => { - try { - const { userId, timestamp, deviceInfo, securityCheck } = req.body - - if (!userId || !securityCheck) { - return res.status(400).json({ message: 'User ID and security check data are required.' }) - } - - // Calculate risk score - let riskScore = 0 - if (securityCheck.suspiciousApps && securityCheck.suspiciousApps.totalSuspiciousApps > 0) { - riskScore += securityCheck.suspiciousApps.totalSuspiciousApps * 10 - } - if (securityCheck.riskLevel === 'high') { - riskScore += 50 - } else if (securityCheck.riskLevel === 'medium') { - riskScore += 25 - } - - // Convert timestamp to MySQL-compatible format - let mysqlTimestamp - if (timestamp) { - if (typeof timestamp === 'string' && timestamp.includes('T')) { - // Handle ISO 8601 format (e.g., '2025-07-04T09:00:49.192Z') - // Convert to MySQL DATETIME format by replacing 'T' with ' ' and removing 'Z' - mysqlTimestamp = timestamp.replace('T', ' ').replace('Z', '') - } else if (timestamp instanceof Date) { - // Handle Date object - mysqlTimestamp = timestamp.toISOString().replace('T', ' ').replace('Z', '') - } else { - // Fallback to current time for invalid formats - mysqlTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '') - } - } else { - // Use current time if no timestamp provided - mysqlTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '') - } - - // Store security check results - await db.execute( - 'INSERT INTO security_checks (user_id, timestamp, device_info, security_data, risk_level, risk_score, created_at) VALUES (?, ?, ?, ?, ?, ?, NOW())', - [userId, mysqlTimestamp, JSON.stringify(deviceInfo), JSON.stringify(securityCheck), securityCheck.riskLevel, riskScore] - ) - - // Check if risk level is too high - if (riskScore >= 50) { - // Log high-risk event - await db.execute( - 'INSERT INTO security_alerts (user_id, alert_type, alert_data, severity, created_at) VALUES (?, ?, ?, ?, NOW())', - [userId, 'high_risk_device', JSON.stringify({ riskScore, securityCheck }), 'high'] - ) - - return res.status(403).json({ - message: 'Device security risk too high. Please contact administrator.', - riskLevel: securityCheck.riskLevel, - riskScore: riskScore - }) - } - - res.json({ - message: 'Security check completed successfully', - riskLevel: securityCheck.riskLevel, - riskScore: riskScore, - status: 'approved' - }) - } catch (error) { - console.error('Security check error:', error) - res.status(500).json({ message: 'Database error during security check.' }) - } - }) - */ - - // Get Security Status Endpoint - app.get('/api/security/status/:userId', authenticateJWT, async (req, res) => { - try { - const { userId } = req.params - - // Get latest security check - const [securityRows] = await db.execute( - 'SELECT * FROM security_checks WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', - [userId] - ) - - // Get recent security alerts - const [alertRows] = await db.execute( - 'SELECT * FROM security_alerts WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY created_at DESC', - [userId] - ) - - res.json({ - latestSecurityCheck: securityRows[0] || null, - recentAlerts: alertRows, - securityStatus: securityRows.length > 0 ? securityRows[0].risk_level : 'unknown' - }) - } catch (error) { - console.error('Get security status error:', error) - res.status(500).json({ message: 'Database error fetching security status.' }) - } - }) - - // Get App Blacklist Endpoint - app.get('/api/security/app-blacklist', authenticateJWT, async (req, res) => { - try { - const [rows] = await db.execute('SELECT package_name FROM app_blacklist'); - const packageNames = rows.map(row => row.package_name); - res.json(packageNames); - } catch (error) { - console.error('Get app blacklist error:', error); - res.status(500).json({ message: 'Database error fetching app blacklist.' }); - } - }); - - // --- Server Start --- - const httpPort = process.env.HTTP_PORT || 3000 - const httpsPort = process.env.HTTPS_PORT || 3443 - const sslEnabled = process.env.SSL_ENABLED === 'true' + const httpPort = process.env.HTTP_PORT || 3000; + const httpsPort = process.env.HTTPS_PORT || 3443; + const sslEnabled = process.env.SSL_ENABLED === 'true'; if (sslEnabled) { try { - // Check if SSL certificate files exist - const currentDir = path.dirname(fileURLToPath(import.meta.url)) - const keyPath = path.join(currentDir, 'key.pem') - const certPath = path.join(currentDir, 'cert.pem') - - console.log('Resolved keyPath:', keyPath) - console.log('Resolved certPath:', certPath) - - if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) { - console.error('SSL certificate or key file not found. Falling back to HTTP.') - startHttpServer() - return - } + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const keyPath = path.join(currentDir, 'key.pem'); + const certPath = path.join(currentDir, 'cert.pem'); const httpsOptions = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath), - } - - // Start HTTPS server - const httpsServer = https.createServer(httpsOptions, app) - httpsServer.listen(httpsPort, '0.0.0.0', () => { - console.log(`🔒 HTTPS Server is running on https://localhost:${httpsPort}`) - if (process.env.SERVER_IP) { - console.log(`🔒 HTTPS Server is also available on https://${process.env.SERVER_IP}:${httpsPort}`) - } - console.log('📱 For Android testing, use the IP address URL') - }) - - // Optional: Start HTTP server that redirects to HTTPS - const httpApp = express() - httpApp.use((req, res) => { - const host = req.headers.host?.split(':')[0] || 'localhost' - const redirectUrl = `https://${host}:${httpsPort}${req.url}` - res.redirect(301, redirectUrl) - }) - - const httpServer = http.createServer(httpApp) - httpServer.listen(httpPort, '0.0.0.0', () => { - console.log(`🔄 HTTP Server (redirect) is running on http://localhost:${httpPort}`) - }) - - // Graceful shutdown - process.on('SIGTERM', () => { - console.log('Shutting down servers...') - httpsServer.close(() => { - console.log('HTTPS server closed') - }) - httpServer.close(() => { - console.log('HTTP server closed') - }) - }) + }; + https.createServer(httpsOptions, app).listen(httpsPort, '0.0.0.0', () => { + console.log(`🔒 HTTPS Server is running on https://localhost:${httpsPort}`); + }); } catch (error) { - console.error('❌ Failed to start HTTPS server:', error.message) - console.log('Falling back to HTTP server...') - startHttpServer() + console.error('❌ Failed to start HTTPS server:', error.message); } - } else { - startHttpServer() } - function startHttpServer() { - const httpServer = http.createServer(app) - httpServer.listen(httpPort, '0.0.0.0', () => { - console.log(`🌐 HTTP Server is running on http://localhost:${httpPort}`) - if (process.env.SERVER_IP) { - console.log(`🌐 HTTP Server is also available on http://${process.env.SERVER_IP}:${httpPort}`) - } - console.log('⚠️ Using HTTP - HTTPS recommended for mobile testing') - }) - } + http.createServer(app).listen(httpPort, '0.0.0.0', () => { + console.log(`🌐 HTTP Server is running on http://localhost:${httpPort}`); + }); } -startServer() +startServer(); diff --git a/backend/workerRoutes.js b/backend/workerRoutes.js new file mode 100644 index 0000000..f64320f --- /dev/null +++ b/backend/workerRoutes.js @@ -0,0 +1,230 @@ +import express from 'express'; +import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +// Removed unused import + +async function validateDeviceForUser(userId, deviceUuid, db) { + const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]); + if (userRows.length === 0) return { valid: false, message: 'User not found' }; + const { device_uuid } = userRows[0]; + if (!device_uuid) { + await db.execute('UPDATE workers SET device_uuid = ? WHERE id = ?', [deviceUuid, userId]); + return { valid: true, message: 'Device registered successfully' }; + } + return { valid: device_uuid === deviceUuid, message: 'Device validation failed' }; +} + +async function isClockingEnabled(db) { + const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD format + const [rows] = await db.execute('SELECT 1 FROM enabled_dates WHERE enabled_date = ? LIMIT 1', [today]); + return rows.length > 0; +} + +export default function(db) { + const router = express.Router(); + + router.post('/auth/login', async (req, res) => { + const { username, password, deviceUuid } = req.body; + const [rows] = await db.execute('SELECT id, role, password_hash FROM workers WHERE username = ?', [username]); + if (rows.length === 0) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + const user = rows[0]; + const passwordMatch = await bcrypt.compare(password, user.password_hash); + if (!passwordMatch) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + if (deviceUuid && user.role !== 'manager') { + const deviceValidation = await validateDeviceForUser(user.id, deviceUuid, db); + if (!deviceValidation.valid) { + return res.status(403).json({ message: deviceValidation.message }); + } + } + const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' }); + res.json({ token }); + }); + + const authenticateJWT = (req, res, next) => { + const authHeader = req.headers.authorization; + if (authHeader) { + const token = authHeader.split(' ')[1]; + jwt.verify(token, process.env.JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).json({ message: 'Invalid or expired token' }); + } + req.user = user; + next(); + }); + } else { + res.status(401).json({ message: 'Authorization header required' }); + } + }; + + router.use(authenticateJWT); +// Definitive version with distance calculation and specific error messages + +// Definitive version with distance calculation and specific error messages + router.post('/clock', async (req, res) => { + try { + const { userId, eventType, qrCodeValue, latitude, longitude } = req.body; + const currentTimestamp = new Date().toISOString().slice(0, 19).replace('T', ' '); + + // 1. Kill Switch Enforcement + const clockingAllowed = await isClockingEnabled(db); + if (!clockingAllowed) { + const note = 'Clock-in/out function is not enabled for today.'; + await db.execute( + 'INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', + [userId, qrCodeValue, latitude, longitude, note, currentTimestamp] + ); + return res.status(403).json({ message: 'error.clockingDisabled' }); + } + + // 2. Geofence Validation with Distance Calculation + if (latitude != null && longitude != null) { + const [activeFences] = await db.execute('SELECT coordinates FROM geofences WHERE is_active = 1'); + + if (activeFences.length === 0) { + const note = 'Cannot clock in: No active work area is defined.'; + await db.execute('INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', [userId, qrCodeValue, latitude, longitude, note, currentTimestamp]); + return res.status(403).json({ message: 'error.noActiveGeofence' }); + } + + const userLocation = point([longitude, latitude]); + const parsedPolygons = []; + let isInside = false; + + for (const fence of activeFences) { + try { + if (!fence.coordinates) continue; + const coordinates = JSON.parse(fence.coordinates); + const fencePolygon = polygon([coordinates]); + parsedPolygons.push(fencePolygon); // Save for distance calculation + if (booleanPointInPolygon(userLocation, fencePolygon)) { + isInside = true; + break; + } + } catch (e) { + console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e }); + } + } + + if (!isInside) { + let minDistance = Infinity; + for (const p of parsedPolygons) { + const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' }); + if (distance < minDistance) { + minDistance = distance; + } + } + const distanceString = minDistance.toFixed(2); + const note = `Outside geofence by ${distanceString}m`; + await db.execute('INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', [userId, qrCodeValue, latitude, longitude, note, currentTimestamp]); + return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` }); + } + } + + // 3. QR Code and Status Validation + if (qrCodeValue !== 'FORCE_CLOCK_OUT') { + const [qrRows] = await db.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]); + if (qrRows.length === 0 || !qrRows[0].is_active) { + return res.status(400).json({ message: 'error.invalidQrCode' }); + } + } + + const [lastEvent] = await db.execute('SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', [userId]); + if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) { + const errorKey = eventType === 'clock_in' ? 'error.alreadyClockedIn' : 'error.alreadyClockedOut'; + return res.status(400).json({ message: errorKey }); + } + + // 4. Record Successful Event + await db.execute( + 'INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, timestamp) VALUES (?, ?, ?, ?, ?, ?)', + [userId, eventType, qrCodeValue, latitude, longitude, currentTimestamp] + ); + res.status(201).json({ message: 'Clock event recorded.' }); + + } catch (error) { + console.error('!!! CRITICAL ERROR in /clock route !!!:', error); + res.status(500).json({ message: 'error.criticalServer' }); + } + }); + + router.get('/workers/:id', async (req, res) => { + const { id } = req.params; + const [rows] = await db.execute("SELECT full_name FROM workers WHERE id = ? AND role = 'worker'", [id]); + if (rows.length === 0) { + return res.status(404).json({ message: 'Worker not found.' }); + } + res.json(rows[0]); + }); + + router.get('/worker/status/:userId', async (req, res) => { + const { userId } = req.params; + const [rows] = await db.execute('SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', [userId]); + res.json({ eventType: rows.length > 0 ? rows[0].event_type : 'clock_out' }); + }); + + router.get('/worker/clock-history/:userId', async (req, res) => { + const { userId } = req.params; + const [rows] = await db.execute(` + SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName + FROM clock_records cr + LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id + WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC + `, [userId]); + res.json(rows); + }); + + router.put('/worker/change-password', async (req, res) => { + const { userId } = req.user; + const { currentPassword, newPassword } = req.body; + if (!currentPassword || !newPassword || newPassword.length < 6) { + return res.status(400).json({ message: 'Invalid input.' }); + } + const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId]); + const passwordMatch = await bcrypt.compare(currentPassword, rows[0].password_hash); + if (!passwordMatch) { + return res.status(401).json({ message: 'Incorrect current password.' }); + } + const newHashedPassword = await bcrypt.hash(newPassword, 10); + await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [newHashedPassword, userId]); + res.json({ message: 'Password updated successfully.' }); + }); + + router.post('/location/update', async (req, res) => { + // Do nothing, always return location updated + res.json({ message: 'Location updated.' }); + }); + + router.post('/device/register', async (req, res) => { + const { userId, deviceUuid } = req.body; + const result = await validateDeviceForUser(userId, deviceUuid, db); + res.status(result.valid ? 200 : 409).json(result); + }); + + router.post('/device/validate', async (req, res) => { + const { userId, deviceUuid } = req.body; + const result = await validateDeviceForUser(userId, deviceUuid, db); + res.json(result); + }); + + router.get('/security/status/:userId', async (req, res) => { + const { userId } = req.params; + const [securityRows] = await db.execute('SELECT * FROM security_checks WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [userId]); + const [alertRows] = await db.execute('SELECT * FROM security_alerts WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)', [userId]); + res.json({ + latestSecurityCheck: securityRows[0] || null, + recentAlerts: alertRows, + }); + }); + + router.get('/security/app-blacklist', async (req, res) => { + const [rows] = await db.execute('SELECT package_name FROM app_blacklist'); + res.json(rows.map(row => row.package_name)); + }); + + return router; +} diff --git a/dev.sql b/dev.sql index 829e153..05787a8 100644 --- a/dev.sql +++ b/dev.sql @@ -1,230 +1,207 @@ # Host: localhost (Version: 5.7.26) -# Date: 2025-07-14 12:03:00 +# Date: 2025-07-16 13:39:50 # Generator: MySQL-Front 5.3 (Build 4.234) /*!40101 SET NAMES utf8 */; # -# Structure for table "system_config" +# Structure for table "app_blacklist" # -CREATE TABLE `system_config` ( +CREATE TABLE `app_blacklist` ( `id` int(11) NOT NULL AUTO_INCREMENT, - `config_key` varchar(255) NOT NULL, - `config_value` text, - `config_type` enum('string','number','boolean','json') DEFAULT 'string', - `description` text, + `package_name` varchar(255) NOT NULL, + `reason` varchar(255) DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `config_key` (`config_key`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='System configuration settings'; + UNIQUE KEY `package_name` (`package_name`) +) ENGINE=MyISAM AUTO_INCREMENT=50 DEFAULT CHARSET=utf8; # -# Data for table "system_config" +# Data for table "app_blacklist" # -INSERT INTO `system_config` VALUES -(1,'geofence_enabled','true','boolean','Enable geofence checking for clock-in/out','2025-07-14 12:00:00','2025-07-14 12:00:00'), -(2,'work_start_time','09:00','string','Standard work start time (HH:MM)','2025-07-14 12:00:00','2025-07-14 12:00:00'), -(3,'work_end_time','17:00','string','Standard work end time (HH:MM)','2025-07-14 12:00:00','2025-07-14 12:00:00'), -(4,'late_threshold_minutes','15','number','Minutes after start time to consider as late','2025-07-14 12:00:00','2025-07-14 12:00:00'), -(5,'early_threshold_minutes','15','number','Minutes before end time to consider as early leave','2025-07-14 12:00:00','2025-07-14 12:00:00'), -(6,'qr_codes_enabled','true','boolean','Enable QR code scanning for clock-in/out','2025-07-14 12:00:00','2025-07-14 12:00:00'), -(7,'location_tracking_enabled','true','boolean','Enable GPS location tracking','2025-07-14 12:00:00','2025-07-14 12:00:00'), -(8,'security_check_enabled','true','boolean','Enable device security checks','2025-07-14 12:00:00','2025-07-14 12:00:00'); +INSERT INTO `app_blacklist` VALUES (4,'com.lexa.fakegps','GPS Spoofing App','2025-07-09 11:59:55'),(5,'com.incorporateapps.fakegps.fre','GPS Spoofing App','2025-07-09 11:59:55'),(6,'com.blogspot.newapphorizons.fakegps','GPS Spoofing App','2025-07-09 11:59:55'),(7,'com.theappninjas.gpsjoystick','GPS Spoofing App','2025-07-09 11:59:55'),(8,'com.fakegps.mock','GPS Spoofing App','2025-07-09 11:59:55'),(9,'com.mock.location.app','GPS Spoofing App','2025-07-09 11:59:55'),(10,'com.fakegps.location','GPS Spoofing App','2025-07-09 11:59:55'),(11,'com.gpsemulator','GPS Spoofing App','2025-07-09 11:59:55'),(12,'com.locationspoofer','GPS Spoofing App','2025-07-09 11:59:55'),(13,'com.fakegps.pro','GPS Spoofing App','2025-07-09 11:59:55'),(14,'com.mock.gps.location','GPS Spoofing App','2025-07-09 11:59:55'),(15,'com.gps.mock.location','GPS Spoofing App','2025-07-09 11:59:55'),(16,'com.fake.location.spoofer','GPS Spoofing App','2025-07-09 11:59:55'),(17,'com.location.faker','GPS Spoofing App','2025-07-09 11:59:55'),(18,'com.gps.faker','GPS Spoofing App','2025-07-09 11:59:55'),(19,'com.mock.location.faker','GPS Spoofing App','2025-07-09 11:59:55'),(20,'com.location.mock.gps','GPS Spoofing App','2025-07-09 11:59:55'),(21,'com.gps.location.faker','GPS Spoofing App','2025-07-09 11:59:55'),(22,'com.fake.gps.location.spoofer','GPS Spoofing App','2025-07-09 11:59:55'),(23,'com.location.spoofer.gps','GPS Spoofing App','2025-07-09 11:59:55'),(24,'com.hola.mocklocation','Location Simulation App','2025-07-09 12:00:50'),(25,'com.lexa.fakegps.route','Location Simulation App','2025-07-09 12:00:50'),(26,'com.fakegps.mock.location.app','Location Simulation App','2025-07-09 12:00:50'),(27,'com.mock.location.app.free','Location Simulation App','2025-07-09 12:00:50'),(28,'com.location.mock.free','Location Simulation App','2025-07-09 12:00:50'),(29,'com.gps.mock.free','Location Simulation App','2025-07-09 12:00:50'),(33,'com.topjohnwu.magisk','Root Management/Evasion Tool','2025-07-09 12:01:02'),(34,'com.noshufou.android.su','Root Management/Evasion Tool','2025-07-09 12:01:02'),(35,'com.koushikdutta.superuser','Root Management/Evasion Tool','2025-07-09 12:01:02'),(36,'com.zachspong.temprootremovejb','Root Management/Evasion Tool','2025-07-09 12:01:02'),(37,'com.ramdroid.appquarantine','Root Management/Evasion Tool','2025-07-09 12:01:02'),(38,'com.devadvance.rootcloak','Root Management/Evasion Tool','2025-07-09 12:01:02'),(39,'com.devadvance.rootcloakplus','Root Management/Evasion Tool','2025-07-09 12:01:02'),(40,'de.robv.android.xposed.installer','Root Management/Evasion Tool','2025-07-09 12:01:02'),(41,'com.saurik.substrate','Root Management/Evasion Tool','2025-07-09 12:01:02'),(42,'com.amphoras.hidemyroot','Root Management/Evasion Tool','2025-07-09 12:01:02'),(43,'com.amphoras.hidemyrootadfree','Root Management/Evasion Tool','2025-07-09 12:01:02'),(44,'com.formyhm.hiderootPremium','Root Management/Evasion Tool','2025-07-09 12:01:02'),(45,'me.phh.superuser','Root Management/Evasion Tool','2025-07-09 12:01:02'),(46,'eu.chainfire.supersu','Root Management/Evasion Tool','2025-07-09 12:01:02'),(47,'com.kingouser.com','Root Management/Evasion Tool','2025-07-09 12:01:02'),(48,'com.android.vending.billing.InAppBillingService.LOCK','App Cracking/Patching Tool','2025-07-09 12:01:02'),(49,'com.android.vending.billing.InAppBillingService.LACK','App Cracking/Patching Tool','2025-07-09 12:01:02'); # -# Structure for table "worker_profiles" +# Structure for table "clock_records" # -CREATE TABLE `worker_profiles` ( +CREATE TABLE `clock_records` ( `id` int(11) NOT NULL AUTO_INCREMENT, `worker_id` int(11) NOT NULL, - `department` varchar(255) DEFAULT NULL, - `position` varchar(255) DEFAULT NULL, - `phone` varchar(50) DEFAULT NULL, - `email` varchar(255) DEFAULT NULL, - `emergency_contact` varchar(255) DEFAULT NULL, - `emergency_phone` varchar(50) DEFAULT NULL, - `address` text, - `hire_date` date DEFAULT NULL, - `salary_type` enum('hourly','monthly','daily') DEFAULT 'hourly', - `hourly_rate` decimal(10,2) DEFAULT NULL, - `monthly_salary` decimal(10,2) DEFAULT NULL, - `bank_account` varchar(50) DEFAULT NULL, - `tax_id` varchar(50) DEFAULT NULL, - `social_security_id` varchar(50) DEFAULT NULL, + `event_type` enum('clock_in','clock_out','failed') NOT NULL, + `timestamp` datetime NOT NULL, + `qr_code_id` varchar(255) DEFAULT NULL, + `latitude` decimal(10,8) DEFAULT NULL, + `longitude` decimal(11,8) DEFAULT NULL, `notes` text, - `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `worker_id` (`worker_id`), - KEY `idx_department` (`department`), - KEY `idx_position` (`position`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Extended worker profile information'; + KEY `worker_id` (`worker_id`), + KEY `qr_code_id` (`qr_code_id`) +) ENGINE=MyISAM AUTO_INCREMENT=106 DEFAULT CHARSET=utf8 COMMENT='Logs every clock-in and clock-out event for all workers.'; # -# Structure for table "attendance_rules" +# Data for table "clock_records" # -CREATE TABLE `attendance_rules` ( +INSERT INTO `clock_records` VALUES (50,6,'clock_in','2025-07-08 14:09:24','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13490670,113.32259360,NULL),(51,6,'clock_out','2025-07-08 15:45:43','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13486030,113.32251780,NULL),(52,6,'clock_in','2025-07-08 15:46:05','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13486030,113.32251780,NULL),(53,4,'failed','2025-07-09 09:50:28','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42199830,-122.08400000,'Clock-in outside of the zone: 11134377.47 meters.'),(54,4,'failed','2025-07-09 09:52:56','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42199830,-122.08400000,'Clock-in outside of the zone: 11134377.47 meters.'),(55,4,'failed','2025-07-09 09:54:43','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,'Clock-in outside of the zone: 392.58 meters.'),(56,4,'failed','2025-07-09 09:57:11','9f72afba-ebb6-445d-a7fc-58df9902777b',23.12999830,113.31499830,'Clock-in outside of the zone: 376.27 meters.'),(57,4,'failed','2025-07-09 09:57:37','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13086670,113.32691330,'Clock-in outside of the zone: 455.18 meters.'),(58,4,'failed','2025-07-09 09:58:01','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13244650,113.32775730,'Clock-in outside of the zone: 399.79 meters.'),(59,4,'failed','2025-07-09 09:58:23','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13244670,113.32784160,'Clock-in outside of the zone: 406.12 meters.'),(60,4,'failed','2025-07-09 09:58:51','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312330,113.32783670,'Clock-in outside of the zone: 354.82 meters.'),(61,4,'failed','2025-07-09 10:05:28','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,'Clock-in outside of the zone: 354.57 meters.'),(62,4,'failed','2025-07-09 10:13:54','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,'Clock-in outside of the zone: 354.57 meters.'),(63,4,'clock_in','2025-07-09 10:16:51','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,NULL),(64,4,'clock_out','2025-07-09 15:01:53','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,NULL),(65,4,'clock_in','2025-07-09 15:06:55','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,NULL),(66,4,'clock_out','2025-07-09 18:02:51','FORCE_CLOCK_OUT',0.00000000,0.00000000,'Blacklisted App Detected'),(67,4,'failed','2025-07-09 18:08:08','FORCE_CLOCK_OUT',0.00000000,0.00000000,'Forced clock-out failed: User already clocked out.'),(68,4,'clock_out','2025-07-09 18:16:54','FORCE_CLOCK_OUT',0.00000000,0.00000000,'Blacklisted App Detected'),(69,4,'failed','2025-07-09 18:22:09','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(70,4,'failed','2025-07-09 18:23:58','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42198810,-122.08399140,'Clock-in outside of the zone: 11134378.78 meters.'),(71,4,'failed','2025-07-09 18:24:58','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42198810,-122.08399140,'Clock-in outside of the zone: 11134378.78 meters.'),(72,4,'failed','2025-07-09 18:29:30','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,'Clock-in outside of the zone: 392.58 meters.'),(73,4,'failed','2025-07-10 10:13:12','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42199830,-122.08400000,'Clock-in outside of the zone: 11134237.56 meters.'),(74,4,'failed','2025-07-10 10:13:43','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42199830,-122.08400000,'Clock-in outside of the zone: 11134237.56 meters.'),(75,4,'failed','2025-07-10 10:14:45','9f72afba-ebb6-445d-a7fc-58df9902777b',25.21599040,141.62428650,'Clock-in outside of the zone: 2871482.21 meters.'),(76,4,'failed','2025-07-10 10:14:48','9f72afba-ebb6-445d-a7fc-58df9902777b',23.92816100,124.13230650,'Clock-in outside of the zone: 1101840.15 meters.'),(77,4,'clock_in','2025-07-10 10:18:00','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(78,4,'clock_out','2025-07-10 10:38:50','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(79,4,'failed','2025-07-10 10:59:12','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(80,4,'clock_out','2025-07-10 11:05:34','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(81,6,'clock_out','2025-07-10 11:11:04','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13362700,113.32298620,NULL),(82,6,'clock_in','2025-07-10 11:11:44','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13362700,113.32298620,NULL),(83,6,'clock_out','2025-07-10 11:25:20','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(84,6,'clock_in','2025-07-10 11:29:30','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(85,6,'clock_out','2025-07-10 11:29:34','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(86,6,'clock_in','2025-07-10 11:29:53','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(87,6,'clock_out','2025-07-10 11:29:58','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(88,6,'clock_in','2025-07-10 11:48:36','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(89,6,'clock_out','2025-07-10 11:48:40','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(90,4,'failed','2025-07-10 13:29:58','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(91,10,'clock_out','2025-07-10 13:31:07','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(92,10,'failed','2025-07-10 13:32:06','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(93,10,'clock_out','2025-07-10 13:33:38','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(94,10,'failed','2025-07-10 13:40:42','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(95,10,'clock_out','2025-07-10 13:41:01','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(96,10,'failed','2025-07-10 13:47:44','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(97,10,'failed','2025-07-10 13:48:00','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(98,10,'clock_in','2025-07-10 14:04:48','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(99,4,'clock_in','2025-07-10 14:17:24','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(100,4,'clock_out','2025-07-10 14:18:00','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(101,4,'clock_in','2025-07-15 08:41:40','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(102,4,'clock_out','2025-07-15 08:48:43','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(103,4,'clock_in','2025-07-15 08:52:03','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(104,4,'clock_out','2025-07-15 08:52:09','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(105,10,'clock_out','2025-07-16 09:44:00',NULL,NULL,NULL,'test'); + +# +# Structure for table "geofences" +# + +CREATE TABLE `geofences` ( `id` int(11) NOT NULL AUTO_INCREMENT, - `rule_name` varchar(255) NOT NULL, - `rule_type` enum('late','early','absence','overtime') NOT NULL, - `condition_type` enum('time','duration','frequency') NOT NULL, - `condition_value` varchar(255) NOT NULL, - `action_type` enum('warning','deduction','notification') NOT NULL, - `action_value` varchar(255) DEFAULT NULL, - `is_active` tinyint(1) DEFAULT 1, + `name` varchar(100) NOT NULL, + `coordinates` text NOT NULL, + `is_active` tinyint(1) DEFAULT '1', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - KEY `idx_rule_type` (`rule_type`), - KEY `idx_is_active` (`is_active`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Attendance rules and policies'; + PRIMARY KEY (`id`) +) ENGINE=MyISAM AUTO_INCREMENT=2 DEFAULT CHARSET=utf8; # -# Data for table "attendance_rules" +# Data for table "geofences" # -INSERT INTO `attendance_rules` VALUES -(1,'Late Arrival','late','time','09:15','warning',NULL,1,'2025-07-14 12:00:00','2025-07-14 12:00:00'), -(2,'Early Departure','early','time','16:45','warning',NULL,1,'2025-07-14 12:00:00','2025-07-14 12:00:00'), -(3,'Excessive Late','late','time','09:30','deduction','30',1,'2025-07-14 12:00:00','2025-07-14 12:00:00'), -(4,'Absent Without Notice','absence','frequency','1','deduction','100',1,'2025-07-14 12:00:00','2025-07-14 12:00:00'); +INSERT INTO `geofences` VALUES (1,'Main Work Area','[[113.35311466293217,23.161344441258407],[113.28591534444001,23.161344441258407],[113.28591534444001,23.091366234233973],[113.35311466293217,23.091366234233973],[113.35311466293217,23.161344441258407]]',1,'2025-07-14 16:07:32'); # -# Structure for table "notifications" +# Structure for table "location_updates" # -CREATE TABLE `notifications` ( +CREATE TABLE `location_updates` ( `id` int(11) NOT NULL AUTO_INCREMENT, - `user_id` int(11) DEFAULT NULL, - `type` enum('system','alert','reminder','report') NOT NULL, - `title` varchar(255) NOT NULL, - `message` text, - `data` json DEFAULT NULL, - `is_read` tinyint(1) DEFAULT 0, - `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `user_id` int(11) NOT NULL, + `longitude` decimal(11,8) NOT NULL COMMENT 'Longitude first for geographic convention', + `latitude` decimal(10,8) NOT NULL COMMENT 'Latitude second for geographic convention', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Single timestamp field', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), - KEY `idx_type` (`type`), - KEY `idx_is_read` (`is_read`), - KEY `idx_created_at` (`created_at`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='System notifications for users'; + KEY `idx_created_at` (`created_at`), + KEY `idx_user_created` (`user_id`,`created_at`) COMMENT 'Composite index for user location history' +) ENGINE=MyISAM AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='Optimized for 30-minute updates - essential fields only (longitude, latitude, created_at)'; # -# Structure for table "geofence_zones" +# Data for table "location_updates" # -CREATE TABLE `geofence_zones` ( - `id` int(11) NOT NULL AUTO_INCREMENT, +INSERT INTO `location_updates` VALUES (1,4,113.32791330,23.13269830,'2025-07-15 16:41:41'),(2,4,113.32791330,23.13269830,'2025-07-15 16:52:04'); + +# +# Structure for table "qr_codes" +# + +CREATE TABLE `qr_codes` ( + `id` varchar(255) NOT NULL COMMENT 'Using the UUID string as the primary key', `name` varchar(255) NOT NULL, - `description` text, - `coordinates` json NOT NULL, - `center_lat` decimal(10,8) NOT NULL, - `center_lng` decimal(11,8) NOT NULL, - `radius_meters` int(11) DEFAULT 0, - `is_active` tinyint(1) DEFAULT 1, + `is_active` tinyint(1) NOT NULL DEFAULT '1', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - KEY `idx_is_active` (`is_active`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Geofence zones for location-based attendance'; + PRIMARY KEY (`id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Stores all physical QR code locations and their status.'; # -# Data for table "geofence_zones" +# Data for table "qr_codes" # -INSERT INTO `geofence_zones` VALUES -(1,'Main Factory','Main factory area','[[113.35311466293217,23.161344441258407],[113.28591534444001,23.161344441258407],[113.28591534444001,23.091366234233973],[113.35311466293217,23.091366234233973],[113.35311466293217,23.161344441258407]]',23.126355,113.319515,5000,1,'2025-07-14 12:00:00','2025-07-14 12:00:00'); +INSERT INTO `qr_codes` VALUES ('19e8f029-2e61-4b34-af3a-ee985f2cff74','Gate A -2',1,'2025-06-20 14:42:29'),('4afb2111-cff8-4706-bc87-44518492d5f6','test',1,'2025-07-02 11:41:21'),('9f72afba-ebb6-445d-a7fc-58df9902777b','GATE A',1,'2025-06-26 15:56:14'),('ASSEMBLY-LINE-1','Assembly Line 1',1,'2025-06-13 13:39:51'),('d654a6bf-2b48-49e9-95c8-4fe9af6c3e44','Gate B',1,'2025-06-13 14:00:31'),('d7ac9594-ad9f-48dc-b984-5a9e7ea7e995','weast',1,'2025-06-20 14:55:09'),('FACTORY-MAIN-ENTRANCE','Factory Main Entrance',1,'2025-06-13 13:39:51'),('WAREHOUSE-SECTION-A','Warehouse Section A',1,'2025-06-13 13:39:51'); # -# Structure for table "shift_schedules" +# Structure for table "security_alerts" # -CREATE TABLE `shift_schedules` ( +CREATE TABLE `security_alerts` ( `id` int(11) NOT NULL AUTO_INCREMENT, - `worker_id` int(11) NOT NULL, - `shift_date` date NOT NULL, - `shift_type` enum('morning','afternoon','night','custom') NOT NULL, - `start_time` time NOT NULL, - `end_time` time NOT NULL, - `break_duration` int(11) DEFAULT 60, - `notes` text, + `user_id` int(11) NOT NULL, + `alert_type` varchar(100) NOT NULL, + `alert_data` json DEFAULT NULL, + `severity` enum('low','medium','high','critical') DEFAULT 'medium', + `is_resolved` tinyint(1) DEFAULT '0', + `resolved_at` timestamp NULL DEFAULT NULL, + `resolved_by` int(11) DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - KEY `idx_worker_id` (`worker_id`), - KEY `idx_shift_date` (`shift_date`), - KEY `idx_shift_type` (`shift_type`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Worker shift schedules'; + KEY `resolved_by` (`resolved_by`), + KEY `idx_user_id` (`user_id`), + KEY `idx_alert_type` (`alert_type`), + KEY `idx_severity` (`severity`), + KEY `idx_is_resolved` (`is_resolved`), + KEY `idx_created_at` (`created_at`) +) ENGINE=MyISAM AUTO_INCREMENT=272 DEFAULT CHARSET=utf8; # -# Structure for table "overtime_records" +# Data for table "security_alerts" # -CREATE TABLE `overtime_records` ( + +# +# Structure for table "security_checks" +# + +CREATE TABLE `security_checks` ( `id` int(11) NOT NULL AUTO_INCREMENT, - `worker_id` int(11) NOT NULL, - `date` date NOT NULL, - `start_time` time NOT NULL, - `end_time` time NOT NULL, - `duration_minutes` int(11) NOT NULL, - `reason` text, - `approved_by` int(11) DEFAULT NULL, - `status` enum('pending','approved','rejected') DEFAULT 'pending', + `user_id` int(11) NOT NULL, + `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `device_info` json DEFAULT NULL, + `security_data` json DEFAULT NULL, + `risk_level` enum('low','medium','high') DEFAULT 'low', + `risk_score` int(11) DEFAULT '0', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - KEY `idx_worker_id` (`worker_id`), - KEY `idx_date` (`date`), - KEY `idx_status` (`status`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Overtime records and approvals'; + KEY `idx_user_id` (`user_id`), + KEY `idx_risk_level` (`risk_level`), + KEY `idx_timestamp` (`timestamp`), + KEY `idx_created_at` (`created_at`) +) ENGINE=MyISAM AUTO_INCREMENT=107 DEFAULT CHARSET=utf8; # -# Structure for table "leave_requests" +# Data for table "security_checks" # -CREATE TABLE `leave_requests` ( + +# +# Structure for table "workers" +# + +CREATE TABLE `workers` ( `id` int(11) NOT NULL AUTO_INCREMENT, - `worker_id` int(11) NOT NULL, - `leave_type` enum('sick','vacation','personal','emergency') NOT NULL, - `start_date` date NOT NULL, - `end_date` date NOT NULL, - `duration_days` int(11) NOT NULL, - `reason` text, - `status` enum('pending','approved','rejected') DEFAULT 'pending', - `approved_by` int(11) DEFAULT NULL, - `approved_at` timestamp NULL DEFAULT NULL, + `username` varchar(255) NOT NULL, + `password_hash` varchar(255) NOT NULL COMMENT 'Store hashed passwords, not plain text!', + `full_name` varchar(255) NOT NULL, + `role` enum('worker','manager') NOT NULL, + `device_uuid` varchar(255) DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `department` varchar(50) DEFAULT NULL, + `position` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), - KEY `idx_worker_id` (`worker_id`), - KEY `idx_status` (`status`), - KEY `idx_dates` (`start_date`,`end_date`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Leave requests and approvals'; + UNIQUE KEY `username` (`username`), + KEY `idx_device_uuid` (`device_uuid`) +) ENGINE=MyISAM AUTO_INCREMENT=29 DEFAULT CHARSET=utf8 COMMENT='Stores user account information for both workers and managers.'; # -# Structure for table "payroll_records" +# Data for table "workers" # -CREATE TABLE `payroll_records` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `worker_id` int(11) NOT NULL, - `period_start` date NOT NULL, - `period_end` date NOT NULL, - `base_salary` decimal(10,2) NOT NULL, - `overtime_pay` decimal(10,2) DEFAULT 0, - `deductions` decimal(10,2) DEFAULT 0, - `net_pay` decimal(10,2) NOT NULL, - `status` enum('draft','calculated','approved','paid') DEFAULT 'draft', - `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - KEY `idx_worker_id` (`worker_id`), - KEY `idx_period` (`period_start`,`period_end`), - KEY `idx_status` (`status`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Payroll records for workers'; +INSERT INTO `workers` VALUES (1,'worker','$2b$10$ej9XEoBLd6Gl0OJvQbScdeACzdHt98VJVsxs7PqV6XSyhfsVCGfNe','John Doe','worker',NULL,'2025-06-13 13:39:51','test','test'),(2,'worker2','$2b$10$SxjhV19fhO1ILISNxxVJXeJ23Z3p/Dclt47c0j7SfuliROKDpANQC','Jane Smith','worker','7cf298e6-7cf2-4cf2-f306-5365d6d7','2025-06-13 13:39:51','test','test'),(3,'manager','$2b$10$GkPmwkSIxv0d6z/R6S/PCe5NLwgKtAOP7/FDCUwR1vCFZ7ex3FeNi','Manager Bob','manager',NULL,'2025-06-13 13:39:51','test','test'),(4,'ryanlee','$2b$10$jsYy.2SzpJ9A0LWu6CpoK.mZ3GZWZoIp8g81sRfKt2G4Dipjp8Sa6','Ryan Lee','worker','557fd11d-557f-457f-f214-14df7cf1','2025-06-13 14:48:06','testb','test'),(6,'modewang','$2b$10$4gb.m2IgY9iJoVjyVUQ9IuUmiKNuR6TlxUsXmiaSpf8XFrnko8bcG','Ryan Qi','worker',NULL,'2025-06-13 15:53:08','testb','test'),(8,'bwilliams','$2b$10$MsJ1baJE.g4tAm1HOhTFD.cz1vd80BRPQE//hNVuikdLf2QOhdLi6','Bob Williams','worker',NULL,'2025-06-13 15:57:30','testb','test'),(10,'dmiller','$2b$10$e0cpp2JcfExeB9APu2Hbf.H21rn8XBcCsJOCuVUjltlBqTOOQJuDm','Diana Miller','worker','3a2641b6-3a26-4a26-ee92-0d1ea028','2025-06-13 15:57:30','testb','test'); + +# +# Structure for table "active_user_devices" +# + +CREATE VIEW `active_user_devices` AS + select `w`.`id` AS `id`,`w`.`id` AS `user_id`,`w`.`device_uuid` AS `device_uuid`,NULL AS `device_info`,NULL AS `registered_at`,NULL AS `last_seen`,(case when (`w`.`device_uuid` is not null) then 1 else 0 end) AS `is_online`,`w`.`created_at` AS `created_at`,`w`.`created_at` AS `updated_at`,`w`.`username` AS `username`,`w`.`full_name` AS `full_name`,`w`.`role` AS `role`,NULL AS `minutes_since_last_seen` from `workers` `w` where ((`w`.`device_uuid` is not null) and (`w`.`role` = 'worker')); + +# +# Structure for table "recent_location_updates" +# + +CREATE VIEW `recent_location_updates` AS + select `lu`.`id` AS `id`,`lu`.`user_id` AS `user_id`,`lu`.`longitude` AS `longitude`,`lu`.`latitude` AS `latitude`,`lu`.`created_at` AS `created_at`,`w`.`username` AS `username`,`w`.`full_name` AS `full_name`,timestampdiff(MINUTE,`lu`.`created_at`,now()) AS `minutes_ago` from (`location_updates` `lu` join `workers` `w` on((`lu`.`user_id` = `w`.`id`))) where (`lu`.`created_at` > (now() - interval 24 hour)) order by `lu`.`created_at` desc; + +# +# Structure for table "security_summary" +# + +CREATE VIEW `security_summary` AS + select `w`.`id` AS `user_id`,`w`.`username` AS `username`,`w`.`full_name` AS `full_name`,`sc`.`risk_level` AS `latest_risk_level`,`sc`.`risk_score` AS `latest_risk_score`,`sc`.`created_at` AS `last_security_check`,count(`sa`.`id`) AS `active_alerts`,`w`.`device_uuid` AS `current_device`,NULL AS `device_last_seen` from ((`workers` `w` left join `security_checks` `sc` on(((`w`.`id` = `sc`.`user_id`) and (`sc`.`id` = (select max(`security_checks`.`id`) from `security_checks` where (`security_checks`.`user_id` = `w`.`id`)))))) left join `security_alerts` `sa` on(((`w`.`id` = `sa`.`user_id`) and (`sa`.`is_resolved` = FALSE)))) where (`w`.`role` = 'worker') group by `w`.`id`,`w`.`username`,`w`.`full_name`,`sc`.`risk_level`,`sc`.`risk_score`,`sc`.`created_at`,`w`.`device_uuid`; diff --git a/eslint.config.js b/eslint.config.js index c1faee0..7dbe8ec 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,6 +8,13 @@ export default defineConfig([ { name: 'app/files-to-lint', files: ['**/*.{js,mjs,jsx,vue}'], + rules: { + 'no-unused-vars': ['error', { + 'argsIgnorePattern': '^_', // Ignore unused parameters starting with underscore + 'varsIgnorePattern': '^_', // Ignore unused variables starting with underscore + 'caughtErrorsIgnorePattern': '^_' // Ignore unused catch error parameters + }] + } }, globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), diff --git a/nilai_clock.sql b/nilai_clock.sql deleted file mode 100644 index a29783e..0000000 --- a/nilai_clock.sql +++ /dev/null @@ -1,33 +0,0 @@ -# Host: localhost (Version: 5.7.26) -# Date: 2025-06-30 17:33:04 -# Generator: MySQL-Front 5.3 (Build 4.234) - -/*!40101 SET NAMES utf8 */; - -# -# Structure for table "clock_records" -# - -DROP TABLE IF EXISTS `clock_records`; -CREATE TABLE `clock_records` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `worker_id` int(11) NOT NULL, - `event_type` enum('clock_in','clock_out','failed') NOT NULL, - `timestamp` datetime NOT NULL, - `qr_code_id` varchar(255) DEFAULT NULL, - `latitude` decimal(10,8) DEFAULT NULL, - `longitude` decimal(11,8) DEFAULT NULL, - `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `notes` text, - PRIMARY KEY (`id`), - KEY `worker_id` (`worker_id`), - KEY `qr_code_id` (`qr_code_id`) -) ENGINE=MyISAM AUTO_INCREMENT=44 DEFAULT CHARSET=utf8 COMMENT='Logs every clock-in and clock-out event for all workers.'; - -# -# Data for table "clock_records" -# - -/*!40000 ALTER TABLE `clock_records` DISABLE KEYS */; -INSERT INTO `clock_records` VALUES (2,1,'clock_out','2025-06-10 17:30:00','FACTORY-MAIN-ENTRANCE',NULL,NULL,'2025-06-13 13:39:51',NULL,NULL),(3,1,'clock_in','2025-06-13 14:09:50','FACTORY-MAIN-ENTRANCE',3.15785050,101.72055800,'2025-06-13 14:09:49',NULL,NULL),(4,1,'clock_out','2025-06-13 14:10:01','FACTORY-MAIN-ENTRANCE',3.15785050,101.72055800,'2025-06-13 14:10:00',NULL,NULL),(5,2,'clock_in','2025-06-13 14:10:41','FACTORY-MAIN-ENTRANCE',3.15785050,101.72055800,'2025-06-13 14:10:41',NULL,NULL),(6,2,'clock_out','2025-06-13 14:10:55','FACTORY-MAIN-ENTRANCE',3.15785050,101.72055800,'2025-06-13 14:10:54',NULL,NULL),(7,2,'clock_in','2025-06-13 14:17:51','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15785050,101.72055800,'2025-06-13 14:17:50',NULL,NULL),(8,2,'clock_out','2025-06-13 14:17:56','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15785050,101.72055800,'2025-06-13 14:17:56',NULL,NULL),(9,4,'clock_in','2025-06-13 14:59:56','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15785050,101.72055800,'2025-06-13 14:59:55',NULL,NULL),(10,4,'clock_out','2025-06-13 15:00:08','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15785050,101.72055800,'2025-06-13 15:00:07',NULL,NULL),(16,8,'clock_in','2025-06-12 09:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-13 16:02:39',NULL,NULL),(17,8,'clock_out','2025-06-12 17:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-13 16:02:39',NULL,NULL),(18,10,'clock_in','2025-06-13 08:45:00','FACTORY-MAIN-ENTRANCE',NULL,NULL,'2025-06-13 16:02:39',NULL,NULL),(19,4,'clock_in','2025-06-12 09:28:19','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15792400,101.72059600,'2025-06-16 11:28:19',NULL,NULL),(20,4,'clock_out','2025-06-12 17:28:56','WAREHOUSE-SECTION-A',3.15760800,101.72043600,'2025-06-16 11:28:55',NULL,NULL),(22,8,'clock_in','2025-06-13 09:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:19:37',NULL,NULL),(23,8,'clock_out','2025-06-13 12:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:19:37',NULL,NULL),(24,8,'clock_in','2025-06-13 14:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:19:37',NULL,NULL),(25,8,'clock_out','2025-06-13 17:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:19:37',NULL,NULL),(30,6,'clock_in','2025-06-13 09:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(31,6,'clock_out','2025-06-13 12:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(32,6,'clock_in','2025-06-13 14:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(33,6,'clock_out','2025-06-13 17:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(34,10,'clock_in','2025-06-13 09:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(35,10,'clock_out','2025-06-13 12:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(36,10,'clock_in','2025-06-13 14:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(37,10,'clock_out','2025-06-13 17:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(38,1,'clock_in','2025-06-17 10:51:00','WAREHOUSE-SECTION-A',3.15786900,101.72065000,'2025-06-17 10:50:59',NULL,NULL),(39,1,'clock_out','2025-06-17 10:58:00',NULL,NULL,NULL,'2025-06-17 10:59:44','testing',NULL),(40,8,'clock_in','2025-06-26 11:39:23','d7ac9594-ad9f-48dc-b984-5a9e7ea7e995',3.15950000,101.71790000,'2025-06-26 11:39:22',NULL,NULL),(41,8,'clock_out','2025-06-26 11:42:00',NULL,NULL,NULL,'2025-06-26 11:43:07','testing',NULL),(42,4,'clock_in','2025-06-30 15:31:24','9f72afba-ebb6-445d-a7fc-58df9902777b',2.83451211,101.80955708,'2025-06-30 15:31:23',NULL,NULL),(43,4,'clock_out','2025-06-30 15:33:07','9f72afba-ebb6-445d-a7fc-58df9902777b',2.83451211,101.80955708,'2025-06-30 15:33:07',NULL,NULL),(44,4,'failed','2025-06-30 15:49:09','9f72afba-ebb6-445d-a7fc-58df9902777b',1.28390000,103.84900000,'2025-06-30 15:49:08','Clock-in outside of the zone: 284575.63 meters.',284575.63),(45,4,'failed','2025-06-30 15:57:56','9f72afba-ebb6-445d-a7fc-58df9902777b',1.28390000,103.84900000,'2025-06-30 15:57:56','Clock-in outside of the zone: 284575.63 meters.',284575.63),(46,4,'clock_in','2025-06-30 16:03:51','9f72afba-ebb6-445d-a7fc-58df9902777b',2.83451211,101.80955708,'2025-06-30 16:03:50',NULL,NULL),(47,4,'failed','2025-06-30 16:04:15','9f72afba-ebb6-445d-a7fc-58df9902777b',1.28390000,103.84900000,'2025-06-30 16:04:14','Clock-in outside of the zone: 284575.63 meters.',284575.63); -/*!40000 ALTER TABLE `clock_records` ENABLE KEYS */; diff --git a/package-lock.json b/package-lock.json index 156df1a..3c42697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,12 @@ "bcrypt": "^6.0.0", "body-parser": "^2.2.0", "cors": "^2.8.5", - "dotenv": "^16.5.0", + "dotenv": "^16.6.1", "express": "^5.1.0", "html5-qrcode": "^2.3.8", "json2csv": "^6.0.0-alpha.2", "jsonwebtoken": "^9.0.2", - "mysql2": "^3.14.1", + "mysql2": "^3.14.2", "primevue": "^4.3.5", "qrcode": "^1.5.4", "uuid": "^11.1.0", @@ -31,6 +31,8 @@ "devDependencies": { "@eslint/js": "^9.22.0", "@tailwindcss/vite": "^4.1.10", + "@types/leaflet": "^1.9.20", + "@types/leaflet-draw": "^1.0.12", "@vitejs/plugin-vue": "^5.2.3", "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.21", @@ -38,6 +40,8 @@ "eslint": "^9.22.0", "eslint-plugin-vue": "~10.0.0", "globals": "^16.0.0", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "postcss": "^8.5.6", "prettier": "3.5.3", "tailwindcss": "^4.1.10", @@ -4458,6 +4462,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.20", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz", + "integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet-draw": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@types/leaflet-draw/-/leaflet-draw-1.0.12.tgz", + "integrity": "sha512-ayjGxelc3pp7532852Qn/LYHs/CHOcUqM9iDVsXuIXbIGfM2h3OtsHO/sQzFO6GAz2IvslPupgJaYocsY8NH+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/leaflet": "*" + } + }, "node_modules/@types/node": { "version": "24.0.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", @@ -5505,9 +5529,9 @@ "license": "MIT" }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -7039,6 +7063,20 @@ "dev": true, "license": "MIT" }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/leaflet-draw": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/leaflet-draw/-/leaflet-draw-1.0.4.tgz", + "integrity": "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7550,9 +7588,9 @@ "license": "MIT" }, "node_modules/mysql2": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz", - "integrity": "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.2.tgz", + "integrity": "sha512-YD6mZMeoypmheHT6b2BrVmQFvouEpRICuvPIREulx2OvP1xAxxeqkMQqZSTBefv0PiOBKGYFa2zQtY+gf/4eQw==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", diff --git a/package.json b/package.json index 2f72148..2232213 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,12 @@ "bcrypt": "^6.0.0", "body-parser": "^2.2.0", "cors": "^2.8.5", - "dotenv": "^16.5.0", + "dotenv": "^16.6.1", "express": "^5.1.0", "html5-qrcode": "^2.3.8", "json2csv": "^6.0.0-alpha.2", "jsonwebtoken": "^9.0.2", - "mysql2": "^3.14.1", + "mysql2": "^3.14.2", "primevue": "^4.3.5", "qrcode": "^1.5.4", "uuid": "^11.1.0", @@ -36,6 +36,8 @@ "devDependencies": { "@eslint/js": "^9.22.0", "@tailwindcss/vite": "^4.1.10", + "@types/leaflet": "^1.9.20", + "@types/leaflet-draw": "^1.0.12", "@vitejs/plugin-vue": "^5.2.3", "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.21", @@ -43,6 +45,8 @@ "eslint": "^9.22.0", "eslint-plugin-vue": "~10.0.0", "globals": "^16.0.0", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "postcss": "^8.5.6", "prettier": "3.5.3", "tailwindcss": "^4.1.10", diff --git a/src/api.js b/src/api.js index f6e6ec8..4d2aee6 100644 --- a/src/api.js +++ b/src/api.js @@ -1,31 +1,56 @@ -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; export async function apiFetch(endpoint, options = {}) { - const token = sessionStorage.getItem('token') + const token = sessionStorage.getItem('token'); const defaultHeaders = { 'ngrok-skip-browser-warning': 'true', 'Content-Type': 'application/json', ...options.headers, - } + }; if (token) { - defaultHeaders['Authorization'] = `Bearer ${token}` + defaultHeaders['Authorization'] = `Bearer ${token}`; + } + + // If body is FormData, don't set Content-Type header + if (options.body instanceof FormData) { + delete defaultHeaders['Content-Type']; } const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers: defaultHeaders, - }) + }); if (!response.ok) { - // Try to parse the error response body from the server - const errorData = await response.json() - throw new Error(errorData.message || `API call failed with status: ${response.status}`) - } - if (response.status === 204) { - return null + // Check content type of the error response + const contentType = response.headers.get('content-type'); + let errorData; + + // If the server sends back a JSON error, parse it. + if (contentType && contentType.includes('application/json')) { + errorData = await response.json(); + // Use the 'details' from our backend error structure, or the message, or a default + throw new Error(errorData.details || errorData.message || `API call failed with status: ${response.status}`); + } else { + // If the server sends back HTML or plain text, use that as the error message. + // This prevents the "Unexpected token '<'" error. + const textError = await response.text(); + throw new Error(textError || `Server returned an unhandled error with status: ${response.status}`); + } } - return response.json() + // Handle successful responses + if (response.status === 204) { + return null; // No Content + } + + // Handle file downloads like CSV + const disposition = response.headers.get('content-disposition'); + if (disposition && disposition.includes('attachment')) { + return response.blob(); + } + + return response.json(); } diff --git a/src/components/AttendanceReporting.vue b/src/components/AttendanceReporting.vue deleted file mode 100644 index 14931c0..0000000 --- a/src/components/AttendanceReporting.vue +++ /dev/null @@ -1,653 +0,0 @@ - - - - - diff --git a/src/components/GeofenceManagement.vue b/src/components/GeofenceManagement.vue new file mode 100644 index 0000000..0539858 --- /dev/null +++ b/src/components/GeofenceManagement.vue @@ -0,0 +1,230 @@ + + + diff --git a/src/components/KillSwitchManagement.vue b/src/components/KillSwitchManagement.vue new file mode 100644 index 0000000..ab45145 --- /dev/null +++ b/src/components/KillSwitchManagement.vue @@ -0,0 +1,193 @@ + + + diff --git a/src/components/PersonnelManagement.vue b/src/components/PersonnelManagement.vue index 64a5ee3..63f0a6a 100644 --- a/src/components/PersonnelManagement.vue +++ b/src/components/PersonnelManagement.vue @@ -2,92 +2,56 @@

{{ $t('addNewUser') }}

-
+
- - + +
- - + +
- - + + +
+
+ + +
+
+ +
-

{{ errorMessage }}

-
-

{{ $t('manageTags') }}

-
-
- - -
- -
-
- - {{ tag.tag_name }} - - -
-
-

{{ $t('workerRoster') }}

-
- -
- {{ $t('filterByTag') }} - - +
+ +
+
+ + +
+
+ + +
+
@@ -95,529 +59,259 @@ - - - - {{ $t('fullName') }} - - - {{ $t('username') }} - - - {{ $t('tags') }} - - - {{ $t('dateJoined') }} - - - {{ $t('actions') }} + + {{ $t('fullName') }} + {{ $t('username') }} + {{ $t('department') }} + {{ $t('position') }} + {{ $t('dateJoined') }} + {{ $t('actions') }} - + - + {{ worker.full_name }} {{ worker.username }} - - - {{ $t('nA') }} - - - {{ new Date(worker.created_at).toLocaleDateString() }} - + {{ worker.department }} + {{ worker.position }} + {{ new Date(worker.created_at).toLocaleDateString() }} - - - - + + + + - + {{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
-
- - - {{ $t('pageOf', { current: currentPage, total: totalPages }) }} - - +
+ + {{ $t('pageOf', { current: currentPage, total: totalPages }) }} +
-
-
-

{{ editorTitle }}

-
- -

- {{ $t('noTagsAvailable') }} -

-
- -
-
- -
+

{{ $t('changePassword') }}

- For user: {{ $t('forUser') }}: ... + {{ $t('forUser') }}: {{ editingWorkerPassword.full_name }}

-
- - + +
- - + +
-

- {{ passwordErrorMessage }} -

-

- {{ passwordSuccessMessage }} -

+

{{ passwordErrorMessage }}

+

{{ passwordSuccessMessage }}

- - +
- -
- {{ selectedWorkerIds.length }} worker(s) selected - - -
- - diff --git a/src/components/QrCodeManagement.vue b/src/components/QrCodeManagement.vue index 3502ff8..c2942d4 100644 --- a/src/components/QrCodeManagement.vue +++ b/src/components/QrCodeManagement.vue @@ -117,8 +117,8 @@ const fetchQrCodes = async () => { // CORRECT: Get the data directly from apiFetch const data = await apiFetch('/api/managers/qr-codes') qrCodes.value = data - } catch (err) { - console.error('Failed to fetch QR codes:', err) + } catch (_err) { + console.error('Failed to fetch QR codes:',_err) } } @@ -144,8 +144,8 @@ const addQrCode = async () => { if (error) console.error(error) }, ) - } catch (err) { - console.error('Failed to add QR code:', err) + } catch (_err) { + console.error('Failed to add QR code:',_err) } } @@ -162,8 +162,8 @@ const toggleQrStatus = async (qr) => { if (index !== -1) { qrCodes.value[index].is_active = !qrCodes.value[index].is_active } - } catch (err) { - console.error('Failed to update QR status:', err) + } catch (_err) { + console.error('Failed to update QR status:',_err) } } @@ -178,8 +178,8 @@ const deleteQrCode = async (id) => { }) // Filter out the deleted QR code on success qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id) - } catch (err) { - console.error('Failed to delete QR code:', err) + } catch (_err) { + console.error('Failed to delete QR code:',_err) } } diff --git a/src/components/WarningReporting.vue b/src/components/WarningReporting.vue new file mode 100644 index 0000000..5496d4b --- /dev/null +++ b/src/components/WarningReporting.vue @@ -0,0 +1,187 @@ + + + diff --git a/src/locales/en.json b/src/locales/en.json index 695e426..a0aa793 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -10,6 +10,7 @@ "invalidToken": "Invalid token received from server.", "english": "English", "malay": "Malay", + "setting": "Setting", "yourStatus": "Your Status", "clockedIn": "Clocked In", @@ -24,19 +25,20 @@ "out": "Out", "cancel": "Cancel", + "clockHistory": "Clock History", "viewMyClockHistory": "View My Clock History", "changeMyPassword": "Change My Password", + "updateYourPassword": "Update Your Password", "myClockHistory": "My Clock History", "backToDashboard": "Back to Dashboard", "noClockHistory": "You have no clocking history.", "clockHistoryFetchFail": "Failed to fetch clock history:", - "viewClockHistory": "View My Clock History →", - "changePassword": "Change My Password →", + "viewClockHistory": "View My Clock History", + "changePassword": "Change My Password", "successClockIn": "Successfully clocked in.", "successClockOut": "Successfully clocked out.", "qrFail": "Could not detect a QR code. Please try again.", - "geoFail": "Unable to retrieve your location: {message}. Please enable location services.", "successClock": "Successfully clocked at {location}.", "changePasswordTitle": "Change Password", "currentPassword": "Current Password", @@ -46,6 +48,14 @@ "tabPersonnel": "Personnel", "tabAttendance": "Attendance", + "tabWarning": "Warnings", + "warningSettings": "Warning Settings", + "failedClockSummary": "Failed Clock Summary", + "failedCount": "Failed Count", + "viewDetails": "View Details", + "fetchRecords": "Fetch Records", + "failedRecordsFor": "Failed Records for ", + "eventType": "Event Type", "tabQrCodes": "QR Codes", "uploadQrImage": "Upload QR Image", @@ -58,7 +68,7 @@ "tryAgain": "Try Again", "qrDetectedGettingLocation": "QR Code detected. Getting location...", "geolocationNotSupported": "Geolocation is not supported by your browser.", - "unableToRetrieveLocation": "Unable to retrieve your location: {message}. Please enable location services.", + "unableToRetrieveLocation": "Unable to retrieve your location. Please check location permissions. (Details: {message})", "qrNotDetectedTryAgain": "Could not detect a QR code. Please try again.", "updatePassword": "Update Password", "passwordsNoMatch": "New passwords do not match.", @@ -118,9 +128,13 @@ "tagLoadError": "Could not load workers for the selected tag.", "generateReportError": "Please select workers, set valid date range, and enter a salary.", "reportGenerationError": "An error occurred while generating the report.", + "exportAll": "Export All", + "export": "Export", "addNewUser": "Add New User", "fullName": "Full Name", + "department": "Department", + "position": "Position", "egJohnSmith": "e.g. John Smith", "egJsmith": "e.g. jsmith", "eg123456": "e.g. 123456", @@ -130,16 +144,20 @@ "manageTags": "Manage Tags", "createNewTag": "Create New Tag", "egTeam": "e.g. Team", + "egSales": "e.g. Sales", + "egManager": "e.g. Manager", "createTag": "Create Tag", "tags": "Tags", "workerRoster": "Worker Roster", "searchByNameOrUsername": "Search by name or username", + "searchByNameOrDepartment": "Search by name or department", "filterByTag": "Filter by tag", "clearFilter": "Clear filter", "dateJoined": "Date Joined", "actions": "Actions", "editTags": "Edit Tags", "viewRecords": "View Records", + "clearDevice": "Clear Device", "delete": "Delete", "loadingWorkers": "Loading workers...", "noWorkersFound": "No workers found.", @@ -180,5 +198,40 @@ "download": "Download", "noQrCodesFound": "No QR codes found. Create one above!", "deleteQrConfirm": "Are you sure you want to delete this QR code? This cannot be undone.", - "qrDownloadError": "Sorry, the QR code could not be downloaded." -} + "qrDownloadError": "Sorry, the QR code could not be downloaded.", + "loading": "Loading...", + + "tabGeofencing": "Geofencing", + "createGeofence": "Create Geofence", + "drawInstruction": "Click the polygon tool on the map to start drawing a new geofence. Click the first point to finish.", + "geofenceName": "Geofence Name", + "geofenceNamePlaceholder": "e.g., Main Warehouse Zone", + "saveGeofence": "Save Geofence", + "existingGeofences": "Existing Geofences", + "view": "View", + "noGeofencesFound": "No Geofences Found", + "startOver" : "Start Over", + + "workScheduleTitle": "Work Schedule", + "workScheduleDescription": "Click on a date to toggle its status. Enabled days are green. Changes will not be saved until you click 'Apply Changes'.", + "pendingChanges": "Pending Changes", + "noPendingChanges": "Click on the calendar to enable or disable dates.", + "datesToEnable": "Enable these dates:", + "datesToDisable": "Disable these dates:", + "applyChanges": "Apply Changes", + "discardChanges": "Discard Changes", + + "statusClockedIn": "You are Clocked In", + "statusClockedOut": "You are Clocked Out", + "scanToClockIn": "Scan QR Code to Clock In", + "scanToClockOut": "Scan QR Code to Clock Out", + + "error.default": "An unexpected error occurred. Please try again.", + "error.clockingDisabled": "Clocking is disabled for today. Your attempt has been logged.", + "error.noActiveGeofence": "Clocking failed: No active work area is defined on the server.", + "error.outsideGeofence": "Clocking failed: You are outside the designated work area by {distance}m.", + "error.invalidQrCode": "Clocking failed: The scanned QR Code is invalid or no longer active.", + "error.alreadyClockedIn": "Action failed: You are already clocked in.", + "error.alreadyClockedOut": "Action failed: You are already clocked out.", + "error.criticalServer": "A critical server error occurred. Please contact support." +} \ No newline at end of file diff --git a/src/locales/ms.json b/src/locales/ms.json index 0148b11..8799ef7 100644 --- a/src/locales/ms.json +++ b/src/locales/ms.json @@ -1,5 +1,5 @@ { - "appTitle": "Sistem Masuk/Keluar Kerja", + "appTitle": "Sistem Kehadiran", "logout": "Log Keluar", "login": "Log Masuk", "username": "Nama Pengguna", @@ -10,7 +10,8 @@ "invalidToken": "Token tidak sah diterima dari pelayan.", "english": "Bahasa Inggeris", "malay": "Bahasa Melayu", - + "setting": "Tetapan", + "yourStatus": "Status Anda", "clockedIn": "Sudah Masuk", "clockedOut": "Sudah Keluar", @@ -24,14 +25,16 @@ "out": "Keluar", "cancel": "Batal", + "clockHistroy": "Sejarah Kehadiran", "viewMyClockHistory": "Lihat Sejarah Kehadiran Saya", "changeMyPassword": "Tukar Kata Laluan Saya", + "updateYourPassword": "Tukar Kata Laluan Anda", "myClockHistory": "Sejarah Kehadiran Saya", "backToDashboard": "Kembali ke Papan Pemuka", "noClockHistory": "Tiada rekod kehadiran.", "clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:", - "viewClockHistory": "Lihat Sejarah Kehadiran Saya →", - "changePassword": "Tukar Kata Laluan Saya →", + "viewClockHistory": "Lihat Sejarah Kehadiran Saya", + "changePassword": "Tukar Kata Laluan Saya", "successClockIn": "Berjaya masuk kerja.", "successClockOut": "Berjaya keluar kerja.", @@ -46,6 +49,14 @@ "tabPersonnel": "Personel", "tabAttendance": "Kehadiran", + "tabWarning": "Amaran", + "warningSettings": "Tetapan Amaran", + "failedClockSummary": "Ringkasan Kegagalan Clock", + "failedCount": "Bilangan Gagal", + "viewDetails": "Lihat Butiran", + "fetchRecords": "Dapatkan Rekod", + "failedRecordsFor": "Rekod Gagal untuk ", + "eventType": "Jenis Peristiwa", "tabQrCodes": "Kod QR", "uploadQrImage": "Muat Naik Imej QR", @@ -58,7 +69,7 @@ "tryAgain": "Cuba Lagi", "qrDetectedGettingLocation": "Kod QR dikesan. Mengambil lokasi...", "geolocationNotSupported": "Geolokasi tidak disokong oleh pelayar anda.", - "unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.", + "unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda. Sila semak kebenaran lokasi. (Butiran: {message})", "qrNotDetectedTryAgain": "Kod QR tidak dapat dikesan. Sila cuba lagi.", "updatePassword": "Kemaskini Kata Laluan", "passwordsNoMatch": "Kata laluan baharu tidak sepadan.", @@ -118,8 +129,12 @@ "tagLoadError": "Tidak dapat memuatkan pekerja untuk tag yang dipilih.", "generateReportError": "Sila pilih pekerja, tetapkan tarikh, dan masukkan gaji.", "reportGenerationError": "Ralat semasa menjana laporan.", + "exportAll": "Eksport Semua", + "export": "Eksport", "addNewUser": "Tambah Pengguna Baharu", "fullName": "Nama Penuh", + "department": "Jabatan", + "position": "Jawatan", "egJohnSmith": "cth. John Smith", "egJsmith": "cth. jsmith", "eg123456": "cth. 123456", @@ -129,16 +144,20 @@ "manageTags": "Urus Tag", "createNewTag": "Cipta Tag Baharu", "egTeam": "cth. Pasukan", + "egSales": "cth. Jualan", + "egManager": "cth. Pengurus", "createTag": "Cipta Tag", "tags": "Tag", "workerRoster": "Senarai Pekerja", "searchByNameOrUsername": "Cari mengikut nama atau nama pengguna", + "searchByNameOrDepartment": "Cari mengikut nama atau jabatan", "filterByTag": "Tapis mengikut tag", "clearFilter": "Padam tapisan", "dateJoined": "Tarikh Sertai", "actions": "Tindakan", "editTags": "Sunting Tag", "viewRecords": "Lihat Rekod", + "clearDevice": "Padam Peranti", "delete": "Padam", "loadingWorkers": "Memuatkan pekerja...", "noWorkersFound": "Tiada pekerja dijumpai.", @@ -179,5 +198,40 @@ "download": "Muat Turun", "noQrCodesFound": "Tiada kod QR dijumpai. Sila cipta di atas!", "deleteQrConfirm": "Adakah anda pasti ingin memadam kod QR ini? Tindakan ini tidak boleh diundur.", - "qrDownloadError": "Maaf, kod QR tidak dapat dimuat turun." -} + "qrDownloadError": "Maaf, kod QR tidak dapat dimuat turun.", + "loading": "Memuatkan...", + + "tabGeofencing": "Geofencing", + "createGeofence": "Cipta Geofence", + "drawInstruction": "Klik alat poligon di peta untuk mula menggambar geofence baru. Klik titik pertama untuk selesai.", + "geofenceName": "Nama Geofence", + "geofenceNamePlaceholder": "cth., Zon Gudang Utama", + "saveGeofence": "Simpan Geofence", + "existingGeofences": "Geofences Sedia Ada", + "view": "Lihat", + "noGeofencesFound": "Tiada Geofences Dijumpai", + "startOver" : "Mula Semula", + + "workScheduleTitle": "Jadual Kerja", + "workScheduleDescription": "Klik pada tarikh untuk menukar statusnya. Hari yang didayakan berwarna hijau. Perubahan tidak akan disimpan sehingga anda mengklik 'Guna Perubahan'.", + "pendingChanges": "Perubahan Belum Selesai", + "noPendingChanges": "Klik pada kalendar untuk mendayakan atau menyahdayakan tarikh.", + "datesToEnable": "Dayakan tarikh ini:", + "datesToDisable": "Nyahdayakan tarikh ini:", + "applyChanges": "Guna Perubahan", + "discardChanges": "Buang Perubahan", + + "statusClockedIn": "Anda Sudah Masuk Kerja", + "statusClockedOut": "Anda Sudah Keluar Kerja", + "scanToClockIn": "Imbas Kod QR untuk Masuk Kerja", + "scanToClockOut": "Imbas Kod QR untuk Keluar Kerja", + + "error.default": "Ralat tidak dijangka telah berlaku. Sila cuba lagi.", + "error.clockingDisabled": "Fungsi masuk/keluar kerja dilumpuhkan untuk hari ini. Percubaan anda telah direkodkan.", + "error.noActiveGeofence": "Gagal masuk/keluar: Tiada kawasan kerja aktif yang ditetapkan pada pelayan.", + "error.outsideGeofence": "Gagal masuk/keluar: Anda berada di luar kawasan kerja yang ditetapkan sejauh {distance}m.", + "error.invalidQrCode": "Gagal masuk/keluar: Kod QR yang diimbas tidak sah atau tidak lagi aktif.", + "error.alreadyClockedIn": "Tindakan gagal: Anda sudah masuk kerja.", + "error.alreadyClockedOut": "Tindakan gagal: Anda sudah keluar kerja.", + "error.criticalServer": "Ralat kritikal pada pelayan telah berlaku. Sila hubungi sokongan." +} \ No newline at end of file diff --git a/src/views/AttendanceRecordView.vue b/src/views/AttendanceRecordView.vue index 62fd609..9de4ca8 100644 --- a/src/views/AttendanceRecordView.vue +++ b/src/views/AttendanceRecordView.vue @@ -54,6 +54,10 @@ class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"> {{ $t('filterRecords') }} +
@@ -150,6 +154,8 @@ const filters = ref({ endDate: today.toISOString().split('T')[0], }) +const exportLoading = ref(false); + const fetchRecords = async () => { let url = `/api/managers/attendance-records?workerIds=${workerId}` if (filters.value.startDate && filters.value.endDate) { @@ -167,9 +173,9 @@ const fetchRecords = async () => { } else { records.value = [] } - } catch (err) { - console.error('Failed to fetch attendance records:', err) - alert(err.message) + } catch (_err) { + console.error('Failed to fetch attendance records:',_err) + alert(_err.message) records.value = [] } } @@ -202,12 +208,39 @@ const addManualClockOut = async () => { manualClockOut.value.notes = '' manualClockOut.value.timestamp = toLocalISOString(new Date()) fetchRecords() - } catch (err) { - console.error('Failed to submit manual clock-out:', err) - alert(t('manualClockOutError', { msg: err.message })) + } catch (_err) { + console.error('Failed to submit manual clock-out:',_err) + alert(t('manualClockOutError', { msg: _err.message })) } } +const exportRawRecords = async () => { + exportLoading.value = true; + const { startDate, endDate } = filters.value; + + try { + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}`, { + headers: { + 'Authorization': `Bearer ${sessionStorage.getItem('token')}` + } + }); + if (!response.ok) throw new Error('Network response was not ok.'); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch (_err) { + alert('Failed to export records.'); + } finally { + exportLoading.value = false; + } +}; + onMounted(() => { fetchRecords() }) diff --git a/src/views/ChangePasswordView.vue b/src/views/ChangePasswordView.vue index 2afa20e..9065909 100644 --- a/src/views/ChangePasswordView.vue +++ b/src/views/ChangePasswordView.vue @@ -47,10 +47,8 @@