Files
Nilai_Clock/backend/server.js
T
sudomarcma d322404007 feat(地理围栏): 添加地理围栏功能并优化打卡记录处理
- 添加@turf/turf依赖用于地理围栏计算
- 实现地理围栏检查,拒绝围栏外的打卡并记录失败事件
- 过滤掉失败事件在工人历史记录中显示
- 修复地图链接中的错误语法
- 移除开发提示和HTTPS相关代码
- 优化视图按钮样式和事件类型颜色标识
2025-06-30 16:03:12 +08:00

706 lines
25 KiB
JavaScript

import express from 'express'
import cors from 'cors'
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 ---
// Main function to start the server
async function startServer() {
dotenv.config()
const app = express()
const port = 3000
// --- Database Connection ---
const db = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
})
try {
const connection = await db.getConnection()
console.log('Database connected successfully!')
connection.release()
} catch (error) {
console.error('!!! DATABASE CONNECTION FAILED !!!')
console.error('Error:', error.message)
process.exit(1)
}
// --- FIX START ---
// 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],
],
])
// --- FIX END ---
app.use(cors())
app.use(express.json())
// --- API Endpoints ---
// Auth Endpoint
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body
const [rows] = await db.execute(
'SELECT id, role, password_hash FROM workers WHERE username = ?',
[username],
)
if (rows.length > 0) {
const user = rows[0]
const passwordMatch = await bcrypt.compare(password, user.password_hash)
if (passwordMatch) {
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, {
expiresIn: '1h',
})
res.json({ message: 'Login successful', token })
} else {
res.status(401).json({ message: 'Invalid credentials' })
}
} else {
res.status(401).json({ message: 'Invalid credentials' })
}
} catch (error) {
console.error('Login error:', error)
res.status(500).json({ message: 'Database error during login.' })
}
})
// Middleware to verify JWT
const authenticateJWT = (req, res, next) => {
const authHeader = req.headers.authorization
if (authHeader) {
const token = authHeader.split(' ')[1]
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.sendStatus(403)
}
req.user = user
next()
})
} else {
res.sendStatus(401)
}
}
// Worker Clock In/Out Endpoint
app.post('/api/clock', authenticateJWT, async (req, res) => {
try {
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body
// Geofencing check using the directly imported functions
const userLocation = point([longitude, latitude]);
const isWithinGeofence = booleanPointInPolygon(userLocation, geofence);
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.`;
// 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, distance_meters) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[userId, 'failed', new Date(), qrCodeValue, latitude, longitude, notes, distance]
);
// Return an error to the user
return res.status(403).json({ message: `You are not within the allowed work area.` });
// --- MODIFICATION END ---
}
const [qrRows] = await db.execute('SELECT name, is_active FROM qr_codes WHERE id = ?', [
qrCodeValue,
])
if (qrRows.length === 0) {
// This code is not in the database at all.
return res.status(400).json({ message: 'Invalid QR Code scanned.' })
}
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.' })
}
})
// Fetch worker details endpoint
app.get('/api/workers/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
const [rows] = await db.execute(
'SELECT full_name FROM workers WHERE id = ? AND role = "worker"',
[id],
)
if (rows.length > 0) {
res.json({ full_name: rows[0].full_name })
} else {
res.status(404).json({ message: 'Worker not found.' })
}
} catch (error) {
console.error('Get worker details error:', error)
res.status(500).json({ message: 'Database error fetching worker details.' })
}
})
// Worker Status Endpoint
app.get('/api/worker/status/:userId', authenticateJWT, async (req, res) => {
try {
const { userId } = req.params
const [rows] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[userId],
)
if (rows.length > 0) {
res.json({ eventType: rows[0].event_type })
} else {
res.json({ eventType: 'clock_out' }) // Default to clocked out
}
} catch (error) {
console.error('Worker status error:', error)
res.status(500).json({ message: 'Database error fetching status.' })
}
})
// Worker History Endpoint
app.get('/api/worker/clock-history/:userId', authenticateJWT, async (req, res) => {
try {
const { userId } = req.params
// MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries
const [rows] = await db.execute(
`SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC`,
[userId],
)
res.json(rows)
} catch (error) {
console.error('Worker history error:', error)
res.status(500).json({ message: 'Database error fetching history.' })
}
})
app.put('/api/worker/change-password', authenticateJWT, async (req, res) => {
try {
const { userId } = req.user // Get user ID from JWT
const { currentPassword, newPassword } = req.body
if (!currentPassword || !newPassword) {
return res.status(400).json({ message: 'Current password and new password are required.' })
}
if (newPassword.length < 6) {
return res.status(400).json({ message: 'New password must be at least 6 characters long.' })
}
// Get user's current password hash
const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId])
if (rows.length === 0) {
return res.status(404).json({ message: 'User not found.' })
}
const user = rows[0]
// Verify current password
const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash)
if (!passwordMatch) {
return res.status(401).json({ message: 'Incorrect current password.' })
}
// Hash new password
const saltRounds = 10
const newHashedPassword = await bcrypt.hash(newPassword, saltRounds)
// Update password in DB
await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [
newHashedPassword,
userId,
])
res.json({ message: 'Password updated successfully.' })
} catch (error) {
console.error('Change password error:', error)
res.status(500).json({ message: 'Database error during password change.' })
}
})
// Manager: PUT (Update) a Worker's Password
app.put('/api/managers/workers/:workerId/password', authenticateJWT, async (req, res) => {
try {
// Ensure the user performing the action is a manager
if (req.user.role !== 'manager') {
return res
.status(403)
.json({ message: 'Forbidden: You do not have permission to perform this action.' })
}
const { workerId } = req.params
const { newPassword } = req.body
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ message: 'Password must be at least 6 characters long.' })
}
const saltRounds = 10
const hashedPassword = await bcrypt.hash(newPassword, saltRounds)
const [result] = await db.execute(
"UPDATE workers SET password_hash = ? WHERE id = ? AND role = 'worker'",
[hashedPassword, workerId],
)
if (result.affectedRows === 0) {
return res
.status(404)
.json({ message: 'Worker not found or you cannot change the password for this user.' })
}
res.status(200).json({ message: 'Password updated successfully.' })
} catch (error) {
console.error('Update password error:', error)
res.status(500).json({ message: 'Database error while updating password.' })
}
})
// GET all tags
app.get('/api/managers/tags', authenticateJWT, async (req, res) => {
try {
const [tags] = await db.execute('SELECT * FROM tags ORDER BY tag_name ASC')
res.json(tags)
} catch (error) {
console.error('Get tags error:', error)
res.status(500).json({ message: 'Database error fetching tags.' })
}
})
// POST a new tag
app.post('/api/managers/tags', authenticateJWT, async (req, res) => {
try {
const { tag_name } = req.body
if (!tag_name) {
return res.status(400).json({ message: 'Tag name is required.' })
}
const [result] = await db.execute('INSERT INTO tags (tag_name) VALUES (?)', [tag_name])
res.status(201).json({ id: result.insertId, tag_name })
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'This tag already exists.' })
}
console.error('Add tag error:', error)
res.status(500).json({ message: 'Database error adding tag.' })
}
})
// NEW: DELETE a tag
app.delete('/api/managers/tags/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
// Optional: Check if the user is a manager before allowing deletion
if (req.user.role !== 'manager') {
return res.status(403).json({ message: 'Forbidden: Only managers can delete tags.' })
}
// Delete the tag from the 'tags' table.
// If 'worker_tags' table has ON DELETE CASCADE for tag_id,
// related entries in 'worker_tags' will automatically be removed.
const [result] = await db.execute('DELETE FROM tags WHERE id = ?', [id])
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Tag not found.' })
}
res.status(204).send() // 204 No Content for successful deletion
} catch (error) {
console.error('Delete tag error:', error)
res.status(500).json({ message: 'Database error deleting tag.' })
}
})
// POST to assign a tag to a worker
app.post('/api/managers/workers/:workerId/tags', authenticateJWT, async (req, res) => {
try {
const { workerId } = req.params
const { tagId } = req.body // Expects a single tag ID
if (!tagId) {
return res.status(400).json({ message: 'Tag ID is required.' })
}
// INSERT IGNORE prevents errors if the tag is already assigned to the worker
await db.query('INSERT IGNORE INTO worker_tags (worker_id, tag_id) VALUES (?, ?)', [
workerId,
tagId,
])
res.status(200).json({ message: 'Tag assigned successfully.' })
} catch (error) {
console.error('Assign tag error:', error)
res.status(500).json({ message: 'Database error assigning tag.' })
}
})
// DELETE to remove a tag from a worker
app.delete('/api/managers/workers/:workerId/tags/:tagId', authenticateJWT, async (req, res) => {
try {
const { workerId, tagId } = req.params
await db.query('DELETE FROM worker_tags WHERE worker_id = ? AND tag_id = ?', [
workerId,
tagId,
])
res.status(204).send() // 204 No Content for successful deletion
} catch (error) {
console.error('Remove tag error:', error)
res.status(500).json({ message: 'Database error removing tag.' })
}
})
// Find this endpoint in your server.js and replace it with the code below.
// Manager: GET All Workers (FIXED for older MySQL versions)
app.get('/api/managers/workers', authenticateJWT, async (req, res) => {
try {
const { search = '', page = 1, limit = 20, tags = '' } = req.query
const offset = (parseInt(page) - 1) * parseInt(limit)
const searchTerm = `%${search}%`
const tagIds = tags
.split(',')
.filter((id) => id)
.map(Number)
const hasTagFilter = tagIds.length > 0
// Base queries
let baseQuery = `
SELECT
w.id, w.username, w.full_name, w.created_at,
(SELECT GROUP_CONCAT(t.tag_name SEPARATOR ', ')
FROM worker_tags wt_sub
JOIN tags t ON wt_sub.tag_id = t.id
WHERE wt_sub.worker_id = w.id) as tags
FROM workers w
`
let countQuery = `SELECT COUNT(DISTINCT w.id) as totalCount FROM workers w`
// Parameters for the queries
const params = []
const countParams = []
// Join with worker_tags if filtering
if (hasTagFilter) {
const joinClause = ` JOIN worker_tags wt ON w.id = wt.worker_id`
baseQuery += joinClause
countQuery += joinClause
}
// Common WHERE clause
const whereClause = ` WHERE w.role = 'worker' AND (w.full_name LIKE ? OR w.username LIKE ?)`
baseQuery += whereClause
countQuery += whereClause
params.push(searchTerm, searchTerm)
countParams.push(searchTerm, searchTerm)
// Add tag filtering logic
if (hasTagFilter) {
const tagPlaceholders = tagIds.map(() => '?').join(',')
const tagFilterClause = ` AND wt.tag_id IN (${tagPlaceholders})`
baseQuery += tagFilterClause
countQuery += tagFilterClause
// Add the tag IDs to the parameters individually
params.push(...tagIds)
countParams.push(...tagIds)
// --- FIX END ---
}
// Grouping and pagination for the main query
if (hasTagFilter) {
baseQuery += ` GROUP BY w.id HAVING COUNT(DISTINCT wt.tag_id) = ?`
params.push(tagIds.length)
}
baseQuery += ` ORDER BY w.created_at DESC LIMIT ? OFFSET ?`
params.push(parseInt(limit), offset)
// Execute queries
const [workers] = await db.execute(baseQuery, params)
const [[{ totalCount }]] = await db.execute(countQuery, countParams)
res.json({ workers, totalCount })
} catch (error) {
// This is the error you are seeing
console.error('Get workers error:', error)
res.status(500).json({ message: 'Database error fetching workers.' })
}
})
// Manager: POST (Add new) Worker
app.post('/api/managers/workers', authenticateJWT, async (req, res) => {
try {
const { username, password, fullName, role = 'worker' } = req.body
if (!username || !password || !fullName) {
return res.status(400).json({ message: 'Username, password, and full name are required.' })
}
if (!['worker', 'manager'].includes(role)) {
return res.status(400).json({ message: 'Invalid role specified.' })
}
const saltRounds = 10
const hashedPassword = await bcrypt.hash(password, saltRounds)
const [result] = await db.execute(
'INSERT INTO workers (username, password_hash, full_name, role) VALUES (?, ?, ?, ?)',
[username, hashedPassword, fullName, role], // Pass role to query
)
res.status(201).json({ id: result.insertId, username, fullName, role })
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'Username already exists.' })
}
console.error('Add worker error:', error)
res.status(500).json({ message: 'Database error adding worker.' })
}
})
// Manager: DELETE Worker
app.delete('/api/managers/workers/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
const [result] = await db.execute("DELETE FROM workers WHERE id = ? AND role = 'worker'", [
id,
])
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found or user is not a worker.' })
}
res.status(204).send()
} catch (error) {
console.error('Delete worker error:', error)
res.status(500).json({ message: 'Database error deleting worker.' })
}
})
// --- NEW --- Manager: POST (Add Manual Attendance Record)
// Note: For this to work, you may need to alter your database table:
// ALTER TABLE clock_records ADD COLUMN notes TEXT;
app.post('/api/managers/add-record', authenticateJWT, async (req, res) => {
try {
const { workerId, eventType, timestamp, notes } = req.body
if (!workerId || !eventType || !timestamp) {
return res
.status(400)
.json({ message: 'Worker ID, event type, and timestamp are required.' })
}
// Check last event to prevent adding a duplicate event type
const [lastEventRows] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[workerId],
)
if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) {
const status = eventType === 'clock_in' ? 'in' : 'out'
return res.status(409).json({ message: `Worker is already clocked ${status}.` })
}
// --- THIS IS THE FIX ---
const sanitizedTimestamp = timestamp.replace('T', ' ')
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, timestamp, notes, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, NULL, NULL, NULL)',
[workerId, eventType, sanitizedTimestamp, notes],
)
res.status(201).json({ message: 'Manual record added successfully.' })
} catch (error) {
console.error('Add manual record error:', error)
res.status(500).json({ message: 'Database error adding manual record.' })
}
})
// Manager: GET Attendance Records
app.get('/api/managers/attendance-records', authenticateJWT, async (req, res) => {
try {
const { workerIds, startDate, endDate, format } = req.query
if (!workerIds) {
return res.status(400).json({ message: 'Worker IDs are required.' })
}
const idsArray = workerIds.split(',').map(Number)
if (idsArray.length === 0) return res.json([])
const placeholders = idsArray.map(() => '?').join(',')
// MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries, and select `notes`
let query = `SELECT cr.id, w.full_name, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName, cr.latitude, cr.longitude, cr.notes, cr.distance_meters FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id JOIN workers w ON cr.worker_id = w.id WHERE cr.worker_id IN (${placeholders})`;
const params = [...idsArray]
if (startDate && endDate) {
const endOfDay = new Date(endDate)
endOfDay.setHours(23, 59, 59, 999)
query += ' AND cr.timestamp BETWEEN ? AND ?'
params.push(startDate, endOfDay)
}
query += ' ORDER BY w.full_name, cr.timestamp DESC'
const [rows] = await db.execute(query, params)
if (format === 'csv') {
// MODIFIED: Add 'notes' to CSV export
const json2csvParser = new Parser({
fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName', 'notes'],
})
const csv = json2csvParser.parse(rows)
res.header('Content-Type', 'text/csv')
res.attachment(`attendance-report-${new Date().toISOString().split('T')[0]}.csv`)
return res.send(csv)
}
res.json(rows)
} catch (error) {
console.error('Attendance records error:', error)
res.status(500).json({ message: 'Database error fetching attendance records.' })
}
})
// Manager: GET QR Codes
app.get('/api/managers/qr-codes', authenticateJWT, async (req, res) => {
try {
const [rows] = await db.execute(
'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC',
)
res.json(rows)
} catch (error) {
console.error('Get QR codes error:', error)
res.status(500).json({ message: 'Database error fetching QR codes.' })
}
})
// Manager: POST QR Code
app.post('/api/managers/qr-codes', authenticateJWT, async (req, res) => {
try {
const { name } = req.body
if (!name) return res.status(400).json({ message: 'QR Code name is required.' })
const newQrCode = { id: uuidv4(), name, isActive: true }
await db.execute('INSERT INTO qr_codes (id, name, is_active) VALUES (?, ?, ?)', [
newQrCode.id,
newQrCode.name,
newQrCode.isActive,
])
res
.status(201)
.json({ id: newQrCode.id, name: newQrCode.name, is_active: newQrCode.isActive })
} catch (error) {
console.error('Add QR code error:', error)
res.status(500).json({ message: 'Database error adding QR code.' })
}
})
// Manager: PUT QR Code
app.put('/api/managers/qr-codes/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
const { isActive } = req.body
if (typeof isActive !== 'boolean')
return res.status(400).json({ message: 'isActive must be a boolean.' })
const [result] = await db.execute('UPDATE qr_codes SET is_active = ? WHERE id = ?', [
isActive,
id,
])
if (result.affectedRows === 0) return res.status(404).json({ message: 'QR Code not found.' })
res.json({ id, isActive })
} catch (error) {
console.error('Update QR code error:', error)
res.status(500).json({ message: 'Database error updating QR code.' })
}
})
// Manager: DELETE QR Code
app.delete('/api/managers/qr-codes/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
const [result] = await db.execute('DELETE FROM qr_codes WHERE id = ?', [id])
if (result.affectedRows === 0) return res.status(404).json({ message: 'QR Code not found.' })
res.status(204).send()
} catch (error) {
console.error('Delete QR code error:', error)
res.status(500).json({ message: 'Database error deleting QR code.' })
}
})
// Manager: GET single worker's details
app.get('/api/managers/worker/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
const [rows] = await db.execute(
"SELECT full_name FROM workers WHERE id = ? AND role = 'worker'",
[id],
)
if (rows.length > 0) {
res.json(rows[0])
} else {
res.status(404).json({ message: 'Worker not found.' })
}
} catch (error) {
console.error('Get single worker error:', error)
res.status(500).json({ message: 'Database error fetching worker details.' })
}
})
// --- Server Start ---
// const httpsOptions = {
// key: fs.readFileSync(process.env.SSL_KEY_PATH),
// cert: fs.readFileSync(process.env.SSL_CERT_PATH),
// }
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`)
})
}
startServer()