VigilanzaTurni/server/routes.ts
marco370 52baa7f6c3 Exclude mobile sites from fixed planning schedules
Filter out sites classified as "mobile" from the fixed planning module in server/routes.ts by modifying the site filtering logic.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/aneFGWm
2025-10-24 10:47:28 +00:00

4114 lines
154 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from "@shared/schema";
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
// Determina quale sistema auth usare basandosi sull'ambiente
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
// Helper per estrarre user ID in modo compatibile
function getUserId(req: any): string {
if (USE_LOCAL_AUTH) {
return req.user?.id || "";
} else {
return req.user?.claims?.sub || "";
}
}
// Usa il middleware auth appropriato
const isAuthenticated = USE_LOCAL_AUTH ? isAuthenticatedLocal : isAuthenticatedReplit;
export async function registerRoutes(app: Express): Promise<Server> {
// 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<number>`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) });
}
});
// Assign guard to site/date with specific time slot (supports multi-day assignments)
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
try {
const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId, force = false } = req.body;
if (!siteId || !date || !guardId || !startTime || !durationHours) {
return res.status(400).json({
message: "Missing required fields: siteId, date, guardId, startTime, durationHours"
});
}
if (consecutiveDays < 1 || consecutiveDays > 30) {
return res.status(400).json({ message: "consecutiveDays must be between 1 and 30" });
}
// Get site to check contract and service details
const site = await storage.getSite(siteId);
if (!site) {
return res.status(404).json({ message: "Site not found" });
}
// Get guard to verify it exists
const guard = await storage.getGuard(guardId);
if (!guard) {
return res.status(404).json({ message: "Guard not found" });
}
// Parse start date components
const [year, month, day] = date.split("-").map(Number);
if (!year || !month || !day || month < 1 || month > 12 || day < 1 || day > 31) {
return res.status(400).json({ message: "Invalid date format. Expected YYYY-MM-DD" });
}
const [hours, minutes] = startTime.split(":").map(Number);
// Atomic transaction: create assignments for all consecutive days
const result = await db.transaction(async (tx) => {
const createdAssignments = [];
// Loop through each consecutive day
for (let dayOffset = 0; dayOffset < consecutiveDays; dayOffset++) {
// Calculate date components for this iteration (avoid timezone issues)
const targetDay = day + dayOffset;
const baseDate = new Date(year, month - 1, 1); // First day of month
baseDate.setDate(targetDay); // Set to target day (handles month overflow)
// Extract actual date components after overflow handling
const actualYear = baseDate.getFullYear();
const actualMonth = baseDate.getMonth();
const actualDay = baseDate.getDate();
// Build dates in LOCAL timezone to match user's selection
const shiftDate = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0);
// Check contract validity for this date
if (site.contractStartDate && site.contractEndDate) {
const contractStart = new Date(site.contractStartDate);
const contractEnd = new Date(site.contractEndDate);
if (shiftDate < contractStart || shiftDate > contractEnd) {
throw new Error(
`Cannot assign guard for date ${shiftDate.toLocaleDateString()}: outside contract period`
);
}
}
// Calculate planned start and end times in LOCAL timezone
const plannedStart = new Date(actualYear, actualMonth, actualDay, hours, minutes, 0, 0);
const plannedEnd = new Date(actualYear, actualMonth, actualDay, hours + durationHours, minutes, 0, 0);
// Find or create shift for this site/date (full day boundaries in LOCAL timezone)
const dayStart = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0);
const dayEnd = new Date(actualYear, actualMonth, actualDay, 23, 59, 59, 999);
let existingShifts = await tx
.select()
.from(shifts)
.where(
and(
eq(shifts.siteId, siteId),
gte(shifts.startTime, dayStart),
lte(shifts.startTime, dayEnd)
)
);
let shift;
if (existingShifts.length > 0) {
shift = existingShifts[0];
} else {
// Create new shift for full service period
const serviceStart = site.serviceStartTime || "00:00";
const serviceEnd = site.serviceEndTime || "23:59";
const [startHour, startMin] = serviceStart.split(":").map(Number);
const [endHour, endMin] = serviceEnd.split(":").map(Number);
const shiftStart = new Date(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0);
let shiftEnd = new Date(actualYear, actualMonth, actualDay, endHour, endMin, 0, 0);
// If end time is before/equal to start time, shift extends to next day
if (shiftEnd <= shiftStart) {
shiftEnd = new Date(actualYear, actualMonth, actualDay + 1, endHour, endMin, 0, 0);
}
[shift] = await tx.insert(shifts).values({
siteId: site.id,
startTime: shiftStart,
endTime: shiftEnd,
shiftType: site.shiftType || "fixed_post",
status: "planned",
vehicleId: vehicleId || null,
}).returning();
}
// Recheck overlaps within transaction for this day
const existingAssignments = await tx
.select()
.from(shiftAssignments)
.where(eq(shiftAssignments.guardId, guard.id));
// Check for time overlaps
for (const existing of existingAssignments) {
const hasOverlap =
plannedStart < existing.plannedEndTime &&
plannedEnd > existing.plannedStartTime;
if (hasOverlap) {
throw new Error(
`Conflitto: guardia già assegnata ${existing.plannedStartTime.toLocaleString()} - ${existing.plannedEndTime.toLocaleString()}`
);
}
}
// CCNL: Check daily hour limit (max 9h/day) - skip if force=true
if (!force) {
const maxDailyHours = 9;
let dailyHoursAlreadyAssigned = 0;
for (const existing of existingAssignments) {
// Check if assignment is on the same day
const existingDate = new Date(existing.plannedStartTime);
if (
existingDate.getUTCFullYear() === actualYear &&
existingDate.getUTCMonth() === actualMonth &&
existingDate.getUTCDate() === actualDay
) {
const assignmentHours = differenceInHours(
existing.plannedEndTime,
existing.plannedStartTime
);
dailyHoursAlreadyAssigned += assignmentHours;
}
}
// Check if new assignment would exceed daily limit
if (dailyHoursAlreadyAssigned + durationHours > maxDailyHours) {
const excessHours = (dailyHoursAlreadyAssigned + durationHours) - maxDailyHours;
throw new Error(
`Limite giornaliero superato: la guardia ha già ${dailyHoursAlreadyAssigned}h assegnate il ${shiftDate.toLocaleDateString('it-IT')}. ` +
`Aggiungendo ${durationHours}h si supererebbero di ${excessHours}h le ${maxDailyHours}h massime giornaliere (CCNL).`
);
}
}
// Create assignment for this day
const [assignment] = await tx.insert(shiftAssignments).values({
shiftId: shift.id,
guardId: guard.id,
plannedStartTime: plannedStart,
plannedEndTime: plannedEnd,
}).returning();
createdAssignments.push(assignment);
}
return { assignments: createdAssignments, count: createdAssignments.length };
});
res.json({
message: `Guard assigned successfully for ${result.count} day(s)`,
assignments: result.assignments,
count: result.count
});
} catch (error: any) {
console.error("Error assigning guard:", error);
// Check for overlap/conflict errors (both English and Italian)
const errorMessage = error.message?.toLowerCase() || '';
if (errorMessage.includes('overlap') ||
errorMessage.includes('conflict') ||
errorMessage.includes('conflitto') ||
errorMessage.includes('già assegnata') ||
errorMessage.includes('limite giornaliero') ||
errorMessage.includes('limite settimanale') ||
errorMessage.includes('ccnl')) {
return res.status(409).json({
message: error.message,
type: errorMessage.includes('limite') ? 'CCNL_VIOLATION' : 'CONFLICT'
});
}
res.status(500).json({ message: "Failed to assign guard", error: String(error) });
}
});
// ============= SERVICE PLANNING ROUTES =============
// Vista per 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<string, number> = {};
const dailyHours: Record<string, number> = {}; // 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<string, any> = {};
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<string, any> = {};
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<string>();
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<string>`${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<string, any[]>);
// 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" });
}
});
// ============= 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" });
}
});
const httpServer = createServer(app);
return httpServer;
}