diff --git a/app-backend/package-lock.json b/app-backend/package-lock.json index f683382de..22164a896 100644 --- a/app-backend/package-lock.json +++ b/app-backend/package-lock.json @@ -23,6 +23,7 @@ "multer": "^2.0.2", "nodemailer": "^7.0.5", "pdfkit": "^0.18.0", + "socket.io": "^4.8.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, @@ -134,7 +135,6 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2669,6 +2669,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", @@ -2723,6 +2729,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2784,7 +2799,6 @@ "version": "24.0.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.15.tgz", "integrity": "sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -2812,6 +2826,15 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -2855,7 +2878,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3561,6 +3583,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -3695,7 +3726,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -4459,6 +4489,79 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz", + "integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4650,7 +4753,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5006,7 +5108,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -8975,6 +9076,90 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9615,7 +9800,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -9962,6 +10146,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/app-backend/package.json b/app-backend/package.json index db178bb27..2030368f0 100644 --- a/app-backend/package.json +++ b/app-backend/package.json @@ -50,6 +50,7 @@ "multer": "^2.0.2", "nodemailer": "^7.0.5", "pdfkit": "^0.18.0", + "socket.io": "^4.8.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, diff --git a/app-backend/server.js b/app-backend/server.js index 70272d455..8e29f420a 100644 --- a/app-backend/server.js +++ b/app-backend/server.js @@ -2,6 +2,7 @@ import dotenv from 'dotenv'; import connectDB from './src/config/connectDB.js'; import app from './src/app.js'; +import { initSocket } from './src/utils/socket.js'; dotenv.config(); @@ -10,14 +11,20 @@ const PORT = process.env.PORT || 3000; const startServer = async () => { try { await connectDB(); - app.listen(PORT, '0.0.0.0', () => { + + + const server = app.listen(PORT, '0.0.0.0', () => { console.log(`🚀 Server running on http://localhost:${PORT}`); console.log(`📘 Swagger UI: http://localhost:${PORT}/api-docs`); + console.log(`🔌 WebSocket server ready for real-time notifications`); }); + + initSocket(server); + } catch (err) { console.error('❌ Failed to start server:', err.message); process.exit(1); } }; -startServer(); +startServer(); \ No newline at end of file diff --git a/app-backend/src/app.js b/app-backend/src/app.js index de877ae8f..6040374cb 100644 --- a/app-backend/src/app.js +++ b/app-backend/src/app.js @@ -8,7 +8,6 @@ import setupSwagger from './config/swagger.js'; // ✅ now using ES module impor import { auditMiddleware } from "./middleware/logger.js"; import path from 'path'; import { fileURLToPath } from 'url'; - const app = express(); app.use(helmet()); diff --git a/app-backend/src/controllers/notification.controller.js b/app-backend/src/controllers/notification.controller.js index cae41b4bc..f158cb078 100644 --- a/app-backend/src/controllers/notification.controller.js +++ b/app-backend/src/controllers/notification.controller.js @@ -1,14 +1,15 @@ import Notification from '../models/Notification.js'; +import User from '../models/User.js'; /** * GET /notifications - * User-specific notifications (paginated + filters) + * User-specific notifications (paginated + filters + priority sorting) */ export const getNotifications = async (req, res) => { try { const userId = req.user._id; - let { page = 1, limit = 20, type, isRead } = req.query; + let { page = 1, limit = 20, type, category, priority, isRead, sortBy = 'priority' } = req.query; page = Math.max(1, parseInt(page)); limit = Math.min(100, Math.max(1, parseInt(limit))); @@ -16,20 +17,33 @@ export const getNotifications = async (req, res) => { const filter = { userId }; if (type) filter.type = type; + if (category) filter.category = category; + if (priority) filter.priority = priority; if (isRead !== undefined) filter.isRead = isRead === 'true'; + // Sort logic: CRITICAL first, then HIGH, then date + let sortOptions = {}; + if (sortBy === 'priority') { + sortOptions = { priority: -1, createdAt: -1 }; + } else { + sortOptions = { createdAt: -1 }; + } + const notifications = await Notification.find(filter) - .sort({ createdAt: -1 }) + .sort(sortOptions) .skip((page - 1) * limit) .limit(limit); const total = await Notification.countDocuments(filter); + // Get unread count by priority + const unreadByPriority = await Notification.getUnreadCountByPriority(userId); + res.json({ - notifications, - total, - page, - limit, + success: true, + data: notifications, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + stats: { unreadByPriority, totalUnread: notifications.filter(n => !n.isRead).length } }); } catch (err) { res.status(500).json({ message: err.message }); @@ -38,25 +52,20 @@ export const getNotifications = async (req, res) => { /** * POST /notifications - * Secure notification creation (restricted roles only) + * Enhanced notification creation with priority support */ export const createNotification = async (req, res) => { try { - const { userId, type, title, message, data } = req.body; + const { userId, type, category, priority, title, message, data, metadata, expiresAt, broadcast, broadcastRoles } = req.body; // Required field validation - if (!userId || !type || !message) { + if (!userId || !type || !category || !message) { return res.status(400).json({ - message: 'userId, type, and message are required', + message: 'userId, type, category, and message are required', }); } - const allowedRoles = [ - 'super_admin', - 'admin', - 'branch_admin', - 'employer', - ]; + const allowedRoles = ['super_admin', 'admin', 'branch_admin', 'employer']; if (!allowedRoles.includes(req.user.role)) { return res.status(403).json({ @@ -64,16 +73,49 @@ export const createNotification = async (req, res) => { }); } - const notification = await Notification.create({ + // Handle broadcast to multiple users + if (broadcast) { + let targetUsers = []; + if (broadcastRoles && broadcastRoles.length > 0) { + targetUsers = await User.find({ role: { $in: broadcastRoles } }).distinct('_id'); + } else if (userId === 'all') { + targetUsers = await User.find().distinct('_id'); + } + + if (targetUsers.length > 0) { + const notifications = await Notification.broadcast({ + type, + category, + priority: priority || 'MEDIUM', + title: title || '', + message, + data: data || {}, + metadata: metadata || {}, + expiresAt: expiresAt || null, + }, targetUsers); + + return res.status(201).json({ + success: true, + message: `Broadcast sent to ${targetUsers.length} users`, + count: notifications.length + }); + } + } + + // Single notification + const notification = await Notification.createNotification({ userId, type, + category, + priority: priority || 'MEDIUM', title: title || '', message, data: data || {}, - createdBy: req.user._id, + metadata: metadata || {}, + expiresAt: expiresAt || null, }); - res.status(201).json(notification); + res.status(201).json({ success: true, data: notification }); } catch (err) { res.status(400).json({ message: err.message }); } @@ -94,7 +136,7 @@ export const getNotificationById = async (req, res) => { return res.status(404).json({ message: 'Notification not found' }); } - res.json(notification); + res.json({ success: true, data: notification }); } catch (err) { res.status(500).json({ message: err.message }); } @@ -111,7 +153,7 @@ export const markAsRead = async (req, res) => { _id: req.params.id, userId: req.user._id, }, - { isRead: true }, + { isRead: true, readAt: new Date() }, { new: true } ); @@ -119,7 +161,7 @@ export const markAsRead = async (req, res) => { return res.status(404).json({ message: 'Notification not found' }); } - res.json(notification); + res.json({ success: true, data: notification }); } catch (err) { res.status(500).json({ message: err.message }); } @@ -131,12 +173,15 @@ export const markAsRead = async (req, res) => { */ export const markAllAsRead = async (req, res) => { try { - await Notification.updateMany( - { userId: req.user._id }, - { isRead: true } + const result = await Notification.updateMany( + { userId: req.user._id, isRead: false }, + { isRead: true, readAt: new Date() } ); - res.json({ success: true }); + res.json({ + success: true, + message: `${result.modifiedCount} notifications marked as read` + }); } catch (err) { res.status(500).json({ message: err.message }); } @@ -152,8 +197,51 @@ export const getUnreadCount = async (req, res) => { isRead: false, }); - res.json({ unreadCount: count }); + const byPriority = await Notification.getUnreadCountByPriority(req.user._id); + + res.json({ + success: true, + unreadCount: count, + byPriority + }); + } catch (err) { + res.status(500).json({ message: err.message }); + } +}; + +/** + * DELETE /notifications/expired + * Clean up expired notifications + */ +export const deleteExpiredNotifications = async (req, res) => { + try { + const result = await Notification.deleteMany({ + expiresAt: { $lt: new Date() } + }); + + res.json({ + success: true, + message: `${result.deletedCount} expired notifications deleted` + }); } catch (err) { res.status(500).json({ message: err.message }); } }; + +/** + * GET /notifications/unread/high-priority + * Get unread HIGH and CRITICAL priority notifications + */ +export const getHighPriorityUnread = async (req, res) => { + try { + const notifications = await Notification.find({ + userId: req.user._id, + isRead: false, + priority: { $in: ['HIGH', 'CRITICAL'] } + }).sort({ priority: -1, createdAt: -1 }); + + res.json({ success: true, data: notifications }); + } catch (err) { + res.status(500).json({ message: err.message }); + } +}; \ No newline at end of file diff --git a/app-backend/src/controllers/shiftrequest.controller.js b/app-backend/src/controllers/shiftrequest.controller.js new file mode 100644 index 000000000..2bafe323d --- /dev/null +++ b/app-backend/src/controllers/shiftrequest.controller.js @@ -0,0 +1,429 @@ +import mongoose from 'mongoose'; +import ShiftRequest from '../models/ShiftRequest.js'; +import Shift from '../models/Shift.js'; +import User from '../models/User.js'; + +/** + * Create a shift request (SWAP or LEAVE) + * POST /api/v1/shifts/request + */ +export const createShiftRequest = async (req, res) => { + const session = await mongoose.startSession(); + + try { + const { type, targetGuardId, originalShiftId, replacementShiftId, leaveStartDate, leaveEndDate, reason } = req.body; + const requestingGuardId = req.user._id; + + if (req.user.role !== 'guard') { + return res.status(403).json({ message: 'Only guards can create shift requests' }); + } + + const originalShift = await Shift.findById(originalShiftId); + if (!originalShift) { + return res.status(404).json({ message: 'Original shift not found' }); + } + + if (!originalShift.guardIds || !originalShift.guardIds.includes(requestingGuardId.toString())) { + return res.status(403).json({ message: 'You are not assigned to this shift' }); + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (originalShift.date < today) { + return res.status(400).json({ message: 'Cannot request changes for past shifts' }); + } + + const existingRequest = await ShiftRequest.findOne({ + originalShiftId, + status: 'PENDING', + requestingGuardId, + }); + if (existingRequest) { + return res.status(400).json({ message: 'You already have a pending request for this shift' }); + } + + if (type === 'SWAP') { + if (!targetGuardId) { + return res.status(400).json({ message: 'Target guard is required for swap requests' }); + } + + const targetGuard = await User.findById(targetGuardId); + if (!targetGuard || targetGuard.role !== 'guard') { + return res.status(404).json({ message: 'Target guard not found' }); + } + + if (targetGuardId.toString() === requestingGuardId.toString()) { + return res.status(400).json({ message: 'Cannot swap shift with yourself' }); + } + + if (replacementShiftId) { + const replacementShift = await Shift.findById(replacementShiftId); + if (!replacementShift) { + return res.status(404).json({ message: 'Replacement shift not found' }); + } + if (!replacementShift.guardIds || !replacementShift.guardIds.includes(targetGuardId.toString())) { + return res.status(403).json({ message: 'Replacement shift must belong to the target guard' }); + } + if (replacementShift.date < today) { + return res.status(400).json({ message: 'Cannot swap with past shifts' }); + } + } + } + + if (type === 'LEAVE') { + if (!leaveStartDate || !leaveEndDate) { + return res.status(400).json({ message: 'Leave start and end dates are required' }); + } + } + + const shiftRequest = await ShiftRequest.create({ + type, + requestingGuardId, + targetGuardId: type === 'SWAP' ? targetGuardId : undefined, + originalShiftId, + replacementShiftId: type === 'SWAP' ? replacementShiftId : undefined, + leaveStartDate: type === 'LEAVE' ? new Date(leaveStartDate) : undefined, + leaveEndDate: type === 'LEAVE' ? new Date(leaveEndDate) : undefined, + reason, + }); + + const populatedRequest = await ShiftRequest.findById(shiftRequest._id) + .populate('requestingGuardId', 'name email') + .populate('targetGuardId', 'name email') + .populate('originalShiftId', 'title date startTime endTime') + .populate('replacementShiftId', 'title date startTime endTime'); + + res.status(201).json({ + success: true, + data: populatedRequest, + message: `${type === 'SWAP' ? 'Swap' : 'Leave'} request created successfully`, + }); + } catch (error) { + console.error('Create shift request error:', error); + res.status(500).json({ message: error.message || 'Failed to create shift request' }); + } finally { + session.endSession(); + } +}; + +/** + * Get shift requests (filtered by role) + * GET /api/v1/shifts/requests + */ +export const getShiftRequests = async (req, res) => { + try { + const { status, type, page = 1, limit = 20 } = req.query; + const filter = {}; + + if (req.user.role === 'guard') { + filter.$or = [ + { requestingGuardId: req.user._id }, + { targetGuardId: req.user._id }, + ]; + } else if (req.user.role === 'employer') { + const employerShifts = await Shift.find({ createdBy: req.user._id }).distinct('_id'); + filter.originalShiftId = { $in: employerShifts }; + } + + if (status) filter.status = status; + if (type) filter.type = type; + + const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); + + const [requests, total] = await Promise.all([ + ShiftRequest.find(filter) + .populate('requestingGuardId', 'name email phone') + .populate('targetGuardId', 'name email') + .populate('originalShiftId', 'title date startTime endTime location urgency') + .populate('replacementShiftId', 'title date startTime endTime') + .populate('approvedBy', 'name email') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(parseInt(limit, 10)), + ShiftRequest.countDocuments(filter), + ]); + + res.json({ + success: true, + data: requests, + pagination: { + page: parseInt(page, 10), + limit: parseInt(limit, 10), + total, + pages: Math.ceil(total / parseInt(limit, 10)), + }, + }); + } catch (error) { + console.error('Get shift requests error:', error); + res.status(500).json({ message: 'Failed to fetch shift requests' }); + } +}; + +/** + * Get single shift request by ID + * GET /api/v1/shifts/request/:id + */ +export const getShiftRequestById = async (req, res) => { + try { + const request = await ShiftRequest.findById(req.params.id) + .populate('requestingGuardId', 'name email phone') + .populate('targetGuardId', 'name email') + .populate('originalShiftId', 'title date startTime endTime location urgency status') + .populate('replacementShiftId', 'title date startTime endTime') + .populate('approvedBy', 'name email'); + + if (!request) { + return res.status(404).json({ message: 'Shift request not found' }); + } + + let hasAccess = false; + + if (req.user.role === 'guard') { + hasAccess = request.requestingGuardId._id.toString() === req.user._id.toString() || + (request.targetGuardId && request.targetGuardId._id.toString() === req.user._id.toString()); + } else if (req.user.role === 'employer') { + const shift = await Shift.findById(request.originalShiftId); + hasAccess = shift && shift.createdBy.toString() === req.user._id.toString(); + } else if (req.user.role === 'admin') { + hasAccess = true; + } + + if (!hasAccess) { + return res.status(403).json({ message: 'Access denied' }); + } + + res.json({ success: true, data: request }); + } catch (error) { + console.error('Get shift request error:', error); + res.status(500).json({ message: 'Failed to fetch shift request' }); + } +}; + +/** + * Update shift request status (APPROVE/REJECT) + * PATCH /api/v1/shifts/request/:id + */ +export const updateShiftRequest = async (req, res) => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const { id } = req.params; + const { status, rejectionReason, targetResponse } = req.body; + + // Added .session(session) to bind to the transaction. + const request = await ShiftRequest.findById(id) + .session(session) + .populate('requestingGuardId', 'name email') + .populate('targetGuardId', 'name email') + .populate('originalShiftId') + .populate('replacementShiftId'); + + if (!request) { + await session.abortTransaction(); + return res.status(404).json({ message: 'Shift request not found' }); + } + + const isAdmin = req.user.role === 'admin'; + const isEmployer = req.user.role === 'employer'; + + // Safely check target guard ID existence + const isTargetGuard = request.type === 'SWAP' && + request.targetGuardId && + request.targetGuardId._id.toString() === req.user._id.toString(); + + // 1. Handle target guard response for SWAP requests + if (targetResponse && request.type === 'SWAP' && isTargetGuard && request.status === 'PENDING') { + request.targetResponse = targetResponse; + request.targetRespondedAt = new Date(); + + if (targetResponse === 'DECLINED') { + request.status = 'REJECTED'; + request.rejectionReason = 'Target guard declined the swap request'; + request.approvedBy = req.user._id; + request.approvedAt = new Date(); + } + + await request.save({ session }); + await session.commitTransaction(); + + return res.json({ + success: true, + data: request, + message: `Swap request ${targetResponse === 'ACCEPTED' ? 'accepted' : 'declined'}`, + }); + } + + // 2. Handle approval/rejection by employer/admin + if (status && (isAdmin || isEmployer) && request.status === 'PENDING') { + + const originalShiftId = request.originalShiftId?._id || request.originalShiftId; + const originalShift = await Shift.findById(originalShiftId).session(session); + + if (!originalShift) { + await session.abortTransaction(); + return res.status(404).json({ message: 'Original shift not found' }); + } + + // Employer ownership check + if (isEmployer) { + if (originalShift.createdBy.toString() !== req.user._id.toString()) { + await session.abortTransaction(); + return res.status(403).json({ message: 'You can only approve requests for shifts you own' }); + } + } + + // Check if this is a SWAP request that needs target acceptance + if (request.type === 'SWAP' && request.targetResponse !== 'ACCEPTED') { + await session.abortTransaction(); + return res.status(400).json({ + message: 'Cannot approve swap until target guard accepts the swap' + }); + } + + if (status === 'APPROVED') { + if (request.type === 'SWAP') { + const targetGuardId = request.targetGuardId._id; + const requestingGuardId = request.requestingGuardId._id; + + // Update original shift + originalShift.acceptedBy = targetGuardId; + + // Use .findIndex and .toString() instead of .indexOf() for ObjectIds + if (originalShift.guardIds && Array.isArray(originalShift.guardIds)) { + const originalGuardIndex = originalShift.guardIds.findIndex( + id => id.toString() === requestingGuardId.toString() + ); + + if (originalGuardIndex !== -1) { + originalShift.guardIds[originalGuardIndex] = targetGuardId; + } else { + originalShift.guardIds.push(targetGuardId); // Fallback + } + } + await originalShift.save({ session }); + + // If replacement shift exists, execute logic properly + if (request.replacementShiftId) { + const replacementShiftId = request.replacementShiftId?._id || request.replacementShiftId; + const replacementShift = await Shift.findById(replacementShiftId).session(session); + + if (replacementShift) { + replacementShift.acceptedBy = requestingGuardId; + + if (replacementShift.guardIds && Array.isArray(replacementShift.guardIds)) { + // Looking for targetGuardId in replacementShift, NOT requestingGuardId + const targetGuardIndex = replacementShift.guardIds.findIndex( + id => id.toString() === targetGuardId.toString() + ); + + if (targetGuardIndex !== -1) { + replacementShift.guardIds[targetGuardIndex] = requestingGuardId; + } else { + replacementShift.guardIds.push(requestingGuardId); + } + } + await replacementShift.save({ session }); + } + } + + } else if (request.type === 'LEAVE') { + // Mark shift as open for reassignment + originalShift.status = 'open'; + originalShift.acceptedBy = null; + originalShift.applicants = []; + + // Remove from guardIds if present + if (originalShift.guardIds && Array.isArray(originalShift.guardIds)) { + const requestingGuardId = request.requestingGuardId?._id || request.requestingGuardId; + const guardIndex = originalShift.guardIds.findIndex( + id => id.toString() === requestingGuardId.toString() + ); + + if (guardIndex !== -1) { + originalShift.guardIds.splice(guardIndex, 1); + } + } + await originalShift.save({ session }); + } + + request.status = 'APPROVED'; + request.approvedBy = req.user._id; + request.approvedAt = new Date(); + + } else if (status === 'REJECTED') { + request.status = 'REJECTED'; + request.rejectionReason = rejectionReason || 'No reason provided'; + request.approvedBy = req.user._id; + request.approvedAt = new Date(); + } + + await request.save({ session }); + + } else if (status && !isAdmin && !isEmployer) { + await session.abortTransaction(); + return res.status(403).json({ message: 'Only employers or admins can approve/reject requests' }); + } else if (!status && !targetResponse) { + // Prevent empty payload from blindly succeeding + await session.abortTransaction(); + return res.status(400).json({ message: 'Invalid payload. "status" or "targetResponse" is required.' }); + } + + await session.commitTransaction(); + + // Fetch the fully updated doc outside the transaction context for the response + const updatedRequest = await ShiftRequest.findById(id) + .populate('requestingGuardId', 'name email') + .populate('targetGuardId', 'name email') + .populate('originalShiftId', 'title date startTime endTime') + .populate('approvedBy', 'name email'); + + res.json({ + success: true, + data: updatedRequest, + message: `Request ${updatedRequest.status.toLowerCase()}`, + }); + + } catch (error) { + // Ensure we only abort if a transaction is currently active + if (session.inTransaction()) { + await session.abortTransaction(); + } + console.error('Update shift request error:', error); + res.status(500).json({ message: error.message || 'Failed to update shift request' }); + } finally { + await session.endSession(); + } +}; + +/** + * Cancel a pending request (guard only) + * DELETE /api/v1/shifts/request/:id + */ +export const cancelShiftRequest = async (req, res) => { + try { + const request = await ShiftRequest.findById(req.params.id); + + if (!request) { + return res.status(404).json({ message: 'Shift request not found' }); + } + + if (request.requestingGuardId.toString() !== req.user._id.toString()) { + return res.status(403).json({ message: 'Only the requesting guard can cancel this request' }); + } + + if (request.status !== 'PENDING') { + return res.status(400).json({ message: 'Cannot cancel a request that is already approved or rejected' }); + } + + await request.deleteOne(); + + res.json({ + success: true, + message: 'Shift request cancelled successfully', + }); + } catch (error) { + console.error('Cancel shift request error:', error); + res.status(500).json({ message: 'Failed to cancel shift request' }); + } +}; \ No newline at end of file diff --git a/app-backend/src/models/Notification.js b/app-backend/src/models/Notification.js index 6ab1a3d60..abd45dfef 100644 --- a/app-backend/src/models/Notification.js +++ b/app-backend/src/models/Notification.js @@ -5,6 +5,7 @@ const notificationSchema = new mongoose.Schema({ type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, + index: true, }, type: { type: String, @@ -12,27 +13,175 @@ const notificationSchema = new mongoose.Schema({ 'SHIFT_APPLIED', 'SHIFT_APPROVED', 'SHIFT_REJECTED', + 'SHIFT_SWAP_REQUEST', + 'SHIFT_SWAP_ACCEPTED', + 'SHIFT_SWAP_DECLINED', + 'SHIFT_LEAVE_REQUEST', + 'SHIFT_LEAVE_APPROVED', + 'SHIFT_LEAVE_REJECTED', + 'SHIFT_REMINDER', 'DOCUMENT_EXPIRING', - 'INCIDENT_REPORTED' + 'INCIDENT_REPORTED', + 'INCIDENT_RESOLVED', + 'SOS_ALERT', + 'SYSTEM_ALERT', + 'PAYROLL_PROCESSED' ], required: true, + index: true, + }, + priority: { + type: String, + enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'], + default: 'MEDIUM', + index: true, + }, + category: { + type: String, + enum: ['SOS', 'INCIDENT', 'SHIFT', 'SYSTEM', 'DOCUMENT', 'PAYROLL'], + required: true, + index: true, }, title: { type: String, required: true, + trim: true, + maxlength: 200, }, message: { type: String, required: true, + trim: true, + maxlength: 1000, }, data: { type: Object, default: {}, }, + metadata: { + shiftId: { type: mongoose.Schema.Types.ObjectId, ref: 'Shift' }, + incidentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Incident' }, + requestId: { type: mongoose.Schema.Types.ObjectId, ref: 'ShiftRequest' }, + location: { + type: { type: String, enum: ['Point'] }, + coordinates: [Number], // [longitude, latitude] + }, + senderId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + actionRequired: { type: Boolean, default: false }, + actionUrl: { type: String }, + }, isRead: { type: Boolean, default: false, + index: true, + }, + readAt: { + type: Date, + default: null, + }, + expiresAt: { + type: Date, + default: null, + index: true, + }, + broadcast: { + type: Boolean, + default: false, + }, + broadcastRoles: [{ + type: String, + enum: ['guard', 'employer', 'admin'], + }], + deliveredAt: { + type: Date, + default: Date.now, + }, + deliveryStatus: { + type: String, + enum: ['PENDING', 'DELIVERED', 'FAILED'], + default: 'PENDING', }, -}, { timestamps: true }); +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Indexes for efficient queries +notificationSchema.index({ userId: 1, isRead: 1, priority: -1, createdAt: -1 }); +notificationSchema.index({ userId: 1, category: 1, createdAt: -1 }); +notificationSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // Auto-delete expired +notificationSchema.index({ priority: 1, deliveryStatus: 1 }); + +// Virtual: check if notification is expired +notificationSchema.virtual('isExpired').get(function() { + return this.expiresAt && this.expiresAt < new Date(); +}); + +// Virtual: time ago +notificationSchema.virtual('timeAgo').get(function() { + const seconds = Math.floor((new Date() - this.createdAt) / 1000); + if (seconds < 60) return `${seconds} seconds ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} minutes ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hours ago`; + const days = Math.floor(hours / 24); + return `${days} days ago`; +}); + +// Pre-save middleware +notificationSchema.pre('save', function(next) { + if (this.isModified('isRead') && this.isRead === true && !this.readAt) { + this.readAt = new Date(); + } + next(); +}); + +// Static method to create notification with priority queue +notificationSchema.statics.createNotification = async function(notificationData) { + const notification = await this.create(notificationData); + + // TODO: Emit WebSocket event for real-time delivery + // if (global.io) { + // global.io.to(`user_${notification.userId}`).emit('new_notification', notification); + // } + + return notification; +}; + +// Static method to broadcast to multiple users +notificationSchema.statics.broadcast = async function(broadcastData, userIds) { + const notifications = []; + for (const userId of userIds) { + notifications.push({ + ...broadcastData, + userId, + broadcast: true, + }); + } + return await this.insertMany(notifications); +}; + +// Static method to get unread count by priority +notificationSchema.statics.getUnreadCountByPriority = async function(userId) { + return await this.aggregate([ + { $match: { userId: mongoose.Types.ObjectId(userId), isRead: false } }, + { $group: { _id: '$priority', count: { $sum: 1 } } }, + { $sort: { + $switch: { + branches: [ + { case: { $eq: ['$_id', 'CRITICAL'] }, then: 1 }, + { case: { $eq: ['$_id', 'HIGH'] }, then: 2 }, + { case: { $eq: ['$_id', 'MEDIUM'] }, then: 3 }, + { case: { $eq: ['$_id', 'LOW'] }, then: 4 }, + ], + default: 5 + } + } + } + ]); +}; -export default mongoose.model('Notification', notificationSchema); \ No newline at end of file +const Notification = mongoose.model('Notification', notificationSchema); +export default Notification; \ No newline at end of file diff --git a/app-backend/src/models/ShiftRequest.js b/app-backend/src/models/ShiftRequest.js new file mode 100644 index 000000000..b2e622067 --- /dev/null +++ b/app-backend/src/models/ShiftRequest.js @@ -0,0 +1,139 @@ +// models/ShiftRequest.js +import mongoose from 'mongoose'; + +const { Schema, model } = mongoose; + +const shiftRequestSchema = new Schema( + { + type: { + type: String, + required: true, + enum: ['SWAP', 'LEAVE'], + index: true, + }, + status: { + type: String, + required: true, + enum: ['PENDING', 'APPROVED', 'REJECTED'], + default: 'PENDING', + index: true, + }, + requestingGuardId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + targetGuardId: { + type: Schema.Types.ObjectId, + ref: 'User', + validate: { + validator: function(v) { + if (this.type === 'SWAP' && !v) return false; + return true; + }, + message: 'Target guard is required for shift swap requests', + }, + }, + originalShiftId: { + type: Schema.Types.ObjectId, + ref: 'Shift', + required: true, + index: true, + }, + replacementShiftId: { + type: Schema.Types.ObjectId, + ref: 'Shift', + default: null, + }, + leaveStartDate: { + type: Date, + validate: { + validator: function(v) { + if (this.type === 'LEAVE' && !v) return false; + return true; + }, + message: 'Leave start date is required for leave requests', + }, + }, + leaveEndDate: { + type: Date, + validate: { + validator: function(v) { + if (this.type === 'LEAVE' && !v) return false; + if (v && this.leaveStartDate && v < this.leaveStartDate) return false; + return true; + }, + message: 'Leave end date must be after start date', + }, + }, + reason: { + type: String, + required: true, + trim: true, + maxlength: 1000, + }, + approvedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + default: null, + }, + approvedAt: { + type: Date, + default: null, + }, + rejectionReason: { + type: String, + trim: true, + maxlength: 500, + default: null, + }, + targetResponse: { + type: String, + enum: ['PENDING', 'ACCEPTED', 'DECLINED'], + default: 'PENDING', + }, + targetRespondedAt: { + type: Date, + default: null, + }, + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + } +); + +// Compound indexes +shiftRequestSchema.index({ requestingGuardId: 1, status: 1, createdAt: -1 }); +shiftRequestSchema.index({ originalShiftId: 1, status: 1 }); +shiftRequestSchema.index({ targetGuardId: 1, status: 1 }); +shiftRequestSchema.index({ type: 1, status: 1 }); + +shiftRequestSchema.virtual('isActionable').get(function() { + return this.status === 'PENDING'; +}); + +shiftRequestSchema.virtual('needsTargetResponse').get(function() { + return this.type === 'SWAP' && this.status === 'PENDING' && this.targetResponse === 'PENDING'; +}); + +shiftRequestSchema.pre('save', function(next) { + if (this.type === 'LEAVE') { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (this.leaveStartDate < today) { + next(new Error('Leave start date cannot be in the past')); + } + + if (this.leaveEndDate < this.leaveStartDate) { + next(new Error('Leave end date must be after start date')); + } + } + next(); +}); + +const ShiftRequest = model('ShiftRequest', shiftRequestSchema); +export default ShiftRequest; \ No newline at end of file diff --git a/app-backend/src/routes/index.js b/app-backend/src/routes/index.js index 575d8a897..4bd6e0892 100644 --- a/app-backend/src/routes/index.js +++ b/app-backend/src/routes/index.js @@ -4,9 +4,9 @@ import healthRoutes from './health.routes.js'; import authRoutes from './auth.routes.js'; import shiftRoutes from './shift.routes.js'; import messageRoutes from './message.routes.js'; -import userRoutes from './user.routes.js'; +import userRoutes from './user.routes.js'; import adminRoutes from './admin.routes.js'; -import availabilityRoutes from './availability.routes.js'; +import availabilityRoutes from './availability.routes.js'; import rbacRoutes from './rbac.routes.js'; import shiftAttendanceRoutes from './shiftattendance.routes.js'; import incidentRoutes from "./incident.routes.js"; @@ -15,6 +15,7 @@ import notificationRoutes from './notification.routes.js' import equipmentRoutes from './equipment.routes.js'; import payrollRoutes from './payroll.routes.js'; import documentRoutes from './document.routes.js'; +import shiftRequestRoutes from "./shiftrequest.routes.js"; import emergencyRoutes from "./emergency.routes.js"; const router = express.Router(); router.use('/documents', documentRoutes); @@ -23,14 +24,15 @@ router.use('/auth', authRoutes); router.use('/shifts', shiftRoutes); router.use('/messages', messageRoutes); router.use('/admin', adminRoutes); -router.use('/availability', availabilityRoutes); -router.use('/users', userRoutes); +router.use('/availability', availabilityRoutes); +router.use('/users', userRoutes); router.use('/rbac', rbacRoutes); router.use('/branch', branchRoutes); router.use('/attendance', shiftAttendanceRoutes); router.use("/incidents", incidentRoutes); router.use('/notifications', notificationRoutes); router.use('/payroll', payrollRoutes); +router.use('/shifts', shiftRequestRoutes); // Shift request routes are nested under /shifts router.use('/equipment', equipmentRoutes); router.use("/emergency", emergencyRoutes); export default router; \ No newline at end of file diff --git a/app-backend/src/routes/notification.routes.js b/app-backend/src/routes/notification.routes.js index 03a92255b..3fb27dbae 100644 --- a/app-backend/src/routes/notification.routes.js +++ b/app-backend/src/routes/notification.routes.js @@ -7,7 +7,9 @@ import { getNotificationById, markAsRead, markAllAsRead, - getUnreadCount + getUnreadCount, + deleteExpiredNotifications, + getHighPriorityUnread } from '../controllers/notification.controller.js'; const router = express.Router(); @@ -16,47 +18,17 @@ const router = express.Router(); * @swagger * tags: * name: Notifications - * description: Notification management system + * description: Enhanced notification management with priority support */ /* ========================================================= - STATIC ROUTES + STATIC ROUTES ========================================================= */ -/** - * @swagger - * /api/v1/notifications/unread-count: - * get: - * summary: Get unread notification count - * tags: [Notifications] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Unread count retrieved - * content: - * application/json: - * schema: - * type: object - * properties: - * unreadCount: - * type: integer - */ router.get('/unread-count', auth, loadUser, getUnreadCount); - -/** - * @swagger - * /api/v1/notifications/read-all: - * patch: - * summary: Mark all notifications as read - * tags: [Notifications] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: All notifications marked as read - */ +router.get('/unread/high-priority', auth, loadUser, getHighPriorityUnread); router.patch('/read-all', auth, loadUser, markAllAsRead); +router.delete('/expired', auth, loadUser, deleteExpiredNotifications); /* ========================================================= MAIN ROUTES @@ -66,91 +38,26 @@ router.patch('/read-all', auth, loadUser, markAllAsRead); * @swagger * /api/v1/notifications: * get: - * summary: Get user notifications (paginated) - * tags: [Notifications] - * security: - * - bearerAuth: [] + * summary: Get user notifications with priority sorting * parameters: * - in: query - * name: page - * schema: - * type: integer - * example: 1 - * - in: query - * name: limit - * schema: - * type: integer - * example: 20 + * name: priority + * schema: { type: string, enum: [LOW, MEDIUM, HIGH, CRITICAL] } * - in: query - * name: type - * schema: - * type: string - * example: SHIFT_APPLIED + * name: category + * schema: { type: string, enum: [SOS, INCIDENT, SHIFT, SYSTEM, DOCUMENT, PAYROLL] } * - in: query - * name: isRead - * schema: - * type: boolean - * example: false - * responses: - * 200: - * description: Notifications fetched successfully + * name: sortBy + * schema: { type: string, enum: [priority, date], default: priority } */ router.get('/', auth, loadUser, getNotifications); - -/** - * @swagger - * /api/v1/notifications: - * post: - * summary: Create a notification - * tags: [Notifications] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [userId, type, message] - * properties: - * userId: - * type: string - * type: - * type: string - * example: SHIFT_APPLIED - * title: - * type: string - * message: - * type: string - * data: - * type: object - */ router.post('/', auth, loadUser, createNotification); /* ========================================================= - PARAM ROUTES + PARAM ROUTES ========================================================= */ -/** - * @swagger - * /api/v1/notifications/{id}: - * get: - * summary: Get single notification - * tags: [Notifications] - * security: - * - bearerAuth: [] - */ router.get('/:id', auth, loadUser, getNotificationById); - -/** - * @swagger - * /api/v1/notifications/{id}/read: - * patch: - * summary: Mark notification as read - * tags: [Notifications] - * security: - * - bearerAuth: [] - */ router.patch('/:id/read', auth, loadUser, markAsRead); export default router; \ No newline at end of file diff --git a/app-backend/src/routes/shiftrequest.routes.js b/app-backend/src/routes/shiftrequest.routes.js new file mode 100644 index 000000000..3aaa628ad --- /dev/null +++ b/app-backend/src/routes/shiftrequest.routes.js @@ -0,0 +1,183 @@ +import express from 'express'; +import protect from '../middleware/auth.js'; +import { + createShiftRequest, + getShiftRequests, + getShiftRequestById, + updateShiftRequest, + cancelShiftRequest, +} from '../controllers/shiftrequest.controller.js'; + +const router = express.Router(); + +/** + * @swagger + * tags: + * name: ShiftRequests + * description: Shift swap and leave request management + */ + +/** + * @swagger + * /api/v1/shifts/request: + * post: + * summary: Create a shift request (SWAP or LEAVE) + * tags: [ShiftRequests] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [type, originalShiftId, reason] + * properties: + * type: + * type: string + * enum: [SWAP, LEAVE] + * targetGuardId: + * type: string + * description: Required for SWAP + * originalShiftId: + * type: string + * replacementShiftId: + * type: string + * description: Optional for SWAP + * leaveStartDate: + * type: string + * format: date + * description: Required for LEAVE + * leaveEndDate: + * type: string + * format: date + * description: Required for LEAVE + * reason: + * type: string + * responses: + * 201: + * description: Request created successfully + * 403: + * description: Only guards can create requests + * 400: + * description: Validation error + */ +router.post('/request', protect, createShiftRequest); + +/** + * @swagger + * /api/v1/shifts/requests: + * get: + * summary: Get shift requests (role-based) + * tags: [ShiftRequests] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: status + * schema: + * type: string + * enum: [PENDING, APPROVED, REJECTED] + * - in: query + * name: type + * schema: + * type: string + * enum: [SWAP, LEAVE] + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * responses: + * 200: + * description: List of shift requests + */ +router.get('/requests', protect, getShiftRequests); + +/** + * @swagger + * /api/v1/shifts/request/{id}: + * get: + * summary: Get a shift request by ID + * tags: [ShiftRequests] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Shift request details + * 404: + * description: Request not found + */ +router.get('/request/:id', protect, getShiftRequestById); + +/** + * @swagger + * /api/v1/shifts/request/{id}: + * patch: + * summary: Update shift request (approve/reject or target response) + * tags: [ShiftRequests] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [APPROVED, REJECTED] + * description: For employer/admin + * rejectionReason: + * type: string + * targetResponse: + * type: string + * enum: [ACCEPTED, DECLINED] + * description: For target guard in SWAP requests + * responses: + * 200: + * description: Request updated + */ +router.patch('/request/:id', protect, updateShiftRequest); + +/** + * @swagger + * /api/v1/shifts/request/{id}: + * delete: + * summary: Cancel a pending request (guard only) + * tags: [ShiftRequests] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Request cancelled + * 400: + * description: Cannot cancel approved/rejected request + */ +router.delete('/request/:id', protect, cancelShiftRequest); + +export default router; \ No newline at end of file diff --git a/app-backend/src/services/notification.service.js b/app-backend/src/services/notification.service.js new file mode 100644 index 000000000..46bcf79f1 --- /dev/null +++ b/app-backend/src/services/notification.service.js @@ -0,0 +1,139 @@ +import Notification from '../models/Notification.js'; +import User from '../models/User.js'; + +class NotificationService { + /** + * Send SOS Alert (CRITICAL priority) + */ + static async sendSOSAlert(guardId, location, message, incidentData = {}) { + // Notify all admins + const admins = await User.find({ role: 'admin' }).select('_id'); + + const notificationData = { + type: 'SOS_ALERT', + category: 'SOS', + priority: 'CRITICAL', + title: ' SOS ALERT! ', + message: `URGENT: ${message || 'Guard requires immediate assistance'}`, + metadata: { + location, + senderId: guardId, + incidentId: incidentData.incidentId, + actionRequired: true, + actionUrl: `/admin/sos/${incidentData.incidentId || guardId}`, + coordinates: location?.coordinates + }, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + }; + + const notifications = []; + for (const admin of admins) { + notifications.push({ + ...notificationData, + userId: admin._id + }); + } + + return await Notification.insertMany(notifications); + } + + /** + * Send Incident Report Notification + */ + static async sendIncidentAlert(employerId, incidentData) { + return await Notification.createNotification({ + userId: employerId, + type: 'INCIDENT_REPORTED', + category: 'INCIDENT', + priority: 'HIGH', + title: `Incident Report: ${incidentData.title}`, + message: `A ${incidentData.severity} severity incident has been reported for shift ${incidentData.shiftTitle}`, + metadata: { + incidentId: incidentData._id, + shiftId: incidentData.shiftId, + severity: incidentData.severity, + actionRequired: true, + actionUrl: `/incidents/${incidentData._id}` + } + }); + } + + /** + * Send Shift Swap Request Notification + */ + static async sendShiftSwapRequest(targetGuardId, requesterName, shiftDetails) { + return await Notification.createNotification({ + userId: targetGuardId, + type: 'SHIFT_SWAP_REQUEST', + category: 'SHIFT', + priority: 'HIGH', + title: 'Shift Swap Request', + message: `${requesterName} wants to swap shift: ${shiftDetails.title} on ${shiftDetails.date}`, + metadata: { + shiftId: shiftDetails._id, + actionRequired: true, + actionUrl: `/shifts/requests` + } + }); + } + + /** + * Send Shift Reminder (24 hours before) + */ + static async sendShiftReminder(guardId, shiftDetails) { + return await Notification.createNotification({ + userId: guardId, + type: 'SHIFT_REMINDER', + category: 'SHIFT', + priority: 'MEDIUM', + title: 'Upcoming Shift Reminder', + message: `Reminder: ${shiftDetails.title} starts tomorrow at ${shiftDetails.startTime}`, + metadata: { + shiftId: shiftDetails._id, + actionUrl: `/shifts/${shiftDetails._id}` + }, + expiresAt: new Date(shiftDetails.date) + }); + } + + /** + * Send Document Expiring Warning + */ + static async sendDocumentExpiryWarning(guardId, documentName, expiryDate) { + const daysUntilExpiry = Math.ceil((expiryDate - new Date()) / (1000 * 60 * 60 * 24)); + const priority = daysUntilExpiry <= 7 ? 'HIGH' : daysUntilExpiry <= 30 ? 'MEDIUM' : 'LOW'; + + return await Notification.createNotification({ + userId: guardId, + type: 'DOCUMENT_EXPIRING', + category: 'DOCUMENT', + priority, + title: `Document Expiring Soon`, + message: `${documentName} will expire in ${daysUntilExpiry} days. Please upload a new copy.`, + metadata: { + documentName, + expiryDate, + actionRequired: true, + actionUrl: `/documents` + } + }); + } + + /** + * Send System Alert (Broadcast) + */ + static async sendSystemAlert(roles, title, message, actionUrl = null) { + const users = await User.find({ role: { $in: roles } }).select('_id'); + + return await Notification.broadcast({ + type: 'SYSTEM_ALERT', + category: 'SYSTEM', + priority: 'HIGH', + title, + message, + metadata: { actionUrl, actionRequired: !!actionUrl } + }, users.map(u => u._id)); + } +} + +export default NotificationService; \ No newline at end of file diff --git a/app-backend/src/utils/socket.js b/app-backend/src/utils/socket.js new file mode 100644 index 000000000..068e5b5c4 --- /dev/null +++ b/app-backend/src/utils/socket.js @@ -0,0 +1,84 @@ +import {Server} from 'socket.io'; +import Notification from '../models/Notification.js'; +import User from '../models/User.js'; + +let io; + +export const initSocket = (server) => { + io = new Server(server, { + cors: { + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true, + }, + }); + + // Authentication middleware + io.use(async (socket, next) => { + try { + const userId = socket.handshake.auth.userId; + if (!userId) { + return next(new Error('Authentication required')); + } + + const user = await User.findById(userId); + if (!user) { + return next(new Error('User not found')); + } + + socket.userId = userId; + socket.userRole = user.role; + next(); + } catch (err) { + next(new Error(`Authentication failed: ${err.message}`)) + } + }); + + io.on('connection', (socket) => { + console.log(` User ${socket.userId} connected to WebSocket`); + + // Join user's personal room + socket.join(`user_${socket.userId}`); + + // Join role-based room for broadcasts + socket.join(`role_${socket.userRole}`); + + // Handle high-priority notification acknowledgment + socket.on('acknowledge_alert', async (notificationId) => { + await Notification.findByIdAndUpdate(notificationId, { + isRead: true, + readAt: new Date(), + deliveryStatus: 'DELIVERED' + }); + + socket.emit('alert_acknowledged', {notificationId}); + }); + + socket.on('disconnect', () => { + console.log(` User ${socket.userId} disconnected from WebSocket`); + }); + }); + + console.log(' Socket.io initialized'); + return io; +}; + +// Helper to emit notifications to a specific user +export const emitNotification = (userId, notification) => { + if (io) { + io.to(`user_${userId}`).emit('new_notification', notification); + } +}; + +// Helper to emit broadcast to a role +export const emitBroadcast = (role, notification) => { + if (io) { + io.to(`role_${role}`).emit('broadcast', notification); + } +}; + +// Helper to emit SOS alerts +export const emitSOS = (notification) => { + if (io) { + io.to('role_admin').emit('sos_alert', notification); + } +}; diff --git a/app-backend/tests/shiftrequest.controller.test.js b/app-backend/tests/shiftrequest.controller.test.js new file mode 100644 index 000000000..78c7a99eb --- /dev/null +++ b/app-backend/tests/shiftrequest.controller.test.js @@ -0,0 +1,208 @@ +import request from 'supertest'; +import app from "../src/app.js"; // your Express app +import mongoose from 'mongoose'; +import User from '../src/models/User.js'; +import Shift from '../src/models/Shift.js'; +import ShiftRequest from '../src/models/ShiftRequest.js'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "@jest/globals"; + +describe('ShiftRequest API', () => { + let guardToken, employerToken; + let guard1, guard2, employer; + let shift; + + beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); + + guard1 = await User.create({ + name: 'Test Guard 1', + email: 'guard1@test.com', + password: 'password123', + role: 'guard' + }); + + guard2 = await User.create({ + name: 'Test Guard 2', + email: 'guard2@test.com', + password: 'password123', + role: 'guard' + }); + + employer = await User.create({ + name: 'Test Employer', + email: 'employer@test.com', + password: 'password123', + role: 'employer' + }); + + shift = await Shift.create({ + title: 'Morning Shift', + date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + startTime: '09:00', + endTime: '17:00', + location: 'Site A', + createdBy: employer._id, + guardIds: [guard1._id], + acceptedBy: guard1._id, + status: 'assigned' + }); + + // fake tokens (replace with real auth helper if you have JWT) + employerToken = `Bearer employer-token-${employer._id}`; + guardToken = `Bearer guard-token-${guard1._id}`; + }); + + afterAll(async () => { + await User.deleteMany({}); + await Shift.deleteMany({}); + await ShiftRequest.deleteMany({}); + await mongoose.connection.close(); + }); + + describe('POST /api/v1/shifts/request', () => { + it('should allow guard to create SWAP request', async () => { + const res = await request(app) + .post('/api/v1/shifts/request') + .set('Authorization', guardToken) + .send({ + type: 'SWAP', + targetGuardId: guard2._id, + originalShiftId: shift._id, + reason: 'Need to swap due to personal emergency' + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.type).toBe('SWAP'); + }); + + it('should block duplicate pending request', async () => { + const res = await request(app) + .post('/api/v1/shifts/request') + .set('Authorization', guardToken) + .send({ + type: 'SWAP', + targetGuardId: guard2._id, + originalShiftId: shift._id, + reason: 'Another swap request' + }); + + expect(res.status).toBe(400); + expect(res.body.message).toContain('already have a pending request'); + }); + + it('should block non-guard from creating request', async () => { + const res = await request(app) + .post('/api/v1/shifts/request') + .set('Authorization', employerToken) + .send({ + type: 'SWAP', + targetGuardId: guard2._id, + originalShiftId: shift._id, + reason: 'Employer trying to swap' + }); + + expect(res.status).toBe(403); + }); + }); + + describe('PATCH /api/v1/shifts/request/:id', () => { + let swapRequestId; + + beforeEach(async () => { + const req = await ShiftRequest.create({ + type: 'SWAP', + requestingGuardId: guard1._id, + targetGuardId: guard2._id, + originalShiftId: shift._id, + reason: 'Test swap request', + status: 'PENDING' + }); + swapRequestId = req._id; + }); + + it('should allow target guard to accept SWAP', async () => { + const guard2Login = await request(app) + .post('/api/v1/auth/login') + .send({ email: 'guard2@test.com', password: 'password123' }); + const guard2Token = guard2Login.body.token; + + const res = await request(app) + .patch(`/api/v1/shifts/request/${swapRequestId}`) + .set('Authorization', guard2Token) + .send({ targetResponse: 'ACCEPTED' }); + + expect(res.status).toBe(200); + expect(res.body.data.targetResponse).toBe('ACCEPTED'); + }); + + it('should allow employer to approve after target acceptance', async () => { + await ShiftRequest.findByIdAndUpdate(swapRequestId, { + targetResponse: 'ACCEPTED' + }); + + const res = await request(app) + .patch(`/api/v1/shifts/request/${swapRequestId}`) + .set('Authorization', employerToken) + .send({ status: 'APPROVED' }); + + expect(res.status).toBe(200); + expect(res.body.data.status).toBe('APPROVED'); + }); + }); + + describe('GET /api/v1/shifts/requests', () => { + it('should return employer-owned shifts only', async () => { + const res = await request(app) + .get('/api/v1/shifts/requests') + .set('Authorization', employerToken); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.every(req => + req.originalShiftId && req.originalShiftId.createdBy === employer._id.toString() + )).toBe(true); + }); + }); + + describe('DELETE /api/v1/shifts/request/:id', () => { + it('should allow guard to cancel pending request', async () => { + const request = await ShiftRequest.create({ + type: 'LEAVE', + requestingGuardId: guard1._id, + originalShiftId: shift._id, + leaveStartDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + leaveEndDate: new Date(Date.now() + 32 * 24 * 60 * 60 * 1000), + reason: 'Vacation', + status: 'PENDING' + }); + + const res = await request(app) + .delete(`/api/v1/shifts/request/${request._id}`) + .set('Authorization', guardToken); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('should block cancellation of approved request', async () => { + const request = await ShiftRequest.create({ + type: 'LEAVE', + requestingGuardId: guard1._id, + originalShiftId: shift._id, + leaveStartDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + leaveEndDate: new Date(Date.now() + 32 * 24 * 60 * 60 * 1000), + reason: 'Sick leave', + status: 'APPROVED', + approvedBy: employer._id, + approvedAt: new Date() + }); + + const res = await request(app) + .delete(`/api/v1/shifts/request/${request._id}`) + .set('Authorization', guardToken); + + expect(res.status).toBe(400); + }); + }); +}); \ No newline at end of file