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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
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 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`);
|
||||
});
|
||||
|
|
|
|||
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();
|
||||
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
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 { 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<UtenteRow | undefined> {
|
|||
|
||||
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[]>(
|
||||
"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));
|
||||
});
|
||||
|
|
|
|||
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