diff --git a/backend/cert.pem b/backend/cert.pem new file mode 100644 index 0000000..b3ebdd9 --- /dev/null +++ b/backend/cert.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEajCCAtKgAwIBAgIQCidY0lKaDwojBgr6MpeBzzANBgkqhkiG9w0BAQsFADCB +kTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlM +XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMM +MW1rY2VydCBNQUlMXG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lh +bikwHhcNMjUwNzA0MDc0NjExWhcNMjcxMDA0MDc0NjExWjBeMScwJQYDVQQKEx5t +a2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxMzAxBgNVBAsMKk1BSUxcbWFz +b25neWFuQERFU0tUT1AtSVFVOERERCAobWFzb25neWFuKTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBANl8SofEGCDGYv2J22Qanu6LgxvvKd9wKB1Lf2x6 +eBD84tHmVZXKuQElo9ZkEbljKA9M8dNCTrxNFzGL6dB2b3fRHBnEYhiANKnMohgb +oul+Tiq2/Pye4SHWglvsM6DboImARRW58L8FyA3mnS9VgS7TUb3W2tRQhLHU1s/R +QjZulIQvpe+k0dW+S1zd7wBg790K5GNs9va/8KEM1v3esBNOpCbKeWzeRT/Si9ZA +Dfm72SSWslHQEXtuz8AQVtfk0qJMUB0URmyadir0aJwuDC6m5iQSKtLTvQp+n0/Z +lundQQbsnm71FnCAD9PSz+IaB3euEOwUGbGnDW9+10kGTekCAwEAAaNwMG4wDgYD +VR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFElR +m5C15845O14vXvSvwjxwtiJEMCYGA1UdEQQfMB2CCWxvY2FsaG9zdIcECgACAocE +fwAAAYcEwKgkNjANBgkqhkiG9w0BAQsFAAOCAYEAsOdvadeTxsAT0Le63PPEYPiZ +drkEJdTyu9Thv9nFhLCD4vUYIZrlE3brFXD1iVTR1muJsalfnmW9azIwGBHw52bZ +B2XdA6HNZEklSRtqNMEAGJsdnbGuCTPa1lLNuzCQodSnmbvu6Y5K13Pq/asl3DVW +h/hczwX5NrQvlvyDwI0kVSDRmEb5AYnEic5h64gEyILTVWopT8RzA+B8AtW3oP3d +pfoCErwQvxfkNd3UGWk+rDlQWwApzh+N4P+3vAjhAra7Yoj+JtT0SnXeAjXhbB0E +WmDcMNQwxUg1FN5ATR5pAMoSSNviLaf/jYb93naZ6YZKgSfSIKNgUJz+ppgHNBFr +326JOYH0yzyhWXUXchzsn1ytMkhddNVZhRbGceOkyZEkaSynZR4om8ZGxPJYfCBB +m9sH27eCeJBy9DXk0ZUkJg+y3C+jizenHiPnED92Z1EZ0ke7fNufiVZs0yQl2uxg +V5mgoQSLxu4LHXQnTm/NQugY9S8rfbz510WutGKi +-----END CERTIFICATE----- diff --git a/backend/hash_passwords.js b/backend/hash_passwords.js deleted file mode 100644 index 7a7b14a..0000000 --- a/backend/hash_passwords.js +++ /dev/null @@ -1,39 +0,0 @@ -import mysql from 'mysql2/promise' -import bcrypt from 'bcrypt' -import dotenv from 'dotenv' - -dotenv.config() - -async function hashPasswords() { - const db = await mysql.createConnection({ - host: process.env.DB_HOST, - user: process.env.DB_USERNAME, - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE, - port: process.env.DB_PORT, - }) - - try { - const [workers] = await db.execute('SELECT id, password_hash FROM workers') - - for (const worker of workers) { - if (worker.password_hash && !worker.password_hash.startsWith('$2b$')) { - const saltRounds = 10 - const hashedPassword = await bcrypt.hash(worker.password_hash, saltRounds) - await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [ - hashedPassword, - worker.id, - ]) - console.log(`Hashed password for worker with ID: ${worker.id}`) - } - } - - console.log('Password hashing complete.') - } catch (error) { - console.error('Error hashing passwords:', error) - } finally { - await db.end() - } -} - -hashPasswords() diff --git a/backend/key.pem b/backend/key.pem new file mode 100644 index 0000000..f667e5f --- /dev/null +++ b/backend/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDZfEqHxBggxmL9 +idtkGp7ui4Mb7ynfcCgdS39sengQ/OLR5lWVyrkBJaPWZBG5YygPTPHTQk68TRcx +i+nQdm930RwZxGIYgDSpzKIYG6Lpfk4qtvz8nuEh1oJb7DOg26CJgEUVufC/BcgN +5p0vVYEu01G91trUUISx1NbP0UI2bpSEL6XvpNHVvktc3e8AYO/dCuRjbPb2v/Ch +DNb93rATTqQmynls3kU/0ovWQA35u9kklrJR0BF7bs/AEFbX5NKiTFAdFEZsmnYq +9GicLgwupuYkEirS070Kfp9P2Zbp3UEG7J5u9RZwgA/T0s/iGgd3rhDsFBmxpw1v +ftdJBk3pAgMBAAECggEAIeDztzx7ybc9umMcMvbWpTBEZziVXEIbbZzSJ7LYO0U5 +jBsGYAQpV51mbUI/ZJKmreN9lDwzCbA0mbpC3P9mE9MWPolSAqEOExlWcszzTs4n +HQ5OUIfraBsDSZB85mTwGBtMJ7tEXm1nIYs4FySJsCKpDBqJEiPM1+rg35SobNP5 +aOvuLgXe3V6wVuihakoGj8nUtCgKsPr/14ybcF6Fcv5ULI6Tls0G8HOY92Kesb/o +NZL1YmMVevY+RKYzrZKca6mRanMIjnjrnYGX5V404mh6GQKpGdgrcMEONMbJje2H +44MjyJYhQ67/ItOKOuC1JG1LuRq/5SXTAS2WW7g+1QKBgQDiWefUn2v3pYd4CIFd +Bz43TpHuQZiqX5UOvPFOrk5LT+EhYHTpSCThrc5piqk+XsnV3G1dyDnbBK8k4FPa +yyrUuNOSvQlspSr0u++5i7cRLwq7C6kRtTzW8nr6Az8bE6u1prvXKFIWKP/doWeg +U7jPMCVKN+oxvNN6Fi0meecLxwKBgQD1+RkfrUCg7xpr+gn2R2LxryL2u/oxVRmo +4TZqBQoXcQJBx+UrTcIL8XENohYYI/7HCZfD/cBxpFGNqclD3DjzjH2NZ43MBlbN +up3wD+Ks2LVOilyOrxK3be/cnvPyQJantd/NBnHOTsQoBUPdhbrqdyrjYW0o4WZQ +5c36f934zwKBgQCRiTEQeviWoG279ewHfpK4SOJ3iOG6Gf7jHQUii9x3fALKzRQe +sm5UVMZ1AdzT52prAXGobQcWFarvUPVZpmwBnl0a6kTXAFPgS75VVMn+WHrTzSmF +4zwdEIeVnOTEah9riqsYKiqtaOsq+45/fZVEUjaHw+/mzvxCcWPSa2rtHQKBgEUe +amDsXmzaw6Hz8TizdqpTfI+44uVZ9IvwPUotgFh1+Rxi/5LbltukTRB3q528/6sO +lwcMFzfX5NLaEyRujdJieCV0I/RhE6Nb/WWoERphCxG276topunEitKEGCjK3Yrj +ILCMTw6aM6TLVfa5zXx1YCflCLekHww8h1UM+WMhAoGAH6U1XzkW3ozty7sQ5vxZ +jzri0xUpp06EA/EtfhkCRPgaYCkL5aXan+jNAZPfTG6mGudULWjTIfEEQrMJ54CN +sItMoPP2S4EDuj4xdQWe8eTeMqtGG/lAmG2Yr9QajWofNLwaBtsXANYCDGadNUxa +2pog6+BDaFEC64IwkoBYgZ8= +-----END PRIVATE KEY----- diff --git a/backend/rootCA.pem b/backend/rootCA.pem new file mode 100644 index 0000000..f8213ef --- /dev/null +++ b/backend/rootCA.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE8zCCA1ugAwIBAgIQGdkeqkj233eI/7av8ih4aTANBgkqhkiG9w0BAQsFADCB +kTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlM +XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMM +MW1rY2VydCBNQUlMXG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lh +bikwHhcNMjUwNzAzMDg1NDI2WhcNMzUwNzAzMDg1NDI2WjCBkTEeMBwGA1UEChMV +bWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlMXG1hc29uZ3lhbkBE +RVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMMMW1rY2VydCBNQUlM +XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikwggGiMA0GCSqG +SIb3DQEBAQUAA4IBjwAwggGKAoIBgQC5YL+VRL/bDBg0SP78IZTCemeLr7Q4Zxtg +8MiaWrDnh6ssVFzmAY3PEnfTdSL/j0JV2I0cSZhmMkUAzoo7136paLA3aGD4QP0B +fDEt6xQZF30U3bRhTglEY8a1zhy6fJGTYOcl2/OTbS0q90fEaLx8wkVa0lf/2wA7 +fYG65BSu9CgTdob6NBWbI3Jpsesxd+36WZCqa6ZPSk07nXozqjMFsG8CThr1Wmei +mZJZF6+ji0mI6RqiqgdWrKBp2FZbPERQS+QfYfKD5/N0cWpwUAxejSLlPxU886Ns +Tcld9vxHQjzcE0afJe7rO4IrzzIeL1oLsz3xhEBgn8JCUeWbU12pk+9j1z+/M0+U +LUt/g+cwHk8fKl7qoL1ydR7afDdFBR8ns+g5l40ZE/uwhgQA8uTsi2E18B5agAtQ +C6+dJC4bMiVn9iyCeQmPKS+xw4YOVmn0yfrkqRLRgSZDjQEd4pUAep4J/8WbI1BY +lNqRwmqBcLuuyQLpExlMBYPMWiWYBakCAwEAAaNFMEMwDgYDVR0PAQH/BAQDAgIE +MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFElRm5C15845O14vXvSvwjxw +tiJEMA0GCSqGSIb3DQEBCwUAA4IBgQAIjI0pKdY1/NKIaCg0WuQcWh8/noTjqYdl +7RDJ+JQZB1W0SkN7XvSLiRcEroWalbq9BMwE/bwV9jcgW1NvQ+00VzUWKh0r9z3B +xjEnKnK+1pEXRdBfkG6bVi2XehNs7KOvqR07xv7o9GdB41R7TSo1Vr228ot82FNO +6B2iDumfIr9RESsx8nVntHvRuFTee/DlhVUEgJPWmw0Kwcewmd2p5XNijdA2V2nI +zwhUxQuuu0LtV8RXmBi5vDanrJHwZZ1kFvGG9SGiVNx6aEtGBjTMIFRpQyLFzeq9 +TPbVLsEGjvi8wLqO8U/aj56BEkFNKAx0idohgyfF2qohRMXoL0MRtEQIpJdL2kMP +Gqg1aY7MWooEM9swji1hHuoDwLriVNS6W3LvT9qXWlI3e/J7f5aLT/QyP4VUW+4N +1oGUL54aXCMYymVXooU3QomakxCildlGbH0jdcf8uX8JVnI0Zeo3ftCmtf46Q+Lu +7Mhu4NO8kQGHH/m0wQwwahh+mBfwYwk= +-----END CERTIFICATE----- diff --git a/backend/server.js b/backend/server.js index 398068c..144271b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,23 +1,166 @@ import express from 'express' import cors from 'cors' +import https from 'https' +import http from 'http' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + import { Parser } from 'json2csv' import { v4 as uuidv4 } from 'uuid' import mysql from 'mysql2/promise' import dotenv from 'dotenv' import bcrypt from 'bcrypt' import jwt from 'jsonwebtoken' -// --- FIX START --- -// Import only the required functions from turf -import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf' -// --- FIX END --- +import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf' + + +async function validateDeviceForUser(userId, deviceUuid, db) { + try { + // Step 1: Get user's current registered device UUID from workers table + const [userRows] = await db.execute( + 'SELECT device_uuid, role, username FROM workers WHERE id = ?', + [userId] + ) + + if (userRows.length === 0) { + return { + valid: false, + message: 'User not found' + } + } + + const user = userRows[0] + const registeredDeviceUuid = user.device_uuid + + // Step 2: If no device is registered for this user (NULL device_uuid) + if (!registeredDeviceUuid) { + // Check if this device UUID is already registered to another user + const [otherUserRows] = await db.execute( + 'SELECT id, username FROM workers WHERE device_uuid = ? AND id != ?', + [deviceUuid, userId] + ) + + if (otherUserRows.length > 0) { + // Log security alert for device conflict + await logSecurityAlert(userId, 'device_conflict', { + attempted_device_uuid: deviceUuid, + conflicting_user: otherUserRows[0].username, + message: 'Attempted to register device already assigned to another user' + }, db) + + return { + valid: false, + message: 'Device is already registered to another account' + } + } + + // Step 3: Auto-register this device for the user (initial setup) + await db.execute( + 'UPDATE workers SET device_uuid = ? WHERE id = ?', + [deviceUuid, userId] + ) + + console.log(`Auto-registered device ${deviceUuid} for user ${userId} (${user.username}) - initial setup`) + return { + valid: true, + message: 'Device registered successfully for first-time login' + } + } + + // Step 4: User has a registered device - check if current device matches + if (registeredDeviceUuid === deviceUuid) { + // Device UUID matches - allow login + return { + valid: true, + message: 'Device validated successfully' + } + } + + // Step 5: Device UUID mismatch - log security alert and reject + await logSecurityAlert(userId, 'unauthorized_device_attempt', { + registered_device_uuid: registeredDeviceUuid, + attempted_device_uuid: deviceUuid, + username: user.username, + message: 'Login attempt from unauthorized device' + }, db) + + return { + valid: false, + message: 'This device is not authorized for your account. Please use your registered device.' + } + } catch (error) { + console.error('Device validation error:', error) + return { + valid: false, + message: 'Device validation failed' + } + } +} + +// Helper function to log security alerts for device validation issues +async function logSecurityAlert(userId, alertType, alertData, db) { + try { + await db.execute( + 'INSERT INTO security_alerts (user_id, alert_type, alert_data, severity, created_at) VALUES (?, ?, ?, ?, NOW())', + [userId, alertType, JSON.stringify(alertData), 'medium'] + ) + console.log(`Security alert logged: ${alertType} for user ${userId}`) + } catch (error) { + console.error('Failed to log security alert:', error) + } +} + +// Helper function to register a new device for user (simplified for workers table) +async function registerDeviceForUser(userId, deviceUuid, db) { + try { + // Check if device is already registered to another user + const [otherUserRows] = await db.execute( + 'SELECT id, username FROM workers WHERE device_uuid = ? AND id != ?', + [deviceUuid, userId] + ) + + if (otherUserRows.length > 0) { + // Log security alert for device conflict + await logSecurityAlert(userId, 'device_registration_conflict', { + attempted_device_uuid: deviceUuid, + conflicting_user: otherUserRows[0].username, + message: 'Attempted to register device already assigned to another user' + }, db) + + return { + success: false, + message: 'Device is already registered to another account' + } + } + + // Register device by updating workers table + await db.execute( + 'UPDATE workers SET device_uuid = ? WHERE id = ?', + [deviceUuid, userId] + ) + + console.log(`Device ${deviceUuid} registered for user ${userId}`) + return { + success: true, + message: 'Device registered successfully' + } + } catch (error) { + console.error('Device registration error:', error) + return { + success: false, + message: 'Database error during device registration' + } + } +} // Main function to start the server async function startServer() { - dotenv.config() + dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') }) + const app = express() - const port = 3000 // --- Database Connection --- const db = mysql.createPool({ @@ -42,29 +185,86 @@ async function startServer() { } // Define the geofence polygon by calling the 'polygon' function directly - const geofence = polygon([ - [ - [101.80827335908509, 2.8350045747358337], - [101.80822799653066, 2.8340134829130363], - [101.80827902940462, 2.8335264317641418], - [101.80941309326164, 2.8332772427247335], - [101.81144873788423, 2.834596811345506], - [101.81166988033686, 2.8345911479647157], - [101.81199875885511, 2.83593336858695], - [101.80827335908509, 2.8350045747358337], - ], - ]) + // const geofence = polygon([ + // [ + // [101.80827335908509, 2.8350045747358337], + // [101.80822799653066, 2.8340134829130363], + // [101.80827902940462, 2.8335264317641418], + // [101.80941309326164, 2.8332772427247335], + // [101.81144873788423, 2.834596811345506], + // [101.81166988033686, 2.8345911479647157], + // [101.81199875885511, 2.83593336858695], + // [101.80827335908509, 2.8350045747358337], + // ], + // ]) + +const geofence = polygon([ + [ + [113.35311466293217, 23.161344441258407], + [113.28591534444001, 23.161344441258407], + [113.28591534444001, 23.091366234233973], + [113.35311466293217, 23.091366234233973], + [113.35311466293217, 23.161344441258407] + ] +]) - app.use(cors()) + // Enhanced CORS configuration for HTTPS and mobile development + const corsOptions = { + origin: function (origin, callback) { + // Allow requests with no origin (mobile apps, Postman, etc.) + if (!origin) return callback(null, true) + + // Define allowed origins + const allowedOrigins = [ + 'http://localhost:5173', + 'https://localhost:5173', + 'capacitor://localhost', + 'ionic://localhost', + 'http://localhost', + 'https://localhost' + ] + + // Add environment-specific origins + if (process.env.CORS_ORIGIN) { + const envOrigins = process.env.CORS_ORIGIN.split(',') + allowedOrigins.push(...envOrigins) + } + + // Add local IP origins for mobile testing + if (process.env.SERVER_IP) { + const serverIP = process.env.SERVER_IP + allowedOrigins.push( + `http://${serverIP}:5173`, + `https://${serverIP}:5173`, + `http://${serverIP}:3000`, + `https://${serverIP}:3443` + ) + } + + // Check if origin is allowed + if (allowedOrigins.indexOf(origin) !== -1 || origin.startsWith('capacitor://') || origin.startsWith('ionic://')) { + callback(null, true) + } else { + console.log('CORS blocked origin:', origin) + callback(null, true) // Allow all origins in development + } + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning'], + exposedHeaders: ['Content-Range', 'X-Content-Range'] + } + + app.use(cors(corsOptions)) app.use(express.json()) // --- API Endpoints --- - // Auth Endpoint + // Auth Endpoint with Device UUID validation app.post('/api/auth/login', async (req, res) => { try { - const { username, password } = req.body + const { username, password, deviceUuid } = req.body const [rows] = await db.execute( 'SELECT id, role, password_hash FROM workers WHERE username = ?', [username], @@ -73,6 +273,17 @@ async function startServer() { const user = rows[0] const passwordMatch = await bcrypt.compare(password, user.password_hash) if (passwordMatch) { + // Check device UUID validation if provided, but skip for managers + if (deviceUuid && user.role !== 'manager') { + const deviceValidation = await validateDeviceForUser(user.id, deviceUuid, db) + if (!deviceValidation.valid) { + return res.status(403).json({ + message: 'Device not authorized for this account', + deviceError: deviceValidation.message + }) + } + } + const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h', }) @@ -98,77 +309,99 @@ async function startServer() { jwt.verify(token, process.env.JWT_SECRET, (err, user) => { if (err) { - return res.sendStatus(403) + return res.status(403).json({ message: 'Forbidden' }) } req.user = user next() }) } else { - res.sendStatus(401) + res.status(401).json({ message: 'Unauthorized' }) } } - // Worker Clock In/Out Endpoint + // Worker Clock In/Out Endpoint - Optimized Version app.post('/api/clock', authenticateJWT, async (req, res) => { try { - const { userId, eventType, qrCodeValue, latitude, longitude } = req.body + const { userId, eventType, qrCodeValue, latitude, longitude, notes } = req.body; + const connection = await db.getConnection(); // Get connection from pool first - // Geofencing check using the directly imported functions - const userLocation = point([longitude, latitude]); - const isWithinGeofence = booleanPointInPolygon(userLocation, geofence); + try { + // Start transaction + await connection.beginTransaction(); - if (!isWithinGeofence) { - // User is outside the geofence, log a 'failed' attempt - // Calculate the distance from the geofence - const distance = pointToLineDistance(userLocation, geofence.geometry.coordinates[0], { units: 'meters' }); - // Create a descriptive note - const notes = `Clock-in outside of the zone: ${distance.toFixed(2)} meters.`; + // Bypass checks for special cases + if (qrCodeValue !== 'FORCE_CLOCK_OUT') { + // Parallelize geofence and QR code checks + const [geofenceCheck, qrCheck] = await Promise.all([ + booleanPointInPolygon(point([longitude, latitude]), geofence), + connection.execute('SELECT name, is_active FROM qr_codes WHERE id = ?', [qrCodeValue]) + ]); - // Insert the failed attempt into the database - await db.execute( - 'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - [userId, 'failed', new Date(), qrCodeValue, latitude, longitude, notes] - ); + if (!geofenceCheck) { + const distance = pointToLineDistance(point([longitude, latitude]), + geofence.geometry.coordinates[0], { units: 'meters' }); + const notes = `Clock-in outside of the zone: ${distance.toFixed(2)} meters.`; - // Return an error to the user - return res.status(403).json({ message: `You are not within the allowed work area.` }); - // --- MODIFICATION END --- - } + await connection.execute( + 'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', + [userId, 'failed', new Date(), qrCodeValue, latitude, longitude, notes] + ); - const [qrRows] = await db.execute('SELECT name, is_active FROM qr_codes WHERE id = ?', [ - qrCodeValue, - ]) + await connection.commit(); + return res.status(403).json({ message: `You are not within the allowed work area.` }); + } - if (qrRows.length === 0) { - // This code is not in the database at all. - return res.status(400).json({ message: 'Invalid QR Code scanned.' }) + if (qrCheck[0].length === 0) { + await connection.rollback(); + return res.status(400).json({ message: 'Invalid QR Code scanned.' }); + } + + if (!qrCheck[0][0].is_active) { + await connection.rollback(); + return res.status(400).json({ message: 'This QR Code has expired and is no longer active.' }); + } + } + + // Check last event + const [lastEvent] = await connection.execute( + 'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', + [userId] + ); + + if (qrCodeValue === 'FORCE_CLOCK_OUT') { + // If it's a forced clock-out, log it as a failed event regardless of previous state + await connection.execute( + 'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', + [userId, 'failed', new Date(), qrCodeValue, latitude, longitude, `FAKE GPS APP Detected.`] + ); + await connection.commit(); + return res.status(200).json({ message: 'Forced clock-out attempt was logged.' }); +} + +if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) { + await connection.rollback(); + return res.status(400).json({ message: `You are already clocked ${eventType === 'clock_in' ? 'in' : 'out'}.` }); +} + + // Insert new record + const timestamp = new Date(); + await connection.execute( + 'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', + [userId, eventType, timestamp, qrCodeValue || null, latitude || null, longitude || null, notes || null] + ); + + await connection.commit(); + res.status(201).json({ message: 'Clock event recorded successfully' }); + } catch (err) { + await connection.rollback(); + throw err; + } finally { + connection.release(); } - - if (!qrRows[0].is_active) { - // This code exists but has been deactivated. - return res - .status(400) - .json({ message: 'This QR Code has expired and is no longer active.' }) - } - const [lastEventRows] = await db.execute( - 'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', - [userId], - ) - if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) { - return res - .status(400) - .json({ message: `You are already clocked ${eventType === 'clock_in' ? 'in' : 'out'}.` }) - } - const timestamp = new Date() - await db.execute( - 'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?)', - [userId, eventType, timestamp, qrCodeValue, latitude, longitude], - ) - res.status(201).json({ message: 'Clock event recorded successfully' }) } catch (error) { - console.error('Clock event error:', error) - res.status(500).json({ message: 'Database error during clock event.' }) + console.error('Clock event error:', error); + res.status(500).json({ message: 'Database error during clock event.' }); } }) @@ -690,15 +923,301 @@ async function startServer() { } }) - // --- Server Start --- - // const httpsOptions = { - // key: fs.readFileSync(process.env.SSL_KEY_PATH), - // cert: fs.readFileSync(process.env.SSL_CERT_PATH), - // } + // --- NEW NATIVE FEATURES API ENDPOINTS --- - app.listen(port, () => { - console.log(`Server is running on http://localhost:${port}`) + // Location Update Endpoint - OPTIMIZED: Removed continuous geofence checking + app.post('/api/location/update', authenticateJWT, async (req, res) => { + try { + const { userId, latitude, longitude, checkGeofence } = req.body + + if (!userId || !latitude || !longitude) { + return res.status(400).json({ message: 'User ID, latitude, and longitude are required.' }) + } + + // OPTIMIZATION: Only check geofence when explicitly requested + // This reduces unnecessary processing on every location update + if (checkGeofence === true) { + try { + const userLocation = point([longitude, latitude]); + const isWithinGeofence = booleanPointInPolygon(userLocation, geofence); + + if (!isWithinGeofence) { + // User is outside the geofence - log security alert silently + const distance = pointToLineDistance(userLocation, geofence.geometry.coordinates[0], { units: 'meters' }); + + const alertData = { + latitude: latitude, + longitude: longitude, + timestamp: new Date().toISOString(), + distance_from_geofence: distance.toFixed(2), + check_type: 'scheduled_update' // Indicate this was a scheduled check + }; + + // Log geofence violation to security_alerts table + await logSecurityAlert(userId, 'geofence_violation', alertData, db); + + console.log(`OPTIMIZED: Geofence violation detected for user ${userId}: ${distance.toFixed(2)} meters outside boundary`); + } else { + console.log(`OPTIMIZED: User ${userId} within geofence during scheduled check`); + } + } catch (geofenceError) { + console.error('OPTIMIZED: Error checking geofence:', geofenceError); + // Continue with location update even if geofence check fails + } + } + + // OPTIMIZED: Simplified location update - only essential fields + // No need for timestamp conversion as we use created_at with NOW() + await db.execute( + 'INSERT INTO location_updates (user_id, longitude, latitude, created_at) VALUES (?, ?, ?, NOW())', + [userId, longitude, latitude] + ) + + res.json({ message: 'Location updated successfully' }) + } catch (error) { + console.error('Location update error:', error) + res.status(500).json({ message: 'Database error during location update.' }) + } }) + + // Device Registration Endpoint + app.post('/api/device/register', authenticateJWT, async (req, res) => { + try { + const { userId, deviceUuid, deviceInfo } = req.body + + if (!userId || !deviceUuid) { + return res.status(400).json({ message: 'User ID and device UUID are required.' }) + } + + const result = await registerDeviceForUser(userId, deviceUuid, deviceInfo, db) + + if (result.success) { + res.json({ message: result.message, success: true }) + } else { + res.status(409).json({ message: result.message, success: false }) + } + } catch (error) { + console.error('Device registration error:', error) + res.status(500).json({ message: 'Database error during device registration.', success: false }) + } + }) + + // Device Validation Endpoint + app.post('/api/device/validate', authenticateJWT, async (req, res) => { + try { + const { userId, deviceUuid } = req.body + + if (!userId || !deviceUuid) { + return res.status(400).json({ message: 'User ID and device UUID are required.' }) + } + + const validation = await validateDeviceForUser(userId, deviceUuid, db) + + res.json({ + valid: validation.valid, + message: validation.message + }) + } catch (error) { + console.error('Device validation error:', error) + res.status(500).json({ message: 'Database error during device validation.', valid: false }) + } + }) + + + // Security Check Endpoint - COMMENTED OUT FOR SERVER-SIDE SECURITY PREFERENCE + // Client-side security computation removed per user preference for server-side security + /* + app.post('/api/security/check', authenticateJWT, async (req, res) => { + try { + const { userId, timestamp, deviceInfo, securityCheck } = req.body + + if (!userId || !securityCheck) { + return res.status(400).json({ message: 'User ID and security check data are required.' }) + } + + // Calculate risk score + let riskScore = 0 + if (securityCheck.suspiciousApps && securityCheck.suspiciousApps.totalSuspiciousApps > 0) { + riskScore += securityCheck.suspiciousApps.totalSuspiciousApps * 10 + } + if (securityCheck.riskLevel === 'high') { + riskScore += 50 + } else if (securityCheck.riskLevel === 'medium') { + riskScore += 25 + } + + // Convert timestamp to MySQL-compatible format + let mysqlTimestamp + if (timestamp) { + if (typeof timestamp === 'string' && timestamp.includes('T')) { + // Handle ISO 8601 format (e.g., '2025-07-04T09:00:49.192Z') + // Convert to MySQL DATETIME format by replacing 'T' with ' ' and removing 'Z' + mysqlTimestamp = timestamp.replace('T', ' ').replace('Z', '') + } else if (timestamp instanceof Date) { + // Handle Date object + mysqlTimestamp = timestamp.toISOString().replace('T', ' ').replace('Z', '') + } else { + // Fallback to current time for invalid formats + mysqlTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '') + } + } else { + // Use current time if no timestamp provided + mysqlTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '') + } + + // Store security check results + await db.execute( + 'INSERT INTO security_checks (user_id, timestamp, device_info, security_data, risk_level, risk_score, created_at) VALUES (?, ?, ?, ?, ?, ?, NOW())', + [userId, mysqlTimestamp, JSON.stringify(deviceInfo), JSON.stringify(securityCheck), securityCheck.riskLevel, riskScore] + ) + + // Check if risk level is too high + if (riskScore >= 50) { + // Log high-risk event + await db.execute( + 'INSERT INTO security_alerts (user_id, alert_type, alert_data, severity, created_at) VALUES (?, ?, ?, ?, NOW())', + [userId, 'high_risk_device', JSON.stringify({ riskScore, securityCheck }), 'high'] + ) + + return res.status(403).json({ + message: 'Device security risk too high. Please contact administrator.', + riskLevel: securityCheck.riskLevel, + riskScore: riskScore + }) + } + + res.json({ + message: 'Security check completed successfully', + riskLevel: securityCheck.riskLevel, + riskScore: riskScore, + status: 'approved' + }) + } catch (error) { + console.error('Security check error:', error) + res.status(500).json({ message: 'Database error during security check.' }) + } + }) + */ + + // Get Security Status Endpoint + app.get('/api/security/status/:userId', authenticateJWT, async (req, res) => { + try { + const { userId } = req.params + + // Get latest security check + const [securityRows] = await db.execute( + 'SELECT * FROM security_checks WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', + [userId] + ) + + // Get recent security alerts + const [alertRows] = await db.execute( + 'SELECT * FROM security_alerts WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY created_at DESC', + [userId] + ) + + res.json({ + latestSecurityCheck: securityRows[0] || null, + recentAlerts: alertRows, + securityStatus: securityRows.length > 0 ? securityRows[0].risk_level : 'unknown' + }) + } catch (error) { + console.error('Get security status error:', error) + res.status(500).json({ message: 'Database error fetching security status.' }) + } + }) + + // Get App Blacklist Endpoint + app.get('/api/security/app-blacklist', authenticateJWT, async (req, res) => { + try { + const [rows] = await db.execute('SELECT package_name FROM app_blacklist'); + const packageNames = rows.map(row => row.package_name); + res.json(packageNames); + } catch (error) { + console.error('Get app blacklist error:', error); + res.status(500).json({ message: 'Database error fetching app blacklist.' }); + } + }); + + // --- Server Start --- + const httpPort = process.env.HTTP_PORT || 3000 + const httpsPort = process.env.HTTPS_PORT || 3443 + const sslEnabled = process.env.SSL_ENABLED === 'true' + + if (sslEnabled) { + try { + // Check if SSL certificate files exist + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const keyPath = path.join(currentDir, 'key.pem') + const certPath = path.join(currentDir, 'cert.pem') + + console.log('Resolved keyPath:', keyPath) + console.log('Resolved certPath:', certPath) + + if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) { + console.error('SSL certificate or key file not found. Falling back to HTTP.') + startHttpServer() + return + } + + const httpsOptions = { + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + } + + // Start HTTPS server + const httpsServer = https.createServer(httpsOptions, app) + httpsServer.listen(httpsPort, '0.0.0.0', () => { + console.log(`🔒 HTTPS Server is running on https://localhost:${httpsPort}`) + if (process.env.SERVER_IP) { + console.log(`🔒 HTTPS Server is also available on https://${process.env.SERVER_IP}:${httpsPort}`) + } + console.log('📱 For Android testing, use the IP address URL') + }) + + // Optional: Start HTTP server that redirects to HTTPS + const httpApp = express() + httpApp.use((req, res) => { + const host = req.headers.host?.split(':')[0] || 'localhost' + const redirectUrl = `https://${host}:${httpsPort}${req.url}` + res.redirect(301, redirectUrl) + }) + + const httpServer = http.createServer(httpApp) + httpServer.listen(httpPort, '0.0.0.0', () => { + console.log(`🔄 HTTP Server (redirect) is running on http://localhost:${httpPort}`) + }) + + // Graceful shutdown + process.on('SIGTERM', () => { + console.log('Shutting down servers...') + httpsServer.close(() => { + console.log('HTTPS server closed') + }) + httpServer.close(() => { + console.log('HTTP server closed') + }) + }) + + } catch (error) { + console.error('❌ Failed to start HTTPS server:', error.message) + console.log('Falling back to HTTP server...') + startHttpServer() + } + } else { + startHttpServer() + } + + function startHttpServer() { + const httpServer = http.createServer(app) + httpServer.listen(httpPort, '0.0.0.0', () => { + console.log(`🌐 HTTP Server is running on http://localhost:${httpPort}`) + if (process.env.SERVER_IP) { + console.log(`🌐 HTTP Server is also available on http://${process.env.SERVER_IP}:${httpPort}`) + } + console.log('⚠️ Using HTTP - HTTPS recommended for mobile testing') + }) + } } startServer() diff --git a/dev.sql b/dev.sql new file mode 100644 index 0000000..829e153 --- /dev/null +++ b/dev.sql @@ -0,0 +1,230 @@ +# Host: localhost (Version: 5.7.26) +# Date: 2025-07-14 12:03:00 +# Generator: MySQL-Front 5.3 (Build 4.234) + +/*!40101 SET NAMES utf8 */; + +# +# Structure for table "system_config" +# + +CREATE TABLE `system_config` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `config_key` varchar(255) NOT NULL, + `config_value` text, + `config_type` enum('string','number','boolean','json') DEFAULT 'string', + `description` text, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `config_key` (`config_key`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='System configuration settings'; + +# +# Data for table "system_config" +# + +INSERT INTO `system_config` VALUES +(1,'geofence_enabled','true','boolean','Enable geofence checking for clock-in/out','2025-07-14 12:00:00','2025-07-14 12:00:00'), +(2,'work_start_time','09:00','string','Standard work start time (HH:MM)','2025-07-14 12:00:00','2025-07-14 12:00:00'), +(3,'work_end_time','17:00','string','Standard work end time (HH:MM)','2025-07-14 12:00:00','2025-07-14 12:00:00'), +(4,'late_threshold_minutes','15','number','Minutes after start time to consider as late','2025-07-14 12:00:00','2025-07-14 12:00:00'), +(5,'early_threshold_minutes','15','number','Minutes before end time to consider as early leave','2025-07-14 12:00:00','2025-07-14 12:00:00'), +(6,'qr_codes_enabled','true','boolean','Enable QR code scanning for clock-in/out','2025-07-14 12:00:00','2025-07-14 12:00:00'), +(7,'location_tracking_enabled','true','boolean','Enable GPS location tracking','2025-07-14 12:00:00','2025-07-14 12:00:00'), +(8,'security_check_enabled','true','boolean','Enable device security checks','2025-07-14 12:00:00','2025-07-14 12:00:00'); + +# +# Structure for table "worker_profiles" +# + +CREATE TABLE `worker_profiles` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `worker_id` int(11) NOT NULL, + `department` varchar(255) DEFAULT NULL, + `position` varchar(255) DEFAULT NULL, + `phone` varchar(50) DEFAULT NULL, + `email` varchar(255) DEFAULT NULL, + `emergency_contact` varchar(255) DEFAULT NULL, + `emergency_phone` varchar(50) DEFAULT NULL, + `address` text, + `hire_date` date DEFAULT NULL, + `salary_type` enum('hourly','monthly','daily') DEFAULT 'hourly', + `hourly_rate` decimal(10,2) DEFAULT NULL, + `monthly_salary` decimal(10,2) DEFAULT NULL, + `bank_account` varchar(50) DEFAULT NULL, + `tax_id` varchar(50) DEFAULT NULL, + `social_security_id` varchar(50) DEFAULT NULL, + `notes` text, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `worker_id` (`worker_id`), + KEY `idx_department` (`department`), + KEY `idx_position` (`position`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Extended worker profile information'; + +# +# Structure for table "attendance_rules" +# + +CREATE TABLE `attendance_rules` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `rule_name` varchar(255) NOT NULL, + `rule_type` enum('late','early','absence','overtime') NOT NULL, + `condition_type` enum('time','duration','frequency') NOT NULL, + `condition_value` varchar(255) NOT NULL, + `action_type` enum('warning','deduction','notification') NOT NULL, + `action_value` varchar(255) DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_rule_type` (`rule_type`), + KEY `idx_is_active` (`is_active`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Attendance rules and policies'; + +# +# Data for table "attendance_rules" +# + +INSERT INTO `attendance_rules` VALUES +(1,'Late Arrival','late','time','09:15','warning',NULL,1,'2025-07-14 12:00:00','2025-07-14 12:00:00'), +(2,'Early Departure','early','time','16:45','warning',NULL,1,'2025-07-14 12:00:00','2025-07-14 12:00:00'), +(3,'Excessive Late','late','time','09:30','deduction','30',1,'2025-07-14 12:00:00','2025-07-14 12:00:00'), +(4,'Absent Without Notice','absence','frequency','1','deduction','100',1,'2025-07-14 12:00:00','2025-07-14 12:00:00'); + +# +# Structure for table "notifications" +# + +CREATE TABLE `notifications` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) DEFAULT NULL, + `type` enum('system','alert','reminder','report') NOT NULL, + `title` varchar(255) NOT NULL, + `message` text, + `data` json DEFAULT NULL, + `is_read` tinyint(1) DEFAULT 0, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_type` (`type`), + KEY `idx_is_read` (`is_read`), + KEY `idx_created_at` (`created_at`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='System notifications for users'; + +# +# Structure for table "geofence_zones" +# + +CREATE TABLE `geofence_zones` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `description` text, + `coordinates` json NOT NULL, + `center_lat` decimal(10,8) NOT NULL, + `center_lng` decimal(11,8) NOT NULL, + `radius_meters` int(11) DEFAULT 0, + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_is_active` (`is_active`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Geofence zones for location-based attendance'; + +# +# Data for table "geofence_zones" +# + +INSERT INTO `geofence_zones` VALUES +(1,'Main Factory','Main factory area','[[113.35311466293217,23.161344441258407],[113.28591534444001,23.161344441258407],[113.28591534444001,23.091366234233973],[113.35311466293217,23.091366234233973],[113.35311466293217,23.161344441258407]]',23.126355,113.319515,5000,1,'2025-07-14 12:00:00','2025-07-14 12:00:00'); + +# +# Structure for table "shift_schedules" +# + +CREATE TABLE `shift_schedules` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `worker_id` int(11) NOT NULL, + `shift_date` date NOT NULL, + `shift_type` enum('morning','afternoon','night','custom') NOT NULL, + `start_time` time NOT NULL, + `end_time` time NOT NULL, + `break_duration` int(11) DEFAULT 60, + `notes` text, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_worker_id` (`worker_id`), + KEY `idx_shift_date` (`shift_date`), + KEY `idx_shift_type` (`shift_type`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Worker shift schedules'; + +# +# Structure for table "overtime_records" +# + +CREATE TABLE `overtime_records` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `worker_id` int(11) NOT NULL, + `date` date NOT NULL, + `start_time` time NOT NULL, + `end_time` time NOT NULL, + `duration_minutes` int(11) NOT NULL, + `reason` text, + `approved_by` int(11) DEFAULT NULL, + `status` enum('pending','approved','rejected') DEFAULT 'pending', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_worker_id` (`worker_id`), + KEY `idx_date` (`date`), + KEY `idx_status` (`status`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Overtime records and approvals'; + +# +# Structure for table "leave_requests" +# + +CREATE TABLE `leave_requests` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `worker_id` int(11) NOT NULL, + `leave_type` enum('sick','vacation','personal','emergency') NOT NULL, + `start_date` date NOT NULL, + `end_date` date NOT NULL, + `duration_days` int(11) NOT NULL, + `reason` text, + `status` enum('pending','approved','rejected') DEFAULT 'pending', + `approved_by` int(11) DEFAULT NULL, + `approved_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_worker_id` (`worker_id`), + KEY `idx_status` (`status`), + KEY `idx_dates` (`start_date`,`end_date`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Leave requests and approvals'; + +# +# Structure for table "payroll_records" +# + +CREATE TABLE `payroll_records` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `worker_id` int(11) NOT NULL, + `period_start` date NOT NULL, + `period_end` date NOT NULL, + `base_salary` decimal(10,2) NOT NULL, + `overtime_pay` decimal(10,2) DEFAULT 0, + `deductions` decimal(10,2) DEFAULT 0, + `net_pay` decimal(10,2) NOT NULL, + `status` enum('draft','calculated','approved','paid') DEFAULT 'draft', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_worker_id` (`worker_id`), + KEY `idx_period` (`period_start`,`period_end`), + KEY `idx_status` (`status`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Payroll records for workers';