installazione Swagger e inserimento rotte corsi.ts e assegnazione-corsi.ts
All checks were successful
Deploy to VPS / build (push) Successful in 19s
Deploy to VPS / deploy (push) Successful in 20s

This commit is contained in:
AV77web 2026-07-14 11:14:18 +02:00
parent 46f3fa6efc
commit 0d2e1544cd
13 changed files with 2062 additions and 4 deletions

46
package-lock.json generated
View file

@ -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",

View file

@ -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"
},

View 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
View 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
View 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,
},
})
);
}

View file

@ -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`);
});

View 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
View 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;

View file

@ -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
View 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;

View file

@ -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));
});

View 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
View 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);
}