import type { Express } from "express"; import { createServer, type Server } from "http"; import { storage } from "./storage"; import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth"; import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth"; import { db } from "./db"; import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema } from "@shared/schema"; import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm"; import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; // Determina quale sistema auth usare basandosi sull'ambiente const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS; // Helper per estrarre user ID in modo compatibile function getUserId(req: any): string { if (USE_LOCAL_AUTH) { return req.user?.id || ""; } else { return req.user?.claims?.sub || ""; } } // Usa il middleware auth appropriato const isAuthenticated = USE_LOCAL_AUTH ? isAuthenticatedLocal : isAuthenticatedReplit; export async function registerRoutes(app: Express): Promise { // Setup auth system appropriato if (USE_LOCAL_AUTH) { console.log("🔐 Usando Local Auth (vt.alfacom.it)"); await setupLocalAuth(app); } else { console.log("🔐 Usando Replit OIDC Auth"); await setupReplitAuth(app); } // ============= AUTH ROUTES ============= app.get("/api/auth/user", isAuthenticated, async (req: any, res) => { try { const userId = getUserId(req); const user = await storage.getUser(userId); res.json(user); } catch (error) { console.error("Error fetching user:", error); res.status(500).json({ message: "Failed to fetch user" }); } }); // ============= USER MANAGEMENT ROUTES ============= app.get("/api/users", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins can view all users if (currentUser?.role !== "admin") { return res.status(403).json({ message: "Forbidden: Admin access required" }); } const allUsers = await storage.getAllUsers(); res.json(allUsers); } catch (error) { console.error("Error fetching users:", error); res.status(500).json({ message: "Failed to fetch users" }); } }); app.post("/api/users", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins can create users if (currentUser?.role !== "admin") { return res.status(403).json({ message: "Forbidden: Admin access required" }); } const { email, firstName, lastName, password, role } = req.body; if (!email || !firstName || !lastName || !password) { return res.status(400).json({ message: "Missing required fields" }); } // Hash password const bcrypt = await import("bcrypt"); const passwordHash = await bcrypt.hash(password, 10); // Generate UUID const crypto = await import("crypto"); const userId = crypto.randomUUID(); const newUser = await storage.upsertUser({ id: userId, email, firstName, lastName, profileImageUrl: null, passwordHash, }); // Set role if provided if (role) { await storage.updateUserRole(newUser.id, role); } res.json(newUser); } catch (error: any) { console.error("Error creating user:", error); if (error.code === '23505') { // Unique violation return res.status(409).json({ message: "Email già esistente" }); } res.status(500).json({ message: "Failed to create user" }); } }); app.patch("/api/users/:id", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins can update user roles if (currentUser?.role !== "admin") { return res.status(403).json({ message: "Forbidden: Admin access required" }); } // Prevent admins from changing their own role if (req.params.id === currentUserId) { return res.status(403).json({ message: "Cannot change your own role" }); } const { role } = req.body; if (!role || !["admin", "coordinator", "guard", "client"].includes(role)) { return res.status(400).json({ message: "Invalid role" }); } const updated = await storage.updateUserRole(req.params.id, role); if (!updated) { return res.status(404).json({ message: "User not found" }); } res.json(updated); } catch (error) { console.error("Error updating user role:", error); res.status(500).json({ message: "Failed to update user role" }); } }); app.put("/api/users/:id", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins can update users if (currentUser?.role !== "admin") { return res.status(403).json({ message: "Forbidden: Admin access required" }); } const { email, firstName, lastName, password, role } = req.body; const existingUser = await storage.getUser(req.params.id); if (!existingUser) { return res.status(404).json({ message: "User not found" }); } // Prepare update data const updateData: any = { id: req.params.id, email: email || existingUser.email, firstName: firstName || existingUser.firstName, lastName: lastName || existingUser.lastName, profileImageUrl: existingUser.profileImageUrl, }; // Hash new password if provided if (password) { const bcrypt = await import("bcrypt"); updateData.passwordHash = await bcrypt.hash(password, 10); } const updated = await storage.upsertUser(updateData); // Update role if provided and not changing own role if (role && req.params.id !== currentUserId) { await storage.updateUserRole(req.params.id, role); } res.json(updated); } catch (error: any) { console.error("Error updating user:", error); if (error.code === '23505') { return res.status(409).json({ message: "Email già esistente" }); } res.status(500).json({ message: "Failed to update user" }); } }); app.delete("/api/users/:id", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins can delete users if (currentUser?.role !== "admin") { return res.status(403).json({ message: "Forbidden: Admin access required" }); } // Prevent admins from deleting themselves if (req.params.id === currentUserId) { return res.status(403).json({ message: "Cannot delete your own account" }); } await storage.deleteUser(req.params.id); res.json({ success: true }); } catch (error) { console.error("Error deleting user:", error); res.status(500).json({ message: "Failed to delete user" }); } }); // ============= GUARD ROUTES ============= app.get("/api/guards", isAuthenticated, async (req, res) => { try { const allGuards = await storage.getAllGuards(); // Fetch related data for each guard const guardsWithDetails = await Promise.all( allGuards.map(async (guard) => { const certs = await storage.getCertificationsByGuard(guard.id); const user = guard.userId ? await storage.getUser(guard.userId) : undefined; // Update certification status based on expiry date for (const cert of certs) { const daysUntilExpiry = differenceInDays(new Date(cert.expiryDate), new Date()); let newStatus: "valid" | "expiring_soon" | "expired" = "valid"; if (daysUntilExpiry < 0) { newStatus = "expired"; } else if (daysUntilExpiry <= 30) { newStatus = "expiring_soon"; } if (cert.status !== newStatus) { await storage.updateCertificationStatus(cert.id, newStatus); cert.status = newStatus; } } return { ...guard, certifications: certs, user, }; }) ); res.json(guardsWithDetails); } catch (error) { console.error("Error fetching guards:", error); res.status(500).json({ message: "Failed to fetch guards" }); } }); app.post("/api/guards", isAuthenticated, async (req, res) => { try { const guard = await storage.createGuard(req.body); res.json(guard); } catch (error) { console.error("Error creating guard:", error); res.status(500).json({ message: "Failed to create guard" }); } }); app.patch("/api/guards/:id", isAuthenticated, async (req, res) => { try { const updated = await storage.updateGuard(req.params.id, req.body); if (!updated) { return res.status(404).json({ message: "Guard not found" }); } res.json(updated); } catch (error) { console.error("Error updating guard:", error); res.status(500).json({ message: "Failed to update guard" }); } }); app.delete("/api/guards/:id", isAuthenticated, async (req, res) => { try { const deleted = await storage.deleteGuard(req.params.id); if (!deleted) { return res.status(404).json({ message: "Guard not found" }); } res.json({ success: true }); } catch (error) { console.error("Error deleting guard:", error); res.status(500).json({ message: "Failed to delete guard" }); } }); // Get guards availability for general planning with time slot conflict detection app.get("/api/guards/availability", isAuthenticated, async (req, res) => { try { const { start, end, siteId, location } = req.query; if (!start || !end || !siteId || !location) { return res.status(400).json({ message: "Missing required parameters: start, end, siteId, location" }); } const startDate = parseISO(start as string); const endDate = parseISO(end as string); if (!isValid(startDate) || !isValid(endDate)) { return res.status(400).json({ message: "Invalid date format for start or end" }); } if (endDate <= startDate) { return res.status(400).json({ message: "End time must be after start time" }); } const availability = await storage.getGuardsAvailability( siteId as string, location as string, startDate, endDate ); res.json(availability); } catch (error) { console.error("Error fetching guards availability:", error); res.status(500).json({ message: "Failed to fetch guards availability" }); } }); // Get vehicles available for a location app.get("/api/vehicles/available", isAuthenticated, async (req, res) => { try { const { location } = req.query; if (!location) { return res.status(400).json({ message: "Missing required parameter: location" }); } // Get all vehicles for this location with status 'available' const availableVehicles = await db .select() .from(vehicles) .where( and( eq(vehicles.location, location as any), eq(vehicles.status, "available") ) ) .orderBy(vehicles.licensePlate); res.json(availableVehicles); } catch (error) { console.error("Error fetching available vehicles:", error); res.status(500).json({ message: "Failed to fetch available vehicles" }); } }); // ============= VEHICLE ROUTES ============= app.get("/api/vehicles", isAuthenticated, async (req, res) => { try { const vehicles = await storage.getAllVehicles(); res.json(vehicles); } catch (error) { console.error("Error fetching vehicles:", error); res.status(500).json({ message: "Failed to fetch vehicles" }); } }); app.post("/api/vehicles", isAuthenticated, async (req, res) => { try { const vehicle = await storage.createVehicle(req.body); res.json(vehicle); } catch (error: any) { console.error("Error creating vehicle:", error); if (error.code === '23505') { return res.status(409).json({ message: "Targa già esistente" }); } res.status(500).json({ message: "Failed to create vehicle" }); } }); app.patch("/api/vehicles/:id", isAuthenticated, async (req, res) => { try { const updated = await storage.updateVehicle(req.params.id, req.body); if (!updated) { return res.status(404).json({ message: "Vehicle not found" }); } res.json(updated); } catch (error: any) { console.error("Error updating vehicle:", error); if (error.code === '23505') { return res.status(409).json({ message: "Targa già esistente" }); } res.status(500).json({ message: "Failed to update vehicle" }); } }); app.delete("/api/vehicles/:id", isAuthenticated, async (req, res) => { try { const deleted = await storage.deleteVehicle(req.params.id); if (!deleted) { return res.status(404).json({ message: "Vehicle not found" }); } res.json({ success: true }); } catch (error) { console.error("Error deleting vehicle:", error); res.status(500).json({ message: "Failed to delete vehicle" }); } }); // ============= CONTRACT PARAMETERS ROUTES ============= app.get("/api/contract-parameters", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins and coordinators can view parameters if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") { return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" }); } let params = await storage.getContractParameters(); // Se non esistono parametri, creali con valori di default CCNL if (!params) { params = await storage.createContractParameters({ contractType: "CCNL_VIGILANZA_2024", }); } res.json(params); } catch (error) { console.error("Error fetching contract parameters:", error); res.status(500).json({ message: "Failed to fetch contract parameters" }); } }); app.put("/api/contract-parameters/:id", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins can update parameters if (currentUser?.role !== "admin") { return res.status(403).json({ message: "Forbidden: Admin access required" }); } // Validate request body with insert schema const { insertContractParametersSchema } = await import("@shared/schema"); const validationResult = insertContractParametersSchema.partial().safeParse(req.body); if (!validationResult.success) { return res.status(400).json({ message: "Invalid parameters data", errors: validationResult.error.errors }); } const updated = await storage.updateContractParameters(req.params.id, validationResult.data); if (!updated) { return res.status(404).json({ message: "Contract parameters not found" }); } res.json(updated); } catch (error) { console.error("Error updating contract parameters:", error); res.status(500).json({ message: "Failed to update contract parameters" }); } }); // ============= CCNL SETTINGS ROUTES ============= app.get("/api/ccnl-settings", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins and coordinators can view CCNL settings if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") { return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" }); } const settings = await storage.getAllCcnlSettings(); res.json(settings); } catch (error) { console.error("Error fetching CCNL settings:", error); res.status(500).json({ message: "Failed to fetch CCNL settings" }); } }); app.get("/api/ccnl-settings/:key", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins and coordinators can view CCNL settings if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") { return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" }); } const setting = await storage.getCcnlSetting(req.params.key); if (!setting) { return res.status(404).json({ message: "CCNL setting not found" }); } res.json(setting); } catch (error) { console.error("Error fetching CCNL setting:", error); res.status(500).json({ message: "Failed to fetch CCNL setting" }); } }); app.post("/api/ccnl-settings", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins can create/update CCNL settings if (currentUser?.role !== "admin") { return res.status(403).json({ message: "Forbidden: Admin access required" }); } const { insertCcnlSettingSchema } = await import("@shared/schema"); const validationResult = insertCcnlSettingSchema.safeParse(req.body); if (!validationResult.success) { return res.status(400).json({ message: "Invalid CCNL setting data", errors: validationResult.error.errors }); } const setting = await storage.upsertCcnlSetting(validationResult.data); res.json(setting); } catch (error) { console.error("Error creating/updating CCNL setting:", error); res.status(500).json({ message: "Failed to create/update CCNL setting" }); } }); app.delete("/api/ccnl-settings/:key", isAuthenticated, async (req: any, res) => { try { const currentUserId = getUserId(req); const currentUser = await storage.getUser(currentUserId); // Only admins can delete CCNL settings if (currentUser?.role !== "admin") { return res.status(403).json({ message: "Forbidden: Admin access required" }); } await storage.deleteCcnlSetting(req.params.key); res.json({ success: true }); } catch (error) { console.error("Error deleting CCNL setting:", error); res.status(500).json({ message: "Failed to delete CCNL setting" }); } }); // ============= CCNL VALIDATION ROUTES ============= app.post("/api/ccnl/validate-shift", isAuthenticated, async (req, res) => { try { const { validateShiftForGuard } = await import("./ccnlRules"); const { guardId, shiftStartTime, shiftEndTime, excludeShiftId } = req.body; if (!guardId || !shiftStartTime || !shiftEndTime) { return res.status(400).json({ message: "Missing required fields: guardId, shiftStartTime, shiftEndTime" }); } const result = await validateShiftForGuard( guardId, new Date(shiftStartTime), new Date(shiftEndTime), excludeShiftId ); res.json(result); } catch (error) { console.error("Error validating shift:", error); res.status(500).json({ message: "Failed to validate shift" }); } }); app.get("/api/ccnl/guard-availability/:guardId", isAuthenticated, async (req, res) => { try { const { getGuardAvailabilityReport } = await import("./ccnlRules"); const { guardId } = req.params; const startDate = req.query.startDate ? new Date(req.query.startDate as string) : new Date(); const endDate = req.query.endDate ? new Date(req.query.endDate as string) : new Date(); const report = await getGuardAvailabilityReport(guardId, startDate, endDate); res.json(report); } catch (error) { console.error("Error fetching guard availability:", error); res.status(500).json({ message: "Failed to fetch guard availability" }); } }); // ============= OPERATIONAL PLANNING ROUTES ============= app.get("/api/operational-planning/availability", isAuthenticated, async (req, res) => { try { const { getGuardAvailabilityReport } = await import("./ccnlRules"); // Sanitizza input: gestisce sia "2025-10-17" che "2025-10-17/2025-10-17" const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd"); const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash // Valida la data const parsedDate = parseISO(normalizedDateStr); if (!isValid(parsedDate)) { return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); } const dateStr = format(parsedDate, "yyyy-MM-dd"); // Ottieni location dalla query (default: roccapiemonte) const location = req.query.location as string || "roccapiemonte"; // Imposta inizio e fine giornata in UTC const startOfDay = new Date(dateStr + "T00:00:00.000Z"); const endOfDay = new Date(dateStr + "T23:59:59.999Z"); // Ottieni tutti i veicoli della sede selezionata const allVehicles = await storage.getAllVehicles(); const locationVehicles = allVehicles.filter(v => v.location === location); // Ottieni turni del giorno SOLO della sede selezionata (join con sites per filtrare per location) const dayShifts = await db .select({ shift: shifts }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .where( and( gte(shifts.startTime, startOfDay), lte(shifts.startTime, endOfDay), eq(sites.location, location) ) ); // Mappa veicoli con disponibilità const vehiclesWithAvailability = await Promise.all( locationVehicles.map(async (vehicle) => { const assignedShiftRecord = dayShifts.find((s: any) => s.shift.vehicleId === vehicle.id); return { ...vehicle, isAvailable: !assignedShiftRecord, assignedShift: assignedShiftRecord ? { id: assignedShiftRecord.shift.id, startTime: assignedShiftRecord.shift.startTime, endTime: assignedShiftRecord.shift.endTime, siteId: assignedShiftRecord.shift.siteId } : null }; }) ); // Ottieni tutte le guardie della sede selezionata const allGuards = await storage.getAllGuards(); const locationGuards = allGuards.filter(g => g.location === location); // Ottieni assegnazioni turni del giorno SOLO della sede selezionata (join con sites per filtrare per location) const dayShiftAssignments = await db .select() .from(shiftAssignments) .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) .innerJoin(sites, eq(shifts.siteId, sites.id)) .where( and( gte(shifts.startTime, startOfDay), lte(shifts.startTime, endOfDay), eq(sites.location, location) ) ); // Calcola disponibilità agenti con report CCNL const guardsWithAvailability = await Promise.all( locationGuards.map(async (guard) => { const assignedShift = dayShiftAssignments.find( (assignment: any) => assignment.shift_assignments.guardId === guard.id ); // Calcola report disponibilità CCNL const availabilityReport = await getGuardAvailabilityReport( guard.id, startOfDay, endOfDay ); return { ...guard, isAvailable: !assignedShift, assignedShift: assignedShift ? { id: assignedShift.shifts.id, startTime: assignedShift.shifts.startTime, endTime: assignedShift.shifts.endTime, siteId: assignedShift.shifts.siteId } : null, availability: { weeklyHours: availabilityReport.weeklyHours.current, remainingWeeklyHours: availabilityReport.remainingWeeklyHours, remainingMonthlyHours: availabilityReport.remainingMonthlyHours, consecutiveDaysWorked: availabilityReport.consecutiveDaysWorked } }; }) ); // Ordina veicoli: disponibili prima, poi per targa const sortedVehicles = vehiclesWithAvailability.sort((a, b) => { if (a.isAvailable && !b.isAvailable) return -1; if (!a.isAvailable && b.isAvailable) return 1; return a.licensePlate.localeCompare(b.licensePlate); }); // Ordina agenti: disponibili prima, poi per ore settimanali (meno ore = più disponibili) const sortedGuards = guardsWithAvailability.sort((a, b) => { if (a.isAvailable && !b.isAvailable) return -1; if (!a.isAvailable && b.isAvailable) return 1; // Se entrambi disponibili, ordina per ore settimanali (meno ore = prima) if (a.isAvailable && b.isAvailable) { return a.availability.weeklyHours - b.availability.weeklyHours; } return 0; }); res.json({ date: dateStr, vehicles: sortedVehicles, guards: sortedGuards }); } catch (error) { console.error("Error fetching operational planning availability:", error); res.status(500).json({ message: "Failed to fetch availability", error: String(error) }); } }); // Endpoint per ottenere siti non completamente coperti per una data e sede app.get("/api/operational-planning/uncovered-sites", isAuthenticated, async (req, res) => { try { // Sanitizza input: gestisce sia "2025-10-17" che "2025-10-17/2025-10-17" const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd"); const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash // Valida la data const parsedDate = parseISO(normalizedDateStr); if (!isValid(parsedDate)) { return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); } const dateStr = format(parsedDate, "yyyy-MM-dd"); // Ottieni location dalla query (default: roccapiemonte) const location = req.query.location as string || "roccapiemonte"; // Ottieni tutti i siti attivi della sede selezionata const allSites = await db .select() .from(sites) .where( and( eq(sites.isActive, true), eq(sites.location, location) ) ); // Filtra siti con contratto valido per la data selezionata const sitesWithValidContract = allSites.filter((site: any) => { // Se il sito non ha date contrattuali, lo escludiamo if (!site.contractStartDate || !site.contractEndDate) { return false; } // Normalizza date per confronto day-only const selectedDate = new Date(dateStr); selectedDate.setHours(0, 0, 0, 0); const contractStart = new Date(site.contractStartDate); contractStart.setHours(0, 0, 0, 0); const contractEnd = new Date(site.contractEndDate); contractEnd.setHours(23, 59, 59, 999); // Verifica che la data selezionata sia dentro il periodo contrattuale return selectedDate >= contractStart && selectedDate <= contractEnd; }); // Ottieni turni del giorno con assegnazioni SOLO della sede selezionata const startOfDayDate = new Date(dateStr); startOfDayDate.setHours(0, 0, 0, 0); const endOfDayDate = new Date(dateStr); endOfDayDate.setHours(23, 59, 59, 999); const dayShifts = await db .select({ shift: shifts, assignmentCount: sql`count(${shiftAssignments.id})::int` }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .leftJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId)) .where( and( gte(shifts.startTime, startOfDayDate), lte(shifts.startTime, endOfDayDate), ne(shifts.status, "cancelled"), eq(sites.location, location) ) ) .groupBy(shifts.id); // Calcola copertura per ogni sito con contratto valido const sitesWithCoverage = sitesWithValidContract.map((site: any) => { const siteShifts = dayShifts.filter((s: any) => s.shift.siteId === site.id); // Verifica copertura per ogni turno const shiftsWithCoverage = siteShifts.map((s: any) => ({ id: s.shift.id, startTime: s.shift.startTime, endTime: s.shift.endTime, assignedGuardsCount: s.assignmentCount, requiredGuards: site.minGuards, isCovered: s.assignmentCount >= site.minGuards, isPartial: s.assignmentCount > 0 && s.assignmentCount < site.minGuards })); // Un sito è completamente coperto solo se TUTTI i turni hanno il numero minimo di guardie const allShiftsCovered = siteShifts.length > 0 && shiftsWithCoverage.every((s: any) => s.isCovered); // Un sito è parzialmente coperto se ha turni ma non tutti sono completamente coperti const hasPartialCoverage = siteShifts.length > 0 && !allShiftsCovered && shiftsWithCoverage.some((s: any) => s.assignedGuardsCount > 0); // Calcola totale guardie assegnate per info const totalAssignedGuards = siteShifts.reduce((sum: number, s: any) => sum + s.assignmentCount, 0); return { ...site, isCovered: allShiftsCovered, isPartiallyCovered: hasPartialCoverage, totalAssignedGuards, requiredGuards: site.minGuards, shiftsCount: siteShifts.length, shifts: shiftsWithCoverage }; }); // Filtra solo siti non completamente coperti const uncoveredSites = sitesWithCoverage.filter( (site: any) => !site.isCovered ); // Ordina: parzialmente coperti prima, poi non coperti const sortedUncoveredSites = uncoveredSites.sort((a: any, b: any) => { if (a.isPartiallyCovered && !b.isPartiallyCovered) return -1; if (!a.isPartiallyCovered && b.isPartiallyCovered) return 1; return a.name.localeCompare(b.name); }); res.json({ date: dateStr, uncoveredSites: sortedUncoveredSites, totalSites: sitesWithValidContract.length, totalUncovered: uncoveredSites.length }); } catch (error) { console.error("Error fetching uncovered sites:", error); res.status(500).json({ message: "Failed to fetch uncovered sites", error: String(error) }); } }); // Endpoint per Planning Generale - vista settimanale con calcolo guardie mancanti app.get("/api/general-planning", isAuthenticated, async (req, res) => { try { // Sanitizza input data inizio settimana const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd"); const normalizedWeekStart = rawWeekStart.split("/")[0]; // Valida la data const parsedWeekStart = parseISO(normalizedWeekStart); if (!isValid(parsedWeekStart)) { return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); } const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd"); // Ottieni location dalla query (default: roccapiemonte) const location = req.query.location as string || "roccapiemonte"; // Calcola fine settimana (weekStart + 6 giorni) const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd"); // Timestamp per filtro contratti const weekStartTimestampForContract = new Date(weekStartDate); const weekEndTimestampForContract = new Date(weekEndDate); // Ottieni tutti i siti attivi della sede con contratto valido nelle date della settimana const activeSites = await db .select() .from(sites) .leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id)) .where( and( eq(sites.isActive, true), eq(sites.location, location as any), // Contratto deve essere valido in almeno un giorno della settimana // contractStartDate <= weekEnd AND contractEndDate >= weekStart lte(sites.contractStartDate, weekEndTimestampForContract), gte(sites.contractEndDate, weekStartTimestampForContract) ) ); // Ottieni tutti i turni della settimana per la sede const weekStartTimestamp = new Date(weekStartDate); weekStartTimestamp.setHours(0, 0, 0, 0); const weekEndTimestamp = new Date(weekEndDate); weekEndTimestamp.setHours(23, 59, 59, 999); const weekShifts = await db .select({ shift: shifts, site: sites, }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .where( and( gte(shifts.startTime, weekStartTimestamp), lte(shifts.startTime, weekEndTimestamp), ne(shifts.status, "cancelled"), eq(sites.location, location as any) ) ); // Ottieni tutte le assegnazioni dei turni della settimana const shiftIds = weekShifts.map((s: any) => s.shift.id); const assignments = shiftIds.length > 0 ? await db .select({ assignment: shiftAssignments, guard: guards, shift: shifts, }) .from(shiftAssignments) .innerJoin(guards, eq(shiftAssignments.guardId, guards.id)) .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) .where( sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})` ) : []; // Ottieni veicoli assegnati const vehicleAssignments = weekShifts .filter((s: any) => s.shift.vehicleId) .map((s: any) => s.shift.vehicleId); const assignedVehicles = vehicleAssignments.length > 0 ? await db .select() .from(vehicles) .where( sql`${vehicles.id} IN (${sql.join(vehicleAssignments.map((id: string) => sql`${id}`), sql`, `)})` ) : []; // Costruisci struttura dati per 7 giorni const weekData = []; for (let dayOffset = 0; dayOffset < 7; dayOffset++) { const currentDay = addDays(parsedWeekStart, dayOffset); const dayStr = format(currentDay, "yyyy-MM-dd"); const dayStartTimestamp = new Date(dayStr); dayStartTimestamp.setHours(0, 0, 0, 0); const dayEndTimestamp = new Date(dayStr); dayEndTimestamp.setHours(23, 59, 59, 999); const sitesData = activeSites.map(({ sites: site, service_types: serviceType }: any) => { // Trova turni del giorno per questo sito const dayShifts = weekShifts.filter((s: any) => s.shift.siteId === site.id && s.shift.startTime >= dayStartTimestamp && s.shift.startTime <= dayEndTimestamp ); // Ottieni assegnazioni guardie per i turni del giorno const dayAssignments = assignments.filter((a: any) => dayShifts.some((ds: any) => ds.shift.id === a.shift.id) ); // Calcola ore per ogni guardia con orari e assignmentId const guardsWithHours = dayAssignments.map((a: any) => { const plannedStart = new Date(a.assignment.plannedStartTime); const plannedEnd = new Date(a.assignment.plannedEndTime); const hours = differenceInHours(plannedEnd, plannedStart); return { assignmentId: a.assignment.id, guardId: a.guard.id, guardName: a.guard.fullName, badgeNumber: a.guard.badgeNumber, hours, plannedStartTime: a.assignment.plannedStartTime, plannedEndTime: a.assignment.plannedEndTime, }; }); // Veicoli assegnati ai turni del giorno const dayVehicles = dayShifts .filter((ds: any) => ds.shift.vehicleId) .map((ds: any) => { const vehicle = assignedVehicles.find((v: any) => v.id === ds.shift.vehicleId); return vehicle ? { vehicleId: vehicle.id, licensePlate: vehicle.licensePlate, brand: vehicle.brand, model: vehicle.model, } : null; }) .filter(Boolean); // Calcolo guardie mancanti const maxOreGuardia = 9; // Max ore per guardia const minGuardie = site.minGuards || 1; // Calcola ore servizio del sito (per calcolo corretto anche senza turni) const serviceStart = site.serviceStartTime || "00:00"; const serviceEnd = site.serviceEndTime || "23:59"; const [startH, startM] = serviceStart.split(":").map(Number); const [endH, endM] = serviceEnd.split(":").map(Number); let serviceHours = (endH + endM/60) - (startH + startM/60); if (serviceHours <= 0) serviceHours += 24; // Servizio notturno (es. 22:00-06:00) // Somma ore totali dei turni del giorno (se esistono) const totalShiftHours = dayShifts.reduce((sum: number, ds: any) => { const start = new Date(ds.shift.startTime); const end = new Date(ds.shift.endTime); return sum + differenceInHours(end, start); }, 0); // Usa ore servizio o ore turni (se già creati) const effectiveHours = totalShiftHours > 0 ? totalShiftHours : serviceHours; // Guardie uniche assegnate (conta ogni guardia una volta anche se ha più turni) const uniqueGuardsAssigned = new Set(guardsWithHours.map((g: any) => g.guardId)).size; // Calcolo guardie necessarie basato su ore servizio // Slot necessari per coprire le ore (ogni guardia max 9h) const slotsNeeded = Math.ceil(effectiveHours / maxOreGuardia); // Guardie totali necessarie (slot × min guardie contemporanee) const totalGuardsNeeded = slotsNeeded * minGuardie; const missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned); return { siteId: site.id, siteName: site.name, serviceType: serviceType?.label || "N/A", serviceStartTime: serviceStart, serviceEndTime: serviceEnd, serviceHours: Math.round(serviceHours * 10) / 10, // Arrotonda a 1 decimale minGuards: site.minGuards, guards: guardsWithHours, vehicles: dayVehicles, totalShiftHours, guardsAssigned: uniqueGuardsAssigned, missingGuards, shiftsCount: dayShifts.length, }; }); weekData.push({ date: dayStr, dayOfWeek: format(currentDay, "EEEE"), sites: sitesData, }); } // Calcola guardie totali necessarie per l'intera settimana let totalGuardsNeededForWeek = 0; let totalGuardsAssignedForWeek = 0; for (const day of weekData) { for (const siteData of day.sites) { // Somma guardie necessarie (già calcolate per sito/giorno) // totalGuardsNeeded per sito = guardsAssigned + missingGuards totalGuardsNeededForWeek += (siteData.guardsAssigned + siteData.missingGuards); // Somma slot guardia assegnati (non guardie uniche) // Questo conta ogni assegnazione, anche se la stessa guardia lavora più turni totalGuardsAssignedForWeek += siteData.guardsAssigned; } } const totalGuardsMissingForWeek = Math.max(0, totalGuardsNeededForWeek - totalGuardsAssignedForWeek); res.json({ weekStart: weekStartDate, weekEnd: weekEndDate, location, days: weekData, summary: { totalGuardsNeeded: totalGuardsNeededForWeek, totalGuardsAssigned: totalGuardsAssignedForWeek, totalGuardsMissing: totalGuardsMissingForWeek, } }); } catch (error) { console.error("Error fetching general planning:", error); res.status(500).json({ message: "Failed to fetch general planning", error: String(error) }); } }); // Create multi-day shift from general planning app.post("/api/general-planning/shifts", isAuthenticated, async (req, res) => { try { // Validate request body const validationResult = createMultiDayShiftSchema.safeParse(req.body); if (!validationResult.success) { return res.status(400).json({ message: "Invalid request data", errors: validationResult.error.errors }); } const { siteId, startDate, days, guardId, shiftType } = validationResult.data; // Get site to check contract and service details const site = await storage.getSite(siteId); if (!site) { return res.status(404).json({ message: "Site not found" }); } // Get guard to verify it exists const guard = await storage.getGuard(guardId); if (!guard) { return res.status(404).json({ message: "Guard not found" }); } // Pre-validate all dates are within contract period const startDateParsed = parseISO(startDate); for (let dayOffset = 0; dayOffset < days; dayOffset++) { const shiftDate = addDays(startDateParsed, dayOffset); const shiftDateStr = format(shiftDate, "yyyy-MM-dd"); if (site.contractStartDate && site.contractEndDate) { const contractStart = new Date(site.contractStartDate); const contractEnd = new Date(site.contractEndDate); if (shiftDate < contractStart || shiftDate > contractEnd) { return res.status(400).json({ message: `Cannot create shift for ${shiftDateStr}: outside contract period` }); } } } // Create shifts atomically in a transaction const createdShifts = await db.transaction(async (tx) => { const createdShiftsInTx = []; for (let dayOffset = 0; dayOffset < days; dayOffset++) { const shiftDate = addDays(startDateParsed, dayOffset); // Use site service schedule or default 24h const serviceStart = site.serviceStartTime || "00:00"; const serviceEnd = site.serviceEndTime || "23:59"; const [startHour, startMin] = serviceStart.split(":").map(Number); const [endHour, endMin] = serviceEnd.split(":").map(Number); const shiftStart = new Date(shiftDate); shiftStart.setHours(startHour, startMin, 0, 0); const shiftEnd = new Date(shiftDate); shiftEnd.setHours(endHour, endMin, 0, 0); // If service ends before it starts, it spans midnight (add 1 day to end) if (shiftEnd <= shiftStart) { shiftEnd.setDate(shiftEnd.getDate() + 1); } // Create shift in transaction const [shift] = await tx.insert(shifts).values({ siteId: site.id, startTime: shiftStart, endTime: shiftEnd, shiftType: shiftType || site.shiftType || "fixed_post", status: "planned", }).returning(); // Create shift assignment in transaction await tx.insert(shiftAssignments).values({ shiftId: shift.id, guardId: guard.id, }); createdShiftsInTx.push(shift); } return createdShiftsInTx; }); res.json({ message: `Created ${createdShifts.length} shifts`, shifts: createdShifts }); } catch (error) { console.error("Error creating multi-day shifts:", error); res.status(500).json({ message: "Failed to create shifts", error: String(error) }); } }); // Delete a shift assignment app.delete("/api/shift-assignments/:assignmentId", isAuthenticated, async (req, res) => { try { const { assignmentId } = req.params; if (!assignmentId) { return res.status(400).json({ message: "Assignment ID is required" }); } // Delete the assignment const deleted = await db .delete(shiftAssignments) .where(eq(shiftAssignments.id, assignmentId)) .returning(); if (deleted.length === 0) { return res.status(404).json({ message: "Assignment not found" }); } res.json({ message: "Assignment deleted successfully", assignment: deleted[0] }); } catch (error: any) { console.error("Error deleting assignment:", error); res.status(500).json({ message: "Failed to delete assignment", error: String(error) }); } }); // Assign guard to site/date with specific time slot (supports multi-day assignments) app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => { try { const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId, force = false } = req.body; if (!siteId || !date || !guardId || !startTime || !durationHours) { return res.status(400).json({ message: "Missing required fields: siteId, date, guardId, startTime, durationHours" }); } if (consecutiveDays < 1 || consecutiveDays > 30) { return res.status(400).json({ message: "consecutiveDays must be between 1 and 30" }); } // Get site to check contract and service details const site = await storage.getSite(siteId); if (!site) { return res.status(404).json({ message: "Site not found" }); } // Get guard to verify it exists const guard = await storage.getGuard(guardId); if (!guard) { return res.status(404).json({ message: "Guard not found" }); } // Parse start date components const [year, month, day] = date.split("-").map(Number); if (!year || !month || !day || month < 1 || month > 12 || day < 1 || day > 31) { return res.status(400).json({ message: "Invalid date format. Expected YYYY-MM-DD" }); } const [hours, minutes] = startTime.split(":").map(Number); // Atomic transaction: create assignments for all consecutive days const result = await db.transaction(async (tx) => { const createdAssignments = []; // Loop through each consecutive day for (let dayOffset = 0; dayOffset < consecutiveDays; dayOffset++) { // Calculate date components for this iteration (avoid timezone issues) const targetDay = day + dayOffset; const baseDate = new Date(year, month - 1, 1); // First day of month baseDate.setDate(targetDay); // Set to target day (handles month overflow) // Extract actual date components after overflow handling const actualYear = baseDate.getFullYear(); const actualMonth = baseDate.getMonth(); const actualDay = baseDate.getDate(); // Build dates in LOCAL timezone to match user's selection const shiftDate = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0); // Check contract validity for this date if (site.contractStartDate && site.contractEndDate) { const contractStart = new Date(site.contractStartDate); const contractEnd = new Date(site.contractEndDate); if (shiftDate < contractStart || shiftDate > contractEnd) { throw new Error( `Cannot assign guard for date ${shiftDate.toLocaleDateString()}: outside contract period` ); } } // Calculate planned start and end times in LOCAL timezone const plannedStart = new Date(actualYear, actualMonth, actualDay, hours, minutes, 0, 0); const plannedEnd = new Date(actualYear, actualMonth, actualDay, hours + durationHours, minutes, 0, 0); // Find or create shift for this site/date (full day boundaries in LOCAL timezone) const dayStart = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0); const dayEnd = new Date(actualYear, actualMonth, actualDay, 23, 59, 59, 999); let existingShifts = await tx .select() .from(shifts) .where( and( eq(shifts.siteId, siteId), gte(shifts.startTime, dayStart), lte(shifts.startTime, dayEnd) ) ); let shift; if (existingShifts.length > 0) { shift = existingShifts[0]; } else { // Create new shift for full service period const serviceStart = site.serviceStartTime || "00:00"; const serviceEnd = site.serviceEndTime || "23:59"; const [startHour, startMin] = serviceStart.split(":").map(Number); const [endHour, endMin] = serviceEnd.split(":").map(Number); const shiftStart = new Date(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0); let shiftEnd = new Date(actualYear, actualMonth, actualDay, endHour, endMin, 0, 0); // If end time is before/equal to start time, shift extends to next day if (shiftEnd <= shiftStart) { shiftEnd = new Date(actualYear, actualMonth, actualDay + 1, endHour, endMin, 0, 0); } [shift] = await tx.insert(shifts).values({ siteId: site.id, startTime: shiftStart, endTime: shiftEnd, shiftType: site.shiftType || "fixed_post", status: "planned", vehicleId: vehicleId || null, }).returning(); } // Recheck overlaps within transaction for this day const existingAssignments = await tx .select() .from(shiftAssignments) .where(eq(shiftAssignments.guardId, guard.id)); // Check for time overlaps for (const existing of existingAssignments) { const hasOverlap = plannedStart < existing.plannedEndTime && plannedEnd > existing.plannedStartTime; if (hasOverlap) { throw new Error( `Conflitto: guardia già assegnata ${existing.plannedStartTime.toLocaleString()} - ${existing.plannedEndTime.toLocaleString()}` ); } } // CCNL: Check daily hour limit (max 9h/day) - skip if force=true if (!force) { const maxDailyHours = 9; let dailyHoursAlreadyAssigned = 0; for (const existing of existingAssignments) { // Check if assignment is on the same day const existingDate = new Date(existing.plannedStartTime); if ( existingDate.getUTCFullYear() === actualYear && existingDate.getUTCMonth() === actualMonth && existingDate.getUTCDate() === actualDay ) { const assignmentHours = differenceInHours( existing.plannedEndTime, existing.plannedStartTime ); dailyHoursAlreadyAssigned += assignmentHours; } } // Check if new assignment would exceed daily limit if (dailyHoursAlreadyAssigned + durationHours > maxDailyHours) { const excessHours = (dailyHoursAlreadyAssigned + durationHours) - maxDailyHours; throw new Error( `Limite giornaliero superato: la guardia ha già ${dailyHoursAlreadyAssigned}h assegnate il ${shiftDate.toLocaleDateString('it-IT')}. ` + `Aggiungendo ${durationHours}h si supererebbero di ${excessHours}h le ${maxDailyHours}h massime giornaliere (CCNL).` ); } } // Create assignment for this day const [assignment] = await tx.insert(shiftAssignments).values({ shiftId: shift.id, guardId: guard.id, plannedStartTime: plannedStart, plannedEndTime: plannedEnd, }).returning(); createdAssignments.push(assignment); } return { assignments: createdAssignments, count: createdAssignments.length }; }); res.json({ message: `Guard assigned successfully for ${result.count} day(s)`, assignments: result.assignments, count: result.count }); } catch (error: any) { console.error("Error assigning guard:", error); // Check for overlap/conflict errors (both English and Italian) const errorMessage = error.message?.toLowerCase() || ''; if (errorMessage.includes('overlap') || errorMessage.includes('conflict') || errorMessage.includes('conflitto') || errorMessage.includes('già assegnata') || errorMessage.includes('limite giornaliero') || errorMessage.includes('limite settimanale') || errorMessage.includes('ccnl')) { return res.status(409).json({ message: error.message, type: errorMessage.includes('limite') ? 'CCNL_VIOLATION' : 'CONFLICT' }); } res.status(500).json({ message: "Failed to assign guard", error: String(error) }); } }); // ============= SERVICE PLANNING ROUTES ============= // Vista per Guardia - mostra orari e dotazioni per ogni guardia app.get("/api/service-planning/by-guard", isAuthenticated, async (req, res) => { try { const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd"); const normalizedWeekStart = rawWeekStart.split("/")[0]; const parsedWeekStart = parseISO(normalizedWeekStart); if (!isValid(parsedWeekStart)) { return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); } const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd"); const location = req.query.location as string || "roccapiemonte"; const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd"); const weekStartTimestamp = new Date(weekStartDate); weekStartTimestamp.setHours(0, 0, 0, 0); const weekEndTimestamp = new Date(weekEndDate); weekEndTimestamp.setHours(23, 59, 59, 999); // Ottieni tutte le guardie della sede const allGuards = await db .select() .from(guards) .where(eq(guards.location, location as any)) .orderBy(guards.fullName); // Ottieni tutti i turni della settimana per la sede (con JOIN su sites per filtrare location) const weekShifts = await db .select({ shift: shifts, site: sites, vehicle: vehicles, }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id)) .where( and( gte(shifts.startTime, weekStartTimestamp), lte(shifts.startTime, weekEndTimestamp), ne(shifts.status, "cancelled"), eq(sites.location, location as any) ) ); // Ottieni tutte le assegnazioni per i turni della settimana const shiftIds = weekShifts.map((s: any) => s.shift.id); const assignments = shiftIds.length > 0 ? await db .select({ assignment: shiftAssignments, guard: guards, }) .from(shiftAssignments) .innerJoin(guards, eq(shiftAssignments.guardId, guards.id)) .where( sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})` ) : []; // Costruisci dati per ogni guardia const guardSchedules = allGuards.map((guard: any) => { // Trova assegnazioni della guardia const guardAssignments = assignments.filter((a: any) => a.guard.id === guard.id); // Costruisci lista turni con dettagli const shifts = guardAssignments.map((a: any) => { const shiftData = weekShifts.find((s: any) => s.shift.id === a.assignment.shiftId); if (!shiftData) return null; const plannedStart = new Date(a.assignment.plannedStartTime); const plannedEnd = new Date(a.assignment.plannedEndTime); const minutes = differenceInMinutes(plannedEnd, plannedStart); const hours = Math.round((minutes / 60) * 10) / 10; // Arrotonda a 1 decimale return { shiftId: shiftData.shift.id, date: format(plannedStart, "yyyy-MM-dd"), from: format(plannedStart, "HH:mm"), to: format(plannedEnd, "HH:mm"), siteName: shiftData.site.name, siteId: shiftData.site.id, vehicle: shiftData.vehicle ? { licensePlate: shiftData.vehicle.licensePlate, brand: shiftData.vehicle.brand, model: shiftData.vehicle.model, } : undefined, hours, }; }).filter(Boolean); const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.hours, 0) * 10) / 10; return { guardId: guard.id, guardName: guard.fullName, badgeNumber: guard.badgeNumber, shifts, totalHours, }; }); // Filtra solo guardie con turni assegnati const guardsWithShifts = guardSchedules.filter((g: any) => g.shifts.length > 0); res.json(guardsWithShifts); } catch (error) { console.error("Error fetching guard schedules:", error); res.status(500).json({ message: "Failed to fetch guard schedules", error: String(error) }); } }); // Vista per Sito - mostra agenti e dotazioni per ogni sito app.get("/api/service-planning/by-site", isAuthenticated, async (req, res) => { try { const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd"); const normalizedWeekStart = rawWeekStart.split("/")[0]; const parsedWeekStart = parseISO(normalizedWeekStart); if (!isValid(parsedWeekStart)) { return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); } const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd"); const location = req.query.location as string || "roccapiemonte"; const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd"); const weekStartTimestamp = new Date(weekStartDate); weekStartTimestamp.setHours(0, 0, 0, 0); const weekEndTimestamp = new Date(weekEndDate); weekEndTimestamp.setHours(23, 59, 59, 999); // Ottieni tutti i siti attivi della sede const activeSites = await db .select() .from(sites) .where( and( eq(sites.isActive, true), eq(sites.location, location as any) ) ) .orderBy(sites.name); // Ottieni tutti i turni della settimana per la sede const weekShifts = await db .select({ shift: shifts, site: sites, vehicle: vehicles, }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id)) .where( and( gte(shifts.startTime, weekStartTimestamp), lte(shifts.startTime, weekEndTimestamp), ne(shifts.status, "cancelled"), eq(sites.location, location as any) ) ); // Ottieni tutte le assegnazioni per i turni della settimana const shiftIds = weekShifts.map((s: any) => s.shift.id); const assignments = shiftIds.length > 0 ? await db .select({ assignment: shiftAssignments, guard: guards, }) .from(shiftAssignments) .innerJoin(guards, eq(shiftAssignments.guardId, guards.id)) .where( sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})` ) : []; // Costruisci dati per ogni sito const siteSchedules = activeSites.map((site: any) => { // Trova turni del sito const siteShifts = weekShifts.filter((s: any) => s.site.id === site.id); // Costruisci lista turni con guardie e veicoli const shifts = siteShifts.map((shiftData: any) => { const shiftAssignments = assignments.filter((a: any) => a.assignment.shiftId === shiftData.shift.id); const guards = shiftAssignments.map((a: any) => { const plannedStart = new Date(a.assignment.plannedStartTime); const plannedEnd = new Date(a.assignment.plannedEndTime); const minutes = differenceInMinutes(plannedEnd, plannedStart); const hours = Math.round((minutes / 60) * 10) / 10; // Arrotonda a 1 decimale return { guardName: a.guard.fullName, badgeNumber: a.guard.badgeNumber, hours, }; }); const shiftStart = new Date(shiftData.shift.startTime); const shiftEnd = new Date(shiftData.shift.endTime); const minutes = differenceInMinutes(shiftEnd, shiftStart); const totalHours = Math.round((minutes / 60) * 10) / 10; return { shiftId: shiftData.shift.id, date: format(shiftStart, "yyyy-MM-dd"), from: format(shiftStart, "HH:mm"), to: format(shiftEnd, "HH:mm"), guards, vehicle: shiftData.vehicle ? { licensePlate: shiftData.vehicle.licensePlate, brand: shiftData.vehicle.brand, model: shiftData.vehicle.model, } : undefined, totalGuards: guards.length, totalHours, }; }); const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.totalHours, 0) * 10) / 10; return { siteId: site.id, siteName: site.name, location: site.location, shifts, totalShifts: shifts.length, totalHours, }; }); // Filtra solo siti con turni programmati const sitesWithShifts = siteSchedules.filter((s: any) => s.shifts.length > 0); res.json(sitesWithShifts); } catch (error) { console.error("Error fetching site schedules:", error); res.status(500).json({ message: "Failed to fetch site schedules", error: String(error) }); } }); // ============= REPORTS ROUTES ============= // Report mensile ore per guardia (ordinarie/straordinarie + buoni pasto) app.get("/api/reports/monthly-guard-hours", isAuthenticated, async (req, res) => { try { const rawMonth = req.query.month as string || format(new Date(), "yyyy-MM"); const location = req.query.location as string || "roccapiemonte"; // Parse mese (formato: YYYY-MM) const [year, month] = rawMonth.split("-").map(Number); const monthStart = new Date(year, month - 1, 1); monthStart.setHours(0, 0, 0, 0); const monthEnd = new Date(year, month, 0); // ultimo giorno del mese monthEnd.setHours(23, 59, 59, 999); // Ottieni tutte le guardie della sede const allGuards = await db .select() .from(guards) .where(eq(guards.location, location as any)) .orderBy(guards.fullName); // Ottieni tutti i turni del mese per la sede const monthShifts = await db .select({ shift: shifts, site: sites, }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .where( and( gte(shifts.startTime, monthStart), lte(shifts.startTime, monthEnd), ne(shifts.status, "cancelled"), eq(sites.location, location as any) ) ); // Ottieni tutte le assegnazioni del mese const shiftIds = monthShifts.map((s: any) => s.shift.id); const assignments = shiftIds.length > 0 ? await db .select({ assignment: shiftAssignments, guard: guards, }) .from(shiftAssignments) .innerJoin(guards, eq(shiftAssignments.guardId, guards.id)) .where( sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})` ) : []; // Calcola statistiche per ogni guardia const guardReports = allGuards.map((guard: any) => { const guardAssignments = assignments.filter((a: any) => a.guard.id === guard.id); // Raggruppa assegnazioni per settimana (lunedì = inizio settimana) const weeklyHours: Record = {}; const dailyHours: Record = {}; // Per calcolare buoni pasto guardAssignments.forEach((a: any) => { const plannedStart = new Date(a.assignment.plannedStartTime); const plannedEnd = new Date(a.assignment.plannedEndTime); const minutes = differenceInMinutes(plannedEnd, plannedStart); const hours = minutes / 60; // Settimana (ISO week - lunedì come primo giorno) const weekStart = startOfWeek(plannedStart, { weekStartsOn: 1 }); const weekKey = format(weekStart, "yyyy-MM-dd"); weeklyHours[weekKey] = (weeklyHours[weekKey] || 0) + hours; // Giorno (per buoni pasto) const dayKey = format(plannedStart, "yyyy-MM-dd"); dailyHours[dayKey] = (dailyHours[dayKey] || 0) + hours; }); // Calcola ore ordinarie e straordinarie let ordinaryHours = 0; let overtimeHours = 0; Object.values(weeklyHours).forEach((weekHours: number) => { if (weekHours <= 40) { ordinaryHours += weekHours; } else { ordinaryHours += 40; overtimeHours += (weekHours - 40); } }); // Calcola buoni pasto (giorni con ore ≥ 6) const mealVouchers = Object.values(dailyHours).filter( (dayHours: number) => dayHours >= 6 ).length; const totalHours = ordinaryHours + overtimeHours; return { guardId: guard.id, guardName: guard.fullName, badgeNumber: guard.badgeNumber, ordinaryHours: Math.round(ordinaryHours * 10) / 10, overtimeHours: Math.round(overtimeHours * 10) / 10, totalHours: Math.round(totalHours * 10) / 10, mealVouchers, workingDays: Object.keys(dailyHours).length, }; }); // Filtra solo guardie con ore lavorate const guardsWithHours = guardReports.filter((g: any) => g.totalHours > 0); res.json({ month: rawMonth, location, guards: guardsWithHours, summary: { totalGuards: guardsWithHours.length, totalOrdinaryHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.ordinaryHours, 0) * 10) / 10, totalOvertimeHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.overtimeHours, 0) * 10) / 10, totalHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.totalHours, 0) * 10) / 10, totalMealVouchers: guardsWithHours.reduce((sum: number, g: any) => sum + g.mealVouchers, 0), }, }); } catch (error) { console.error("Error fetching monthly guard hours:", error); res.status(500).json({ message: "Failed to fetch monthly guard hours", error: String(error) }); } }); // Report ore fatturabili per sito per tipologia servizio app.get("/api/reports/billable-site-hours", isAuthenticated, async (req, res) => { try { const rawMonth = req.query.month as string || format(new Date(), "yyyy-MM"); const location = req.query.location as string || "roccapiemonte"; // Parse mese (formato: YYYY-MM) const [year, month] = rawMonth.split("-").map(Number); const monthStart = new Date(year, month - 1, 1); monthStart.setHours(0, 0, 0, 0); const monthEnd = new Date(year, month, 0); monthEnd.setHours(23, 59, 59, 999); // Ottieni tutti i turni del mese per la sede con dettagli sito e servizio const monthShifts = await db .select({ shift: shifts, site: sites, serviceType: serviceTypes, }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id)) .where( and( gte(shifts.startTime, monthStart), lte(shifts.startTime, monthEnd), ne(shifts.status, "cancelled"), eq(sites.location, location as any) ) ); // Raggruppa per sito const siteHoursMap: Record = {}; monthShifts.forEach((shiftData: any) => { const siteId = shiftData.site.id; const siteName = shiftData.site.name; const serviceTypeName = shiftData.serviceType?.name || "Non specificato"; const shiftStart = new Date(shiftData.shift.startTime); const shiftEnd = new Date(shiftData.shift.endTime); const minutes = differenceInMinutes(shiftEnd, shiftStart); const hours = minutes / 60; if (!siteHoursMap[siteId]) { siteHoursMap[siteId] = { siteId, siteName, serviceTypes: {}, totalHours: 0, totalShifts: 0, }; } if (!siteHoursMap[siteId].serviceTypes[serviceTypeName]) { siteHoursMap[siteId].serviceTypes[serviceTypeName] = { hours: 0, shifts: 0, }; } siteHoursMap[siteId].serviceTypes[serviceTypeName].hours += hours; siteHoursMap[siteId].serviceTypes[serviceTypeName].shifts += 1; siteHoursMap[siteId].totalHours += hours; siteHoursMap[siteId].totalShifts += 1; }); // Converti mappa in array e arrotonda ore const siteReports = Object.values(siteHoursMap).map((site: any) => { const serviceTypesArray = Object.entries(site.serviceTypes).map(([name, data]: [string, any]) => ({ name, hours: Math.round(data.hours * 10) / 10, shifts: data.shifts, })); return { siteId: site.siteId, siteName: site.siteName, serviceTypes: serviceTypesArray, totalHours: Math.round(site.totalHours * 10) / 10, totalShifts: site.totalShifts, }; }); // Ordina per ore totali (decrescente) siteReports.sort((a: any, b: any) => b.totalHours - a.totalHours); res.json({ month: rawMonth, location, sites: siteReports, summary: { totalSites: siteReports.length, totalHours: Math.round(siteReports.reduce((sum: number, s: any) => sum + s.totalHours, 0) * 10) / 10, totalShifts: siteReports.reduce((sum: number, s: any) => sum + s.totalShifts, 0), }, }); } catch (error) { console.error("Error fetching billable site hours:", error); res.status(500).json({ message: "Failed to fetch billable site hours", error: String(error) }); } }); // ============= CERTIFICATION ROUTES ============= app.post("/api/certifications", isAuthenticated, async (req, res) => { try { const cert = await storage.createCertification(req.body); res.json(cert); } catch (error) { console.error("Error creating certification:", error); res.status(500).json({ message: "Failed to create certification" }); } }); // ============= SERVICE TYPE ROUTES ============= app.get("/api/service-types", isAuthenticated, async (req, res) => { try { const serviceTypes = await storage.getAllServiceTypes(); res.json(serviceTypes); } catch (error) { console.error("Error fetching service types:", error); res.status(500).json({ message: "Failed to fetch service types" }); } }); app.post("/api/service-types", isAuthenticated, async (req, res) => { try { const serviceType = await storage.createServiceType(req.body); res.json(serviceType); } catch (error) { console.error("Error creating service type:", error); res.status(500).json({ message: "Failed to create service type" }); } }); app.patch("/api/service-types/:id", isAuthenticated, async (req, res) => { try { const serviceType = await storage.updateServiceType(req.params.id, req.body); if (!serviceType) { return res.status(404).json({ message: "Service type not found" }); } res.json(serviceType); } catch (error) { console.error("Error updating service type:", error); res.status(500).json({ message: "Failed to update service type" }); } }); app.delete("/api/service-types/:id", isAuthenticated, async (req, res) => { try { const serviceType = await storage.deleteServiceType(req.params.id); if (!serviceType) { return res.status(404).json({ message: "Service type not found" }); } res.json(serviceType); } catch (error) { console.error("Error deleting service type:", error); res.status(500).json({ message: "Failed to delete service type" }); } }); // ============= CUSTOMER ROUTES ============= app.get("/api/customers", isAuthenticated, async (req, res) => { try { const customers = await storage.getAllCustomers(); res.json(customers); } catch (error) { console.error("Error fetching customers:", error); res.status(500).json({ message: "Failed to fetch customers" }); } }); app.post("/api/customers", isAuthenticated, async (req, res) => { try { const validatedData = insertCustomerSchema.parse(req.body); const customer = await storage.createCustomer(validatedData); res.json(customer); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ message: "Validation failed", errors: fromZodError(error).message }); } console.error("Error creating customer:", error); res.status(500).json({ message: "Failed to create customer" }); } }); app.patch("/api/customers/:id", isAuthenticated, async (req, res) => { try { const validatedData = insertCustomerSchema.partial().parse(req.body); const customer = await storage.updateCustomer(req.params.id, validatedData); if (!customer) { return res.status(404).json({ message: "Customer not found" }); } res.json(customer); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ message: "Validation failed", errors: fromZodError(error).message }); } console.error("Error updating customer:", error); res.status(500).json({ message: "Failed to update customer" }); } }); app.delete("/api/customers/:id", isAuthenticated, async (req, res) => { try { const customer = await storage.deleteCustomer(req.params.id); if (!customer) { return res.status(404).json({ message: "Customer not found" }); } res.json(customer); } catch (error) { console.error("Error deleting customer:", error); res.status(500).json({ message: "Failed to delete customer" }); } }); // ============= SITE ROUTES ============= app.get("/api/sites", isAuthenticated, async (req, res) => { try { const allSites = await storage.getAllSites(); res.json(allSites); } catch (error) { console.error("Error fetching sites:", error); res.status(500).json({ message: "Failed to fetch sites" }); } }); app.post("/api/sites", isAuthenticated, async (req, res) => { try { const site = await storage.createSite(req.body); res.json(site); } catch (error) { console.error("Error creating site:", error); res.status(500).json({ message: "Failed to create site" }); } }); app.patch("/api/sites/:id", isAuthenticated, async (req, res) => { try { const updated = await storage.updateSite(req.params.id, req.body); if (!updated) { return res.status(404).json({ message: "Site not found" }); } res.json(updated); } catch (error) { console.error("Error updating site:", error); res.status(500).json({ message: "Failed to update site" }); } }); app.delete("/api/sites/:id", isAuthenticated, async (req, res) => { try { const deleted = await storage.deleteSite(req.params.id); if (!deleted) { return res.status(404).json({ message: "Site not found" }); } res.json({ success: true }); } catch (error) { console.error("Error deleting site:", error); res.status(500).json({ message: "Failed to delete site" }); } }); // ============= SHIFT ROUTES ============= app.get("/api/shifts", isAuthenticated, async (req, res) => { try { const allShifts = await storage.getAllShifts(); // Fetch related data for each shift const shiftsWithDetails = await Promise.all( allShifts.map(async (shift) => { const site = await storage.getSite(shift.siteId); const assignments = await storage.getShiftAssignments(shift.id); // Fetch guard details for each assignment const assignmentsWithGuards = await Promise.all( assignments.map(async (assignment) => { const guard = await storage.getGuard(assignment.guardId); const certs = guard ? await storage.getCertificationsByGuard(guard.id) : []; const user = guard?.userId ? await storage.getUser(guard.userId) : undefined; return { ...assignment, guard: guard ? { ...guard, certifications: certs, user, } : null, }; }) ); return { ...shift, site: site!, assignments: assignmentsWithGuards.filter(a => a.guard !== null), }; }) ); res.json(shiftsWithDetails); } catch (error) { console.error("Error fetching shifts:", error); res.status(500).json({ message: "Failed to fetch shifts" }); } }); app.get("/api/shifts/active", isAuthenticated, async (req, res) => { try { const activeShifts = await storage.getActiveShifts(); // Fetch related data for each shift const shiftsWithDetails = await Promise.all( activeShifts.map(async (shift) => { const site = await storage.getSite(shift.siteId); const assignments = await storage.getShiftAssignments(shift.id); // Fetch guard details for each assignment const assignmentsWithGuards = await Promise.all( assignments.map(async (assignment) => { const guard = await storage.getGuard(assignment.guardId); const certs = guard ? await storage.getCertificationsByGuard(guard.id) : []; const user = guard?.userId ? await storage.getUser(guard.userId) : undefined; return { ...assignment, guard: guard ? { ...guard, certifications: certs, user, } : null, }; }) ); return { ...shift, site: site!, assignments: assignmentsWithGuards.filter(a => a.guard !== null), }; }) ); res.json(shiftsWithDetails); } catch (error) { console.error("Error fetching active shifts:", error); res.status(500).json({ message: "Failed to fetch active shifts" }); } }); app.post("/api/shifts", isAuthenticated, async (req, res) => { try { // Validate that required fields are present and dates are valid strings if (!req.body.siteId || !req.body.startTime || !req.body.endTime) { return res.status(400).json({ message: "Missing required fields" }); } // Verifica stato contratto del sito const site = await storage.getSite(req.body.siteId); if (!site) { return res.status(404).json({ message: "Sito non trovato" }); } // Controllo validità contratto - richiesto per creare turni if (!site.contractStartDate || !site.contractEndDate) { return res.status(400).json({ message: `Impossibile creare turno: il sito "${site.name}" non ha un contratto attivo` }); } // Convert and validate shift dates first const startTime = new Date(req.body.startTime); const endTime = new Date(req.body.endTime); if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) { return res.status(400).json({ message: "Invalid date format" }); } // Normalizza date contratto a giorno intero (00:00 - 23:59) const contractStart = new Date(site.contractStartDate); contractStart.setHours(0, 0, 0, 0); const contractEnd = new Date(site.contractEndDate); contractEnd.setHours(23, 59, 59, 999); // Normalizza data turno a giorno (per confronto) const shiftDate = new Date(startTime); shiftDate.setHours(0, 0, 0, 0); // Verifica che il turno sia dentro il periodo contrattuale if (shiftDate > contractEnd) { return res.status(400).json({ message: `Impossibile creare turno: il contratto per il sito "${site.name}" scade il ${new Date(site.contractEndDate).toLocaleDateString('it-IT')}` }); } if (shiftDate < contractStart) { return res.status(400).json({ message: `Impossibile creare turno: il contratto per il sito "${site.name}" inizia il ${new Date(site.contractStartDate).toLocaleDateString('it-IT')}` }); } // Validate and transform the request body const validatedData = insertShiftSchema.parse({ siteId: req.body.siteId, startTime, endTime, status: req.body.status || "planned", vehicleId: req.body.vehicleId || null, }); const shift = await storage.createShift(validatedData); // Se ci sono guardie da assegnare, crea le assegnazioni if (req.body.guardIds && Array.isArray(req.body.guardIds) && req.body.guardIds.length > 0) { for (const guardId of req.body.guardIds) { await storage.createShiftAssignment({ shiftId: shift.id, guardId, }); } } res.json(shift); } catch (error) { console.error("Error creating shift:", error); res.status(500).json({ message: "Failed to create shift" }); } }); app.patch("/api/shifts/:id/status", isAuthenticated, async (req, res) => { try { await storage.updateShiftStatus(req.params.id, req.body.status); res.json({ success: true }); } catch (error) { console.error("Error updating shift status:", error); res.status(500).json({ message: "Failed to update shift status" }); } }); app.patch("/api/shifts/:id", isAuthenticated, async (req, res) => { try { const { startTime: startTimeStr, endTime: endTimeStr, ...rest } = req.body; const updateData: any = { ...rest }; if (startTimeStr) { const startTime = new Date(startTimeStr); if (isNaN(startTime.getTime())) { return res.status(400).json({ message: "Invalid start time format" }); } updateData.startTime = startTime; } if (endTimeStr) { const endTime = new Date(endTimeStr); if (isNaN(endTime.getTime())) { return res.status(400).json({ message: "Invalid end time format" }); } updateData.endTime = endTime; } const updated = await storage.updateShift(req.params.id, updateData); if (!updated) { return res.status(404).json({ message: "Shift not found" }); } res.json(updated); } catch (error) { console.error("Error updating shift:", error); res.status(500).json({ message: "Failed to update shift" }); } }); app.delete("/api/shifts/:id", isAuthenticated, async (req, res) => { try { const deleted = await storage.deleteShift(req.params.id); if (!deleted) { return res.status(404).json({ message: "Shift not found" }); } res.json({ success: true }); } catch (error) { console.error("Error deleting shift:", error); res.status(500).json({ message: "Failed to delete shift" }); } }); // ============= CCNL VALIDATION ============= app.post("/api/shifts/validate-ccnl", isAuthenticated, async (req, res) => { try { const { guardId, shiftStartTime, shiftEndTime } = req.body; if (!guardId || !shiftStartTime || !shiftEndTime) { return res.status(400).json({ message: "Missing required fields" }); } const startTime = new Date(shiftStartTime); const endTime = new Date(shiftEndTime); // Load CCNL parameters const params = await db.select().from(contractParameters).limit(1); if (params.length === 0) { return res.status(500).json({ message: "CCNL parameters not found" }); } const ccnl = params[0]; const violations: Array<{type: string, message: string}> = []; // Calculate shift hours precisely (in minutes, then convert) const shiftMinutes = differenceInMinutes(endTime, startTime); const shiftHours = Math.round((shiftMinutes / 60) * 100) / 100; // Round to 2 decimals // Check max daily hours if (shiftHours > ccnl.maxHoursPerDay) { violations.push({ type: "MAX_DAILY_HOURS", message: `Turno di ${shiftHours}h supera il limite giornaliero di ${ccnl.maxHoursPerDay}h` }); } // Get guard's shifts for the week (overlapping with week window) const weekStart = startOfWeek(startTime, { weekStartsOn: 1 }); // Monday const weekEnd = endOfWeek(startTime, { weekStartsOn: 1 }); const weekShifts = await db .select() .from(shiftAssignments) .innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId)) .where( and( eq(shiftAssignments.guardId, guardId), // Shift overlaps week if: (shift_start <= week_end) AND (shift_end >= week_start) lte(shifts.startTime, weekEnd), gte(shifts.endTime, weekStart) ) ); // Calculate weekly hours precisely (only overlap with week window) let weeklyMinutes = 0; // Add overlap of new shift with week (clamp to 0 if outside window) const newShiftStart = startTime < weekStart ? weekStart : startTime; const newShiftEnd = endTime > weekEnd ? weekEnd : endTime; weeklyMinutes += Math.max(0, differenceInMinutes(newShiftEnd, newShiftStart)); // Add overlap of existing shifts with week (clamp to 0 if outside window) for (const { shifts: shift } of weekShifts) { const shiftStart = shift.startTime < weekStart ? weekStart : shift.startTime; const shiftEnd = shift.endTime > weekEnd ? weekEnd : shift.endTime; weeklyMinutes += Math.max(0, differenceInMinutes(shiftEnd, shiftStart)); } const weeklyHours = Math.round((weeklyMinutes / 60) * 100) / 100; if (weeklyHours > ccnl.maxHoursPerWeek) { violations.push({ type: "MAX_WEEKLY_HOURS", message: `Ore settimanali (${weeklyHours}h) superano il limite di ${ccnl.maxHoursPerWeek}h` }); } // Check consecutive days (chronological order ASC, only past shifts before this one) const pastShifts = await db .select() .from(shiftAssignments) .innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId)) .where( and( eq(shiftAssignments.guardId, guardId), lte(shifts.startTime, startTime) ) ) .orderBy(asc(shifts.startTime)); // Build consecutive days map const shiftDays = new Set(); for (const { shifts: shift } of pastShifts) { shiftDays.add(startOfDay(shift.startTime).toISOString()); } shiftDays.add(startOfDay(startTime).toISOString()); // Count consecutive days backwards from new shift let consecutiveDays = 0; let checkDate = startOfDay(startTime); while (shiftDays.has(checkDate.toISOString())) { consecutiveDays++; checkDate = new Date(checkDate); checkDate.setDate(checkDate.getDate() - 1); } if (consecutiveDays > 6) { violations.push({ type: "MAX_CONSECUTIVE_DAYS", message: `${consecutiveDays} giorni consecutivi superano il limite di 6 giorni` }); } // Check for overlapping shifts (guard already assigned to another shift at same time) // Overlap if: (existing_start < new_end) AND (existing_end > new_start) // Note: Use strict inequalities to allow back-to-back shifts (end == start is OK) const allAssignments = await db .select() .from(shiftAssignments) .innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId)) .where(eq(shiftAssignments.guardId, guardId)); const overlappingShifts = allAssignments.filter(({ shifts: shift }: any) => { return shift.startTime < endTime && shift.endTime > startTime; }); if (overlappingShifts.length > 0) { violations.push({ type: "OVERLAPPING_SHIFT", message: `Guardia già assegnata a un turno sovrapposto in questo orario` }); } // Check rest hours (only last shift that ended before this one) const lastCompletedShifts = await db .select() .from(shiftAssignments) .innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId)) .where( and( eq(shiftAssignments.guardId, guardId), lte(shifts.endTime, startTime) ) ) .orderBy(desc(shifts.endTime)) .limit(1); if (lastCompletedShifts.length > 0) { const lastShift = lastCompletedShifts[0].shifts; const restMinutes = differenceInMinutes(startTime, lastShift.endTime); const restHours = Math.round((restMinutes / 60) * 100) / 100; if (restHours < ccnl.minDailyRestHours) { violations.push({ type: "MIN_REST_HOURS", message: `Riposo di ${restHours}h inferiore al minimo di ${ccnl.minDailyRestHours}h` }); } } res.json({ violations, ccnlParams: ccnl }); } catch (error) { console.error("Error validating CCNL:", error); res.status(500).json({ message: "Failed to validate CCNL" }); } }); // ============= SHIFT ASSIGNMENT ROUTES ============= app.post("/api/shift-assignments", isAuthenticated, async (req, res) => { try { const assignment = await storage.createShiftAssignment(req.body); res.json(assignment); } catch (error) { console.error("Error creating shift assignment:", error); res.status(500).json({ message: "Failed to create shift assignment" }); } }); app.delete("/api/shift-assignments/:id", isAuthenticated, async (req, res) => { try { await storage.deleteShiftAssignment(req.params.id); res.json({ success: true }); } catch (error) { console.error("Error deleting shift assignment:", error); res.status(500).json({ message: "Failed to delete shift assignment" }); } }); // Create shift assignment with planned time slots app.post("/api/shifts/:shiftId/assignments", isAuthenticated, async (req, res) => { try { const { shiftId } = req.params; const { guardId, plannedStartTime, plannedEndTime } = req.body; if (!guardId || !plannedStartTime || !plannedEndTime) { return res.status(400).json({ message: "Missing required fields: guardId, plannedStartTime, plannedEndTime" }); } // Validate times const startDate = new Date(plannedStartTime); const endDate = new Date(plannedEndTime); if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { return res.status(400).json({ message: "Invalid date format for plannedStartTime or plannedEndTime" }); } if (endDate <= startDate) { return res.status(400).json({ message: "plannedEndTime must be after plannedStartTime" }); } // Create assignment const assignment = await storage.createShiftAssignment({ shiftId, guardId, plannedStartTime: startDate, plannedEndTime: endDate, }); res.json(assignment); } catch (error: any) { console.error("Error creating shift assignment with time slot:", error); if (error.message?.includes('overlap') || error.message?.includes('conflict')) { return res.status(409).json({ message: error.message }); } res.status(500).json({ message: "Failed to create shift assignment" }); } }); // ============= NOTIFICATION ROUTES ============= app.get("/api/notifications", isAuthenticated, async (req: any, res) => { try { const userId = getUserId(req); const userNotifications = await storage.getNotificationsByUser(userId); res.json(userNotifications); } catch (error) { console.error("Error fetching user notifications:", error); res.status(500).json({ message: "Failed to fetch notifications" }); } }); app.patch("/api/notifications/:id/read", isAuthenticated, async (req, res) => { try { await storage.markNotificationAsRead(req.params.id); res.json({ success: true }); } catch (error) { console.error("Error marking notification as read:", error); res.status(500).json({ message: "Failed to mark notification as read" }); } }); // ============= GUARD CONSTRAINTS ROUTES ============= app.get("/api/guard-constraints/:guardId", isAuthenticated, async (req, res) => { try { const constraints = await storage.getGuardConstraints(req.params.guardId); res.json(constraints || null); } catch (error) { console.error("Error fetching guard constraints:", error); res.status(500).json({ message: "Failed to fetch guard constraints" }); } }); app.post("/api/guard-constraints", isAuthenticated, async (req, res) => { try { const constraints = await storage.upsertGuardConstraints(req.body); res.json(constraints); } catch (error) { console.error("Error upserting guard constraints:", error); res.status(500).json({ message: "Failed to save guard constraints" }); } }); // ============= SITE PREFERENCES ROUTES ============= app.get("/api/site-preferences/:siteId", isAuthenticated, async (req, res) => { try { const preferences = await storage.getSitePreferences(req.params.siteId); res.json(preferences); } catch (error) { console.error("Error fetching site preferences:", error); res.status(500).json({ message: "Failed to fetch site preferences" }); } }); app.post("/api/site-preferences", isAuthenticated, async (req, res) => { try { const preference = await storage.createSitePreference(req.body); res.json(preference); } catch (error) { console.error("Error creating site preference:", error); res.status(500).json({ message: "Failed to create site preference" }); } }); app.delete("/api/site-preferences/:id", isAuthenticated, async (req, res) => { try { await storage.deleteSitePreference(req.params.id); res.json({ success: true }); } catch (error) { console.error("Error deleting site preference:", error); res.status(500).json({ message: "Failed to delete site preference" }); } }); // ============= TRAINING COURSES ROUTES ============= app.get("/api/training-courses", isAuthenticated, async (req, res) => { try { const guardId = req.query.guardId as string | undefined; const courses = guardId ? await storage.getTrainingCoursesByGuard(guardId) : await storage.getAllTrainingCourses(); res.json(courses); } catch (error) { console.error("Error fetching training courses:", error); res.status(500).json({ message: "Failed to fetch training courses" }); } }); app.post("/api/training-courses", isAuthenticated, async (req, res) => { try { const course = await storage.createTrainingCourse(req.body); res.json(course); } catch (error) { console.error("Error creating training course:", error); res.status(500).json({ message: "Failed to create training course" }); } }); app.patch("/api/training-courses/:id", isAuthenticated, async (req, res) => { try { const updated = await storage.updateTrainingCourse(req.params.id, req.body); if (!updated) { return res.status(404).json({ message: "Training course not found" }); } res.json(updated); } catch (error) { console.error("Error updating training course:", error); res.status(500).json({ message: "Failed to update training course" }); } }); app.delete("/api/training-courses/:id", isAuthenticated, async (req, res) => { try { await storage.deleteTrainingCourse(req.params.id); res.json({ success: true }); } catch (error) { console.error("Error deleting training course:", error); res.status(500).json({ message: "Failed to delete training course" }); } }); // ============= HOLIDAYS ROUTES ============= app.get("/api/holidays", isAuthenticated, async (req, res) => { try { const year = req.query.year ? parseInt(req.query.year as string) : undefined; const holidays = await storage.getAllHolidays(year); res.json(holidays); } catch (error) { console.error("Error fetching holidays:", error); res.status(500).json({ message: "Failed to fetch holidays" }); } }); app.post("/api/holidays", isAuthenticated, async (req, res) => { try { const holiday = await storage.createHoliday(req.body); res.json(holiday); } catch (error) { console.error("Error creating holiday:", error); res.status(500).json({ message: "Failed to create holiday" }); } }); app.delete("/api/holidays/:id", isAuthenticated, async (req, res) => { try { await storage.deleteHoliday(req.params.id); res.json({ success: true }); } catch (error) { console.error("Error deleting holiday:", error); res.status(500).json({ message: "Failed to delete holiday" }); } }); // ============= ABSENCES ROUTES ============= app.get("/api/absences", isAuthenticated, async (req, res) => { try { const guardId = req.query.guardId as string | undefined; const absences = guardId ? await storage.getAbsencesByGuard(guardId) : await storage.getAllAbsences(); res.json(absences); } catch (error) { console.error("Error fetching absences:", error); res.status(500).json({ message: "Failed to fetch absences" }); } }); app.post("/api/absences", isAuthenticated, async (req, res) => { try { const absence = await storage.createAbsence(req.body); res.json(absence); } catch (error) { console.error("Error creating absence:", error); res.status(500).json({ message: "Failed to create absence" }); } }); app.patch("/api/absences/:id", isAuthenticated, async (req, res) => { try { const updated = await storage.updateAbsence(req.params.id, req.body); if (!updated) { return res.status(404).json({ message: "Absence not found" }); } res.json(updated); } catch (error) { console.error("Error updating absence:", error); res.status(500).json({ message: "Failed to update absence" }); } }); app.delete("/api/absences/:id", isAuthenticated, async (req, res) => { try { await storage.deleteAbsence(req.params.id); res.json({ success: true }); } catch (error) { console.error("Error deleting absence:", error); res.status(500).json({ message: "Failed to delete absence" }); } }); // ============= DEV UTILITIES (Reset & Seed Data) ============= // DELETE all sites and guards (for testing) app.delete("/api/dev/reset-data", isAuthenticated, async (req, res) => { try { // Delete all shift assignments first (foreign key constraints) await db.delete(shiftAssignments); // Delete all shifts await db.delete(shifts); // Delete all sites await db.delete(sites); // Delete all certifications await db.delete(certifications); // Delete all guards await db.delete(guards); // Delete all vehicles await db.delete(vehicles); res.json({ success: true, message: "Tutti i dati (siti, guardie, turni, veicoli) sono stati eliminati" }); } catch (error) { console.error("Error resetting data:", error); res.status(500).json({ message: "Errore durante reset dati" }); } }); // Create sample data (3 sites Milano + 3 Roccapiemonte, 10 guards each) app.post("/api/dev/seed-data", isAuthenticated, async (req, res) => { try { // Create service types first const [serviceTypePresidioFisso] = await db.insert(serviceTypes).values({ name: "Presidio Fisso", description: "Servizio di presidio fisso con guardia armata", shiftType: "fixed_post", fixedPostHours: 24, }).returning(); const [serviceTypePattuglia] = await db.insert(serviceTypes).values({ name: "Pattuglia Mobile", description: "Servizio di pattuglia mobile con passaggi programmati", shiftType: "patrol", patrolPassages: 4, }).returning(); // Create 3 sites for Milano const [siteMilano1] = await db.insert(sites).values({ name: "Banca Centrale Milano", address: "Via Dante 45, Milano", city: "Milano", location: "milano", serviceTypeId: serviceTypePresidioFisso.id, shiftType: "fixed_post", requiresArmed: true, requiresDriverLicense: false, minGuardsRequired: 1, serviceStartTime: "00:00", serviceEndTime: "24:00", contractReference: "CTR-MI-001-2025", contractStartDate: "2025-01-01", contractEndDate: "2025-12-31", }).returning(); const [siteMilano2] = await db.insert(sites).values({ name: "Museo Arte Moderna Milano", address: "Corso Magenta 12, Milano", city: "Milano", location: "milano", serviceTypeId: serviceTypePattuglia.id, shiftType: "patrol", requiresArmed: false, requiresDriverLicense: true, minGuardsRequired: 1, serviceStartTime: "08:00", serviceEndTime: "20:00", contractReference: "CTR-MI-002-2025", contractStartDate: "2025-01-01", contractEndDate: "2025-06-30", }).returning(); const [siteMilano3] = await db.insert(sites).values({ name: "Centro Commerciale Porta Nuova", address: "Piazza Gae Aulenti 1, Milano", city: "Milano", location: "milano", serviceTypeId: serviceTypePresidioFisso.id, shiftType: "fixed_post", requiresArmed: true, requiresDriverLicense: false, minGuardsRequired: 2, serviceStartTime: "06:00", serviceEndTime: "22:00", contractReference: "CTR-MI-003-2025", contractStartDate: "2025-01-01", contractEndDate: "2025-12-31", }).returning(); // Create 3 sites for Roccapiemonte const [siteRocca1] = await db.insert(sites).values({ name: "Deposito Logistica Roccapiemonte", address: "Via Industriale 23, Roccapiemonte", city: "Roccapiemonte", location: "roccapiemonte", serviceTypeId: serviceTypePresidioFisso.id, shiftType: "fixed_post", requiresArmed: true, requiresDriverLicense: false, minGuardsRequired: 1, serviceStartTime: "00:00", serviceEndTime: "24:00", contractReference: "CTR-RC-001-2025", contractStartDate: "2025-01-01", contractEndDate: "2025-12-31", }).returning(); const [siteRocca2] = await db.insert(sites).values({ name: "Cantiere Edile Salerno Nord", address: "SS 18 km 45, Roccapiemonte", city: "Roccapiemonte", location: "roccapiemonte", serviceTypeId: serviceTypePattuglia.id, shiftType: "patrol", requiresArmed: false, requiresDriverLicense: true, minGuardsRequired: 1, serviceStartTime: "18:00", serviceEndTime: "06:00", contractReference: "CTR-RC-002-2025", contractStartDate: "2025-01-15", contractEndDate: "2025-07-15", }).returning(); const [siteRocca3] = await db.insert(sites).values({ name: "Stabilimento Farmaceutico", address: "Via delle Industrie 89, Roccapiemonte", city: "Roccapiemonte", location: "roccapiemonte", serviceTypeId: serviceTypePresidioFisso.id, shiftType: "fixed_post", requiresArmed: true, requiresDriverLicense: false, minGuardsRequired: 2, serviceStartTime: "00:00", serviceEndTime: "24:00", contractReference: "CTR-RC-003-2025", contractStartDate: "2025-01-01", contractEndDate: "2025-12-31", }).returning(); // Create 10 guards for Milano const milanNames = [ { firstName: "Marco", lastName: "Rossi", badgeNumber: "MI-001" }, { firstName: "Giulia", lastName: "Bianchi", badgeNumber: "MI-002" }, { firstName: "Luca", lastName: "Ferrari", badgeNumber: "MI-003" }, { firstName: "Sara", lastName: "Romano", badgeNumber: "MI-004" }, { firstName: "Andrea", lastName: "Colombo", badgeNumber: "MI-005" }, { firstName: "Elena", lastName: "Ricci", badgeNumber: "MI-006" }, { firstName: "Francesco", lastName: "Marino", badgeNumber: "MI-007" }, { firstName: "Chiara", lastName: "Greco", badgeNumber: "MI-008" }, { firstName: "Matteo", lastName: "Bruno", badgeNumber: "MI-009" }, { firstName: "Alessia", lastName: "Gallo", badgeNumber: "MI-010" }, ]; for (let i = 0; i < milanNames.length; i++) { await db.insert(guards).values({ ...milanNames[i], location: "milano", isArmed: i % 2 === 0, // Alternare armati/non armati hasDriverLicense: i % 3 === 0, // 1 su 3 con patente hasFireSafety: true, hasFirstAid: i % 2 === 1, phone: `+39 333 ${String(i).padStart(3, '0')}${String(i).padStart(4, '0')}`, email: `${milanNames[i].firstName.toLowerCase()}.${milanNames[i].lastName.toLowerCase()}@vigilanza.it`, }); } // Create 10 guards for Roccapiemonte const roccaNames = [ { firstName: "Antonio", lastName: "Esposito", badgeNumber: "RC-001" }, { firstName: "Maria", lastName: "De Luca", badgeNumber: "RC-002" }, { firstName: "Giuseppe", lastName: "Russo", badgeNumber: "RC-003" }, { firstName: "Anna", lastName: "Costa", badgeNumber: "RC-004" }, { firstName: "Vincenzo", lastName: "Ferrara", badgeNumber: "RC-005" }, { firstName: "Rosa", lastName: "Gatti", badgeNumber: "RC-006" }, { firstName: "Salvatore", lastName: "Leone", badgeNumber: "RC-007" }, { firstName: "Lucia", lastName: "Longo", badgeNumber: "RC-008" }, { firstName: "Michele", lastName: "Martino", badgeNumber: "RC-009" }, { firstName: "Carmela", lastName: "Moretti", badgeNumber: "RC-010" }, ]; for (let i = 0; i < roccaNames.length; i++) { await db.insert(guards).values({ ...roccaNames[i], location: "roccapiemonte", isArmed: i % 2 === 0, hasDriverLicense: i % 3 === 0, hasFireSafety: true, hasFirstAid: i % 2 === 1, phone: `+39 333 ${String(i + 10).padStart(3, '0')}${String(i + 10).padStart(4, '0')}`, email: `${roccaNames[i].firstName.toLowerCase()}.${roccaNames[i].lastName.toLowerCase()}@vigilanza.it`, }); } // Create 5 vehicles for Milano const vehiclesMilano = [ { licensePlate: "MI123AB", brand: "Fiat", model: "Ducato", vehicleType: "van" as const }, { licensePlate: "MI456CD", brand: "Volkswagen", model: "Transporter", vehicleType: "van" as const }, { licensePlate: "MI789EF", brand: "Ford", model: "Transit", vehicleType: "van" as const }, { licensePlate: "MI012GH", brand: "Renault", model: "Kangoo", vehicleType: "car" as const }, { licensePlate: "MI345IJ", brand: "Opel", model: "Vivaro", vehicleType: "van" as const }, ]; for (const vehicle of vehiclesMilano) { await db.insert(vehicles).values({ ...vehicle, location: "milano", year: 2022, status: "available", }); } // Create 5 vehicles for Roccapiemonte const vehiclesRocca = [ { licensePlate: "SA123AB", brand: "Fiat", model: "Ducato", vehicleType: "van" as const }, { licensePlate: "SA456CD", brand: "Volkswagen", model: "Caddy", vehicleType: "car" as const }, { licensePlate: "SA789EF", brand: "Ford", model: "Transit", vehicleType: "van" as const }, { licensePlate: "SA012GH", brand: "Renault", model: "Master", vehicleType: "van" as const }, { licensePlate: "SA345IJ", brand: "Peugeot", model: "Partner", vehicleType: "car" as const }, ]; for (const vehicle of vehiclesRocca) { await db.insert(vehicles).values({ ...vehicle, location: "roccapiemonte", year: 2023, status: "available", }); } res.json({ success: true, message: "Dati di esempio creati con successo", summary: { sites: { milano: 3, roccapiemonte: 3, }, guards: { milano: 10, roccapiemonte: 10, }, vehicles: { milano: 5, roccapiemonte: 5, }, }, }); } catch (error) { console.error("Error seeding data:", error); res.status(500).json({ message: "Errore durante creazione dati di esempio" }); } }); const httpServer = createServer(app); return httpServer; }