import express from 'express' import { Parser } from 'json2csv' import bcrypt from 'bcrypt' import jwt from 'jsonwebtoken' import { v4 as uuidv4 } from 'uuid' import ExcelJS from 'exceljs' import { APP_TIMEZONE } from './config/db.js' import { getConnection } from './pool.js' export default function () { const router = express.Router() router.use((req, _res, next) => { req.tz = APP_TIMEZONE next() }) /* === TZ helpers (no deps) === */ const _partsToObj = (parts) => parts.reduce((a, p) => ((a[p.type] = p.value), a), {}) const _tzOffsetMinutes = (zone, dUtc) => { const fmt = new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) const p = _partsToObj(fmt.formatToParts(dUtc)) const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) return (asUTC - dUtc.getTime()) / 60000 // e.g. +480 for +08:00 } const parseNaiveAsTZ = (s, zone) => { if (s instanceof Date) return s if (typeof s === 'number') return new Date(s) if (typeof s === 'string' && s.includes('T')) return new Date(s) // ISO with Z/offset const [d, t = '00:00:00'] = String(s).split(' ') const [Y, M, D] = d.split('-').map(Number) const [h, m, sec = 0] = t.split(':').map(Number) const guessUtc = new Date(Date.UTC(Y, M - 1, D, h, m, sec)) const off = _tzOffsetMinutes(zone, guessUtc) return new Date(guessUtc.getTime() - off * 60000) } const ymdInTZ = (date, zone) => new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', }).format(date) const hmInTZ = (date, zone) => new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', hour12: false, }).format(date) // const hmsInTZ = (date, zone) => // new Intl.DateTimeFormat('en-GB', { // timeZone: zone, // hour: '2-digit', // minute: '2-digit', // second: '2-digit', // hour12: false, // }).format(date) //const ymdHmsInTZ = (date, zone) => `${ymdInTZ(date, zone)} ${hmsInTZ(date, zone)}` const dayNameFromYMD = (yyyyMmDd) => { const [y, m, dd] = yyyyMmDd.split('-').map(Number) const d = new Date(y, m - 1, dd, 12, 0, 0, 0) // noon avoids DST edges return ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'][d.getDay()] } // --- bool helper (handles BIT(1) returning Buffer) --- const toBool = (v) => typeof v === 'number' ? v === 1 : Buffer.isBuffer(v) ? v[0] === 1 : Boolean(v) // ---- Lunch / totals helpers ---- const minutesBetween = (a, b) => Math.max(0, (b - a) / 60000) const overlapMinutes = (aStart, aEnd, bStart, bEnd) => { const start = Math.max(aStart.getTime(), bStart.getTime()) const end = Math.min(aEnd.getTime(), bEnd.getTime()) return Math.max(0, (end - start) / 60000) } // Measures total "gap between sessions" that overlaps lunch window (11:00–15:00), capped to policyMin. const calcLunchGapMinutes = (sessions, ymd, TZ, policyMin) => { if (!sessions || sessions.length < 2) return 0 const lunchStart = parseNaiveAsTZ(`${ymd} 11:00:00`, TZ) const lunchEnd = parseNaiveAsTZ(`${ymd} 15:00:00`, TZ) const s = sessions.slice().sort((x, y) => x.start - y.start) let total = 0 for (let i = 0; i < s.length - 1; i++) { const gapStart = s[i].end const gapEnd = s[i + 1].start if (gapEnd <= gapStart) continue total += overlapMinutes(gapStart, gapEnd, lunchStart, lunchEnd) if (total >= policyMin) return policyMin } return Math.min(total, policyMin) } // 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, id: user.userId } // Correctly map userId to id next() }) } else { res.status(401).json({ message: 'Unauthorized' }) } } // Middleware to check for specific permissions const checkPermission = (requiredPermission) => { return async (req, res, next) => { const db = await getConnection() try { const managerId = req.user.id const [rows] = await db.execute('SELECT * FROM manager_permissions WHERE manager_id = ?', [ managerId, ]) if (rows.length === 0 || !toBool(rows[0][requiredPermission])) { return res.status(403).json({ message: 'Forbidden: Insufficient permissions.' }) } next() } catch (error) { console.error('Permission check error:', error) res.status(500).json({ message: 'Database error during permission check.' }) } finally { db.release() } } } router.use(authenticateJWT) // --- START: Date Management Routes --- router.get('/enabled-dates', checkPermission('view_all'), async (req, res) => { const db = await getConnection() 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.' }) } finally { db.release() } }) router.post('/enabled-dates/update', checkPermission('manage_resources'), async (req, res) => { const db = await getConnection() try { const { datesToEnable, datesToDisable } = req.body if (!Array.isArray(datesToEnable) || !Array.isArray(datesToDisable)) { return res.status(400).json({ message: 'Invalid input format.' }) } // Process all deletions sequentially for (const date of datesToDisable) { await db.execute('DELETE FROM enabled_dates WHERE enabled_date = ?', [date]) } // Process all insertions sequentially for (const date of datesToEnable) { await db.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 { db.release() } }) // --- END: Date Management Routes --- // --- ATTENDANCE & REPORTING --- router.get('/failed-records', checkPermission('view_all'), async (req, res) => { const db = await getConnection() 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, }) } finally { db.release() } }) router.get('/failed-records/details', checkPermission('view_all'), async (req, res) => { const db = await getConnection() 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, }) } finally { db.release() } }) router.get('/attendance-records/export-raw', checkPermission('view_all'), async (req, res) => { const db = await getConnection() try { const { workerIds, startDate, endDate } = req.query const TZ = req.tz if (!startDate || !endDate) { return res.status(400).json({ message: 'Start date and end date are required.' }) } let workerIdClause = '' const params = [`${startDate} 00:00:00`, `${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 shaped = rows.map((r) => ({ username: r.username, full_name: r.full_name, event_type: r.event_type, timestamp: r.timestamp, qr_code_name: r.qr_code_name, notes: r.notes, })) const json2csvParser = new Parser({ fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'], }) const csv = json2csvParser.parse(shaped) res.set('X-Export-TZ', TZ) 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 }) } finally { db.release() } }) router.post('/add-record', checkPermission('edit_workers'), async (req, res) => { const db = await getConnection() 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}.` }) } const dt = parseNaiveAsTZ(timestamp, req.tz) const utcSql = dt.toISOString().slice(0, 19).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, utcSql, 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.' }) } finally { db.release() } }) router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => { const db = await getConnection() try { const { workerIds, startDate, endDate } = req.query const TZ = req.tz if (!startDate || !endDate) { return res.status(400).json({ message: 'Start date and end date are required.' }) } const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx' const wantTxt = String(req.query.format || '').toLowerCase() === 'txt' let workerIdClause = '' let departmentClause = '' const params = [`${startDate} 00:00:00`, `${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 { department } = req.query if (department) { departmentClause = ` AND LOWER(w.department) = LOWER(?)` params.push(department) } const query = ` SELECT cr.worker_id, w.username, w.full_name, w.department, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') AS qr_code_name 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}${departmentClause} AND cr.event_type IN ('clock_in','clock_out') ORDER BY cr.worker_id, cr.timestamp ASC ` const [rows] = await db.execute(query, params) // ---- Group events by worker/day ---- const workByDay = {} rows.forEach((row) => { const ts = parseNaiveAsTZ(row.timestamp, TZ) const day = ymdInTZ(ts, TZ) if (!workByDay[row.worker_id]) { workByDay[row.worker_id] = { username: row.username, full_name: row.full_name, department: row.department || '', days: {}, } } if (!workByDay[row.worker_id].days[day]) { workByDay[row.worker_id].days[day] = [] } workByDay[row.worker_id].days[day].push({ type: row.event_type, time: ts, qr_code_name: row.qr_code_name, }) }) // ---- Build rows: one per successful [clock_in, clock_out] session ---- const csvData = [] const byWorkerForXlsx = new Map() // key = "username||full_name||department" → session rows for (const workerId in workByDay) { const w = workByDay[workerId] const perWorkerRows = [] for (const day of Object.keys(w.days).sort()) { // events for this day in ascending time const events = w.days[day].slice().sort((a, b) => a.time - b.time) let open = null let openQr = 'Manual Entry' const sessions = [] const dayRows = [] for (const e of events) { if (e.type === 'clock_in' && open == null) { open = e.time openQr = e.qr_code_name || 'Manual Entry' } else if (e.type === 'clock_out' && open != null) { const start = open const end = e.time sessions.push({ start, end }) const dailyRow = { username: w.username, full_name: w.full_name, date: day, day: dayNameFromYMD(day), clock_in: hmInTZ(start, TZ), clock_out: hmInTZ(end, TZ), work_hours: ((end - start) / 3600000).toFixed(2), qr_code_name: openQr, daily_total: '', } csvData.push(dailyRow) perWorkerRows.push(dailyRow) dayRows.push(dailyRow) // close the session open = null openQr = 'Manual Entry' } } // ---- Daily total: worked - missing lunch (policy 60min) ---- if (dayRows.length) { const unpaidLunchMin = 60 const workedMin = sessions.reduce((sum, s) => sum + minutesBetween(s.start, s.end), 0) const lunchGapMin = calcLunchGapMinutes(sessions, day, TZ, unpaidLunchMin) const missingLunchMin = Math.max(0, unpaidLunchMin - lunchGapMin) const paidMin = Math.max(0, workedMin - missingLunchMin) dayRows[dayRows.length - 1].daily_total = (paidMin / 60).toFixed(2) } } byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows) } // ===== TXT branch ===== if (wantTxt) { const lines = [] for (const wId in workByDay) { const w = workByDay[wId] const seen = new Set() for (const day of Object.keys(w.days).sort()) { const events = w.days[day].slice().sort((a, b) => a.time - b.time) for (const e of events) { const code = e.type === 'clock_in' ? '1' : '0' const date = ymdInTZ(e.time, TZ) const timeStr = new Intl.DateTimeFormat('en-GB', { timeZone: TZ, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).format(e.time) const line = `${w.username};${code};${date};${timeStr};${w.full_name}` if (!seen.has(line)) { seen.add(line) lines.push(line) } } } } res.set('X-Export-TZ', TZ) res .header('Content-Type', 'text/plain') .attachment(`attendance_${startDate}_to_${endDate}.txt`) .send(lines.join('\n')) return } // ===== XLSX branch: grouped header + per-day summary sheet ===== if (wantXlsx) { const wb = new ExcelJS.Workbook() const ws = wb.addWorksheet('Attendance') const wsSum = wb.addWorksheet('Summary') wsSum.columns = [ { header: 'Username', key: 'username', width: 16 }, { header: 'Full Name', key: 'full_name', width: 24 }, { header: 'Department', key: 'department', width: 18 }, { header: 'Days', key: 'days', width: 8 }, { header: 'Worked Hours', key: 'worked_hours', width: 14 }, { header: 'Paid Hours', key: 'paid_hours', width: 14 }, { header: 'Avg Paid/Day', key: 'avg_paid', width: 14 }, ] wsSum.getRow(1).font = { bold: true } wsSum.views = [{ state: 'frozen', ySplit: 1 }] wsSum.autoFilter = { from: 'A1', to: 'G1' } wsSum.getColumn('worked_hours').numFmt = '0.00' wsSum.getColumn('paid_hours').numFmt = '0.00' wsSum.getColumn('avg_paid').numFmt = '0.00' ws.columns = [ { header: 'Date', key: 'date', width: 12 }, { header: 'Day', key: 'day', width: 8 }, { header: 'Clock In', key: 'clock_in', width: 10 }, { header: 'Clock Out', key: 'clock_out', width: 10 }, { header: 'Work Hours', key: 'work_hours', width: 12 }, { header: 'QR Code', key: 'qr_code_name', width: 24 }, { header: 'Daily Total', key: 'daily_total', width: 12 }, ] ws.getColumn('work_hours').numFmt = '0.00' ws.getColumn('daily_total').numFmt = '0.00' ws.getColumn('work_hours').alignment = { horizontal: 'right' } ws.getColumn('daily_total').alignment = { horizontal: 'right' } for (const [key, rowsForWorker] of byWorkerForXlsx.entries()) { const [username, full_name, dept] = key.split('||') if (ws.lastRow) ws.addRow([]) // Bold merged group header: "username full_name Dept: X" const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1 ws.mergeCells(`A${titleRowIdx}:G${titleRowIdx}`) const titleCell = ws.getCell(`A${titleRowIdx}`) titleCell.value = dept ? `${username} ${full_name} Dept: ${dept}` : `${username} ${full_name}` titleCell.font = { bold: true, size: 12 } titleCell.alignment = { horizontal: 'left', vertical: 'middle' } // Header row under the group const hdr = ws.addRow({ date: 'Date', day: 'Day', clock_in: 'Clock In', clock_out: 'Clock Out', work_hours: 'Work Hours', qr_code_name: 'QR Code', daily_total: 'Daily Total', }) hdr.font = { bold: true } // Detail rows (one per day) for (const r of rowsForWorker) { ws.addRow({ ...r, work_hours: r.work_hours === '' ? null : Number(r.work_hours), daily_total: r.daily_total === '' ? null : Number(r.daily_total), }) } const totalPaid = rowsForWorker.reduce((sum, r) => { const v = Number(r.daily_total) return sum + (Number.isFinite(v) ? v : 0) }, 0) const totalWorked = rowsForWorker.reduce((sum, r) => { const v = Number(r.work_hours) return sum + (Number.isFinite(v) ? v : 0) }, 0) const daysCount = rowsForWorker.reduce((set, r) => { if (r.daily_total) set.add(r.date) return set }, new Set()).size wsSum.addRow({ username, full_name, department: dept || '', days: daysCount, worked_hours: totalWorked, paid_hours: totalPaid, avg_paid: daysCount ? totalPaid / daysCount : 0, }) const totalRow = ws.addRow({ date: '', day: '', clock_in: '', clock_out: 'TOTAL', work_hours: '', qr_code_name: '', daily_total: totalPaid, }) totalRow.font = { bold: true } totalRow.eachCell((cell) => { cell.border = { top: { style: 'thin' } } }) } ws.eachRow((row) => { row.eachCell((cell) => { cell.alignment = { ...(cell.alignment || {}), vertical: 'middle' } }) }) const buf = await wb.xlsx.writeBuffer() res.set('X-Export-TZ', TZ) res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ) res.setHeader( 'Content-Disposition', `attachment; filename="work_hours_${startDate}_to_${endDate}.xlsx"`, ) return res.send(Buffer.from(buf)) } // ===== CSV fallback ===== const json2csvParser = new Parser({ fields: [ 'username', 'full_name', 'date', 'day', 'clock_in', 'clock_out', 'work_hours', 'qr_code_name', 'daily_total', ], }) const csv = json2csvParser.parse(csvData) res.set('X-Export-TZ', TZ) 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 }) } finally { db.release() } }) router.get('/attendance-records', checkPermission('view_all'), async (req, res) => { const db = await getConnection() 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 ?' params.push(`${startDate} 00:00:00`, `${endDate} 23:59:59`) } 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 }) } finally { db.release() } }) // --- All other manager routes remain the same --- // GET a specific manager's permissions router.get('/permissions/:id', async (req, res) => { const db = await getConnection() try { const requesterId = req.user.id const targetId = parseInt(req.params.id, 10) // Check if the user is trying to access their own permissions if (requesterId !== targetId) { // If not, check if they have permission to manage permissions const [permissionRows] = await db.execute( 'SELECT manager_permissions FROM manager_permissions WHERE manager_id = ?', [requesterId], ) if (permissionRows.length === 0 || !toBool(permissionRows[0].manager_permissions)) { return res .status(403) .json({ message: "Forbidden: Insufficient permissions to view others' permissions." }) } } // If they are accessing their own, or have permission, fetch the target's permissions const [rows] = await db.execute('SELECT * FROM manager_permissions WHERE manager_id = ?', [ targetId, ]) if (rows.length === 0) { // If no permissions are set, return a default set of all false const [fields] = await db.execute('DESCRIBE manager_permissions') const defaultPermissions = fields.reduce((acc, field) => { if (field.Field !== 'manager_id') { acc[field.Field] = 0 // Use 0 for false } return acc }, {}) return res.json(defaultPermissions) } // Convert buffer values to booleans const permissions = Object.entries(rows[0]).reduce((acc, [key, value]) => { if (key !== 'manager_id') { acc[key] = toBool(value) } return acc }, {}) res.json(permissions) } catch (error) { console.error('Get manager permissions error:', error) res .status(500) .json({ message: 'Database error fetching manager permissions.', details: error.message }) } }) // PUT (update) a manager's permissions router.put('/permissions/:id', checkPermission('manager_permissions'), async (req, res) => { const db = await getConnection() try { const { id } = req.params const permissions = req.body const fields = ['view_all', 'edit_workers', 'manage_resources', 'manager_permissions'] const values = fields.map((field) => permissions[field] || false) // Convert to new simplified permissions schema const query = ` INSERT INTO manager_permissions (manager_id, view_all, edit_workers, manage_resources, manager_permissions) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE view_all = VALUES(view_all), edit_workers = VALUES(edit_workers), manage_resources = VALUES(manage_resources), manager_permissions = VALUES(manager_permissions) ` const queryParams = [id, ...values] await db.execute(query, queryParams) res.status(200).json({ message: 'Permissions updated successfully.' }) } catch (error) { console.error('Update manager permissions error:', error) res .status(500) .json({ message: 'Database error updating manager permissions.', details: error.message }) } finally { db.release() } }) // GET all workers with filtering and pagination router.get('/workers', checkPermission('view_all'), async (req, res) => { const db = await getConnection() 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, w.status FROM workers w ` let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w` const params = [] const countParams = [] let whereClauses = ["w.role = 'worker'", "w.status != 'deleted'"] // Filter out soft-deleted workers if (search) { whereClauses.push(`w.full_name LIKE ?`) params.push(searchTerm) countParams.push(searchTerm) } const { department } = req.query if (department) { whereClauses.push(`LOWER(w.department) = LOWER(?)`) params.push(department) countParams.push(department) } 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 }) } finally { db.release() } }) // GET all managers with their permissions router.get('/managers', checkPermission('manager_permissions'), async (req, res) => { const db = await getConnection() 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, w.status, mp.* FROM workers w LEFT JOIN manager_permissions mp ON w.id = mp.manager_id ` let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w` const params = [] const countParams = [] let whereClauses = ["w.role = 'manager'", "w.status != 'deleted'"] 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 [managers] = await db.execute(baseQuery, params) const [[{ totalCount }]] = await db.execute(countQuery, countParams) res.json({ managers, totalCount }) } catch (error) { console.error('Get managers error:', error) res.status(500).json({ message: 'Database error fetching managers.', details: error.message }) } finally { db.release() } }) // POST (add) a new manager router.post('/managers', checkPermission('manager_permissions'), async (req, res) => { const db = await getConnection() try { const { username, password, fullName, department, position } = 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, status) VALUES (?, ?, ?, ?, ?, ?, ?)', [username, hashedPassword, fullName, 'manager', department, position, 'active'], ) // Set default view_all permission await db.execute('INSERT INTO manager_permissions (manager_id, view_all) VALUES (?, ?)', [ result.insertId, true, ]) res.status(201).json({ id: result.insertId, username, fullName, role: 'manager', department, position, status: 'active', view_all: true, }) } catch (error) { console.error('Add manager 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 manager.', details: error.message }) } finally { db.release() } }) // POST (add) a new worker (with soft-deleted reactivation) router.post('/workers', checkPermission('edit_workers'), async (req, res) => { const db = await getConnection() 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.' }) } // Check for existing worker with this username const [existingRows] = await db.execute( 'SELECT id, status, role FROM workers WHERE username = ?', [username], ) const hashedPassword = await bcrypt.hash(password, 10) if (existingRows.length > 0) { const existing = existingRows[0] if (existing.status === 'deleted' && existing.role === 'worker') { await db.execute( ` UPDATE workers SET password_hash = ?, full_name = ?, role = ?, department = ?, position = ?, status = 'active' WHERE id = ? `, [hashedPassword, fullName, role, department, position, existing.id], ) return res.status(200).json({ id: existing.id, username, fullName, role, department, position, status: 'active', restored: true, }) } return res.status(409).json({ message: 'Username already exists.' }) } const [result] = await db.execute( ` INSERT INTO workers (username, password_hash, full_name, role, department, position, status) VALUES (?, ?, ?, ?, ?, ?, 'active') `, [username, hashedPassword, fullName, role, department, position], ) return res.status(201).json({ id: result.insertId, username, fullName, role, department, position, status: 'active', }) } catch (error) { console.error('Add worker error:', error) if (error.code === 'ER_DUP_ENTRY') { return res.status(409).json({ message: 'Username already exists.' }) } return res.status(500).json({ message: 'Database error adding worker.', details: error.message, }) } finally { db.release() } }) // Soft DELETE a worker (update status to 'deleted') router.delete('/workers/:id', checkPermission('edit_workers'), async (req, res) => { const db = await getConnection() try { const { id } = req.params const [result] = await db.execute( "UPDATE workers SET status = 'deleted' WHERE id = ? AND role = 'worker'", [id], ) if (result.affectedRows === 0) { return res.status(404).json({ message: 'Worker not found or already deleted.' }) } res.status(204).send() // Maintain existing response for client compatibility } catch (error) { console.error('Soft delete worker error:', error) res .status(500) .json({ message: 'Database error soft deleting worker.', details: error.message }) } finally { db.release() } }) // Soft DELETE a manager (update status to 'deleted') router.delete('/managers/:id', checkPermission('manager_permissions'), async (req, res) => { const db = await getConnection() try { const { id } = req.params const [result] = await db.execute( "UPDATE workers SET status = 'deleted' WHERE id = ? AND role = 'manager'", [id], ) if (result.affectedRows === 0) { return res.status(404).json({ message: 'Manager not found or already deleted.' }) } res.status(204).send() } catch (error) { console.error('Soft delete manager error:', error) res .status(500) .json({ message: 'Database error soft deleting manager.', details: error.message }) } finally { db.release() } }) // PUT (update) a worker's details (department, position, status) router.put('/workers/:id', checkPermission('edit_workers'), async (req, res) => { const db = await getConnection() try { const { id } = req.params const { department, position, status, fullName } = req.body // Basic validation if (!department && !position && !status && !fullName) { return res.status(400).json({ message: 'No update information provided.' }) } if (status && !['active', 'inactive'].includes(status)) { return res.status(400).json({ message: 'Invalid status value.' }) } let updateQuery = 'UPDATE workers SET' const params = [] const fieldsToUpdate = [] if (department) { fieldsToUpdate.push('department = ?') params.push(department) } if (position) { fieldsToUpdate.push('position = ?') params.push(position) } if (status) { fieldsToUpdate.push('status = ?') params.push(status) } if (fullName) { fieldsToUpdate.push('full_name = ?') params.push(fullName) } updateQuery += ` ${fieldsToUpdate.join(', ')} WHERE id = ? AND role = 'worker'` params.push(id) const [result] = await db.execute(updateQuery, params) if (result.affectedRows === 0) { return res.status(404).json({ message: 'Worker not found.' }) } res.status(200).json({ message: 'Worker details updated successfully.' }) } catch (error) { console.error('Update worker details error:', error) res.status(500).json({ message: 'Database error updating worker details.', details: error.message, }) } finally { db.release() } }) // PUT (update) a manager's details (department, position, status) router.put('/managers/:id', checkPermission('manager_permissions'), async (req, res) => { const db = await getConnection() try { const { id } = req.params const { department, position, status } = req.body // Basic validation if (!department && !position && !status) { return res.status(400).json({ message: 'No update information provided.' }) } if (status && !['active', 'inactive'].includes(status)) { return res.status(400).json({ message: 'Invalid status value.' }) } let updateQuery = 'UPDATE workers SET' const params = [] const fieldsToUpdate = [] if (department) { fieldsToUpdate.push('department = ?') params.push(department) } if (position) { fieldsToUpdate.push('position = ?') params.push(position) } if (status) { fieldsToUpdate.push('status = ?') params.push(status) } updateQuery += ` ${fieldsToUpdate.join(', ')} WHERE id = ? AND role = 'manager'` params.push(id) const [result] = await db.execute(updateQuery, params) if (result.affectedRows === 0) { return res.status(404).json({ message: 'Manager not found.' }) } res.status(200).json({ message: 'Manager details updated successfully.' }) } catch (error) { console.error('Update manager details error:', error) res .status(500) .json({ message: 'Database error updating manager details.', details: error.message }) } finally { db.release() } }) // PUT (update) a worker's password router.put('/workers/:workerId/password', checkPermission('edit_workers'), async (req, res) => { const db = await getConnection() 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 }) } finally { db.release() } }) // PUT (update) a manager's password router.put( '/managers/:managerId/password', checkPermission('manager_permissions'), async (req, res) => { const db = await getConnection() try { const { managerId } = 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 = 'manager'", [hashedPassword, managerId], ) if (result.affectedRows === 0) { return res.status(404).json({ message: 'Manager not found.' }) } res.status(200).json({ message: 'Password updated successfully.' }) } catch (error) { console.error('Update manager password error:', error) res .status(500) .json({ message: 'Database error updating manager password.', details: error.message }) } finally { db.release() } }, ) // PUT (clear) a worker's device UUID and/or update status router.put( '/workers/:workerId/reset-device', checkPermission('edit_workers'), async (req, res) => { const db = await getConnection() try { const { workerId } = req.params const { status } = req.body // Optional status field let updateQuery = 'UPDATE workers SET device_uuid = NULL' const params = [workerId] if (status && ['active', 'inactive', 'deleted'].includes(status)) { updateQuery += ', status = ?' params.unshift(status) // Add status to the beginning of params for correct order } updateQuery += ' WHERE id = ?' const [result] = await db.execute(updateQuery, params) if (result.affectedRows === 0) { return res.status(404).json({ message: 'Worker not found.' }) } res.status(200).json({ message: 'Device registration cleared and/or status updated.' }) } catch (error) { console.error('Reset device/update status error:', error) res.status(500).json({ message: 'Database error resetting device or updating status.', details: error.message, }) } finally { db.release() } }, ) // Geofence Management Routes router.get('/geofences', checkPermission('view_all'), async (req, res) => { const db = await getConnection() 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 }) } finally { db.release() } }) router.post('/geofences', checkPermission('manage_resources'), async (req, res) => { const db = await getConnection() 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 }) } finally { db.release() } }) router.put('/geofences/:id', checkPermission('manage_resources'), async (req, res) => { const db = await getConnection() 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 }) } finally { db.release() } }) router.delete('/geofences/:id', checkPermission('manage_resources'), async (req, res) => { const db = await getConnection() 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 }) } finally { db.release() } }) // QR Code Management Routes router.get('/qr-codes', checkPermission('view_all'), async (req, res) => { const db = await getConnection() 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.' }) } finally { db.release() } }) router.post('/qr-codes', checkPermission('manage_resources'), async (req, res) => { const db = await getConnection() 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.' }) } finally { db.release() } }) router.put('/qr-codes/:id', checkPermission('manage_resources'), async (req, res) => { const db = await getConnection() 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.' }) } finally { db.release() } }) router.delete('/qr-codes/:id', checkPermission('manage_resources'), async (req, res) => { const db = await getConnection() 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.' }) } finally { db.release() } }) // GET distinct departments for filter tabs router.get('/departments', checkPermission('view_all'), async (req, res) => { const db = await getConnection() try { const [rows] = await db.execute(` SELECT DISTINCT department FROM workers WHERE role = 'worker' AND status != 'deleted' AND department IS NOT NULL AND department != '' ORDER BY department ASC `) const departments = rows.map((r) => r.department) res.json(departments) } catch (error) { console.error('Get departments error:', error) res .status(500) .json({ message: 'Database error fetching departments.', details: error.message }) } finally { db.release() } }) return router }