Files
Schichtenplaner/backend/src/controllers/employeeController.ts

847 lines
26 KiB
TypeScript

// backend/src/controllers/employeeController.ts
import { Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcryptjs';
import { db } from '../services/databaseService.js';
import { AuthRequest } from '../middleware/auth.js';
import { CreateEmployeeRequest } from '../models/Employee.js';
function generateEmail(firstname: string, lastname: string): string {
const convertUmlauts = (str: string): string => {
return str
.toLowerCase()
.replace(/ü/g, 'ue')
.replace(/ö/g, 'oe')
.replace(/ä/g, 'ae')
.replace(/ß/g, 'ss');
};
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
}
export const getEmployees = async (req: AuthRequest, res: Response): Promise<void> => {
try {
console.log('🔍 Fetching employees - User:', req.user);
const { includeInactive } = req.query;
const includeInactiveFlag = includeInactive === 'true';
let query = `
SELECT
e.id, e.email, e.firstname, e.lastname,
e.is_active,
e.employee_type,
e.contract_type,
e.can_work_alone,
e.is_trainee,
e.created_at,
e.last_login,
er.role
FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id
`;
if (!includeInactiveFlag) {
query += ' WHERE e.is_active = 1';
}
query += ' ORDER BY e.firstname, e.lastname';
const employees = await db.all<any>(query);
// Format employees with proper field names and roles array
const employeesWithRoles = employees.map(emp => ({
id: emp.id,
email: emp.email,
firstname: emp.firstname,
lastname: emp.lastname,
isActive: emp.is_active === 1,
employeeType: emp.employee_type,
contractType: emp.contract_type,
canWorkAlone: emp.can_work_alone === 1,
isTrainee: emp.is_trainee === 1,
createdAt: emp.created_at,
lastLogin: emp.last_login,
roles: emp.role ? [emp.role] : ['user']
}));
console.log('✅ Employees found:', employeesWithRoles.length);
res.json(employeesWithRoles);
} catch (error) {
console.error('❌ Error fetching employees:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const getEmployee = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const employee = await db.get<any>(`
SELECT
e.id, e.email, e.firstname, e.lastname,
e.is_active,
e.employee_type,
e.contract_type,
e.can_work_alone,
e.is_trainee,
e.created_at,
e.last_login,
er.role
FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id
WHERE e.id = ?
LIMIT 1
`, [id]);
if (!employee) {
res.status(404).json({ error: 'Employee not found' });
return;
}
// Format employee with proper field names
const employeeWithRoles = {
id: employee.id,
email: employee.email,
firstname: employee.firstname,
lastname: employee.lastname,
isActive: employee.is_active === 1,
employeeType: employee.employee_type,
contractType: employee.contract_type,
canWorkAlone: employee.can_work_alone === 1,
isTrainee: employee.is_trainee === 1,
createdAt: employee.created_at,
lastLogin: employee.last_login,
roles: employee.role ? [employee.role] : ['user']
};
res.json(employeeWithRoles);
} catch (error) {
console.error('Error fetching employee:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const createEmployee = async (req: AuthRequest, res: Response): Promise<void> => {
try {
console.log('🔍 Starting employee creation process with data:', {
...req.body,
password: '***hidden***'
});
const {
password,
firstname,
lastname,
roles = ['user'],
employeeType,
contractType,
canWorkAlone = false,
isTrainee = false
} = req.body as CreateEmployeeRequest;
// Validation
if (!password || !firstname || !lastname || !employeeType) {
console.log('❌ Validation failed: Missing required fields');
res.status(400).json({
error: 'Password, firstname, lastname und employeeType sind erforderlich'
});
return;
}
// ✅ ENHANCED: Validate employee type exists and get category info
const employeeTypeInfo = await db.get<{type: string, category: string, has_contract_type: number}>(
'SELECT type, category, has_contract_type FROM employee_types WHERE type = ?',
[employeeType]
);
if (!employeeTypeInfo) {
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}. Gültige Typen: manager, personell, apprentice, guest`
});
return;
}
// ✅ ENHANCED: Contract type validation based on category
if (employeeTypeInfo.has_contract_type === 1) {
// Internal types require contract type
if (!contractType) {
res.status(400).json({
error: `contractType ist erforderlich für employeeType: ${employeeType}`
});
return;
}
if (!['small', 'large', 'flexible'].includes(contractType)) {
res.status(400).json({
error: `Ungültiger contractType: ${contractType}. Gültige Werte: small, large, flexible`
});
return;
}
} else {
// External types (guest) should not have contract type
if (contractType) {
res.status(400).json({
error: `contractType ist nicht erlaubt für employeeType: ${employeeType}`
});
return;
}
}
// ✅ ENHANCED: isTrainee validation - only applicable for personell type
if (isTrainee && employeeType !== 'personell') {
res.status(400).json({
error: `isTrainee ist nur für employeeType 'personell' erlaubt`
});
return;
}
// Generate email automatically
const email = generateEmail(firstname, lastname);
console.log('📧 Generated email:', email);
// Check if generated email already exists
const existingUser = await db.get<any>('SELECT id FROM employees WHERE email = ? AND is_active = 1', [email]);
if (existingUser) {
console.log('❌ Generated email already exists:', email);
res.status(409).json({
error: `Employee with email ${email} already exists. Please use different firstname/lastname.`
});
return;
}
// Hash password and create employee
const hashedPassword = await bcrypt.hash(password, 10);
const employeeId = uuidv4();
// Start transaction for employee creation and role assignment
await db.run('BEGIN TRANSACTION');
try {
// Insert employee with proper contract type handling
await db.run(
`INSERT INTO employees (
id, email, password, firstname, lastname, employee_type, contract_type, can_work_alone,
is_active, is_trainee
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
employeeId,
email,
hashedPassword,
firstname,
lastname,
employeeType,
contractType, // Will be NULL for external types
canWorkAlone ? 1 : 0,
1,
isTrainee ? 1 : 0
]
);
// Insert roles into employee_roles table
for (const role of roles) {
await db.run(
`INSERT INTO employee_roles (employee_id, role) VALUES (?, ?)`,
[employeeId, role]
);
}
await db.run('COMMIT');
// Return created employee
const newEmployee = await db.get<any>(`
SELECT
e.id, e.email, e.firstname, e.lastname,
e.is_active,
e.employee_type,
e.contract_type,
e.can_work_alone,
e.is_trainee,
e.created_at,
e.last_login,
er.role
FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id
WHERE e.id = ?
LIMIT 1
`, [employeeId]);
// Format response with proper field names
const employeeWithRoles = {
id: newEmployee.id,
email: newEmployee.email,
firstname: newEmployee.firstname,
lastname: newEmployee.lastname,
isActive: newEmployee.is_active === 1,
employeeType: newEmployee.employee_type,
contractType: newEmployee.contract_type,
canWorkAlone: newEmployee.can_work_alone === 1,
isTrainee: newEmployee.is_trainee === 1,
createdAt: newEmployee.created_at,
lastLogin: newEmployee.last_login,
roles: newEmployee.role ? [newEmployee.role] : ['user']
};
res.status(201).json(employeeWithRoles);
} catch (error) {
await db.run('ROLLBACK');
throw error;
}
} catch (error) {
console.error('Error creating employee:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const updateEmployee = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { firstname, lastname, roles, isActive, employeeType, contractType, canWorkAlone, isTrainee } = req.body;
console.log('📝 Update Employee Request:', {
id, firstname, lastname, roles, isActive,
employeeType, contractType, canWorkAlone, isTrainee
});
// Check if employee exists and get current data
const existingEmployee = await db.get<any>('SELECT * FROM employees WHERE id = ?', [id]);
if (!existingEmployee) {
res.status(404).json({ error: 'Employee not found' });
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(
'SELECT type FROM employee_types WHERE type = ?',
[employeeType]
);
if (!validEmployeeType) {
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}`
});
return;
}
}
// Generate new email if firstname or lastname changed
let email = existingEmployee.email;
if (firstname || lastname) {
const newFirstname = firstname || existingEmployee.firstname;
const newLastname = lastname || existingEmployee.lastname;
email = generateEmail(newFirstname, newLastname);
// Check if new email already exists (for another employee)
const emailExists = await db.get<any>(
'SELECT id FROM employees WHERE email = ? AND id != ? AND is_active = 1',
[email, id]
);
if (emailExists) {
res.status(409).json({
error: `Cannot update name - email ${email} already exists for another employee`
});
return;
}
}
// Start transaction for employee update and role management
await db.run('BEGIN TRANSACTION');
try {
// Update employee with new schema
await db.run(
`UPDATE employees
SET firstname = COALESCE(?, firstname),
lastname = COALESCE(?, lastname),
email = ?,
is_active = COALESCE(?, is_active),
employee_type = COALESCE(?, employee_type),
contract_type = COALESCE(?, contract_type),
can_work_alone = COALESCE(?, can_work_alone),
is_trainee = COALESCE(?, is_trainee)
WHERE id = ?`,
[firstname, lastname, email, isActive, employeeType, contractType, canWorkAlone, isTrainee, id]
);
// Update roles if provided
if (roles) {
// Delete existing roles
await db.run('DELETE FROM employee_roles WHERE employee_id = ?', [id]);
// Insert new roles
for (const role of roles) {
await db.run(
`INSERT INTO employee_roles (employee_id, role) VALUES (?, ?)`,
[id, role]
);
}
}
await db.run('COMMIT');
console.log('✅ Employee updated successfully with email:', email);
// Return updated employee
const updatedEmployee = await db.get<any>(`
SELECT
e.id, e.email, e.firstname, e.lastname,
e.is_active,
e.employee_type,
e.contract_type,
e.can_work_alone,
e.is_trainee,
e.created_at,
e.last_login,
er.role
FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id
WHERE e.id = ?
LIMIT 1
`, [id]);
// Format response with proper field names
const employeeWithRoles = {
id: updatedEmployee.id,
email: updatedEmployee.email,
firstname: updatedEmployee.firstname,
lastname: updatedEmployee.lastname,
isActive: updatedEmployee.is_active === 1,
employeeType: updatedEmployee.employee_type,
contractType: updatedEmployee.contract_type,
canWorkAlone: updatedEmployee.can_work_alone === 1,
isTrainee: updatedEmployee.is_trainee === 1,
createdAt: updatedEmployee.created_at,
lastLogin: updatedEmployee.last_login,
roles: updatedEmployee.role ? [updatedEmployee.role] : ['user']
};
res.json(employeeWithRoles);
} catch (error) {
await db.run('ROLLBACK');
throw error;
}
} catch (error) {
console.error('Error updating employee:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
console.log('🗑️ Starting deletion process for employee ID:', id);
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,
er.role
FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id
WHERE e.id = ?
LIMIT 1
`, [id]);
if (!existingEmployee) {
console.log('❌ Employee not found for deletion:', id);
res.status(404).json({ error: 'Employee not found' });
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
await db.run('BEGIN TRANSACTION');
try {
// 1. Remove availabilities
await db.run('DELETE FROM employee_availability WHERE employee_id = ?', [id]);
// 2. Remove from assigned_shifts (JSON field cleanup)
interface AssignedShift {
id: string;
assigned_employees: string;
}
const assignedShifts = await db.all<AssignedShift>(
'SELECT id, assigned_employees FROM scheduled_shifts WHERE json_extract(assigned_employees, "$") LIKE ?',
[`%${id}%`]
);
for (const shift of assignedShifts) {
try {
const employeesArray: string[] = JSON.parse(shift.assigned_employees || '[]');
const filteredEmployees = employeesArray.filter((empId: string) => empId !== id);
await db.run(
'UPDATE scheduled_shifts SET assigned_employees = ? WHERE id = ?',
[JSON.stringify(filteredEmployees), shift.id]
);
} catch (parseError) {
console.warn(`Could not parse assigned_employees for shift ${shift.id}:`, shift.assigned_employees);
await db.run(
'UPDATE scheduled_shifts SET assigned_employees = ? WHERE id = ?',
[JSON.stringify([]), shift.id]
);
}
}
// 3. Remove roles from employee_roles
await db.run('DELETE FROM employee_roles WHERE employee_id = ?', [id]);
// 4. Nullify created_by references
await db.run('UPDATE shift_plans SET created_by = NULL WHERE created_by = ?', [id]);
// 5. Finally delete the employee
await db.run('DELETE FROM employees WHERE id = ?', [id]);
await db.run('COMMIT');
console.log('✅ Successfully deleted employee:', existingEmployee.email);
res.status(204).send();
} catch (error) {
await db.run('ROLLBACK');
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' });
}
};
export const getAvailabilities = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { employeeId } = req.params;
// Check if employee exists
const existingEmployee = await db.get('SELECT id FROM employees WHERE id = ?', [employeeId]);
if (!existingEmployee) {
res.status(404).json({ error: 'Employee not found' });
return;
}
const availabilities = await db.all<any>(`
SELECT ea.*, s.day_of_week, s.time_slot_id
FROM employee_availability ea
JOIN shifts s ON ea.shift_id = s.id
WHERE ea.employee_id = ?
ORDER BY s.day_of_week, s.time_slot_id
`, [employeeId]);
res.json(availabilities.map(avail => ({
id: avail.id,
employeeId: avail.employee_id,
planId: avail.plan_id,
shiftId: avail.shift_id,
dayOfWeek: avail.day_of_week,
timeSlotId: avail.time_slot_id,
preferenceLevel: avail.preference_level,
notes: avail.notes
})));
} catch (error) {
console.error('Error fetching availabilities:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const updateAvailabilities = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { employeeId } = req.params;
const { planId, availabilities } = req.body;
// 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 {
// Delete existing availabilities for this plan
await db.run('DELETE FROM employee_availability WHERE employee_id = ? AND plan_id = ?', [employeeId, planId]);
// Insert new availabilities
for (const availability of availabilities) {
const availabilityId = uuidv4();
await db.run(
`INSERT INTO employee_availability (id, employee_id, plan_id, shift_id, preference_level, notes)
VALUES (?, ?, ?, ?, ?, ?)`,
[
availabilityId,
employeeId,
planId,
availability.shiftId,
availability.preferenceLevel,
availability.notes || null
]
);
}
await db.run('COMMIT');
// Return updated availabilities
const updatedAvailabilities = await db.all<any>(`
SELECT ea.*, s.day_of_week, s.time_slot_id
FROM employee_availability ea
JOIN shifts s ON ea.shift_id = s.id
WHERE ea.employee_id = ? AND ea.plan_id = ?
ORDER BY s.day_of_week, s.time_slot_id
`, [employeeId, planId]);
res.json(updatedAvailabilities.map(avail => ({
id: avail.id,
employeeId: avail.employee_id,
planId: avail.plan_id,
dayOfWeek: avail.day_of_week,
timeSlotId: avail.time_slot_id,
preferenceLevel: avail.preference_level,
notes: avail.notes
})));
console.log('✅ Successfully updated employee availabilities');
} catch (error) {
await db.run('ROLLBACK');
throw error;
}
} catch (error) {
console.error('Error updating availabilities:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const changePassword = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { currentPassword, newPassword } = req.body;
// Get the current user from the auth middleware
const currentUser = (req as AuthRequest).user;
// Check if user is changing their own password or is an admin
if (currentUser?.userId !== id && currentUser?.role !== 'admin') {
res.status(403).json({ error: 'You can only change your own password' });
return;
}
// Check if employee exists and get password
const employee = await db.get<{ password: string }>('SELECT password FROM employees WHERE id = ?', [id]);
if (!employee) {
res.status(404).json({ error: 'Employee not found' });
return;
}
// For non-admin users, verify current password
if (currentUser?.role !== 'admin') {
const isValidPassword = await bcrypt.compare(currentPassword, employee.password);
if (!isValidPassword) {
res.status(400).json({ error: 'Current password is incorrect' });
return;
}
}
// Validate new password
if (!newPassword || newPassword.length < 6) {
res.status(400).json({ error: 'New password must be at least 6 characters long' });
return;
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password
await db.run('UPDATE employees SET password = ? WHERE id = ?', [hashedPassword, id]);
res.json({ message: 'Password updated successfully' });
} catch (error) {
console.error('Error changing password:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const updateLastLogin = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
// Check if employee exists
const employee = await db.get('SELECT id FROM employees WHERE id = ?', [id]);
if (!employee) {
res.status(404).json({ error: 'Employee not found' });
return;
}
// Update last_login with current timestamp
const currentTimestamp = new Date().toISOString();
await db.run(
'UPDATE employees SET last_login = ? WHERE id = ?',
[currentTimestamp, id]
);
console.log(`✅ Last login updated for employee ${id}: ${currentTimestamp}`);
res.json({
message: 'Last login updated successfully',
lastLogin: currentTimestamp
});
} catch (error) {
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;
}
};