Implement worker authentication and clocking routes with device UUID validation and geofence checks
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
#VITE_API_BASE_URL=https://localhost:3443
|
||||
VITE_API_BASE_URL=https://myapp.ouji.com/nilai_clock_api
|
||||
VITE_API_BASE_URL=https://10.0.2.2:3443
|
||||
# VITE_API_BASE_URL=https://myapp.ouji.com/nilai_clock_api
|
||||
VITE_HTTPS_ENABLED=true
|
||||
VITE_ALLOW_SELF_SIGNED=true
|
||||
|
||||
@@ -2,3 +2,6 @@ node_modules
|
||||
dist
|
||||
android/.idea/deploymentTargetSelector.xml
|
||||
android/.idea/deploymentTargetSelector.xml
|
||||
android/app/build.gradle
|
||||
backend/.env
|
||||
android/.idea/deploymentTargetSelector.xml
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-09-03T03:14:53.565616900Z">
|
||||
<DropdownSelection timestamp="2025-09-03T05:38:10.402873300Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\masongyan\.android\avd\api25.avd" />
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\masongyan\.android\avd\API_30.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
@@ -8,7 +8,7 @@ android {
|
||||
minSdk 24
|
||||
targetSdk 29
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionName '1.0.1'
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
|
||||
+5
-2
@@ -1,9 +1,12 @@
|
||||
SSL_ENABLED=true
|
||||
SSL_KEY_PATH=key.pem
|
||||
SSL_CERT_PATH=cert.pem
|
||||
# SSL_KEY_PATH=key.pem
|
||||
# SSL_CERT_PATH=cert.pem
|
||||
HTTPS_PORT=3443
|
||||
HTTP_PORT=3000
|
||||
|
||||
# A comma-separated list of allowed origins for CORS.
|
||||
# Example: https://your-frontend.com,https://another-domain.com
|
||||
CORS_ALLOWED_ORIGINS=['http://localhost:5173', 'https://localhost:5173', 'capacitor://localhost', 'ionic://localhost', 'http://localhost', 'https://localhost']
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_USER=dev
|
||||
|
||||
@@ -0,0 +1,967 @@
|
||||
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, 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) => {
|
||||
try {
|
||||
const managerId = req.user.id;
|
||||
const [rows] = await db.execute(
|
||||
'SELECT * FROM manager_permissions WHERE manager_id = ?',
|
||||
[managerId]
|
||||
);
|
||||
|
||||
if (rows.length === 0 || !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.' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
router.use(authenticateJWT);
|
||||
|
||||
// --- START: Date Management Routes ---
|
||||
router.get('/enabled-dates', checkPermission('view_all'), 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', checkPermission('manage_resources'), 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', checkPermission('view_all'), 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', checkPermission('view_all'), 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', checkPermission('view_all'), 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', checkPermission('edit_workers'), 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', checkPermission('view_all'), 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', checkPermission('view_all'), 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 a specific manager's permissions
|
||||
router.get('/permissions/:id', async (req, res) => {
|
||||
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 can_manage_permissions FROM manager_permissions WHERE manager_id = ?',
|
||||
[requesterId]
|
||||
);
|
||||
|
||||
if (permissionRows.length === 0 || !permissionRows[0].can_manage_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] = Boolean(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) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// GET all workers with filtering and pagination
|
||||
router.get('/workers', checkPermission('view_all'), 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, 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// GET all managers with their permissions
|
||||
router.get('/managers', checkPermission('manager_permissions'), 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, 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// POST (add) a new manager
|
||||
router.post('/managers', checkPermission('manager_permissions'), async (req, res) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// POST (add) a new worker
|
||||
router.post('/workers', checkPermission('edit_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, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[username, hashedPassword, fullName, role, department, position, 'active'] // Default status to 'active'
|
||||
);
|
||||
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.' });
|
||||
}
|
||||
res.status(500).json({ message: 'Database error adding worker.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Soft DELETE a worker (update status to 'deleted')
|
||||
router.delete('/workers/:id', checkPermission('edit_workers'), async (req, res) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Soft DELETE a manager (update status to 'deleted')
|
||||
router.delete('/managers/:id', checkPermission('manager_permissions'), async (req, res) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT (update) a worker's details (department, position, status)
|
||||
router.put('/workers/:id', checkPermission('edit_workers'), async (req, res) => {
|
||||
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 = '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 });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT (update) a manager's details (department, position, status)
|
||||
router.put('/managers/:id', checkPermission('manager_permissions'), async (req, res) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT (update) a worker's password
|
||||
router.put('/workers/:workerId/password', checkPermission('edit_workers'), 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 (update) a manager's password
|
||||
router.put('/managers/:managerId/password', checkPermission('manager_permissions'), async (req, res) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT (clear) a worker's device UUID and/or update status
|
||||
router.put('/workers/:workerId/reset-device', checkPermission('edit_workers'), async (req, res) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Geofence Management Routes
|
||||
router.get('/geofences', checkPermission('view_all'), 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', checkPermission('manage_resources'), 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', checkPermission('manage_resources'), 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', checkPermission('manage_resources'), 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', checkPermission('view_all'), 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', checkPermission('manage_resources'), 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', checkPermission('manage_resources'), 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', checkPermission('manage_resources'), 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;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
-- OPTIMIZATION: Simplify location_updates table schema
|
||||
-- Remove redundant and unnecessary fields for better performance
|
||||
|
||||
-- Step 1: Create new optimized table structure
|
||||
CREATE TABLE `location_updates_new` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`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_created_at` (`created_at`),
|
||||
KEY `idx_user_created` (`user_id`, `created_at`) COMMENT 'Composite index for user location history'
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Optimized location updates - essential fields only';
|
||||
|
||||
-- Step 2: Migrate existing data (longitude, latitude order)
|
||||
INSERT INTO `location_updates_new` (`user_id`, `longitude`, `latitude`, `created_at`)
|
||||
SELECT `user_id`, `longitude`, `latitude`, `created_at`
|
||||
FROM `location_updates`
|
||||
ORDER BY `created_at` ASC;
|
||||
|
||||
-- Step 3: Backup old table and replace with new one
|
||||
RENAME TABLE `location_updates` TO `location_updates_backup`;
|
||||
RENAME TABLE `location_updates_new` TO `location_updates`;
|
||||
|
||||
-- Step 4: Update the view to work with new schema
|
||||
DROP VIEW IF EXISTS `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;
|
||||
|
||||
-- Step 5: Add comment about optimization
|
||||
ALTER TABLE `location_updates` COMMENT = 'Optimized for 30-minute updates - essential fields only (longitude, latitude, created_at)';
|
||||
|
||||
-- Verification queries (run these to verify the migration)
|
||||
-- SELECT COUNT(*) as old_count FROM location_updates_backup;
|
||||
-- SELECT COUNT(*) as new_count FROM location_updates;
|
||||
-- SELECT * FROM location_updates ORDER BY created_at DESC LIMIT 5;
|
||||
-- SELECT * FROM recent_location_updates LIMIT 5;
|
||||
|
||||
-- Note: After verifying the migration is successful, you can drop the backup table:
|
||||
-- DROP TABLE `location_updates_backup`;
|
||||
+52
-1181
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
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();
|
||||
|
||||
// ===== DEVICE UUID CONFIGURATION =====
|
||||
// Set DEVICE_UUID_ENABLED to false to completely disable device UUID checking
|
||||
const DEVICE_UUID_ENABLED = true; // Master switch: enables/disables all UUID functionality
|
||||
const REQUIRE_DEVICE_FOR_WORKERS = true; // When true, workers must have a device UUID to login
|
||||
const AUTO_REGISTER_NEW_DEVICES = true; // When true, automatically registers new device UUIDs
|
||||
// =====================================
|
||||
|
||||
router.post('/auth/login', async (req, res) => {
|
||||
const { username, password, deviceUuid } = req.body;
|
||||
const [rows] = await db.execute('SELECT id, role, password_hash, status FROM workers WHERE username = ?', [username]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
const user = rows[0];
|
||||
|
||||
// Allow both workers and managers to login
|
||||
// Check if the user's status is 'active'
|
||||
if (user.status !== 'active') {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(password, user.password_hash);
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Device UUID handling - controlled by configuration flags above
|
||||
if (DEVICE_UUID_ENABLED && user.role === 'worker') {
|
||||
const [deviceRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [user.id]);
|
||||
const existingDeviceUuid = deviceRows[0].device_uuid;
|
||||
|
||||
if (existingDeviceUuid) {
|
||||
// User already has a registered device
|
||||
if (deviceUuid && deviceUuid !== existingDeviceUuid) {
|
||||
// Different device trying to login
|
||||
return res.status(403).json({ message: 'deviceMismatch' });
|
||||
} else if (!deviceUuid) {
|
||||
// Web login attempt when device is registered
|
||||
return res.status(403).json({ message: 'useMobileApp' });
|
||||
}
|
||||
} else {
|
||||
// User has no registered device
|
||||
if (deviceUuid && AUTO_REGISTER_NEW_DEVICES) {
|
||||
// Register new device
|
||||
const deviceResult = await validateDeviceForUser(user.id, deviceUuid, db);
|
||||
if (!deviceResult.valid) {
|
||||
return res.status(500).json({ message: 'deviceRegistrationFailed' });
|
||||
}
|
||||
console.log(`Device UUID registered for worker ${user.id}: ${deviceUuid}`);
|
||||
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
|
||||
// No device provided but device is required
|
||||
return res.status(403).json({ message: 'deviceRequired' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Managers can always login, workers without device_uuid can login
|
||||
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, id: user.userId }; // Correctly map userId to id
|
||||
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;
|
||||
}
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>Vite App</title>
|
||||
<script type="module" crossorigin src="/assets/index-CNLmg7Jt.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-6hSXk9eK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-PWd6qU--.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -210,6 +210,9 @@
|
||||
"servicesReady": "সব সার্ভিস প্রস্তুত",
|
||||
"autoLoginFailed": "অটো লগইন ব্যর্থ। অনুগ্রহ করে ম্যানুয়ালি লগ ইন করুন।",
|
||||
"deviceValidationFailed": "ডিভাইস ভেরিফিকেশন ব্যর্থ। অনুগ্রহ করে সাপোর্টের সাথে যোগাযোগ করুন।",
|
||||
"deviceMismatch": "এই ডিভাইসটি আপনার অ্যাকাউন্টের জন্য অনুমোদিত নয়।",
|
||||
"deviceRegistrationFailed": "ডিভাইস নিবন্ধন ব্যর্থ। আবার চেষ্টা করুন।",
|
||||
"deviceRequired": "কর্মী লগইনের জন্য ডিভাইস নিবন্ধন প্রয়োজন।",
|
||||
|
||||
"servicesStatus": "সার্ভিসের স্ট্যাটাস",
|
||||
"overallStatus": "সামগ্রিক স্ট্যাটাস",
|
||||
|
||||
+4
-1
@@ -71,7 +71,7 @@
|
||||
"updatePassword": "Update Password",
|
||||
"passwordsNoMatch": "New passwords do not match.",
|
||||
"passwordTooShort": "New password must be at least 6 characters long.",
|
||||
"passwordUpdated": "Password updated successfully! You can now use your new password to log in.",
|
||||
"passwordUpdated": "Password updated successfully!",
|
||||
"passwordUpdateError": "An error occurred while updating the password.",
|
||||
|
||||
"attendanceLogFor": "Attendance Log for",
|
||||
@@ -208,6 +208,9 @@
|
||||
"servicesReady": "All services are ready",
|
||||
"autoLoginFailed": "Auto-login failed. Please log in manually.",
|
||||
"deviceValidationFailed": "Device validation failed. Please contact support.",
|
||||
"deviceMismatch": "This device is not authorized for your account.",
|
||||
"deviceRegistrationFailed": "Failed to register device. Please try again.",
|
||||
"deviceRequired": "Device registration is required for worker login.",
|
||||
|
||||
"servicesStatus": "Services Status",
|
||||
"overallStatus": "Overall Status",
|
||||
|
||||
+4
-1
@@ -71,7 +71,7 @@
|
||||
"updatePassword": "Kemaskini Kata Laluan",
|
||||
"passwordsNoMatch": "Kata laluan baharu tidak sepadan.",
|
||||
"passwordTooShort": "Kata laluan baharu mesti sekurang-kurangnya 6 aksara.",
|
||||
"passwordUpdated": "Kata laluan berjaya dikemaskini! Anda boleh guna kata laluan baharu untuk log masuk.",
|
||||
"passwordUpdated": "Kata laluan berjaya dikemaskini!",
|
||||
"passwordUpdateError": "Ralat semasa mengemaskini kata laluan.",
|
||||
|
||||
"attendanceLogFor": "Log Kehadiran untuk",
|
||||
@@ -208,6 +208,9 @@
|
||||
"servicesReady": "Semua perkhidmatan sedia",
|
||||
"autoLoginFailed": "Log masuk automatik gagal. Sila log masuk secara manual.",
|
||||
"deviceValidationFailed": "Pengesahan peranti gagal. Sila hubungi sokongan.",
|
||||
"deviceMismatch": "Peranti ini tidak dibenarkan untuk akaun anda.",
|
||||
"deviceRegistrationFailed": "Gagal mendaftarkan peranti. Sila cuba lagi.",
|
||||
"deviceRequired": "Pendaftaran peranti diperlukan untuk log masuk pekerja.",
|
||||
|
||||
"personal": "Peribadi",
|
||||
"clockHistory": "Sejarah Kehadiran",
|
||||
|
||||
@@ -208,6 +208,9 @@
|
||||
"servicesReady": "ဝန်ဆောင်မှုများ အားလုံး အဆင်သင့်ပါပြီ",
|
||||
"autoLoginFailed": "အလိုအလျောက်ဝင်ရောက်မှု မအောင်မြင်ပါ။ လက်ဖြင့် ဝင်ရောက်ပါ။",
|
||||
"deviceValidationFailed": "စက်ပစ္စည်း အတည်ပြုခြင်း မအောင်မြင်ပါ။ ပံ့ပိုးမှုကို ဆက်သွယ်ပါ။",
|
||||
"deviceMismatch": "ဤစက်ပစ္စည်းသည် သင့်အကောင့်အတွက် ခွင့်ပြုမထားပါ။",
|
||||
"deviceRegistrationFailed": "စက်ပစ္စည်း မှတ်ပုံတင်မှု မအောင်မြင်ပါ။ ပြန်လည်ကြိုးစားပါ။",
|
||||
"deviceRequired": "အလုပ်သမား အကောင့်ဝင်ရောက်မှုအတွက် စက်ပစ္စည်း မှတ်ပုံတင်ခြင်း လိုအပ်ပါသည်။",
|
||||
|
||||
"servicesStatus": "ဝန်ဆောင်မှုများ အခြေအနေ",
|
||||
"overallStatus": "ခြုံငုံအခြေအနေ",
|
||||
|
||||
@@ -208,6 +208,9 @@
|
||||
"servicesReady": "सबै सेवाहरू तयार छन्",
|
||||
"autoLoginFailed": "अटो-लगइन असफल। कृपया म्यानुअल रूपमा लगइन गर्नुहोस्।",
|
||||
"deviceValidationFailed": "उपकरण प्रमाणीकरण असफल। कृपया सहयोगलाई सम्पर्क गर्नुहोस्।",
|
||||
"deviceMismatch": "यो उपकरण तपाईंको खाताको लागि अधिकृत छैन।",
|
||||
"deviceRegistrationFailed": "उपकरण दर्ता असफल। फेरि प्रयास गर्नुहोस्।",
|
||||
"deviceRequired": "कामदार लगइनको लागि उपकरण दर्ता आवश्यक छ।",
|
||||
|
||||
"servicesStatus": "सेवाहरूको स्थिति",
|
||||
"overallStatus": "समग्र स्थिति",
|
||||
|
||||
@@ -198,6 +198,9 @@
|
||||
"servicesReady": "அனைத்து சேவைகளும் தயாராக உள்ளன",
|
||||
"autoLoginFailed": "தானியங்கு உள்நுழைவு தோல்வி. தயவுசெய்து கைமுறையாக உள்நுழைக.",
|
||||
"deviceValidationFailed": "சாதன சரிபார்ப்பு தோல்வி. தயவுசெய்து ஆதரவைத் தொடர்பு கொள்ளவும்.",
|
||||
"deviceMismatch": "இந்த சாதனம் உங்கள் கணக்கிற்கு அங்கீகரிக்கப்படவில்லை.",
|
||||
"deviceRegistrationFailed": "சாதன பதிவு தோல்வி. மீண்டும் முயற்சிக்கவும்.",
|
||||
"deviceRequired": "தொழிலாளர் உள்நுழைவிற்கு சாதன பதிவு தேவை.",
|
||||
"servicesStatus": "சேவைகளின் நிலை",
|
||||
"overallStatus": "ஒட்டுமொத்த நிலை",
|
||||
"locationTracking": "இருப்பிட கண்காணிப்பு",
|
||||
|
||||
@@ -84,28 +84,23 @@ const handleChangePassword = async () => {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiFetch('/api/worker/change-password', {
|
||||
await apiFetch('/api/worker/change-password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
currentPassword: passwords.value.currentPassword,
|
||||
newPassword: passwords.value.newPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
if (response.status === 401) {
|
||||
errorMessage.value = 'invalidCurrentPassword'
|
||||
} else {
|
||||
errorMessage.value = errorData.message || 'passwordUpdateError'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
successMessage.value = 'passwordUpdated'
|
||||
passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
|
||||
} catch (err) {
|
||||
errorMessage.value = err.message || 'passwordUpdateError'
|
||||
if (err.message.includes('Incorrect current password') || err.message.includes('401')) {
|
||||
errorMessage.value = 'invalidCurrentPassword'
|
||||
} else if (err.message.includes('Invalid input')) {
|
||||
errorMessage.value = 'passwordUpdateError'
|
||||
} else {
|
||||
errorMessage.value = 'passwordUpdateError'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user