Files
Nilai_Clock/backend/managerRoutes.js
T
2025-12-17 08:43:19 +08:00

1485 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:0015: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'
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
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}
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)
}
// ===== 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 ? 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 })
} 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()
}
})
return router
}