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, customers, patrolRoutes, patrolRouteStops, insertPatrolRouteSchema, absences } 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"; /** * Calcola l'offset di Europe/Rome rispetto a UTC per una specifica data/ora. * Gestisce correttamente orari serali e transizioni DST. * Italy: UTC+1 (ora solare inverno) / UTC+2 (ora legale estate) * @param year, month (0-11), day, hour, minute * @returns offset in ore (1 o 2) */ function getItalyTimezoneOffsetHours(year: number, month: number, day: number, hour: number, minute: number = 0): number { // Crea timestamp UTC esatto per l'input Italy time // Useremo formatToParts per ottenere tutti i componenti date/time const utcTimestamp = Date.UTC(year, month, day, hour, minute, 0); const utcDate = new Date(utcTimestamp); // Ottieni tutti i componenti in Europe/Rome timezone const formatter = new Intl.DateTimeFormat('en-US', { timeZone: 'Europe/Rome', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); const parts = formatter.formatToParts(utcDate); const italyYear = parseInt(parts.find(p => p.type === 'year')?.value || '0'); const italyMonth = parseInt(parts.find(p => p.type === 'month')?.value || '0') - 1; const italyDay = parseInt(parts.find(p => p.type === 'day')?.value || '0'); let italyHour = parseInt(parts.find(p => p.type === 'hour')?.value || '0'); const italyMinute = parseInt(parts.find(p => p.type === 'minute')?.value || '0'); // formatToParts restituisce hour=24 per mezzanotte (00:00 del giorno successivo) // Il giorno è già stato incrementato automaticamente da formatToParts // Normalizziamo solo l'ora: 24:00 → 00:00 const normalizedHour = italyHour === 24 ? 0 : italyHour; // Crea timestamp Italy come se fosse UTC (per calcolare differenza) const italyAsUtcTimestamp = Date.UTC(italyYear, italyMonth, italyDay, normalizedHour, italyMinute, 0); // Calcola differenza in millisecondi e converti in ore const offsetMs = italyAsUtcTimestamp - utcTimestamp; const offsetHours = Math.round(offsetMs / (1000 * 60 * 60)); console.log("🕐 Offset calculation:", { input: `${year}-${String(month+1).padStart(2,'0')}-${String(day).padStart(2,'0')} ${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}`, utcTimestamp: utcDate.toISOString(), italyComponents: { year: italyYear, month: italyMonth+1, day: italyDay, hour: italyHour, minute: italyMinute }, offsetHours }); // Italy è sempre UTC+1 o UTC+2 if (offsetHours !== 1 && offsetHours !== 2) { console.error("⚠️ Unexpected offset:", offsetHours, "- defaulting to UTC+1"); return 1; } return offsetHours; } // 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 // TIMEZONE FIX: Valida formato senza parseISO per evitare shift timezone const dateRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dateRegex.test(normalizedDateStr)) { return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); } const dateStr = normalizedDateStr; // 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) ) ); // Ottieni patrol routes del giorno SOLO della sede selezionata const dayPatrolRoutes = await db .select() .from(patrolRoutes) .where( and( eq(patrolRoutes.shiftDate, dateStr), eq(patrolRoutes.location, location as any) ) ); // 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 ); const assignedPatrolRoute = dayPatrolRoutes.find( (route: any) => route.guardId === guard.id ); // Calcola report disponibilità CCNL const availabilityReport = await getGuardAvailabilityReport( guard.id, startOfDay, endOfDay ); return { ...guard, isAvailable: !assignedShift && !assignedPatrolRoute, assignedShift: assignedShift ? { id: assignedShift.shifts.id, startTime: assignedShift.shifts.startTime, endTime: assignedShift.shifts.endTime, siteId: assignedShift.shifts.siteId } : null, assignedPatrolRoute: assignedPatrolRoute ? { id: assignedPatrolRoute.id, startTime: assignedPatrolRoute.startTime, endTime: assignedPatrolRoute.endTime, } : 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 // TIMEZONE FIX: Valida formato senza parseISO per evitare shift timezone const dateRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dateRegex.test(normalizedDateStr)) { return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); } const dateStr = normalizedDateStr; // 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]; // ✅ CORRETTO: Valida date con regex, NON parseISO const dateRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dateRegex.test(normalizedWeekStart)) { return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); } // ✅ CORRETTO: Costruisci Date da componenti per evitare timezone shift const [year, month, day] = normalizedWeekStart.split("-").map(Number); const parsedWeekStart = new Date(year, month - 1, day, 0, 0, 0, 0); const weekStartDate = normalizedWeekStart; // Ottieni location dalla query (default: roccapiemonte) const location = req.query.location as string || "roccapiemonte"; // Calcola fine settimana (weekStart + 6 giorni) usando componenti const tempWeekEnd = new Date(year, month - 1, day + 6, 23, 59, 59, 999); const weekEndYear = tempWeekEnd.getFullYear(); const weekEndMonth = tempWeekEnd.getMonth() + 1; const weekEndDay = tempWeekEnd.getDate(); const weekEndDate = `${weekEndYear}-${String(weekEndMonth).padStart(2, '0')}-${String(weekEndDay).padStart(2, '0')}`; // ✅ CORRETTO: Timestamp da componenti per query database const weekStartTimestampForContract = new Date(year, month - 1, day, 0, 0, 0, 0); const weekEndTimestampForContract = new Date(weekEndYear, weekEndMonth - 1, weekEndDay, 23, 59, 59, 999); // Ottieni tutti i siti attivi della sede con contratto valido nelle date della settimana const allActiveSites = 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) ) ); // Filtra solo siti FISSI in base alla classificazione del serviceType // Esclude siti con classificazione "mobile" che vanno gestiti in Planning Mobile const activeSites = allActiveSites.filter((s: any) => !s.service_types || s.service_types.classification?.toLowerCase() === "fisso" ); // Ottieni tutti i turni della settimana per la sede // ✅ CORRETTO: Usa timestamp già creati correttamente sopra const weekStartTimestamp = weekStartTimestampForContract; const weekEndTimestamp = weekEndTimestampForContract; 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++) { // ✅ CORRETTO: Calcola date usando componenti per evitare timezone shift const currentDayTimestamp = new Date(year, month - 1, day + dayOffset, 0, 0, 0, 0); const currentYear = currentDayTimestamp.getFullYear(); const currentMonth = currentDayTimestamp.getMonth() + 1; const currentDay_num = currentDayTimestamp.getDate(); const dayStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(currentDay_num).padStart(2, '0')}`; const dayStartTimestamp = new Date(currentYear, currentMonth - 1, currentDay_num, 0, 0, 0, 0); const dayEndTimestamp = new Date(currentYear, currentMonth - 1, currentDay_num, 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(currentDayTimestamp, "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 // TIMEZONE FIX: Parse date as YYYY-MM-DD components to avoid timezone shifts const [year, month, day] = startDate.split("-").map(Number); for (let dayOffset = 0; dayOffset < days; dayOffset++) { // Create date using local timezone components (no UTC conversion) const shiftDate = new Date(year, month - 1, day + 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++) { // TIMEZONE FIX: Build date from components to maintain correct day const shiftDate = new Date(year, month - 1, day + 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); // Build timestamps using date components (no timezone conversion) const shiftStart = new Date(year, month - 1, day + dayOffset, startHour, startMin, 0, 0); const shiftEnd = new Date(year, month - 1, day + dayOffset, 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) }); } }); // Copy weekly shift assignments to next week app.post("/api/shift-assignments/copy-week", isAuthenticated, async (req, res) => { try { const { weekStart, location } = req.body; if (!weekStart || !location) { return res.status(400).json({ message: "Missing required fields: weekStart, location" }); } // Parse week start date const [year, month, day] = weekStart.split("-").map(Number); if (!year || !month || !day) { return res.status(400).json({ message: "Invalid weekStart format. Expected YYYY-MM-DD" }); } // Calculate week boundaries (Monday to Sunday) const weekStartDate = new Date(year, month - 1, day, 0, 0, 0, 0); const weekEndDate = new Date(year, month - 1, day + 6, 23, 59, 59, 999); console.log("📋 Copying weekly shifts:", { weekStart: weekStartDate.toISOString(), weekEnd: weekEndDate.toISOString(), location }); // Transaction: copy all shifts and assignments const result = await db.transaction(async (tx) => { // 1. Find all shifts in the source week filtered by location const sourceShifts = await tx .select({ shift: shifts, site: sites }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .where( and( gte(shifts.startTime, weekStartDate), lte(shifts.startTime, weekEndDate), eq(sites.location, location) ) ); if (sourceShifts.length === 0) { throw new Error("Nessun turno trovato nella settimana selezionata"); } console.log(`📋 Found ${sourceShifts.length} shifts to copy`); let copiedShiftsCount = 0; let copiedAssignmentsCount = 0; // 2. For each shift, copy to next week (+7 days) for (const { shift: sourceShift, site } of sourceShifts) { // Calculate new dates (+7 days) const newStartTime = new Date(sourceShift.startTime); newStartTime.setDate(newStartTime.getDate() + 7); const newEndTime = new Date(sourceShift.endTime); newEndTime.setDate(newEndTime.getDate() + 7); // Create new shift const [newShift] = await tx .insert(shifts) .values({ siteId: sourceShift.siteId, startTime: newStartTime, endTime: newEndTime, status: "planned", vehicleId: sourceShift.vehicleId, notes: sourceShift.notes, }) .returning(); copiedShiftsCount++; // 3. Copy all assignments for this shift const sourceAssignments = await tx .select() .from(shiftAssignments) .where(eq(shiftAssignments.shiftId, sourceShift.id)); for (const sourceAssignment of sourceAssignments) { // Calculate new planned times (+7 days) const newPlannedStart = new Date(sourceAssignment.plannedStartTime); newPlannedStart.setDate(newPlannedStart.getDate() + 7); const newPlannedEnd = new Date(sourceAssignment.plannedEndTime); newPlannedEnd.setDate(newPlannedEnd.getDate() + 7); // Create new assignment await tx .insert(shiftAssignments) .values({ shiftId: newShift.id, guardId: sourceAssignment.guardId, plannedStartTime: newPlannedStart, plannedEndTime: newPlannedEnd, isArmedOnDuty: sourceAssignment.isArmedOnDuty, assignedVehicleId: sourceAssignment.assignedVehicleId, }); copiedAssignmentsCount++; } } return { copiedShiftsCount, copiedAssignmentsCount }; }); res.json({ message: `Settimana copiata con successo: ${result.copiedShiftsCount} turni, ${result.copiedAssignmentsCount} assegnazioni`, copiedShifts: result.copiedShiftsCount, copiedAssignments: result.copiedAssignmentsCount, }); } catch (error: any) { console.error("❌ Error copying weekly shifts:", error); res.status(500).json({ message: error.message || "Errore durante la copia dei turni settimanali", 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; // DEBUG: Log per capire il problema timezone console.log("🔍 DEBUG assign-guard - Input ricevuto:", { date, startTime, durationHours, serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, serverOffset: new Date().getTimezoneOffset() }); 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); // CRITICAL: Gli orari dal frontend sono in fuso orario Europe/Rome (UTC+1 o UTC+2) // Calcola offset corretto per convertire a UTC const italyOffsetHours = getItalyTimezoneOffsetHours(year, month - 1, day, hours, minutes); console.log("🕐 DEBUG Timezone setup:", { inputDate: date, inputTime: `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}`, italyOffsetHours, isDST: italyOffsetHours === 2 }); // 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 converting from Europe/Rome to UTC // IMPORTANTE: l'utente inserisce orari in fuso orario Italia (Europe/Rome) // Il server è in UTC, quindi dobbiamo convertire Italy time → UTC // Formula: UTC time = Italy time - offset // Esempio: 09:00 Italy (UTC+2) = 07:00 UTC const plannedStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours - italyOffsetHours, minutes, 0, 0)); const plannedEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours + durationHours - italyOffsetHours, minutes, 0, 0)); console.log("🕐 DEBUG Timestamp conversion:", { inputTime: `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}`, italyOffset: `UTC+${italyOffsetHours}`, plannedStartUTC: plannedStart.toISOString(), plannedEndUTC: plannedEnd.toISOString() }); // 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 Agente Fisso - mostra orari e dotazioni operative per turni fissi app.get("/api/service-planning/guards-fixed", 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.firstName, guards.lastName); // Ottieni tutti i turni della settimana const allWeekShifts = await db .select({ shift: shifts, site: sites, vehicle: vehicles, serviceType: serviceTypes, }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id)) .leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id)) .where( and( gte(shifts.startTime, weekStartTimestamp), lte(shifts.startTime, weekEndTimestamp), ne(shifts.status, "cancelled"), eq(sites.location, location as any) ) ); // Filtra solo turni FISSI in base alla classificazione del serviceType const weekShifts = allWeekShifts.filter((s: any) => s.serviceType && s.serviceType.classification?.toLowerCase() === "fisso" ); // 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; 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, siteAddress: shiftData.site.address, siteId: shiftData.site.id, isArmed: guard.isArmed, 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.firstName} ${guard.lastName}`, 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 fixed guard schedules:", error); res.status(500).json({ message: "Failed to fetch fixed guard schedules", error: String(error) }); } }); // Vista per Agente Mobile - mostra percorsi pattuglia con siti e indirizzi app.get("/api/service-planning/guards-mobile", 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"); // Ottieni tutte le guardie della sede const allGuards = await db .select() .from(guards) .where(eq(guards.location, location as any)) .orderBy(guards.firstName, guards.lastName); // Ottieni tutte le patrol routes della settimana per la sede const weekRoutes = await db .select({ route: patrolRoutes, guard: guards, vehicle: vehicles, }) .from(patrolRoutes) .innerJoin(guards, eq(patrolRoutes.guardId, guards.id)) .leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id)) .where( and( gte(sql`${patrolRoutes.shiftDate}::date`, weekStartDate), lte(sql`${patrolRoutes.shiftDate}::date`, weekEndDate), eq(patrolRoutes.location, location as any) ) ); // Per ogni route, ottieni le stops const routesWithStops = await Promise.all( weekRoutes.map(async (routeData: any) => { const stops = await db .select({ stop: patrolRouteStops, site: sites, }) .from(patrolRouteStops) .innerJoin(sites, eq(patrolRouteStops.siteId, sites.id)) .where(eq(patrolRouteStops.patrolRouteId, routeData.route.id)) .orderBy(asc(patrolRouteStops.sequenceOrder)); return { routeId: routeData.route.id, guardId: routeData.guard.id, shiftDate: routeData.route.shiftDate, startTime: routeData.route.startTime, endTime: routeData.route.endTime, isArmedRoute: routeData.route.isArmedRoute, vehicle: routeData.vehicle ? { licensePlate: routeData.vehicle.licensePlate, brand: routeData.vehicle.brand, model: routeData.vehicle.model, } : undefined, stops: stops.map((s: any) => ({ siteId: s.site.id, siteName: s.site.name, siteAddress: s.site.address, sequenceOrder: s.stop.sequenceOrder, })), }; }) ); // Costruisci dati per ogni guardia const guardSchedules = allGuards.map((guard: any) => { // Trova routes della guardia const guardRoutes = routesWithStops.filter((r: any) => r.guardId === guard.id); const totalRoutes = guardRoutes.length; return { guardId: guard.id, guardName: `${guard.firstName} ${guard.lastName}`, badgeNumber: guard.badgeNumber, routes: guardRoutes, totalRoutes, }; }); // Filtra solo guardie con routes assegnate const guardsWithRoutes = guardSchedules.filter((g: any) => g.routes.length > 0); res.json(guardsWithRoutes); } catch (error) { console.error("Error fetching mobile guard schedules:", error); res.status(500).json({ message: "Failed to fetch mobile guard schedules", error: String(error) }); } }); // Vista per Guardia - mostra orari e dotazioni per ogni guardia (LEGACY - manteniamo per compatibilità) 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.firstName, guards.lastName); // 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.firstName} ${guard.lastName}`, 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, isArmed: a.guard.isArmed, }; }); 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.firstName, guards.lastName); // 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) }); } }); // Report fatturazione per cliente app.get("/api/reports/customer-billing", 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 cliente e servizio const monthShifts = await db .select({ shift: shifts, site: sites, customer: customers, serviceType: serviceTypes, }) .from(shifts) .innerJoin(sites, eq(shifts.siteId, sites.id)) .leftJoin(customers, eq(sites.customerId, customers.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 cliente const customerBillingMap: Record = {}; monthShifts.forEach((shiftData: any) => { const customerId = shiftData.customer?.id || "no-customer"; const customerName = shiftData.customer?.name || "Nessun Cliente"; 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; // Inizializza customer se non esiste if (!customerBillingMap[customerId]) { customerBillingMap[customerId] = { customerId, customerName, sites: {}, totalHours: 0, totalShifts: 0, totalPatrolPassages: 0, totalInspections: 0, totalInterventions: 0, }; } // Inizializza sito se non esiste if (!customerBillingMap[customerId].sites[siteId]) { customerBillingMap[customerId].sites[siteId] = { siteId, siteName, serviceTypes: {}, totalHours: 0, totalShifts: 0, }; } // Inizializza service type se non esiste if (!customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName]) { customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName] = { name: serviceTypeName, hours: 0, shifts: 0, passages: 0, inspections: 0, interventions: 0, }; } // Aggiorna conteggi customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].hours += hours; customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].shifts += 1; customerBillingMap[customerId].sites[siteId].totalHours += hours; customerBillingMap[customerId].sites[siteId].totalShifts += 1; customerBillingMap[customerId].totalHours += hours; customerBillingMap[customerId].totalShifts += 1; // Conteggio specifico per tipo servizio (basato su parametri) const serviceType = shiftData.serviceType; if (serviceType) { // Pattuglia/Ronda: conta numero passaggi if (serviceType.name.toLowerCase().includes("pattuglia") || serviceType.name.toLowerCase().includes("ronda")) { const passages = serviceType.patrolPassages || 1; customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].passages += passages; customerBillingMap[customerId].totalPatrolPassages += passages; } // Ispezione: conta numero ispezioni else if (serviceType.name.toLowerCase().includes("ispezione")) { customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].inspections += 1; customerBillingMap[customerId].totalInspections += 1; } // Pronto Intervento: conta numero interventi else if (serviceType.name.toLowerCase().includes("pronto") || serviceType.name.toLowerCase().includes("intervento")) { customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].interventions += 1; customerBillingMap[customerId].totalInterventions += 1; } } }); // Converti mappa in array e arrotonda ore const customerReports = Object.values(customerBillingMap).map((customer: any) => { const sitesArray = Object.values(customer.sites).map((site: any) => { const serviceTypesArray = Object.values(site.serviceTypes).map((st: any) => ({ ...st, hours: Math.round(st.hours * 10) / 10, })); return { ...site, serviceTypes: serviceTypesArray, totalHours: Math.round(site.totalHours * 10) / 10, }; }); return { ...customer, sites: sitesArray, totalHours: Math.round(customer.totalHours * 10) / 10, }; }); res.json({ month: rawMonth, location, customers: customerReports, summary: { totalCustomers: customerReports.length, totalHours: Math.round(customerReports.reduce((sum: number, c: any) => sum + c.totalHours, 0) * 10) / 10, totalShifts: customerReports.reduce((sum: number, c: any) => sum + c.totalShifts, 0), totalPatrolPassages: customerReports.reduce((sum: number, c: any) => sum + c.totalPatrolPassages, 0), totalInspections: customerReports.reduce((sum: number, c: any) => sum + c.totalInspections, 0), totalInterventions: customerReports.reduce((sum: number, c: any) => sum + c.totalInterventions, 0), }, }); } catch (error) { console.error("Error fetching customer billing report:", error); res.status(500).json({ message: "Failed to fetch customer billing report", 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 { shiftId, guardId } = req.body; // Recupera il shift per ottenere la data const [shift] = await db.select().from(shifts).where(eq(shifts.id, shiftId)).limit(1); if (!shift) { return res.status(404).json({ message: "Turno non trovato" }); } // VINCOLO ESCLUSIVITÀ: Verifica che la guardia NON abbia patrol routes (turni mobile) nella stessa data const existingMobileShifts = await db .select() .from(patrolRoutes) .where( and( eq(patrolRoutes.guardId, guardId), eq(patrolRoutes.shiftDate, shift.shiftDate) ) ) .limit(1); if (existingMobileShifts.length > 0) { return res.status(400).json({ message: `Vincolo esclusività: la guardia è già assegnata a un turno pattuglia mobile in questa data. Una guardia non può essere assegnata contemporaneamente a turni fissi e mobile.` }); } 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" }); } // Recupera il shift per ottenere la data const [shift] = await db.select().from(shifts).where(eq(shifts.id, shiftId)).limit(1); if (!shift) { return res.status(404).json({ message: "Turno non trovato" }); } // VINCOLO ESCLUSIVITÀ: Verifica che la guardia NON abbia patrol routes (turni mobile) nella stessa data const existingMobileShifts = await db .select() .from(patrolRoutes) .where( and( eq(patrolRoutes.guardId, guardId), eq(patrolRoutes.shiftDate, shift.shiftDate) ) ) .limit(1); if (existingMobileShifts.length > 0) { return res.status(400).json({ message: `Vincolo esclusività: la guardia è già assegnata a un turno pattuglia mobile in questa data. Una guardia non può essere assegnata contemporaneamente a turni fissi e mobile.` }); } // 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" }); } }); // ============= MY SHIFTS (GUARD VIEW) ROUTES ============= // GET - Turni fissi della guardia loggata app.get("/api/my-shifts/fixed", isAuthenticated, async (req: any, res) => { try { const userId = getUserId(req); const currentUser = await storage.getUser(userId); if (!currentUser) { return res.status(401).json({ message: "User not authenticated" }); } // Trova la guardia associata all'utente const [guard] = await db .select() .from(guards) .where(eq(guards.userId, userId)) .limit(1); if (!guard) { return res.status(404).json({ message: "Guardia non trovata per questo utente" }); } // Estrai filtri data (opzionali) const { startDate, endDate } = req.query; // Query per recuperare i turni fissi assegnati alla guardia let query = db .select({ id: shiftAssignments.id, shiftId: shiftAssignments.shiftId, plannedStartTime: shiftAssignments.plannedStartTime, plannedEndTime: shiftAssignments.plannedEndTime, armed: shiftAssignments.armed, vehicleId: shiftAssignments.vehicleId, vehiclePlate: vehicles.licensePlate, site: { id: sites.id, name: sites.name, address: sites.address, location: sites.location, }, shift: { shiftDate: shifts.shiftDate, startTime: shifts.startTime, endTime: shifts.endTime, }, }) .from(shiftAssignments) .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) .innerJoin(sites, eq(shifts.siteId, sites.id)) .leftJoin(vehicles, eq(shiftAssignments.vehicleId, vehicles.id)) .where(eq(shiftAssignments.guardId, guard.id)); // Applica filtri data se presenti if (startDate && endDate) { const start = new Date(startDate as string); const end = new Date(endDate as string); if (isValid(start) && isValid(end)) { query = query.where( and( eq(shiftAssignments.guardId, guard.id), gte(shifts.shiftDate, format(start, "yyyy-MM-dd")), lte(shifts.shiftDate, format(end, "yyyy-MM-dd")) ) ); } } const myShifts = await query.orderBy(asc(shifts.shiftDate), asc(shiftAssignments.plannedStartTime)); res.json(myShifts); } catch (error) { console.error("Error fetching guard's fixed shifts:", error); res.status(500).json({ message: "Errore caricamento turni fissi" }); } }); // GET - Turni pattuglia mobile della guardia loggata app.get("/api/my-shifts/mobile", isAuthenticated, async (req: any, res) => { try { const userId = getUserId(req); const currentUser = await storage.getUser(userId); if (!currentUser) { return res.status(401).json({ message: "User not authenticated" }); } // Trova la guardia associata all'utente const [guard] = await db .select() .from(guards) .where(eq(guards.userId, userId)) .limit(1); if (!guard) { return res.status(404).json({ message: "Guardia non trovata per questo utente" }); } // Estrai filtri data (opzionali) const { startDate, endDate } = req.query; // Query per recuperare i patrol routes assegnati alla guardia let query = db .select({ id: patrolRoutes.id, shiftDate: patrolRoutes.shiftDate, startTime: patrolRoutes.startTime, endTime: patrolRoutes.endTime, location: patrolRoutes.location, status: patrolRoutes.status, vehicleId: patrolRoutes.vehicleId, vehiclePlate: vehicles.licensePlate, }) .from(patrolRoutes) .leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id)) .where(eq(patrolRoutes.guardId, guard.id)); // Applica filtri data se presenti if (startDate && endDate) { const start = new Date(startDate as string); const end = new Date(endDate as string); if (isValid(start) && isValid(end)) { query = query.where( and( eq(patrolRoutes.guardId, guard.id), gte(patrolRoutes.shiftDate, format(start, "yyyy-MM-dd")), lte(patrolRoutes.shiftDate, format(end, "yyyy-MM-dd")) ) ); } } const routes = await query.orderBy(asc(patrolRoutes.shiftDate), asc(patrolRoutes.startTime)); // Per ogni route, recupera gli stops const routesWithStops = await Promise.all( routes.map(async (route) => { const stops = await db .select({ siteId: patrolRouteStops.siteId, siteName: sites.name, siteAddress: sites.address, sequenceOrder: patrolRouteStops.sequenceOrder, latitude: sites.latitude, longitude: sites.longitude, }) .from(patrolRouteStops) .leftJoin(sites, eq(patrolRouteStops.siteId, sites.id)) .where(eq(patrolRouteStops.patrolRouteId, route.id)) .orderBy(asc(patrolRouteStops.sequenceOrder)); return { ...route, stops, }; }) ); res.json(routesWithStops); } catch (error) { console.error("Error fetching guard's patrol routes:", error); res.status(500).json({ message: "Errore caricamento turni pattuglia" }); } }); // GET - Planning per un sito specifico (tutti gli agenti assegnati) app.get("/api/site-planning/:siteId", isAuthenticated, async (req: any, res) => { try { const { siteId } = req.params; const { startDate, endDate } = req.query; if (!startDate || !endDate) { return res.status(400).json({ message: "Missing required parameters: startDate, endDate" }); } const start = new Date(startDate as string); const end = new Date(endDate as string); if (!isValid(start) || !isValid(end)) { return res.status(400).json({ message: "Invalid date format" }); } // Query per recuperare tutti i turni del sito nel range di date const assignments = await db .select({ guardId: guards.id, guardName: sql`${guards.firstName} || ' ' || ${guards.lastName}`, badgeNumber: guards.badgeNumber, shiftDate: shifts.shiftDate, plannedStartTime: shiftAssignments.plannedStartTime, plannedEndTime: shiftAssignments.plannedEndTime, armed: shiftAssignments.armed, vehicleId: shiftAssignments.vehicleId, vehiclePlate: vehicles.licensePlate, }) .from(shiftAssignments) .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) .innerJoin(guards, eq(shiftAssignments.guardId, guards.id)) .leftJoin(vehicles, eq(shiftAssignments.vehicleId, vehicles.id)) .where( and( eq(shifts.siteId, siteId), gte(shifts.shiftDate, format(start, "yyyy-MM-dd")), lte(shifts.shiftDate, format(end, "yyyy-MM-dd")) ) ) .orderBy(asc(shifts.shiftDate), asc(shiftAssignments.plannedStartTime)); // Raggruppa per data const byDay = assignments.reduce((acc, assignment) => { const date = assignment.shiftDate; if (!acc[date]) { acc[date] = []; } acc[date].push({ guardId: assignment.guardId, guardName: assignment.guardName, badgeNumber: assignment.badgeNumber, plannedStartTime: assignment.plannedStartTime, plannedEndTime: assignment.plannedEndTime, armed: assignment.armed, vehicleId: assignment.vehicleId, vehiclePlate: assignment.vehiclePlate, }); return acc; }, {} as Record); // Converti in array const result = Object.entries(byDay).map(([date, guards]) => ({ date, guards, })); res.json(result); } catch (error) { console.error("Error fetching site planning:", error); res.status(500).json({ message: "Errore caricamento planning sito" }); } }); // ============= 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" }); } }); // ============= PLANNING MOBILE ROUTES ============= // GET /api/planning-mobile/sites?location=X - Siti con servizi mobili (ronde/ispezioni/interventi) app.get("/api/planning-mobile/sites", isAuthenticated, async (req, res) => { try { const { location } = req.query; if (!location || !["roccapiemonte", "milano", "roma"].includes(location as string)) { return res.status(400).json({ message: "Location parameter required (roccapiemonte|milano|roma)" }); } // Query siti con serviceType.classification = 'mobile' e location matching const mobileSites = await db .select({ id: sites.id, name: sites.name, address: sites.address, serviceTypeId: sites.serviceTypeId, serviceTypeName: serviceTypes.label, location: sites.location, latitude: sites.latitude, longitude: sites.longitude, }) .from(sites) .leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id)) .where( and( eq(sites.location, location as "roccapiemonte" | "milano" | "roma"), eq(serviceTypes.classification, "mobile"), eq(sites.isActive, true) ) ) .orderBy(sites.name); res.json(mobileSites); } catch (error) { console.error("Error fetching mobile sites:", error); res.status(500).json({ message: "Errore caricamento siti mobili" }); } }); // GET /api/planning-mobile/guards?location=X&date=YYYY-MM-DD - Guardie disponibili per location e data app.get("/api/planning-mobile/guards", isAuthenticated, async (req, res) => { try { const { location, date } = req.query; if (!location || !["roccapiemonte", "milano", "roma"].includes(location as string)) { return res.status(400).json({ message: "Location parameter required" }); } if (!date || typeof date !== "string") { return res.status(400).json({ message: "Date parameter required (YYYY-MM-DD)" }); } // Valida formato data const dateRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dateRegex.test(date)) { return res.status(400).json({ message: "Invalid date format (use YYYY-MM-DD)" }); } // Ottieni tutte le guardie per location CHE HANNO LA PATENTE const allGuards = await db .select() .from(guards) .where( and( eq(guards.location, location as "roccapiemonte" | "milano" | "roma"), eq(guards.hasDriverLicense, true) ) ) .orderBy(guards.lastName); // Calcola settimana corrente per calcolare ore settimanali const [year, month, day] = date.split("-").map(Number); const targetDate = new Date(year, month - 1, day); const weekStart = startOfWeek(targetDate, { weekStartsOn: 1 }); // lunedì const weekEnd = endOfWeek(targetDate, { weekStartsOn: 1 }); // Per ogni guardia, calcola ore già assegnate nella settimana const guardsWithAvailability = await Promise.all( allGuards.map(async (guard) => { // Query shifts assegnati alla guardia nella settimana const weekShifts = await db .select({ shiftId: shifts.id, startTime: shifts.startTime, endTime: shifts.endTime, }) .from(shiftAssignments) .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) .where( and( eq(shiftAssignments.guardId, guard.id), gte(shifts.startTime, weekStart), lte(shifts.startTime, weekEnd) ) ); // Calcola ore totali nella settimana const weeklyHours = weekShifts.reduce((total, shift) => { const hours = differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); return total + hours; }, 0); const maxWeeklyHours = 45; // CCNL limit const availableHours = Math.max(0, maxWeeklyHours - weeklyHours); return { id: guard.id, firstName: guard.firstName, lastName: guard.lastName, badgeNumber: guard.badgeNumber, location: guard.location, weeklyHours, availableHours, }; }) ); // Filtra solo guardie con ore disponibili const availableGuards = guardsWithAvailability.filter(g => g.availableHours > 0); res.json(availableGuards); } catch (error) { console.error("Error fetching available guards:", error); res.status(500).json({ message: "Errore caricamento guardie disponibili" }); } }); // ============= PATROL ROUTES API ============= // GET patrol routes per guardia e data app.get("/api/patrol-routes", isAuthenticated, async (req: any, res) => { try { const { guardId, date, location } = req.query; const conditions = []; if (guardId && guardId !== "all") { conditions.push(eq(patrolRoutes.guardId, guardId)); } if (date) { conditions.push(eq(patrolRoutes.shiftDate, date)); } if (location) { conditions.push(eq(patrolRoutes.location, location as any)); } const routes = await db .select({ id: patrolRoutes.id, guardId: patrolRoutes.guardId, shiftDate: patrolRoutes.shiftDate, startTime: patrolRoutes.startTime, endTime: patrolRoutes.endTime, status: patrolRoutes.status, location: patrolRoutes.location, vehicleId: patrolRoutes.vehicleId, isArmedRoute: patrolRoutes.isArmedRoute, notes: patrolRoutes.notes, guardFirstName: guards.firstName, guardLastName: guards.lastName, vehiclePlate: vehicles.licensePlate, }) .from(patrolRoutes) .leftJoin(guards, eq(patrolRoutes.guardId, guards.id)) .leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id)) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(patrolRoutes.shiftDate)); // Carica stops per ogni route const routesWithStops = await Promise.all( routes.map(async (route) => { const stops = await db .select({ id: patrolRouteStops.id, siteId: patrolRouteStops.siteId, siteName: sites.name, siteAddress: sites.address, latitude: sites.latitude, longitude: sites.longitude, sequenceOrder: patrolRouteStops.sequenceOrder, estimatedArrivalTime: patrolRouteStops.estimatedArrivalTime, isCompleted: patrolRouteStops.isCompleted, notes: patrolRouteStops.notes, }) .from(patrolRouteStops) .leftJoin(sites, eq(patrolRouteStops.siteId, sites.id)) .where(eq(patrolRouteStops.patrolRouteId, route.id)) .orderBy(asc(patrolRouteStops.sequenceOrder)); return { ...route, stops, }; }) ); res.json(routesWithStops); } catch (error) { console.error("Error fetching patrol routes:", error); res.status(500).json({ message: "Errore caricamento turni pattuglia" }); } }); // POST - Crea nuovo patrol route con stops app.post("/api/patrol-routes", isAuthenticated, async (req: any, res) => { try { const routeData = insertPatrolRouteSchema.parse(req.body); const { stops } = req.body; // Array di siti in sequenza // Verifica che non esista già un patrol route per questa guardia/data const existing = await db .select() .from(patrolRoutes) .where( and( eq(patrolRoutes.guardId, routeData.guardId), eq(patrolRoutes.shiftDate, routeData.shiftDate) ) ) .limit(1); if (existing.length > 0) { return res.status(400).json({ message: "Esiste già un turno pattuglia per questa guardia in questa data" }); } // VINCOLO ESCLUSIVITÀ: Verifica che la guardia NON abbia shift assignments (turni fissi) nella stessa data const existingFixedShifts = await db .select({ shiftId: shifts.id, siteName: sites.name, }) .from(shiftAssignments) .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) .innerJoin(sites, eq(shifts.siteId, sites.id)) .where( and( eq(shiftAssignments.guardId, routeData.guardId), sql`DATE(${shifts.startTime}) = ${routeData.shiftDate}` ) ) .limit(1); if (existingFixedShifts.length > 0) { return res.status(400).json({ message: `Vincolo esclusività: la guardia è già assegnata a un turno fisso (${existingFixedShifts[0].siteName}) in questa data. Una guardia non può essere assegnata contemporaneamente a turni fissi e mobile.` }); } // Crea patrol route const [newRoute] = await db.insert(patrolRoutes).values(routeData).returning(); // Crea stops se presenti if (stops && Array.isArray(stops) && stops.length > 0) { const stopsData = stops.map((stop: any, index: number) => ({ patrolRouteId: newRoute.id, siteId: stop.siteId, sequenceOrder: index + 1, estimatedArrivalTime: stop.estimatedArrivalTime || null, })); await db.insert(patrolRouteStops).values(stopsData); } res.json(newRoute); } catch (error) { console.error("Error creating patrol route:", error); res.status(500).json({ message: "Errore creazione turno pattuglia" }); } }); // PUT - Aggiorna patrol route esistente app.put("/api/patrol-routes/:id", isAuthenticated, async (req: any, res) => { try { const { id } = req.params; const { stops, ...routeData } = req.body; // Aggiorna patrol route const [updated] = await db .update(patrolRoutes) .set(routeData) .where(eq(patrolRoutes.id, id)) .returning(); if (!updated) { return res.status(404).json({ message: "Turno pattuglia non trovato" }); } // Se ci sono stops, elimina quelli vecchi e inserisci i nuovi if (stops && Array.isArray(stops)) { await db.delete(patrolRouteStops).where(eq(patrolRouteStops.patrolRouteId, id)); if (stops.length > 0) { const stopsData = stops.map((stop: any, index: number) => ({ patrolRouteId: id, siteId: stop.siteId, sequenceOrder: index + 1, estimatedArrivalTime: stop.estimatedArrivalTime || null, })); await db.insert(patrolRouteStops).values(stopsData); } } res.json(updated); } catch (error) { console.error("Error updating patrol route:", error); res.status(500).json({ message: "Errore aggiornamento turno pattuglia" }); } }); // DELETE - Elimina patrol route app.delete("/api/patrol-routes/:id", isAuthenticated, async (req: any, res) => { try { const { id } = req.params; await db.delete(patrolRoutes).where(eq(patrolRoutes.id, id)); res.json({ message: "Turno pattuglia eliminato" }); } catch (error) { console.error("Error deleting patrol route:", error); res.status(500).json({ message: "Errore eliminazione turno pattuglia" }); } }); // POST - Duplica o modifica patrol route app.post("/api/patrol-routes/duplicate", isAuthenticated, async (req: any, res) => { try { const { sourceRouteId, targetDate, guardId } = req.body; if (!sourceRouteId || !targetDate) { return res.status(400).json({ message: "sourceRouteId e targetDate sono obbligatori" }); } // Carica patrol route sorgente con tutti gli stops const sourceRoute = await db.query.patrolRoutes.findFirst({ where: eq(patrolRoutes.id, sourceRouteId), with: { stops: { orderBy: (stops, { asc }) => [asc(stops.sequenceOrder)], }, }, }); if (!sourceRoute) { return res.status(404).json({ message: "Sequenza pattuglia sorgente non trovata" }); } // Controlla se targetDate è uguale a sourceRoute.shiftDate const sourceDate = new Date(sourceRoute.shiftDate).toISOString().split('T')[0]; const targetDateNormalized = new Date(targetDate).toISOString().split('T')[0]; if (sourceDate === targetDateNormalized) { // UPDATE: stessa data, modifica solo guardia se fornita if (guardId && guardId !== sourceRoute.guardId) { const updated = await db .update(patrolRoutes) .set({ guardId }) .where(eq(patrolRoutes.id, sourceRouteId)) .returning(); return res.json({ action: "updated", route: updated[0], message: "Guardia assegnata alla sequenza esistente", }); } else { return res.status(400).json({ message: "Nessuna modifica da applicare (stessa data e stessa guardia)" }); } } else { // CREATE: data diversa, duplica sequenza con stops // Crea nuova patrol route const newRoute = await db .insert(patrolRoutes) .values({ guardId: guardId || sourceRoute.guardId, // Usa nuova guardia o mantieni originale shiftDate: targetDate, startTime: sourceRoute.startTime, endTime: sourceRoute.endTime, status: "planned", // Nuova sequenza sempre in stato planned location: sourceRoute.location, notes: sourceRoute.notes, }) .returning(); const newRouteId = newRoute[0].id; // Duplica tutti gli stops if (sourceRoute.stops && sourceRoute.stops.length > 0) { const stopsData = sourceRoute.stops.map((stop) => ({ patrolRouteId: newRouteId, siteId: stop.siteId, sequenceOrder: stop.sequenceOrder, estimatedArrivalTime: stop.estimatedArrivalTime, })); await db.insert(patrolRouteStops).values(stopsData); } return res.json({ action: "created", route: newRoute[0], copiedStops: sourceRoute.stops?.length || 0, message: "Sequenza pattuglia duplicata con successo", }); } } catch (error) { console.error("Error duplicating patrol route:", error); res.status(500).json({ message: "Errore durante duplicazione sequenza pattuglia" }); } }); // ============= GEOCODING API (Nominatim/OSM) ============= // Rate limiter semplice per rispettare 1 req/sec di Nominatim let lastGeocodingRequest = 0; app.post("/api/geocode", isAuthenticated, async (req: any, res) => { try { const { address } = req.body; if (!address || typeof address !== 'string') { return res.status(400).json({ message: "Address parameter required" }); } // Rispetta rate limit di 1 req/sec const now = Date.now(); const timeSinceLastRequest = now - lastGeocodingRequest; if (timeSinceLastRequest < 1000) { const waitTime = 1000 - timeSinceLastRequest; await new Promise(resolve => setTimeout(resolve, waitTime)); } lastGeocodingRequest = Date.now(); // Chiama Nominatim API const nominatimUrl = new URL("https://nominatim.openstreetmap.org/search"); nominatimUrl.searchParams.set("q", address); nominatimUrl.searchParams.set("format", "json"); nominatimUrl.searchParams.set("limit", "1"); nominatimUrl.searchParams.set("addressdetails", "1"); // Nominatim Usage Policy richiede User-Agent con contatto email // Ref: https://operations.osmfoundation.org/policies/nominatim/ const response = await fetch(nominatimUrl.toString(), { headers: { "User-Agent": "VigilanzaTurni/1.0 (Security Shift Management System; contact: support@vigilanzaturni.it)", }, }); if (!response.ok) { throw new Error(`Nominatim API error: ${response.status}`); } const data = await response.json(); if (!data || data.length === 0) { return res.status(404).json({ message: "Indirizzo non trovato. Prova a essere più specifico (es. Via, Città, Italia)" }); } const result = data[0]; res.json({ latitude: result.lat, longitude: result.lon, displayName: result.display_name, address: result.address, }); } catch (error) { console.error("Error geocoding address:", error); res.status(500).json({ message: "Errore durante la geocodifica dell'indirizzo" }); } }); // ============= ROUTE OPTIMIZATION API (OSRM + TSP) ============= app.post("/api/optimize-route", isAuthenticated, async (req: any, res) => { try { const { coordinates } = req.body; // Validazione: array di coordinate [{lat, lon, id}] if (!Array.isArray(coordinates) || coordinates.length < 2) { return res.status(400).json({ message: "Almeno 2 coordinate richieste per l'ottimizzazione" }); } // Verifica formato coordinate for (const coord of coordinates) { if (!coord.lat || !coord.lon || !coord.id) { return res.status(400).json({ message: "Ogni coordinata deve avere lat, lon e id" }); } } // STEP 1: Calcola matrice distanze usando OSRM Table API const coordsString = coordinates.map(c => `${c.lon},${c.lat}`).join(';'); const osrmTableUrl = `https://router.project-osrm.org/table/v1/driving/${coordsString}?annotations=distance,duration`; const osrmResponse = await fetch(osrmTableUrl); if (!osrmResponse.ok) { throw new Error(`OSRM API error: ${osrmResponse.status}`); } const osrmData = await osrmResponse.json(); if (osrmData.code !== 'Ok' || !osrmData.distances || !osrmData.durations) { throw new Error("OSRM non ha restituito dati validi"); } const distances = osrmData.distances; // Matrice NxN in metri const durations = osrmData.durations; // Matrice NxN in secondi // STEP 2: Applica algoritmo TSP Nearest Neighbor // Inizia dalla prima tappa (indice 0) const n = coordinates.length; const visited = new Set(); const route: number[] = []; let current = 0; let totalDistance = 0; let totalDuration = 0; visited.add(current); route.push(current); // Trova sempre il vicino più vicino non visitato for (let i = 1; i < n; i++) { let nearest = -1; let minDistance = Infinity; for (let j = 0; j < n; j++) { if (!visited.has(j) && distances[current][j] < minDistance) { minDistance = distances[current][j]; nearest = j; } } if (nearest !== -1) { totalDistance += distances[current][nearest]; totalDuration += durations[current][nearest]; visited.add(nearest); route.push(nearest); current = nearest; } } // Ritorna al punto di partenza (circuito chiuso) totalDistance += distances[current][0]; totalDuration += durations[current][0]; // STEP 3: Prepara risposta const optimizedRoute = route.map(index => ({ ...coordinates[index], order: route.indexOf(index) + 1, })); res.json({ optimizedRoute, totalDistanceMeters: Math.round(totalDistance), totalDistanceKm: (totalDistance / 1000).toFixed(2), totalDurationSeconds: Math.round(totalDuration), totalDurationMinutes: Math.round(totalDuration / 60), estimatedTimeFormatted: formatDuration(totalDuration), }); } catch (error) { console.error("Error optimizing route:", error); res.status(500).json({ message: "Errore durante l'ottimizzazione del percorso" }); } }); // Helper per formattare durata in ore e minuti function formatDuration(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (hours > 0) { return `${hours}h ${minutes}m`; } return `${minutes}m`; } // ============= WEEKLY GUARDS SCHEDULE ============= app.get("/api/weekly-guards-schedule", isAuthenticated, async (req: any, res) => { try { const location = req.query.location as string; const startDate = req.query.startDate as string; if (!location || !startDate) { return res.status(400).json({ message: "Location e startDate richiesti" }); } // Calcola l'intervallo della settimana (7 giorni da startDate) const weekStart = parseISO(startDate); const weekEnd = addDays(weekStart, 6); // Recupera tutte le guardie della sede const guardsInLocation = await db .select() .from(guards) .where(eq(guards.location, location)) .orderBy(guards.lastName, guards.firstName); // Prepara la struttura dati const guardsSchedule = []; for (const guard of guardsInLocation) { // Recupera turni fissi della settimana (shift_assignments) const fixedShifts = await db .select({ assignmentId: shiftAssignments.id, shiftId: shifts.id, plannedStartTime: shiftAssignments.plannedStartTime, plannedEndTime: shiftAssignments.plannedEndTime, siteName: sites.name, siteId: sites.id, }) .from(shiftAssignments) .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) .innerJoin(sites, eq(shifts.siteId, sites.id)) .where( and( eq(shiftAssignments.guardId, guard.id), gte(shiftAssignments.plannedStartTime, weekStart), lte(shiftAssignments.plannedStartTime, weekEnd) ) ); // Recupera turni mobili della settimana (patrol_routes) const mobileShifts = await db .select({ routeId: patrolRoutes.id, shiftDate: patrolRoutes.shiftDate, startTime: patrolRoutes.startTime, endTime: patrolRoutes.endTime, }) .from(patrolRoutes) .where( and( eq(patrolRoutes.guardId, guard.id), gte(sql`${patrolRoutes.shiftDate}`, startDate), lte(sql`${patrolRoutes.shiftDate}`, format(weekEnd, 'yyyy-MM-dd')) ) ); // Recupera assenze della settimana const absencesData = await db .select() .from(absences) .where( and( eq(absences.guardId, guard.id), lte(sql`${absences.startDate}`, format(weekEnd, 'yyyy-MM-dd')), gte(sql`${absences.endDate}`, startDate) ) ); guardsSchedule.push({ guard: { id: guard.id, firstName: guard.firstName, lastName: guard.lastName, badgeNumber: guard.badgeNumber, }, fixedShifts, mobileShifts, absences: absencesData, }); } res.json({ weekStart: format(weekStart, 'yyyy-MM-dd'), weekEnd: format(weekEnd, 'yyyy-MM-dd'), location, guards: guardsSchedule, }); } catch (error) { console.error("Error fetching weekly guards schedule:", error); res.status(500).json({ message: "Errore nel recupero della pianificazione settimanale" }); } }); const httpServer = createServer(app); return httpServer; }