added admin check for deletion and updates

This commit is contained in:
2025-10-30 21:07:27 +01:00
parent 82a30f6bb8
commit 14dce28698
3 changed files with 266 additions and 15 deletions

View File

@@ -314,6 +314,56 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
return;
}
// Check if user is trying to remove their own admin role
const currentUser = req.user;
if (currentUser?.userId === id && roles) {
const currentUserRoles = await db.all<{ role: string }>(
'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<v
const { id } = req.params;
console.log('🗑️ Starting deletion process for employee ID:', id);
// UPDATED: Check if employee exists with role from employee_roles
const currentUser = req.user;
// Prevent self-deletion
if (currentUser?.userId === id) {
res.status(400).json({ error: 'You cannot delete yourself' });
return;
}
// Check if employee exists with role from employee_roles
const existingEmployee = await db.get<any>(`
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<v
return;
}
const employeeRoles = await db.all<{ role: string }>(`
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<v
console.error('Error during deletion transaction:', error);
throw error;
}
} catch (error) {
console.error('Error deleting employee:', error);
res.status(500).json({ error: 'Internal server error' });
@@ -558,13 +635,49 @@ export const updateAvailabilities = async (req: AuthRequest, res: Response): Pro
const { employeeId } = req.params;
const { planId, availabilities } = req.body;
// Check if employee exists
const existingEmployee = await db.get('SELECT id FROM employees WHERE id = ?', [employeeId]);
// Check if employee exists and get contract type
const existingEmployee = await db.get<any>(`
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<void> => {
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;
}
};

View File

@@ -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
- 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

View File

@@ -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()