From 0d2e1544cd106420afcf6146acc35f1267802a02 Mon Sep 17 00:00:00 2001 From: AV77web Date: Tue, 14 Jul 2026 11:14:18 +0200 Subject: [PATCH] installazione Swagger e inserimento rotte corsi.ts e assegnazione-corsi.ts --- package-lock.json | 46 ++- package.json | 4 +- scripts/apply-academy-seed.sql | 18 + src/config/openapi.ts | 686 +++++++++++++++++++++++++++++++ src/config/swagger.ts | 27 ++ src/index.ts | 21 + src/routes/assegnazioni-corsi.ts | 535 ++++++++++++++++++++++++ src/routes/corsi.ts | 258 ++++++++++++ src/routes/me.ts | 4 + src/routes/statistiche.ts | 122 ++++++ src/routes/utenti.ts | 21 +- src/validation/assegnazione.ts | 195 +++++++++ src/validation/corso.ts | 129 ++++++ 13 files changed, 2062 insertions(+), 4 deletions(-) create mode 100644 scripts/apply-academy-seed.sql create mode 100644 src/config/openapi.ts create mode 100644 src/config/swagger.ts create mode 100644 src/routes/assegnazioni-corsi.ts create mode 100644 src/routes/corsi.ts create mode 100644 src/routes/statistiche.ts create mode 100644 src/validation/assegnazione.ts create mode 100644 src/validation/corso.ts diff --git a/package-lock.json b/package-lock.json index 7a69215..8723e3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,15 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", - "mysql2": "^3.22.6" + "mysql2": "^3.22.6", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^26.1.1", + "@types/swagger-ui-express": "^4.1.8", "tsx": "^4.23.0", "typescript": "^7.0.2" } @@ -513,6 +515,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -627,6 +636,17 @@ "@types/node": "*" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -1993,6 +2013,30 @@ "node": ">= 0.8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index 6e125ca..6bcc194 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,15 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", - "mysql2": "^3.22.6" + "mysql2": "^3.22.6", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^26.1.1", + "@types/swagger-ui-express": "^4.1.8", "tsx": "^4.23.0", "typescript": "^7.0.2" }, diff --git a/scripts/apply-academy-seed.sql b/scripts/apply-academy-seed.sql new file mode 100644 index 0000000..3634a1d --- /dev/null +++ b/scripts/apply-academy-seed.sql @@ -0,0 +1,18 @@ +-- Dati di test per corsi e assegnazioni su database già migrato. +-- Eseguire dopo scripts/apply-academy-schema.sql se le tabelle sono vuote. + +USE `esameits24-26`; + +INSERT INTO corso (Titolo, Descrizione, Categoria, DurataOre, Obbligatorio, Attivo) +SELECT * FROM ( + SELECT 'Sicurezza sul lavoro' AS Titolo, 'Norme base su rischi, DPI e procedure di emergenza in azienda.' AS Descrizione, 'Sicurezza' AS Categoria, 8 AS DurataOre, 1 AS Obbligatorio, 1 AS Attivo + UNION ALL SELECT 'GDPR e privacy', 'Trattamento dei dati personali, diritti degli interessati e documentazione privacy.', 'Compliance', 4, 1, 1 + UNION ALL SELECT 'Comunicazione efficace', 'Tecniche di ascolto attivo, feedback e gestione dei conflitti nel team.', 'Soft Skills', 6, 0, 1 + UNION ALL SELECT 'Excel avanzato', 'Tabelle pivot, funzioni avanzate e automazioni per report aziendali.', 'IT', 10, 0, 1 + UNION ALL SELECT 'Primo soccorso (edizione 2023)', 'Corso storico non più disponibile per nuove assegnazioni.', 'Sicurezza', 4, 0, 0 +) AS seed_corsi +WHERE NOT EXISTS (SELECT 1 FROM corso LIMIT 1); + +INSERT INTO assegnazione_corso (CorsoID, DipendenteID, DataAssegnazione, DataScadenza, Stato, DataCompletamento) +SELECT 1, 2, '2026-05-01', '2026-06-30', 'Assegnato', NULL +WHERE NOT EXISTS (SELECT 1 FROM assegnazione_corso LIMIT 1); diff --git a/src/config/openapi.ts b/src/config/openapi.ts new file mode 100644 index 0000000..a26c165 --- /dev/null +++ b/src/config/openapi.ts @@ -0,0 +1,686 @@ +//====================================================== +// File: openapi.ts +// Script per la gestione dello scehma di configurazione +// di swagger. +// @author: "villari.andrea@libero.it" +// @version: "1.0.0 2026-07-14" +//====================================================== + +const errorSchema = { + type: "object", + properties: { + error: { type: "string" }, + fields: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, +} as const; + +const utenteSchema = { + type: "object", + properties: { + id: { type: "integer" }, + nome: { type: "string" }, + cognome: { type: "string" }, + email: { type: "string", format: "email" }, + ruolo: { type: "string", enum: ["Dipendente", "Referente Academy"] }, + }, +} as const; + +const corsoSchema = { + type: "object", + properties: { + id: { type: "integer" }, + titolo: { type: "string" }, + descrizione: { type: "string", nullable: true }, + categoria: { type: "string" }, + durataOre: { type: "integer", minimum: 1 }, + obbligatorio: { type: "boolean" }, + attivo: { type: "boolean" }, + }, +} as const; + +const assegnazioneSchema = { + type: "object", + properties: { + id: { type: "integer" }, + corsoId: { type: "integer" }, + dipendenteId: { type: "integer" }, + dataAssegnazione: { type: "string", format: "date" }, + dataScadenza: { type: "string", format: "date" }, + stato: { + type: "string", + enum: ["Assegnato", "Completato", "Scaduto", "Annullato"], + }, + dataCompletamento: { type: "string", format: "date", nullable: true }, + corso: { + type: "object", + properties: { + id: { type: "integer" }, + titolo: { type: "string" }, + categoria: { type: "string" }, + durataOre: { type: "integer" }, + }, + }, + dipendente: { + type: "object", + properties: { + id: { type: "integer" }, + nome: { type: "string" }, + cognome: { type: "string" }, + email: { type: "string", format: "email" }, + }, + }, + }, +} as const; + +const statisticaSchema = { + type: "object", + properties: { + mese: { type: "string", example: "2026-05" }, + categoria: { type: "string", example: "Sicurezza" }, + numeroAssegnazioni: { type: "integer", example: 18 }, + numeroCompletamenti: { type: "integer", example: 12 }, + percentualeCompletamento: { type: "number", example: 66.67 }, + }, +} as const; + +const idParam = { + name: "id", + in: "path", + required: true, + schema: { type: "integer", minimum: 1 }, +} as const; + +const responses = { + unauthorized: { + description: "Autenticazione richiesta", + content: { "application/json": { schema: errorSchema } }, + }, + forbidden: { + description: "Operazione non consentita per il ruolo corrente", + content: { "application/json": { schema: errorSchema } }, + }, + notFound: { + description: "Risorsa non trovata", + content: { "application/json": { schema: errorSchema } }, + }, + validationError: { + description: "Validazione fallita", + content: { "application/json": { schema: errorSchema } }, + }, +} as const; + +export const openApiSpec = { + openapi: "3.0.3", + info: { + title: "Esame ITS 24-26 – Academy API", + version: "1.0.0", + description: [ + "API REST per la gestione dell'Academy aziendale (corsi, assegnazioni, statistiche).", + "", + "### Autenticazione", + "Le API protette usano sessioni Auth.js tramite cookie HTTP-only.", + "1. `GET /api/auth/csrf` per ottenere il token CSRF", + "2. `POST /api/auth/callback/credentials` con `email`, `password`, `csrfToken`", + "3. Le richieste successive inviano automaticamente il cookie di sessione", + "", + "### Utenti di test", + "| Email | Password | Ruolo |", + "|-------|----------|-------|", + "| admin@academy.it | Password123! | Referente Academy |", + "| maria.bianchi@academy.it | Password123! | Dipendente |", + ].join("\n"), + }, + servers: [{ url: "/", description: "Server corrente" }], + tags: [ + { name: "Sistema", description: "Health check e documentazione" }, + { name: "Autenticazione", description: "Login e sessione" }, + { name: "Profilo", description: "Utente autenticato" }, + { name: "Utenti", description: "Gestione utenti (Referente Academy)" }, + { name: "Corsi", description: "Catalogo corsi Academy" }, + { name: "Assegnazioni", description: "Assegnazioni corsi ai dipendenti" }, + { name: "Statistiche", description: "Riepiloghi formativi" }, + ], + components: { + securitySchemes: { + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "authjs.session-token", + description: + "Cookie di sessione Auth.js. In produzione può essere __Secure-authjs.session-token.", + }, + }, + schemas: { + Error: errorSchema, + Utente: utenteSchema, + Corso: corsoSchema, + AssegnazioneCorso: assegnazioneSchema, + StatisticaAcademy: statisticaSchema, + UtenteCreate: { + type: "object", + required: ["nome", "cognome", "email", "password", "ruolo"], + properties: { + nome: { type: "string" }, + cognome: { type: "string" }, + email: { type: "string", format: "email" }, + password: { type: "string", minLength: 8 }, + ruolo: { type: "string", enum: ["Dipendente", "Referente Academy"] }, + }, + }, + CorsoCreate: { + type: "object", + required: ["titolo", "categoria", "durataOre"], + properties: { + titolo: { type: "string" }, + descrizione: { type: "string", nullable: true }, + categoria: { type: "string" }, + durataOre: { type: "integer", minimum: 1 }, + obbligatorio: { type: "boolean", default: false }, + attivo: { type: "boolean", default: true }, + }, + }, + AssegnazioneCreate: { + type: "object", + required: ["corsoId", "dipendenteId", "dataAssegnazione", "dataScadenza"], + properties: { + corsoId: { type: "integer" }, + dipendenteId: { type: "integer" }, + dataAssegnazione: { type: "string", format: "date" }, + dataScadenza: { type: "string", format: "date" }, + }, + }, + AssegnazioneCompleta: { + type: "object", + properties: { + dataCompletamento: { + type: "string", + format: "date", + description: "Se omessa, viene usata la data odierna", + }, + }, + }, + }, + }, + paths: { + "/api/health": { + get: { + tags: ["Sistema"], + summary: "Health check", + responses: { + 200: { + description: "Servizio attivo", + content: { + "application/json": { + schema: { + type: "object", + properties: { status: { type: "string", example: "ok" } }, + }, + }, + }, + }, + }, + }, + }, + "/api/auth/csrf": { + get: { + tags: ["Autenticazione"], + summary: "Ottieni token CSRF per il login", + responses: { + 200: { + description: "Token CSRF", + content: { + "application/json": { + schema: { + type: "object", + properties: { csrfToken: { type: "string" } }, + }, + }, + }, + }, + }, + }, + }, + "/api/auth/callback/credentials": { + post: { + tags: ["Autenticazione"], + summary: "Login con email e password", + requestBody: { + required: true, + content: { + "application/x-www-form-urlencoded": { + schema: { + type: "object", + required: ["csrfToken", "email", "password"], + properties: { + csrfToken: { type: "string" }, + email: { type: "string", format: "email" }, + password: { type: "string" }, + redirect: { type: "string", example: "false" }, + }, + }, + }, + }, + }, + responses: { + 200: { description: "Login riuscito, cookie di sessione impostato" }, + 401: { description: "Credenziali non valide" }, + }, + }, + }, + "/api/auth/signout": { + post: { + tags: ["Autenticazione"], + summary: "Logout", + security: [{ cookieAuth: [] }], + responses: { + 200: { description: "Logout effettuato" }, + }, + }, + }, + "/api/me": { + get: { + tags: ["Profilo"], + summary: "Profilo utente autenticato", + security: [{ cookieAuth: [] }], + responses: { + 200: { + description: "Profilo corrente", + content: { "application/json": { schema: utenteSchema } }, + }, + 401: responses.unauthorized, + }, + }, + }, + "/api/utenti": { + get: { + tags: ["Utenti"], + summary: "Elenco utenti", + security: [{ cookieAuth: [] }], + parameters: [ + { + name: "ruolo", + in: "query", + schema: { type: "string", enum: ["Dipendente", "Referente Academy"] }, + description: "Filtra per ruolo (es. Dipendente per l'elenco dipendenti)", + }, + ], + responses: { + 200: { + description: "Lista utenti", + content: { + "application/json": { + schema: { type: "array", items: utenteSchema }, + }, + }, + }, + 401: responses.unauthorized, + 403: responses.forbidden, + }, + }, + post: { + tags: ["Utenti"], + summary: "Crea utente", + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UtenteCreate" }, + }, + }, + }, + responses: { + 201: { + description: "Utente creato", + content: { "application/json": { schema: utenteSchema } }, + }, + 400: responses.validationError, + 403: responses.forbidden, + 409: { description: "Email già registrata" }, + }, + }, + }, + "/api/utenti/{id}": { + get: { + tags: ["Utenti"], + summary: "Dettaglio utente", + security: [{ cookieAuth: [] }], + parameters: [idParam], + responses: { + 200: { + description: "Utente trovato", + content: { "application/json": { schema: utenteSchema } }, + }, + 404: responses.notFound, + }, + }, + put: { + tags: ["Utenti"], + summary: "Aggiorna utente", + security: [{ cookieAuth: [] }], + parameters: [idParam], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UtenteCreate" }, + }, + }, + }, + responses: { + 200: { + description: "Utente aggiornato", + content: { "application/json": { schema: utenteSchema } }, + }, + 400: responses.validationError, + 404: responses.notFound, + }, + }, + delete: { + tags: ["Utenti"], + summary: "Elimina utente", + security: [{ cookieAuth: [] }], + parameters: [idParam], + responses: { + 204: { description: "Utente eliminato" }, + 400: { description: "Impossibile eliminare il proprio account" }, + 404: responses.notFound, + }, + }, + }, + "/api/corsi": { + get: { + tags: ["Corsi"], + summary: "Elenco corsi", + security: [{ cookieAuth: [] }], + parameters: [ + { name: "categoria", in: "query", schema: { type: "string" } }, + { name: "attivo", in: "query", schema: { type: "boolean" } }, + ], + responses: { + 200: { + description: "Lista corsi", + content: { + "application/json": { + schema: { type: "array", items: corsoSchema }, + }, + }, + }, + }, + }, + post: { + tags: ["Corsi"], + summary: "Crea corso", + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CorsoCreate" }, + }, + }, + }, + responses: { + 201: { + description: "Corso creato", + content: { "application/json": { schema: corsoSchema } }, + }, + 403: responses.forbidden, + }, + }, + }, + "/api/corsi/{id}": { + get: { + tags: ["Corsi"], + summary: "Dettaglio corso", + security: [{ cookieAuth: [] }], + parameters: [idParam], + responses: { + 200: { + description: "Corso trovato", + content: { "application/json": { schema: corsoSchema } }, + }, + 404: responses.notFound, + }, + }, + put: { + tags: ["Corsi"], + summary: "Modifica corso", + security: [{ cookieAuth: [] }], + parameters: [idParam], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CorsoCreate" }, + }, + }, + }, + responses: { + 200: { + description: "Corso aggiornato", + content: { "application/json": { schema: corsoSchema } }, + }, + 403: responses.forbidden, + }, + }, + delete: { + tags: ["Corsi"], + summary: "Elimina corso", + security: [{ cookieAuth: [] }], + parameters: [idParam], + responses: { + 204: { description: "Corso eliminato" }, + 409: { description: "Corso con assegnazioni collegate" }, + }, + }, + }, + "/api/corsi/{id}/disattiva": { + put: { + tags: ["Corsi"], + summary: "Disattiva corso", + security: [{ cookieAuth: [] }], + parameters: [idParam], + responses: { + 200: { + description: "Corso disattivato", + content: { "application/json": { schema: corsoSchema } }, + }, + }, + }, + }, + "/api/assegnazioni-corsi": { + get: { + tags: ["Assegnazioni"], + summary: "Elenco assegnazioni", + security: [{ cookieAuth: [] }], + parameters: [ + { + name: "stato", + in: "query", + schema: { + type: "string", + enum: ["Assegnato", "Completato", "Scaduto", "Annullato"], + }, + }, + { name: "categoria", in: "query", schema: { type: "string" } }, + { name: "corsoId", in: "query", schema: { type: "integer" } }, + { + name: "dipendenteId", + in: "query", + schema: { type: "integer" }, + description: "Solo Referente Academy", + }, + ], + responses: { + 200: { + description: "Lista assegnazioni", + content: { + "application/json": { + schema: { type: "array", items: assegnazioneSchema }, + }, + }, + }, + }, + }, + post: { + tags: ["Assegnazioni"], + summary: "Assegna corso a dipendente", + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AssegnazioneCreate" }, + }, + }, + }, + responses: { + 201: { + description: "Assegnazione creata", + content: { "application/json": { schema: assegnazioneSchema } }, + }, + 403: responses.forbidden, + }, + }, + }, + "/api/assegnazioni-corsi/{id}": { + get: { + tags: ["Assegnazioni"], + summary: "Dettaglio assegnazione", + security: [{ cookieAuth: [] }], + parameters: [idParam], + responses: { + 200: { + description: "Assegnazione trovata", + content: { "application/json": { schema: assegnazioneSchema } }, + }, + 403: responses.forbidden, + 404: responses.notFound, + }, + }, + put: { + tags: ["Assegnazioni"], + summary: "Modifica assegnazione", + security: [{ cookieAuth: [] }], + parameters: [idParam], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + corsoId: { type: "integer" }, + dipendenteId: { type: "integer" }, + dataAssegnazione: { type: "string", format: "date" }, + dataScadenza: { type: "string", format: "date" }, + stato: { + type: "string", + enum: ["Assegnato", "Completato", "Scaduto", "Annullato"], + }, + }, + }, + } + }, + }, + responses: { + 200: { + description: "Assegnazione aggiornata", + content: { "application/json": { schema: assegnazioneSchema } }, + }, + }, + }, + delete: { + tags: ["Assegnazioni"], + summary: "Elimina assegnazione", + security: [{ cookieAuth: [] }], + parameters: [idParam], + responses: { + 204: { description: "Assegnazione eliminata" }, + }, + }, + }, + "/api/assegnazioni-corsi/{id}/completa": { + put: { + tags: ["Assegnazioni"], + summary: "Segna assegnazione come completata", + description: "Solo il dipendente titolare può completare la propria assegnazione.", + security: [{ cookieAuth: [] }], + parameters: [idParam], + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AssegnazioneCompleta" }, + }, + }, + }, + responses: { + 200: { + description: "Assegnazione completata", + content: { "application/json": { schema: assegnazioneSchema } }, + }, + 403: responses.forbidden, + }, + }, + }, + "/api/assegnazioni-corsi/{id}/annulla": { + put: { + tags: ["Assegnazioni"], + summary: "Annulla assegnazione", + security: [{ cookieAuth: [] }], + parameters: [idParam], + responses: { + 200: { + description: "Assegnazione annullata", + content: { "application/json": { schema: assegnazioneSchema } }, + }, + }, + }, + }, + "/api/statistiche/academy": { + get: { + tags: ["Statistiche"], + summary: "Riepilogo formazione per mese e categoria", + security: [{ cookieAuth: [] }], + parameters: [ + { + name: "mese", + in: "query", + schema: { type: "string", example: "2026-05" }, + description: "Filtra per mese (YYYY-MM)", + }, + { + name: "daMese", + in: "query", + schema: { type: "string", example: "2026-01" }, + }, + { + name: "aMese", + in: "query", + schema: { type: "string", example: "2026-12" }, + }, + { name: "categoria", in: "query", schema: { type: "string" } }, + { name: "dipendenteId", in: "query", schema: { type: "integer" } }, + ], + responses: { + 200: { + description: "Statistiche aggregate", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/StatisticaAcademy" }, + }, + }, + }, + }, + 403: responses.forbidden, + }, + }, + }, + }, +} as const; diff --git a/src/config/swagger.ts b/src/config/swagger.ts new file mode 100644 index 0000000..4127acd --- /dev/null +++ b/src/config/swagger.ts @@ -0,0 +1,27 @@ +//======================================== +// File: swagger.ts +// Script per la configurazione di swagger +// @author: "villari.andrea@libeor.it" +// @version: "1.0.0 2026-07-14" +//======================================== +import type { Express } from "express"; +import swaggerUi from "swagger-ui-express"; +import { openApiSpec } from "./openapi.js"; + +export function setupSwagger(app: Express): void { + app.get("/api/docs.json", (_req, res) => { + res.json(openApiSpec); + }); + + app.use( + "/api/docs", + swaggerUi.serve, + swaggerUi.setup(openApiSpec, { + customSiteTitle: "Esame ITS 24-26 – API Academy", + swaggerOptions: { + persistAuthorization: true, + withCredentials: true, + }, + }) + ); +} diff --git a/src/index.ts b/src/index.ts index f6d22b5..1c8cde5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,19 @@ +//==================================== +// File: index.ts +// Script per la gestione delle rotte +// @author: "villari.andrea@libero.it" +// @version: "1.0.0 2026-07-14" +//==================================== import { ExpressAuth } from "@auth/express"; import dotenv from "dotenv"; import express from "express"; import { authConfig } from "./auth/config.js"; import { corsMiddleware } from "./config/cors.js"; +import { setupSwagger } from "./config/swagger.js"; +import assegnazioniCorsiRouter from "./routes/assegnazioni-corsi.js"; +import corsiRouter from "./routes/corsi.js"; import meRouter from "./routes/me.js"; +import statisticheRouter from "./routes/statistiche.js"; import utentiRouter from "./routes/utenti.js"; dotenv.config(); @@ -14,8 +24,11 @@ const port = Number(process.env.PORT ?? 3001); app.set("trust proxy", 1); app.use(corsMiddleware); app.use(express.json()); +app.use(express.urlencoded({ extended: true })); app.options(/.*/, corsMiddleware); +setupSwagger(app); + app.get("/api/health", (_req, res) => { res.json({ status: "ok" }); }); @@ -23,7 +36,15 @@ app.get("/api/health", (_req, res) => { app.use("/api/auth", ExpressAuth(authConfig)); app.use("/api/me", meRouter); app.use("/api/utenti", utentiRouter); +app.use("/api/corsi", corsiRouter); +app.use("/api/assegnazioni-corsi", assegnazioniCorsiRouter); +app.use("/api/statistiche", statisticheRouter); + +app.use("/api", (_req, res) => { + res.status(404).json({ error: "Endpoint non trovato" }); +}); app.listen(port, "0.0.0.0", () => { console.log(`Server in ascolto sulla porta ${port}`); + console.log(`Documentazione API: http://localhost:${port}/api/docs`); }); diff --git a/src/routes/assegnazioni-corsi.ts b/src/routes/assegnazioni-corsi.ts new file mode 100644 index 0000000..c19ebcb --- /dev/null +++ b/src/routes/assegnazioni-corsi.ts @@ -0,0 +1,535 @@ +import { Router } from "express"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import pool from "../db/pool.js"; +import { mapDbError } from "../errors/db.js"; +import { requireAuth, requireReferenteAcademy } from "../middleware/auth.js"; +import type { Ruolo, StatoAssegnazione } from "../types/index.js"; +import { + validateAssegnazioneCompleta, + validateAssegnazioneCreate, + validateAssegnazioneUpdate, + validateCompletamentoDate, + validateStatoWithCompletamento, + type AssegnazioneCompletaInput, + type AssegnazioneCreateInput, + type AssegnazioneUpdateInput, +} from "../validation/assegnazione.js"; +import { ASSEGNAZIONE_CONSTRAINTS } from "../validation/constraints.js"; +import { validateBody } from "../validation/middleware.js"; + +const router = Router(); + +interface AssegnazioneRow extends RowDataPacket { + AssegnazioneID: number; + CorsoID: number; + DipendenteID: number; + DataAssegnazione: Date | string; + DataScadenza: Date | string; + Stato: StatoAssegnazione; + DataCompletamento: Date | string | null; + CorsoTitolo: string; + CorsoCategoria: string; + CorsoDurataOre: number; + DipendenteNome: string; + DipendenteCognome: string; + DipendenteEmail: string; +} + +interface UtenteRow extends RowDataPacket { + UtenteID: number; + Ruolo: Ruolo; +} + +interface CorsoRow extends RowDataPacket { + CorsoID: number; + Attivo: number; +} + +const SELECT_ASSEGNAZIONE = ` + SELECT + a.AssegnazioneID, + a.CorsoID, + a.DipendenteID, + a.DataAssegnazione, + a.DataScadenza, + a.Stato, + a.DataCompletamento, + c.Titolo AS CorsoTitolo, + c.Categoria AS CorsoCategoria, + c.DurataOre AS CorsoDurataOre, + u.Nome AS DipendenteNome, + u.Cognome AS DipendenteCognome, + u.Email AS DipendenteEmail + FROM assegnazione_corso a + INNER JOIN corso c ON c.CorsoID = a.CorsoID + INNER JOIN utente u ON u.UtenteID = a.DipendenteID +`; + +function formatDate(value: Date | string | null): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString().slice(0, 10); + return value.toString().slice(0, 10); +} + +function todayIsoDate(): string { + return new Date().toISOString().slice(0, 10); +} + +function parseIdParam(value: string | string[]): number | null { + const raw = Array.isArray(value) ? value[0] : value; + const id = Number(raw); + if (!Number.isInteger(id) || id <= 0) return null; + return id; +} + +function isReferenteAcademy(ruolo: Ruolo | undefined): boolean { + return ruolo === "Referente Academy"; +} + +function toAssegnazioneResponse(row: AssegnazioneRow) { + return { + id: row.AssegnazioneID, + corsoId: row.CorsoID, + dipendenteId: row.DipendenteID, + dataAssegnazione: formatDate(row.DataAssegnazione), + dataScadenza: formatDate(row.DataScadenza), + stato: row.Stato, + dataCompletamento: formatDate(row.DataCompletamento), + corso: { + id: row.CorsoID, + titolo: row.CorsoTitolo, + categoria: row.CorsoCategoria, + durataOre: row.CorsoDurataOre, + }, + dipendente: { + id: row.DipendenteID, + nome: row.DipendenteNome, + cognome: row.DipendenteCognome, + email: row.DipendenteEmail, + }, + }; +} + +async function findAssegnazioneById(id: number): Promise { + const [rows] = await pool.execute( + `${SELECT_ASSEGNAZIONE} WHERE a.AssegnazioneID = ?`, + [id] + ); + return rows[0]; +} + +async function findUtenteById(id: number): Promise { + const [rows] = await pool.execute( + "SELECT UtenteID, Ruolo FROM utente WHERE UtenteID = ?", + [id] + ); + return rows[0]; +} + +async function findCorsoById(id: number): Promise { + const [rows] = await pool.execute( + "SELECT CorsoID, Attivo FROM corso WHERE CorsoID = ?", + [id] + ); + return rows[0]; +} + +function canAccessAssegnazione( + userId: number, + ruolo: Ruolo | undefined, + dipendenteId: number +): boolean { + return isReferenteAcademy(ruolo) || userId === dipendenteId; +} + +router.use(requireAuth); + +router.get("/", async (req, res) => { + const conditions: string[] = []; + const values: (string | number)[] = []; + + if (!isReferenteAcademy(req.user?.ruolo)) { + conditions.push("a.DipendenteID = ?"); + values.push(req.user!.id); + } else if (req.query.dipendenteId) { + const dipendenteId = parseIdParam(req.query.dipendenteId.toString()); + if (!dipendenteId) { + res.status(400).json({ error: "ID dipendente non valido" }); + return; + } + conditions.push("a.DipendenteID = ?"); + values.push(dipendenteId); + } + + const stato = req.query.stato?.toString().trim(); + if (stato) { + if (!ASSEGNAZIONE_CONSTRAINTS.stato.allowedValues.includes(stato as StatoAssegnazione)) { + res.status(400).json({ error: "Stato non valido" }); + return; + } + conditions.push("a.Stato = ?"); + values.push(stato); + } + + const categoria = req.query.categoria?.toString().trim(); + if (categoria) { + conditions.push("c.Categoria = ?"); + values.push(categoria); + } + + if (req.query.corsoId) { + const corsoId = parseIdParam(req.query.corsoId.toString()); + if (!corsoId) { + res.status(400).json({ error: "ID corso non valido" }); + return; + } + conditions.push("a.CorsoID = ?"); + values.push(corsoId); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const [rows] = await pool.execute( + `${SELECT_ASSEGNAZIONE} + ${whereClause} + ORDER BY a.DataScadenza ASC, a.AssegnazioneID DESC`, + values + ); + + res.json(rows.map(toAssegnazioneResponse)); +}); + +router.get("/:id", async (req, res) => { + const id = parseIdParam(req.params.id); + if (!id) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const assegnazione = await findAssegnazioneById(id); + if (!assegnazione) { + res.status(404).json({ error: "Assegnazione non trovata" }); + return; + } + + if (!canAccessAssegnazione(req.user!.id, req.user?.ruolo, assegnazione.DipendenteID)) { + res.status(403).json({ error: "Non autorizzato ad accedere a questa assegnazione" }); + return; + } + + res.json(toAssegnazioneResponse(assegnazione)); +}); + +router.post("/", requireReferenteAcademy, validateBody(validateAssegnazioneCreate), async (_req, res) => { + const body = res.locals.validatedBody as AssegnazioneCreateInput; + + const corso = await findCorsoById(body.corsoId); + if (!corso) { + res.status(400).json({ + error: "Corso non valido", + fields: { corsoId: "Corso non trovato" }, + }); + return; + } + if (!corso.Attivo) { + res.status(400).json({ + error: "Corso non disponibile", + fields: { corsoId: "Un corso non attivo non può essere assegnato" }, + }); + return; + } + + const dipendente = await findUtenteById(body.dipendenteId); + if (!dipendente) { + res.status(400).json({ + error: "Dipendente non valido", + fields: { dipendenteId: "Dipendente non trovato" }, + }); + return; + } + if (dipendente.Ruolo !== "Dipendente") { + res.status(400).json({ + error: "Dipendente non valido", + fields: { dipendenteId: "L'assegnazione deve essere associata a un dipendente" }, + }); + return; + } + + try { + const [result] = await pool.execute( + `INSERT INTO assegnazione_corso + (CorsoID, DipendenteID, DataAssegnazione, DataScadenza, Stato, DataCompletamento) + VALUES (?, ?, ?, ?, 'Assegnato', NULL)`, + [body.corsoId, body.dipendenteId, body.dataAssegnazione, body.dataScadenza] + ); + + const created = await findAssegnazioneById(result.insertId); + if (!created) { + res.status(500).json({ error: "Errore durante la creazione dell'assegnazione" }); + return; + } + + res.status(201).json(toAssegnazioneResponse(created)); + } catch (error) { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ + error: mapped.error, + fields: mapped.fields, + }); + return; + } + throw error; + } +}); + +router.put("/:id/completa", validateBody(validateAssegnazioneCompleta), async (req, res) => { + const id = parseIdParam(req.params.id); + if (!id) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const assegnazione = await findAssegnazioneById(id); + if (!assegnazione) { + res.status(404).json({ error: "Assegnazione non trovata" }); + return; + } + + if (req.user!.id !== assegnazione.DipendenteID) { + res.status(403).json({ error: "Puoi completare solo i corsi assegnati a te" }); + return; + } + + if (assegnazione.Stato === "Annullato") { + res.status(400).json({ error: "L'assegnazione è stata annullata" }); + return; + } + + if (assegnazione.Stato === "Completato") { + res.status(400).json({ error: "Il corso risulta già completato" }); + return; + } + + const body = res.locals.validatedBody as AssegnazioneCompletaInput; + const dataCompletamento = body.dataCompletamento ?? todayIsoDate(); + const dataAssegnazione = formatDate(assegnazione.DataAssegnazione)!; + const completamentoError = validateCompletamentoDate(dataAssegnazione, dataCompletamento); + if (completamentoError) { + res.status(400).json({ + error: "Data completamento non valida", + fields: { dataCompletamento: completamentoError }, + }); + return; + } + + try { + await pool.execute( + `UPDATE assegnazione_corso + SET Stato = 'Completato', DataCompletamento = ? + WHERE AssegnazioneID = ?`, + [dataCompletamento, id] + ); + + const updated = await findAssegnazioneById(id); + res.json(toAssegnazioneResponse(updated!)); + } catch (error) { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ + error: mapped.error, + fields: mapped.fields, + }); + return; + } + throw error; + } +}); + +router.put("/:id/annulla", requireReferenteAcademy, async (req, res) => { + const id = parseIdParam(req.params.id); + if (!id) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const assegnazione = await findAssegnazioneById(id); + if (!assegnazione) { + res.status(404).json({ error: "Assegnazione non trovata" }); + return; + } + + if (assegnazione.Stato === "Annullato") { + res.status(400).json({ error: "L'assegnazione è già annullata" }); + return; + } + + await pool.execute( + `UPDATE assegnazione_corso + SET Stato = 'Annullato', DataCompletamento = NULL + WHERE AssegnazioneID = ?`, + [id] + ); + + const updated = await findAssegnazioneById(id); + res.json(toAssegnazioneResponse(updated!)); +}); + +router.put("/:id", requireReferenteAcademy, validateBody(validateAssegnazioneUpdate), async (req, res) => { + const id = parseIdParam(req.params.id); + if (!id) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findAssegnazioneById(id); + if (!existing) { + res.status(404).json({ error: "Assegnazione non trovata" }); + return; + } + + const updates = res.locals.validatedBody as AssegnazioneUpdateInput; + const nextCorsoId = updates.corsoId ?? existing.CorsoID; + const nextDipendenteId = updates.dipendenteId ?? existing.DipendenteID; + const nextDataAssegnazione = + updates.dataAssegnazione ?? formatDate(existing.DataAssegnazione)!; + const nextDataScadenza = updates.dataScadenza ?? formatDate(existing.DataScadenza)!; + const nextStato = updates.stato ?? existing.Stato; + let nextDataCompletamento = formatDate(existing.DataCompletamento); + + if (nextDataScadenza < nextDataAssegnazione) { + res.status(400).json({ + error: "Date assegnazione non valide", + fields: { + dataScadenza: + "La data di scadenza non può essere precedente alla data di assegnazione", + }, + }); + return; + } + + if (updates.corsoId !== undefined) { + const corso = await findCorsoById(nextCorsoId); + if (!corso) { + res.status(400).json({ + error: "Corso non valido", + fields: { corsoId: "Corso non trovato" }, + }); + return; + } + if (!corso.Attivo && nextCorsoId !== existing.CorsoID) { + res.status(400).json({ + error: "Corso non disponibile", + fields: { corsoId: "Un corso non attivo non può essere assegnato" }, + }); + return; + } + } + + if (updates.dipendenteId !== undefined) { + const dipendente = await findUtenteById(nextDipendenteId); + if (!dipendente || dipendente.Ruolo !== "Dipendente") { + res.status(400).json({ + error: "Dipendente non valido", + fields: { dipendenteId: "L'assegnazione deve essere associata a un dipendente" }, + }); + return; + } + } + + if (updates.stato !== undefined) { + if (nextStato === "Completato") { + nextDataCompletamento = nextDataCompletamento ?? todayIsoDate(); + } else { + nextDataCompletamento = null; + } + } + + const statoError = validateStatoWithCompletamento(nextStato, nextDataCompletamento); + if (statoError) { + res.status(400).json({ + error: "Stato assegnazione non coerente", + fields: { stato: statoError }, + }); + return; + } + + if (nextDataCompletamento) { + const completamentoError = validateCompletamentoDate( + nextDataAssegnazione, + nextDataCompletamento + ); + if (completamentoError) { + res.status(400).json({ + error: "Data completamento non valida", + fields: { dataCompletamento: completamentoError }, + }); + return; + } + } + + const setClauses: string[] = []; + const values: (string | number | null)[] = []; + + if (updates.corsoId !== undefined) { + setClauses.push("CorsoID = ?"); + values.push(nextCorsoId); + } + if (updates.dipendenteId !== undefined) { + setClauses.push("DipendenteID = ?"); + values.push(nextDipendenteId); + } + if (updates.dataAssegnazione !== undefined) { + setClauses.push("DataAssegnazione = ?"); + values.push(nextDataAssegnazione); + } + if (updates.dataScadenza !== undefined) { + setClauses.push("DataScadenza = ?"); + values.push(nextDataScadenza); + } + if (updates.stato !== undefined) { + setClauses.push("Stato = ?"); + values.push(nextStato); + setClauses.push("DataCompletamento = ?"); + values.push(nextDataCompletamento); + } + + try { + await pool.execute( + `UPDATE assegnazione_corso SET ${setClauses.join(", ")} WHERE AssegnazioneID = ?`, + [...values, id] + ); + + const updated = await findAssegnazioneById(id); + res.json(toAssegnazioneResponse(updated!)); + } catch (error) { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ + error: mapped.error, + fields: mapped.fields, + }); + return; + } + throw error; + } +}); + +router.delete("/:id", requireReferenteAcademy, async (req, res) => { + const id = parseIdParam(req.params.id); + if (!id) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findAssegnazioneById(id); + if (!existing) { + res.status(404).json({ error: "Assegnazione non trovata" }); + return; + } + + await pool.execute("DELETE FROM assegnazione_corso WHERE AssegnazioneID = ?", [id]); + res.status(204).send(); +}); + +export default router; diff --git a/src/routes/corsi.ts b/src/routes/corsi.ts new file mode 100644 index 0000000..3fd47ae --- /dev/null +++ b/src/routes/corsi.ts @@ -0,0 +1,258 @@ +import { Router } from "express"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import pool from "../db/pool.js"; +import { mapDbError } from "../errors/db.js"; +import { requireAuth, requireReferenteAcademy } from "../middleware/auth.js"; +import { validateBody } from "../validation/middleware.js"; +import { + validateCorsoCreate, + validateCorsoUpdate, + type CorsoCreateInput, + type CorsoUpdateInput, +} from "../validation/corso.js"; + +const router = Router(); + +interface CorsoRow extends RowDataPacket { + CorsoID: number; + Titolo: string; + Descrizione: string | null; + Categoria: string; + DurataOre: number; + Obbligatorio: number; + Attivo: number; +} + +function toCorsoResponse(row: CorsoRow) { + return { + id: row.CorsoID, + titolo: row.Titolo, + descrizione: row.Descrizione, + categoria: row.Categoria, + durataOre: row.DurataOre, + obbligatorio: Boolean(row.Obbligatorio), + attivo: Boolean(row.Attivo), + }; +} + +function parseIdParam(value: string | string[]): number | null { + const raw = Array.isArray(value) ? value[0] : value; + const id = Number(raw); + if (!Number.isInteger(id) || id <= 0) return null; + return id; +} + +function parseAttivoFilter(value: unknown): boolean | null { + if (value === "true" || value === true) return true; + if (value === "false" || value === false) return false; + return null; +} + +async function findCorsoById(id: number): Promise { + const [rows] = await pool.execute( + `SELECT CorsoID, Titolo, Descrizione, Categoria, DurataOre, Obbligatorio, Attivo + FROM corso + WHERE CorsoID = ?`, + [id] + ); + return rows[0]; +} + +router.use(requireAuth); + +router.get("/", async (req, res) => { + const conditions: string[] = []; + const values: (string | number)[] = []; + + const categoria = req.query.categoria?.toString().trim(); + if (categoria) { + conditions.push("Categoria = ?"); + values.push(categoria); + } + + const attivo = parseAttivoFilter(req.query.attivo); + if (attivo !== null) { + conditions.push("Attivo = ?"); + values.push(attivo ? 1 : 0); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const [rows] = await pool.execute( + `SELECT CorsoID, Titolo, Descrizione, Categoria, DurataOre, Obbligatorio, Attivo + FROM corso + ${whereClause} + ORDER BY Categoria, Titolo`, + values + ); + + res.json(rows.map(toCorsoResponse)); +}); + +router.get("/:id", async (req, res) => { + const id = parseIdParam(req.params.id); + if (!id) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const corso = await findCorsoById(id); + if (!corso) { + res.status(404).json({ error: "Corso non trovato" }); + return; + } + + res.json(toCorsoResponse(corso)); +}); + +router.post("/", requireReferenteAcademy, validateBody(validateCorsoCreate), async (_req, res) => { + const body = res.locals.validatedBody as CorsoCreateInput; + + try { + const [result] = await pool.execute( + `INSERT INTO corso (Titolo, Descrizione, Categoria, DurataOre, Obbligatorio, Attivo) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + body.titolo, + body.descrizione, + body.categoria, + body.durataOre, + body.obbligatorio ? 1 : 0, + body.attivo ? 1 : 0, + ] + ); + + const created = await findCorsoById(result.insertId); + if (!created) { + res.status(500).json({ error: "Errore durante la creazione del corso" }); + return; + } + + res.status(201).json(toCorsoResponse(created)); + } catch (error) { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ + error: mapped.error, + fields: mapped.fields, + }); + return; + } + throw error; + } +}); + +router.put("/:id", requireReferenteAcademy, validateBody(validateCorsoUpdate), async (req, res) => { + const id = parseIdParam(req.params.id); + if (!id) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findCorsoById(id); + if (!existing) { + res.status(404).json({ error: "Corso non trovato" }); + return; + } + + const updates = res.locals.validatedBody as CorsoUpdateInput; + const setClauses: string[] = []; + const values: (string | number | null)[] = []; + + if (updates.titolo !== undefined) { + setClauses.push("Titolo = ?"); + values.push(updates.titolo); + } + if (updates.descrizione !== undefined) { + setClauses.push("Descrizione = ?"); + values.push(updates.descrizione); + } + if (updates.categoria !== undefined) { + setClauses.push("Categoria = ?"); + values.push(updates.categoria); + } + if (updates.durataOre !== undefined) { + setClauses.push("DurataOre = ?"); + values.push(updates.durataOre); + } + if (updates.obbligatorio !== undefined) { + setClauses.push("Obbligatorio = ?"); + values.push(updates.obbligatorio ? 1 : 0); + } + if (updates.attivo !== undefined) { + setClauses.push("Attivo = ?"); + values.push(updates.attivo ? 1 : 0); + } + + try { + await pool.execute( + `UPDATE corso SET ${setClauses.join(", ")} WHERE CorsoID = ?`, + [...values, id] + ); + + const updated = await findCorsoById(id); + if (!updated) { + res.status(500).json({ error: "Errore durante l'aggiornamento del corso" }); + return; + } + + res.json(toCorsoResponse(updated)); + } catch (error) { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ + error: mapped.error, + fields: mapped.fields, + }); + return; + } + throw error; + } +}); + +router.put("/:id/disattiva", requireReferenteAcademy, async (req, res) => { + const id = parseIdParam(req.params.id); + if (!id) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findCorsoById(id); + if (!existing) { + res.status(404).json({ error: "Corso non trovato" }); + return; + } + + await pool.execute("UPDATE corso SET Attivo = 0 WHERE CorsoID = ?", [id]); + + const updated = await findCorsoById(id); + res.json(toCorsoResponse(updated!)); +}); + +router.delete("/:id", requireReferenteAcademy, async (req, res) => { + const id = parseIdParam(req.params.id); + if (!id) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findCorsoById(id); + if (!existing) { + res.status(404).json({ error: "Corso non trovato" }); + return; + } + + try { + await pool.execute("DELETE FROM corso WHERE CorsoID = ?", [id]); + res.status(204).send(); + } catch (error) { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ error: mapped.error }); + return; + } + throw error; + } +}); + +export default router; diff --git a/src/routes/me.ts b/src/routes/me.ts index 02ea73e..1d9f5ae 100644 --- a/src/routes/me.ts +++ b/src/routes/me.ts @@ -12,9 +12,13 @@ import { requireAuth } from "../middleware/auth.js"; const router = Router(); router.get("/", requireAuth, (req, res) => { + const nameParts = (req.user!.name ?? "").trim().split(/\s+/); + res.json({ id: req.user!.id, email: req.user!.email, + nome: nameParts[0] ?? "", + cognome: nameParts.slice(1).join(" "), name: req.user!.name, ruolo: req.user!.ruolo, }); diff --git a/src/routes/statistiche.ts b/src/routes/statistiche.ts new file mode 100644 index 0000000..41b64c9 --- /dev/null +++ b/src/routes/statistiche.ts @@ -0,0 +1,122 @@ +import { Router } from "express"; +import type { RowDataPacket } from "mysql2"; +import pool from "../db/pool.js"; +import { requireAuth, requireReferenteAcademy } from "../middleware/auth.js"; + +const router = Router(); +const MESE_REGEX = /^\d{4}-\d{2}$/; + +interface StatisticaRow extends RowDataPacket { + mese: string; + categoria: string; + numeroAssegnazioni: number; + numeroCompletamenti: number; +} + +function parseMese(value: unknown): string | null { + if (!value) return null; + const mese = value.toString().trim(); + if (!MESE_REGEX.test(mese)) return null; + return mese; +} + +function parseDipendenteId(value: unknown): number | null { + if (!value) return null; + const id = Number(value); + if (!Number.isInteger(id) || id <= 0) return null; + return id; +} + +function calcolaPercentuale(assegnazioni: number, completamenti: number): number { + if (assegnazioni === 0) return 0; + return Math.round((completamenti / assegnazioni) * 10000) / 100; +} + +router.use(requireAuth, requireReferenteAcademy); + +router.get("/academy", async (req, res) => { + const mese = parseMese(req.query.mese); + const daMese = parseMese(req.query.daMese); + const aMese = parseMese(req.query.aMese); + const categoria = req.query.categoria?.toString().trim() || null; + const dipendenteId = parseDipendenteId(req.query.dipendenteId); + + if (req.query.mese && !mese) { + res.status(400).json({ error: "Formato mese non valido (atteso YYYY-MM)" }); + return; + } + if (req.query.daMese && !daMese) { + res.status(400).json({ error: "Formato daMese non valido (atteso YYYY-MM)" }); + return; + } + if (req.query.aMese && !aMese) { + res.status(400).json({ error: "Formato aMese non valido (atteso YYYY-MM)" }); + return; + } + if (daMese && aMese && daMese > aMese) { + res.status(400).json({ error: "Il periodo indicato non è valido" }); + return; + } + if (req.query.dipendenteId && !dipendenteId) { + res.status(400).json({ error: "ID dipendente non valido" }); + return; + } + + const conditions = ["a.Stato <> 'Annullato'"]; + const values: (string | number)[] = []; + + if (mese) { + conditions.push("DATE_FORMAT(a.DataAssegnazione, '%Y-%m') = ?"); + values.push(mese); + } + if (daMese) { + conditions.push("DATE_FORMAT(a.DataAssegnazione, '%Y-%m') >= ?"); + values.push(daMese); + } + if (aMese) { + conditions.push("DATE_FORMAT(a.DataAssegnazione, '%Y-%m') <= ?"); + values.push(aMese); + } + if (categoria) { + conditions.push("c.Categoria = ?"); + values.push(categoria); + } + if (dipendenteId) { + conditions.push("a.DipendenteID = ?"); + values.push(dipendenteId); + } + + const [rows] = await pool.execute( + `SELECT + DATE_FORMAT(a.DataAssegnazione, '%Y-%m') AS mese, + c.Categoria AS categoria, + COUNT(*) AS numeroAssegnazioni, + SUM(CASE WHEN a.Stato = 'Completato' THEN 1 ELSE 0 END) AS numeroCompletamenti + FROM assegnazione_corso a + INNER JOIN corso c ON c.CorsoID = a.CorsoID + WHERE ${conditions.join(" AND ")} + GROUP BY DATE_FORMAT(a.DataAssegnazione, '%Y-%m'), c.Categoria + ORDER BY mese DESC, categoria ASC`, + values + ); + + res.json( + rows.map((row) => { + const numeroAssegnazioni = Number(row.numeroAssegnazioni); + const numeroCompletamenti = Number(row.numeroCompletamenti); + + return { + mese: row.mese, + categoria: row.categoria, + numeroAssegnazioni, + numeroCompletamenti, + percentualeCompletamento: calcolaPercentuale( + numeroAssegnazioni, + numeroCompletamenti + ), + }; + }) + ); +}); + +export default router; diff --git a/src/routes/utenti.ts b/src/routes/utenti.ts index f74d377..1918ef7 100644 --- a/src/routes/utenti.ts +++ b/src/routes/utenti.ts @@ -11,6 +11,7 @@ import pool from "../db/pool.js"; import { mapDbError } from "../errors/db.js"; import { requireAuth, requireReferenteAcademy } from "../middleware/auth.js"; import type { Ruolo } from "../types/index.js"; +import { UTENTE_CONSTRAINTS } from "../validation/constraints.js"; import { validateBody } from "../validation/middleware.js"; import { validateUtenteCreate, @@ -49,9 +50,25 @@ async function findUtenteById(id: number): Promise { router.use(requireAuth, requireReferenteAcademy); -router.get("/", async (_req, res) => { +router.get("/", async (req, res) => { + const conditions: string[] = []; + const values: string[] = []; + + const ruolo = req.query.ruolo?.toString().trim(); + if (ruolo) { + if (!UTENTE_CONSTRAINTS.ruolo.allowedValues.includes(ruolo as Ruolo)) { + res.status(400).json({ error: "Ruolo non valido" }); + return; + } + conditions.push("Ruolo = ?"); + values.push(ruolo); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const [rows] = await pool.execute( - "SELECT UtenteID, Nome, Cognome, Email, Ruolo FROM utente ORDER BY Cognome, Nome" + `SELECT UtenteID, Nome, Cognome, Email, Ruolo FROM utente ${whereClause} ORDER BY Cognome, Nome`, + values ); res.json(rows.map(toUtenteResponse)); }); diff --git a/src/validation/assegnazione.ts b/src/validation/assegnazione.ts new file mode 100644 index 0000000..2620048 --- /dev/null +++ b/src/validation/assegnazione.ts @@ -0,0 +1,195 @@ +import type { StatoAssegnazione } from "../types/index.js"; +import { ASSEGNAZIONE_CONSTRAINTS } from "./constraints.js"; +import { + buildResult, + normalizePositiveInt, + normalizeString, + validateDate, + validateEnum, + validatePositiveInt, + validateRequired, + type FieldErrors, + type ValidationResult, +} from "./primitives.js"; + +export interface AssegnazioneCreateInput { + corsoId: number; + dipendenteId: number; + dataAssegnazione: string; + dataScadenza: string; +} + +export interface AssegnazioneUpdateInput { + corsoId?: number; + dipendenteId?: number; + dataAssegnazione?: string; + dataScadenza?: string; + stato?: StatoAssegnazione; +} + +export interface AssegnazioneCompletaInput { + dataCompletamento?: string; +} + +function validateDateRange( + errors: FieldErrors, + dataAssegnazione: string, + dataScadenza: string +): void { + if (!dataAssegnazione || !dataScadenza) return; + if (dataScadenza < dataAssegnazione) { + errors.dataScadenza = + "La data di scadenza non può essere precedente alla data di assegnazione"; + } +} + +function validateAssegnazioneDates( + data: AssegnazioneCreateInput, + errors: FieldErrors, + options: { requireAll: boolean } +): void { + if (options.requireAll || data.dataAssegnazione !== undefined) { + validateRequired( + errors, + "dataAssegnazione", + data.dataAssegnazione, + "Data di assegnazione obbligatoria" + ); + validateDate(errors, "dataAssegnazione", data.dataAssegnazione, "Data di assegnazione"); + } + + if (options.requireAll || data.dataScadenza !== undefined) { + validateRequired( + errors, + "dataScadenza", + data.dataScadenza, + "Data di scadenza obbligatoria" + ); + validateDate(errors, "dataScadenza", data.dataScadenza, "Data di scadenza"); + } + + if (data.dataAssegnazione && data.dataScadenza) { + validateDateRange(errors, data.dataAssegnazione, data.dataScadenza); + } +} + +export function validateAssegnazioneCreate( + raw: Record +): ValidationResult { + const data: AssegnazioneCreateInput = { + corsoId: normalizePositiveInt(raw.corsoId), + dipendenteId: normalizePositiveInt(raw.dipendenteId), + dataAssegnazione: normalizeString(raw.dataAssegnazione), + dataScadenza: normalizeString(raw.dataScadenza), + }; + const errors: FieldErrors = {}; + + validatePositiveInt(errors, "corsoId", data.corsoId, "Corso"); + validatePositiveInt(errors, "dipendenteId", data.dipendenteId, "Dipendente"); + validateAssegnazioneDates(data, errors, { requireAll: true }); + + return buildResult(data, errors); +} + +export function validateAssegnazioneUpdate( + raw: Record +): ValidationResult { + const errors: FieldErrors = {}; + const data: AssegnazioneUpdateInput = {}; + + if ("corsoId" in raw) { + data.corsoId = normalizePositiveInt(raw.corsoId); + validatePositiveInt(errors, "corsoId", data.corsoId, "Corso"); + } + if ("dipendenteId" in raw) { + data.dipendenteId = normalizePositiveInt(raw.dipendenteId); + validatePositiveInt(errors, "dipendenteId", data.dipendenteId, "Dipendente"); + } + if ("dataAssegnazione" in raw) { + data.dataAssegnazione = normalizeString(raw.dataAssegnazione); + validateRequired( + errors, + "dataAssegnazione", + data.dataAssegnazione, + "Data di assegnazione obbligatoria" + ); + validateDate(errors, "dataAssegnazione", data.dataAssegnazione, "Data di assegnazione"); + } + if ("dataScadenza" in raw) { + data.dataScadenza = normalizeString(raw.dataScadenza); + validateRequired( + errors, + "dataScadenza", + data.dataScadenza, + "Data di scadenza obbligatoria" + ); + validateDate(errors, "dataScadenza", data.dataScadenza, "Data di scadenza"); + } + if ("stato" in raw) { + data.stato = normalizeString(raw.stato) as StatoAssegnazione; + validateRequired(errors, "stato", data.stato, "Stato obbligatorio"); + validateEnum( + errors, + "stato", + data.stato, + ASSEGNAZIONE_CONSTRAINTS.stato.allowedValues, + "Stato" + ); + } + + if (Object.keys(data).length === 0) { + errors._form = "Nessun campo da aggiornare"; + } else if (data.dataAssegnazione && data.dataScadenza) { + validateDateRange(errors, data.dataAssegnazione, data.dataScadenza); + } + + return buildResult(data, errors); +} + +export function validateAssegnazioneCompleta( + raw: Record +): ValidationResult { + const errors: FieldErrors = {}; + const data: AssegnazioneCompletaInput = {}; + + if ("dataCompletamento" in raw) { + data.dataCompletamento = normalizeString(raw.dataCompletamento); + validateRequired( + errors, + "dataCompletamento", + data.dataCompletamento, + "Data di completamento obbligatoria" + ); + validateDate( + errors, + "dataCompletamento", + data.dataCompletamento, + "Data di completamento" + ); + } + + return buildResult(data, errors); +} + +export function validateCompletamentoDate( + dataAssegnazione: string, + dataCompletamento: string +): string | null { + if (dataCompletamento < dataAssegnazione) { + return "La data di completamento non può essere precedente alla data di assegnazione"; + } + return null; +} + +export function validateStatoWithCompletamento( + stato: StatoAssegnazione, + dataCompletamento: string | null +): string | null { + if (stato === "Completato" && !dataCompletamento) { + return "Lo stato Completato richiede una data di completamento"; + } + if (stato !== "Completato" && dataCompletamento) { + return "La data di completamento è consentita solo per lo stato Completato"; + } + return null; +} diff --git a/src/validation/corso.ts b/src/validation/corso.ts new file mode 100644 index 0000000..988b643 --- /dev/null +++ b/src/validation/corso.ts @@ -0,0 +1,129 @@ +import { CORSO_CONSTRAINTS } from "./constraints.js"; +import { + buildResult, + normalizeBoolean, + normalizePositiveInt, + normalizeString, + validateBoolean, + validateMaxLength, + validatePositiveInt, + validateRequired, + type FieldErrors, + type ValidationResult, +} from "./primitives.js"; + +export interface CorsoCreateInput { + titolo: string; + descrizione: string | null; + categoria: string; + durataOre: number; + obbligatorio: boolean; + attivo: boolean; +} + +export type CorsoUpdateInput = Partial; + +function validateCorsoFields( + data: CorsoCreateInput, + errors: FieldErrors, + options: { requireAll: boolean } +): void { + if (options.requireAll || data.titolo !== undefined) { + validateRequired(errors, "titolo", data.titolo, "Titolo obbligatorio"); + validateMaxLength( + errors, + "titolo", + data.titolo, + CORSO_CONSTRAINTS.titolo.maxLength, + "Titolo" + ); + } + + if (options.requireAll || data.categoria !== undefined) { + validateRequired(errors, "categoria", data.categoria, "Categoria obbligatoria"); + validateMaxLength( + errors, + "categoria", + data.categoria, + CORSO_CONSTRAINTS.categoria.maxLength, + "Categoria" + ); + } + + if (options.requireAll || data.durataOre !== undefined) { + validatePositiveInt(errors, "durataOre", data.durataOre, "Durata prevista"); + if ( + !Number.isNaN(data.durataOre) && + data.durataOre > CORSO_CONSTRAINTS.durataOre.max + ) { + errors.durataOre = "Durata prevista non valida"; + } + } + + if (data.descrizione) { + validateMaxLength( + errors, + "descrizione", + data.descrizione, + CORSO_CONSTRAINTS.descrizione.maxLength, + "Descrizione" + ); + } +} + +export function validateCorsoCreate( + raw: Record +): ValidationResult { + const data: CorsoCreateInput = { + titolo: normalizeString(raw.titolo), + descrizione: normalizeString(raw.descrizione) || null, + categoria: normalizeString(raw.categoria), + durataOre: normalizePositiveInt(raw.durataOre), + obbligatorio: normalizeBoolean(raw.obbligatorio, false), + attivo: normalizeBoolean(raw.attivo, true), + }; + const errors: FieldErrors = {}; + + validateBoolean(errors, "obbligatorio", raw.obbligatorio, "Obbligatorietà"); + validateBoolean(errors, "attivo", raw.attivo, "Stato attivo"); + validateCorsoFields(data, errors, { requireAll: true }); + + return buildResult(data, errors); +} + +export function validateCorsoUpdate( + raw: Record +): ValidationResult { + const errors: FieldErrors = {}; + const data: CorsoUpdateInput = {}; + + if ("titolo" in raw) { + data.titolo = normalizeString(raw.titolo); + } + if ("descrizione" in raw) { + const descrizione = normalizeString(raw.descrizione); + data.descrizione = descrizione || null; + } + if ("categoria" in raw) { + data.categoria = normalizeString(raw.categoria); + } + if ("durataOre" in raw) { + data.durataOre = normalizePositiveInt(raw.durataOre); + } + if ("obbligatorio" in raw) { + validateBoolean(errors, "obbligatorio", raw.obbligatorio, "Obbligatorietà"); + data.obbligatorio = normalizeBoolean(raw.obbligatorio, false); + } + if ("attivo" in raw) { + validateBoolean(errors, "attivo", raw.attivo, "Stato attivo"); + data.attivo = normalizeBoolean(raw.attivo, true); + } + + if (Object.keys(data).length === 0) { + errors._form = "Nessun campo da aggiornare"; + } else { + validateCorsoFields(data as CorsoCreateInput, errors, { requireAll: false }); + } + + return buildResult(data, errors); +}