installazione Swagger e inserimento rotte corsi.ts e assegnazione-corsi.ts
This commit is contained in:
parent
46f3fa6efc
commit
0d2e1544cd
13 changed files with 2062 additions and 4 deletions
46
package-lock.json
generated
46
package-lock.json
generated
|
|
@ -10,13 +10,15 @@
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"mysql2": "^3.22.6"
|
"mysql2": "^3.22.6",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"tsx": "^4.23.0",
|
"tsx": "^4.23.0",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -513,6 +515,13 @@
|
||||||
"url": "https://github.com/sponsors/panva"
|
"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": {
|
"node_modules/@types/bcryptjs": {
|
||||||
"version": "2.4.6",
|
"version": "2.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
|
@ -627,6 +636,17 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
|
@ -1993,6 +2013,30 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"mysql2": "^3.22.6"
|
"mysql2": "^3.22.6",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"tsx": "^4.23.0",
|
"tsx": "^4.23.0",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
18
scripts/apply-academy-seed.sql
Normal file
18
scripts/apply-academy-seed.sql
Normal file
|
|
@ -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);
|
||||||
686
src/config/openapi.ts
Normal file
686
src/config/openapi.ts
Normal file
|
|
@ -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;
|
||||||
27
src/config/swagger.ts
Normal file
27
src/config/swagger.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/index.ts
21
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 { ExpressAuth } from "@auth/express";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { authConfig } from "./auth/config.js";
|
import { authConfig } from "./auth/config.js";
|
||||||
import { corsMiddleware } from "./config/cors.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 meRouter from "./routes/me.js";
|
||||||
|
import statisticheRouter from "./routes/statistiche.js";
|
||||||
import utentiRouter from "./routes/utenti.js";
|
import utentiRouter from "./routes/utenti.js";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
@ -14,8 +24,11 @@ const port = Number(process.env.PORT ?? 3001);
|
||||||
app.set("trust proxy", 1);
|
app.set("trust proxy", 1);
|
||||||
app.use(corsMiddleware);
|
app.use(corsMiddleware);
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.options(/.*/, corsMiddleware);
|
app.options(/.*/, corsMiddleware);
|
||||||
|
|
||||||
|
setupSwagger(app);
|
||||||
|
|
||||||
app.get("/api/health", (_req, res) => {
|
app.get("/api/health", (_req, res) => {
|
||||||
res.json({ status: "ok" });
|
res.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
@ -23,7 +36,15 @@ app.get("/api/health", (_req, res) => {
|
||||||
app.use("/api/auth", ExpressAuth(authConfig));
|
app.use("/api/auth", ExpressAuth(authConfig));
|
||||||
app.use("/api/me", meRouter);
|
app.use("/api/me", meRouter);
|
||||||
app.use("/api/utenti", utentiRouter);
|
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", () => {
|
app.listen(port, "0.0.0.0", () => {
|
||||||
console.log(`Server in ascolto sulla porta ${port}`);
|
console.log(`Server in ascolto sulla porta ${port}`);
|
||||||
|
console.log(`Documentazione API: http://localhost:${port}/api/docs`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
535
src/routes/assegnazioni-corsi.ts
Normal file
535
src/routes/assegnazioni-corsi.ts
Normal file
|
|
@ -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<AssegnazioneRow | undefined> {
|
||||||
|
const [rows] = await pool.execute<AssegnazioneRow[]>(
|
||||||
|
`${SELECT_ASSEGNAZIONE} WHERE a.AssegnazioneID = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findUtenteById(id: number): Promise<UtenteRow | undefined> {
|
||||||
|
const [rows] = await pool.execute<UtenteRow[]>(
|
||||||
|
"SELECT UtenteID, Ruolo FROM utente WHERE UtenteID = ?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCorsoById(id: number): Promise<CorsoRow | undefined> {
|
||||||
|
const [rows] = await pool.execute<CorsoRow[]>(
|
||||||
|
"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<AssegnazioneRow[]>(
|
||||||
|
`${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<ResultSetHeader>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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;
|
||||||
258
src/routes/corsi.ts
Normal file
258
src/routes/corsi.ts
Normal file
|
|
@ -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<CorsoRow | undefined> {
|
||||||
|
const [rows] = await pool.execute<CorsoRow[]>(
|
||||||
|
`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<CorsoRow[]>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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<ResultSetHeader>(
|
||||||
|
`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;
|
||||||
|
|
@ -12,9 +12,13 @@ import { requireAuth } from "../middleware/auth.js";
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", requireAuth, (req, res) => {
|
router.get("/", requireAuth, (req, res) => {
|
||||||
|
const nameParts = (req.user!.name ?? "").trim().split(/\s+/);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: req.user!.id,
|
id: req.user!.id,
|
||||||
email: req.user!.email,
|
email: req.user!.email,
|
||||||
|
nome: nameParts[0] ?? "",
|
||||||
|
cognome: nameParts.slice(1).join(" "),
|
||||||
name: req.user!.name,
|
name: req.user!.name,
|
||||||
ruolo: req.user!.ruolo,
|
ruolo: req.user!.ruolo,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
122
src/routes/statistiche.ts
Normal file
122
src/routes/statistiche.ts
Normal file
|
|
@ -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<StatisticaRow[]>(
|
||||||
|
`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;
|
||||||
|
|
@ -11,6 +11,7 @@ import pool from "../db/pool.js";
|
||||||
import { mapDbError } from "../errors/db.js";
|
import { mapDbError } from "../errors/db.js";
|
||||||
import { requireAuth, requireReferenteAcademy } from "../middleware/auth.js";
|
import { requireAuth, requireReferenteAcademy } from "../middleware/auth.js";
|
||||||
import type { Ruolo } from "../types/index.js";
|
import type { Ruolo } from "../types/index.js";
|
||||||
|
import { UTENTE_CONSTRAINTS } from "../validation/constraints.js";
|
||||||
import { validateBody } from "../validation/middleware.js";
|
import { validateBody } from "../validation/middleware.js";
|
||||||
import {
|
import {
|
||||||
validateUtenteCreate,
|
validateUtenteCreate,
|
||||||
|
|
@ -49,9 +50,25 @@ async function findUtenteById(id: number): Promise<UtenteRow | undefined> {
|
||||||
|
|
||||||
router.use(requireAuth, requireReferenteAcademy);
|
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<UtenteRow[]>(
|
const [rows] = await pool.execute<UtenteRow[]>(
|
||||||
"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));
|
res.json(rows.map(toUtenteResponse));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
195
src/validation/assegnazione.ts
Normal file
195
src/validation/assegnazione.ts
Normal file
|
|
@ -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<string, unknown>
|
||||||
|
): ValidationResult<AssegnazioneCreateInput> {
|
||||||
|
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<string, unknown>
|
||||||
|
): ValidationResult<AssegnazioneUpdateInput> {
|
||||||
|
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<string, unknown>
|
||||||
|
): ValidationResult<AssegnazioneCompletaInput> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
129
src/validation/corso.ts
Normal file
129
src/validation/corso.ts
Normal file
|
|
@ -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<CorsoCreateInput>;
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
): ValidationResult<CorsoCreateInput> {
|
||||||
|
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<string, unknown>
|
||||||
|
): ValidationResult<CorsoUpdateInput> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue