diff --git a/app-backend/server.js b/app-backend/server.js index 70272d455..a964a5102 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/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 ff5c85ca7..ef14211fe 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..c660a6eeb --- /dev/null +++ b/app-backend/src/controllers/shiftrequest.controller.js @@ -0,0 +1,353 @@ +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/requests + */ +export const createShiftRequest = async (req, res) => { + try { + const { type, targetGuardId, originalShiftId, replacementShiftId, leaveStartDate, leaveEndDate, reason } = req.body; + const requestingGuardId = req.user._id; + + // Validate guard role + if (req.user.role !== 'guard') { + return res.status(403).json({ message: 'Only guards can create shift requests' }); + } + + // Validate original shift exists and belongs to the guard + const originalShift = await Shift.findById(originalShiftId); + if (!originalShift) { + return res.status(404).json({ message: 'Original shift not found' }); + } + + // Check if guard is assigned to this shift + if (originalShift.acceptedBy?.toString() !== requestingGuardId.toString()) { + return res.status(403).json({ message: 'You are not assigned to this shift' }); + } + + // Check if shift is in the future + 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' }); + } + + // Check for pending requests on this shift + 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' }); + } + + // SWAP-specific validations + if (type === 'SWAP') { + if (!targetGuardId) { + return res.status(400).json({ message: 'Target guard is required for swap requests' }); + } + + // Validate target guard exists and is a guard + 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 replacement shift provided, validate it + if (replacementShiftId) { + const replacementShift = await Shift.findById(replacementShiftId); + if (!replacementShift) { + return res.status(404).json({ message: 'Replacement shift not found' }); + } + if (replacementShift.acceptedBy?.toString() !== 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' }); + } + } + } + + // LEAVE-specific validations + if (type === 'LEAVE') { + if (!leaveStartDate || !leaveEndDate) { + return res.status(400).json({ message: 'Leave start and end dates are required' }); + } + } + + // Create request + 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, + }); + + // Populate references for response + 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' }); + } +}; + +/** + * 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 = {}; + + // Role-based filtering + if (req.user.role === 'guard') { + filter.$or = [ + { requestingGuardId: req.user._id }, + { targetGuardId: req.user._id }, + ]; + } else if (req.user.role === 'employer') { + // Employer sees requests for shifts they created + const employerShifts = await Shift.find({ createdBy: req.user._id }).distinct('_id'); + filter.originalShiftId = { $in: employerShifts }; + } + // Admin sees all - no filter + + // Add optional filters + if (status) filter.status = status; + if (type) filter.type = type; + + const skip = (parseInt(page) - 1) * parseInt(limit); + + 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)), + ShiftRequest.countDocuments(filter), + ]); + + res.json({ + success: true, + data: requests, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + pages: Math.ceil(total / parseInt(limit)), + }, + }); + } 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/requests/: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' }); + } + + // Authorization: check if user has permission to view + const isGuard = req.user.role === 'guard' && + (request.requestingGuardId._id.toString() === req.user._id.toString() || + request.targetGuardId?._id.toString() === req.user._id.toString()); + + const isEmployer = req.user.role === 'employer'; + const isAdmin = req.user.role === 'admin'; + + if (!isGuard && !isEmployer && !isAdmin) { + 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/requests/:id + */ +export const updateShiftRequest = async (req, res) => { + try { + const { id } = req.params; + const { status, rejectionReason, targetResponse } = req.body; + + const request = await ShiftRequest.findById(id) + .populate('requestingGuardId', 'name email') + .populate('targetGuardId', 'name email') + .populate('originalShiftId', 'title date startTime endTime acceptedBy') + .populate('replacementShiftId', 'title date startTime endTime acceptedBy'); + + if (!request) { + return res.status(404).json({ message: 'Shift request not found' }); + } + + // Authorization and workflow logic + const isAdmin = req.user.role === 'admin'; + const isEmployer = req.user.role === 'employer'; + const isTargetGuard = request.type === 'SWAP' && + request.targetGuardId?._id.toString() === req.user._id.toString(); + + // 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(); + + return res.json({ + success: true, + data: request, + message: `Swap request ${targetResponse === 'ACCEPTED' ? 'accepted' : 'declined'}`, + }); + } + + // Handle approval/rejection by employer/admin + if (status && (isAdmin || isEmployer) && request.status === 'PENDING') { + // Check if this is a SWAP request that needs target acceptance + if (request.type === 'SWAP' && request.targetResponse !== 'ACCEPTED') { + return res.status(400).json({ + message: 'Cannot approve swap until target guard accepts the swap' + }); + } + + if (status === 'APPROVED') { + // Execute the shift change + const originalShift = request.originalShiftId; + + if (request.type === 'SWAP') { + // Swap shifts between two guards + const targetGuard = request.targetGuardId; + + // Swap assignments + const originalGuardId = originalShift.acceptedBy; + originalShift.acceptedBy = targetGuard._id; + await originalShift.save(); + + // If replacement shift exists, swap that too + if (request.replacementShiftId) { + const replacementShift = request.replacementShiftId; + replacementShift.acceptedBy = originalGuardId; + await replacementShift.save(); + } + } else if (request.type === 'LEAVE') { + // Mark shift as open for reassignment + originalShift.status = 'open'; + originalShift.acceptedBy = null; + originalShift.applicants = []; + await originalShift.save(); + } + + 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(); + } else if (status && !isAdmin && !isEmployer) { + return res.status(403).json({ message: 'Only employers or admins can approve/reject requests' }); + } + + 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 ${request.status.toLowerCase()}`, + }); + } catch (error) { + console.error('Update shift request error:', error); + res.status(500).json({ message: error.message || 'Failed to update shift request' }); + } +}; + +/** + * Cancel a pending request (guard only) + * DELETE /api/v1/shifts/requests/: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' }); + } + + // Only the requesting guard can cancel + 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..7129508b9 --- /dev/null +++ b/app-backend/src/models/ShiftRequest.js @@ -0,0 +1,172 @@ +import mongoose from 'mongoose'; + +const { Schema, model } = mongoose; + +const shiftRequestSchema = new Schema( + { + // Request type + type: { + type: String, + required: true, + enum: ['SWAP', 'LEAVE'], + index: true, + }, + + // Status workflow + status: { + type: String, + required: true, + enum: ['PENDING', 'APPROVED', 'REJECTED'], + default: 'PENDING', + index: true, + }, + + // Who requested + requestingGuardId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + + // For SWAP: which guard will take the shift (optional until proposed) + targetGuardId: { + type: Schema.Types.ObjectId, + ref: 'User', + validate: { + validator: function (v) { + // Required for SWAP requests, not for LEAVE + if (this.type === 'SWAP' && !v) return false; + return true; + }, + message: 'Target guard is required for shift swap requests', + }, + }, + + // The shift being swapped or taken leave from + originalShiftId: { + type: Schema.Types.ObjectId, + ref: 'Shift', + required: true, + index: true, + }, + + // For SWAP: the shift the target guard gives in return (optional) + replacementShiftId: { + type: Schema.Types.ObjectId, + ref: 'Shift', + validate: { + validator: function (v) { + // Only required for mutual swaps, can be null for gift/cover + return true; // Optional field + }, + }, + }, + + // For LEAVE: date range + 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/justification + reason: { + type: String, + required: true, + trim: true, + maxlength: 1000, + }, + + // Admin/Employer approval tracking + approvedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + default: null, + }, + approvedAt: { + type: Date, + default: null, + }, + rejectionReason: { + type: String, + trim: true, + maxlength: 500, + default: null, + }, + + // Target guard's response (for SWAP requests) + targetResponse: { + type: String, + enum: ['PENDING', 'ACCEPTED', 'DECLINED'], + default: 'PENDING', + }, + targetRespondedAt: { + type: Date, + default: null, + }, + + // Notifications tracking + employerNotifiedAt: Date, + adminNotifiedAt: Date, + targetNotifiedAt: Date, + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + } +); + +// Compound indexes for efficient queries +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 }); + +// Virtual: check if request is still actionable +shiftRequestSchema.virtual('isActionable').get(function () { + return this.status === 'PENDING'; +}); + +// Virtual: check if SWAP request needs target response +shiftRequestSchema.virtual('needsTargetResponse').get(function () { + return this.type === 'SWAP' && this.status === 'PENDING' && this.targetResponse === 'PENDING'; +}); + +// Pre-save middleware to validate dates +shiftRequestSchema.pre('save', function (next) { + if (this.type === 'LEAVE') { + // Ensure leave dates are in the future + 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 6cda24ec9..975ca0805 100644 --- a/app-backend/src/routes/index.js +++ b/app-backend/src/routes/index.js @@ -4,31 +4,34 @@ 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"; import branchRoutes from './branch.routes.js'; import notificationRoutes from './notification.routes.js' - import payrollRoutes from './payroll.routes.js'; import documentRoutes from './document.routes.js'; +import shiftRequestRoutes from "./shiftrequest.routes.js"; + const router = express.Router(); + router.use('/documents', documentRoutes); router.use('/health', healthRoutes); 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('/shiftrequests', shiftRequestRoutes) 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..88df1abbd --- /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/shiftrequests/requests: + * 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('/requests', protect, createShiftRequest); + +/** + * @swagger + * /api/v1/shiftrequests/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/shiftrequests/requests/{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('/requests/:id', protect, getShiftRequestById); + +/** + * @swagger + * /api/v1/shiftrequests/requests/{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('/requests/:id', protect, updateShiftRequest); + +/** + * @swagger + * /api/v1/shiftrequests/requests/{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('/requests/: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..c7df24d08 --- /dev/null +++ b/app-backend/src/utils/socket.js @@ -0,0 +1,86 @@ +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')); + } + }); + + 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); + } +}; + +export default { initSocket, emitNotification, emitBroadcast, emitSOS }; \ No newline at end of file