From 14dce2869826c4d586f3ed276a90adc77d622daf Mon Sep 17 00:00:00 2001 From: donpat1to Date: Thu, 30 Oct 2025 21:07:27 +0100 Subject: [PATCH] added admin check for deletion and updates --- backend/src/controllers/employeeController.ts | 153 +++++++++++++++++- .../src/middleware/Validation/Assignment.md | 19 ++- backend/src/middleware/validation.ts | 109 +++++++++++-- 3 files changed, 266 insertions(+), 15 deletions(-) diff --git a/backend/src/controllers/employeeController.ts b/backend/src/controllers/employeeController.ts index bdac94a..bfb6f2d 100644 --- a/backend/src/controllers/employeeController.ts +++ b/backend/src/controllers/employeeController.ts @@ -314,6 +314,56 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise( + 'SELECT role FROM employee_roles WHERE employee_id = ?', + [currentUser.userId] + ); + + const isCurrentlyAdmin = currentUserRoles.some(role => role.role === 'admin'); + const willBeAdmin = roles.includes('admin'); + + if (isCurrentlyAdmin && !willBeAdmin) { + res.status(400).json({ error: 'You cannot remove your own admin role' }); + return; + } + } + + // Check admin count if roles are being updated + if (roles) { + try { + await checkAdminCount(id, roles); + } catch (error: any) { + res.status(400).json({ error: error.message }); + return; + } + } + + // Check if trying to deactivate the last admin + if (isActive === false) { + const isEmployeeAdmin = await db.get<{ count: number }>( + `SELECT COUNT(*) as count FROM employee_roles WHERE employee_id = ? AND role = 'admin'`, + [id] + ); + + if (isEmployeeAdmin && isEmployeeAdmin.count > 0) { + const otherAdminCount = await db.get<{ count: number }>( + `SELECT COUNT(*) as count + FROM employee_roles er + JOIN employees e ON er.employee_id = e.id + WHERE er.role = 'admin' AND e.is_active = 1 AND er.employee_id != ?`, + [id] + ); + + if (!otherAdminCount || otherAdminCount.count === 0) { + res.status(400).json({ error: 'Cannot deactivate the last admin user' }); + return; + } + } + } + // Validate employee type if provided if (employeeType) { const validEmployeeType = await db.get( @@ -438,7 +488,15 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise(` SELECT e.id, e.email, e.firstname, e.lastname, e.is_active, @@ -455,6 +513,26 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise(` + SELECT role FROM employee_roles WHERE employee_id = ? + `, [id]); + + const isEmployeeAdmin = employeeRoles.some(role => role.role === 'admin'); + + // Check if this is the last admin + if (isEmployeeAdmin) { + const adminCount = await db.get<{ count: number }>( + `SELECT COUNT(*) as count + FROM employee_roles + WHERE role = 'admin'` + ); + + if (adminCount && adminCount.count <= 1) { + res.status(400).json({ error: 'Cannot delete the last admin user' }); + return; + } + } + console.log('📝 Found employee to delete:', existingEmployee); // Start transaction @@ -511,7 +589,6 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise(` + SELECT e.*, er.role + FROM employees e + LEFT JOIN employee_roles er ON e.id = er.employee_id + WHERE e.id = ? + `, [employeeId]); + if (!existingEmployee) { res.status(404).json({ error: 'Employee not found' }); return; } + // Check if employee is active + if (!existingEmployee.is_active) { + res.status(400).json({ error: 'Cannot set availability for inactive employee' }); + return; + } + + // Validate contract type requirements + const availableCount = availabilities.filter((avail: any) => + avail.preferenceLevel === 1 || avail.preferenceLevel === 2 + ).length; + + const contractType = existingEmployee.contract_type; + + // Apply contract type minimum requirements + if (contractType === 'small' && availableCount < 2) { + res.status(400).json({ + error: 'Employees with small contract must have at least 2 available shifts' + }); + return; + } + + if (contractType === 'large' && availableCount < 3) { + res.status(400).json({ + error: 'Employees with large contract must have at least 3 available shifts' + }); + return; + } + + // Flexible contract has no minimum requirement + await db.run('BEGIN TRANSACTION'); try { @@ -699,4 +812,36 @@ export const updateLastLogin = async (req: AuthRequest, res: Response): Promise< console.error('Error updating last login:', error); res.status(500).json({ error: 'Internal server error' }); } +}; + +const checkAdminCount = async (employeeId: string, newRoles: string[]): Promise => { + try { + // Count current admins excluding the employee being updated + const adminCountResult = await db.get<{ count: number }>( + `SELECT COUNT(DISTINCT employee_id) as count + FROM employee_roles + WHERE role = 'admin' AND employee_id != ?`, + [employeeId] + ); + + const currentAdminCount = adminCountResult?.count || 0; + + // Check ALL current roles for the employee + const currentEmployeeRoles = await db.all<{ role: string }>( + `SELECT role FROM employee_roles WHERE employee_id = ?`, + [employeeId] + ); + + const currentRoles = currentEmployeeRoles.map(role => role.role); + const isCurrentlyAdmin = currentRoles.includes('admin'); + const willBeAdmin = newRoles.includes('admin'); + + // If removing admin role from the last admin, throw error + if (isCurrentlyAdmin && !willBeAdmin && currentAdminCount === 0) { + throw new Error('Cannot remove admin role from the last admin user'); + } + + } catch (error) { + throw error; + } }; \ No newline at end of file diff --git a/backend/src/middleware/Validation/Assignment.md b/backend/src/middleware/Validation/Assignment.md index 26265fc..08d53c6 100644 --- a/backend/src/middleware/Validation/Assignment.md +++ b/backend/src/middleware/Validation/Assignment.md @@ -17,4 +17,21 @@ * Automatically fixes data structure inconsistencies: - timeSlotId mapping (handles both naming conventions) - requiredEmployees fallback to 2 if missing - - assignedEmployees fallback to empty array if missing \ No newline at end of file + - assignedEmployees fallback to empty array if missing + +## Availability + +### [UPDATE] availability +* planId: required valid UUID +* availabilities: required array with strict validation: + - shiftId: valid UUID + - preferenceLevel: 0 (unavailable), 1 (available), or 2 (preferred) + - notes: optional, max 500 characters + +## Scheduling + +### [ACTION: generate schedule] +* shiftPlan: required object with id (valid UUID) +* employees: required array with at least one employee, each with valid UUID +* availabilities: required array +* constraints: optional array diff --git a/backend/src/middleware/validation.ts b/backend/src/middleware/validation.ts index a1a9620..a5482f0 100644 --- a/backend/src/middleware/validation.ts +++ b/backend/src/middleware/validation.ts @@ -20,12 +20,16 @@ export const validateRegister = [ body('firstname') .isLength({ min: 1, max: 100 }) .withMessage('First name must be between 1-100 characters') + .notEmpty() + .withMessage('First name must not be empty') .trim() .escape(), body('lastname') .isLength({ min: 1, max: 100 }) .withMessage('Last name must be between 1-100 characters') + .notEmpty() + .withMessage('Last name must not be empty') .trim() .escape(), @@ -42,12 +46,16 @@ export const validateEmployee = [ body('firstname') .isLength({ min: 1, max: 100 }) .withMessage('First name must be between 1-100 characters') + .notEmpty() + .withMessage('First name must not be empty') .trim() .escape(), body('lastname') .isLength({ min: 1, max: 100 }) .withMessage('Last name must be between 1-100 characters') + .notEmpty() + .withMessage('Last name must not be empty') .trim() .escape(), @@ -62,6 +70,32 @@ export const validateEmployee = [ .isIn(['manager', 'personell', 'apprentice', 'guest']) .withMessage('Employee type must be manager, personell, apprentice or guest'), + body('contractType') + .custom((value, { req }) => { + const employeeType = req.body.employeeType; + + // Manager, apprentice => contractType must be flexible + if (['manager', 'apprentice'].includes(employeeType)) { + if (value !== 'flexible') { + throw new Error(`contractType must be 'flexible' for employeeType: ${employeeType}`); + } + } + // Guest => contractType must be undefined/NONE + else if (employeeType === 'guest') { + if (value !== undefined && value !== null) { + throw new Error(`contractType is not allowed for employeeType: ${employeeType}`); + } + } + // Personell => contractType must be small or large + else if (employeeType === 'personell') { + if (!['small', 'large'].includes(value)) { + throw new Error(`contractType must be 'small' or 'large' for employeeType: ${employeeType}`); + } + } + + return true; + }), + body('contractType') .optional() .isIn(['small', 'large', 'flexible']) @@ -98,6 +132,8 @@ export const validateEmployeeUpdate = [ .optional() .isLength({ min: 1, max: 100 }) .withMessage('First name must be between 1-100 characters') + .notEmpty() + .withMessage('First name must not be empty') .trim() .escape(), @@ -105,6 +141,8 @@ export const validateEmployeeUpdate = [ .optional() .isLength({ min: 1, max: 100 }) .withMessage('Last name must be between 1-100 characters') + .notEmpty() + .withMessage('Last name must not be empty') .trim() .escape(), @@ -115,8 +153,29 @@ export const validateEmployeeUpdate = [ body('contractType') .optional() - .isIn(['small', 'large', 'flexible']) - .withMessage('Contract type must be small, large or flexible'), + .custom((value, { req }) => { + const employeeType = req.body.employeeType; + if (!employeeType) return true; // Skip if employeeType not provided + + // Same validation logic as create + if (['manager', 'apprentice'].includes(employeeType)) { + if (value !== 'flexible') { + throw new Error(`contractType must be 'flexible' for employeeType: ${employeeType}`); + } + } + else if (employeeType === 'guest') { + if (value !== undefined && value !== null) { + throw new Error(`contractType is not allowed for employeeType: ${employeeType}`); + } + } + else if (employeeType === 'personell') { + if (!['small', 'large'].includes(value)) { + throw new Error(`contractType must be 'small' or 'large' for employeeType: ${employeeType}`); + } + } + + return true; + }), body('roles') .optional() @@ -147,15 +206,22 @@ export const validateEmployeeUpdate = [ export const validateChangePassword = [ body('currentPassword') .optional() - .isLength({ min: 8 }) - .withMessage('Current password must be at least 8 characters'), + .isLength({ min: 1 }) + .withMessage('Current password is required for self-password change'), body('password') - .optional() .isLength({ min: 8 }) .withMessage('Password must be at least 8 characters') .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/) .withMessage('Password must contain uppercase, lowercase, number and special character'), + + body('confirmPassword') + .custom((value, { req }) => { + if (value !== req.body.password) { + throw new Error('Passwords do not match'); + } + return true; + }) ]; // ===== SHIFT PLAN VALIDATION ===== @@ -297,14 +363,24 @@ export const validateCreateFromPreset = [ .escape(), body('startDate') - .optional() .isISO8601() - .withMessage('Must be a valid date (ISO format)'), + .withMessage('Must be a valid date (ISO format)') + .custom((value, { req }) => { + if (req.body.endDate && new Date(value) > new Date(req.body.endDate)) { + throw new Error('Start date must be before end date'); + } + return true; + }), body('endDate') - .optional() .isISO8601() - .withMessage('Must be a valid date (ISO format)'), + .withMessage('Must be a valid date (ISO format)') + .custom((value, { req }) => { + if (req.body.startDate && new Date(value) < new Date(req.body.startDate)) { + throw new Error('End date must be after start date'); + } + return true; + }), body('isTemplate') .optional() @@ -386,7 +462,20 @@ export const validateAvailabilities = [ body('availabilities') .isArray() - .withMessage('Availabilities must be an array'), + .withMessage('Availabilities must be an array') + .custom((availabilities, { req }) => { + // Count available shifts (preference level 1 or 2) + const availableCount = availabilities.filter((avail: any) => + avail.preferenceLevel === 1 || avail.preferenceLevel === 2 + ).length; + + // Basic validation - at least one available shift + if (availableCount === 0) { + throw new Error('At least one available shift is required'); + } + + return true; + }), body('availabilities.*.shiftId') .isUUID()