diff --git a/src/config/cors.ts b/src/config/cors.ts new file mode 100644 index 0000000..5b63d50 --- /dev/null +++ b/src/config/cors.ts @@ -0,0 +1,28 @@ +import cors from "cors"; + +const allowedOrigins = [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "https://corrierifrontend.andreavillari.it", + process.env.FRONTEND_URL, +].filter(Boolean) as string[]; + +export const corsMiddleware = cors({ + origin(origin, callback) { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + console.warn(`CORS: origin non consentita: ${origin}`); + callback(null, false); + }, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "Cookie", + "X-Requested-With", + "X-Auth-Return-Redirect", + ], +}); diff --git a/src/db/pool.ts b/src/db/pool.ts index 384b8cc..ae031ad 100644 --- a/src/db/pool.ts +++ b/src/db/pool.ts @@ -1,10 +1,10 @@ import mysql from "mysql2/promise"; const pool = mysql.createPool({ - host: process.env.DB_HOST ?? "127.0.0.1", - port: Number(process.env.DB_PORT ?? 3307), - database: process.env.DB_NAME ?? "prenotazioni", - user: process.env.DB_USER ?? "prenotazioni_user", + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT), + database: process.env.DB_NAME, + user: process.env.DB_USER, password: process.env.DB_PASSWORD ?? "", waitForConnections: true, connectionLimit: 10, diff --git a/src/errors/db.ts b/src/errors/db.ts new file mode 100644 index 0000000..027b6db --- /dev/null +++ b/src/errors/db.ts @@ -0,0 +1,80 @@ +export interface DbErrorResponse { + status: number; + error: string; + fields?: Record; +} + +interface MysqlError extends Error { + code?: string; + errno?: number; + sqlMessage?: string; +} + +export function mapDbError(error: unknown): DbErrorResponse | null { + if (!error || typeof error !== "object") return null; + + const err = error as MysqlError; + const message = err.sqlMessage ?? err.message ?? ""; + + if (err.code === "ER_DUP_ENTRY") { + if (message.includes("uk_cliente_email")) { + return { + status: 409, + error: "Email già registrata", + fields: { email: "Email già registrata" }, + }; + } + if (message.includes("uk_utente_email")) { + return { + status: 409, + error: "Email già registrata", + fields: { email: "Email già registrata" }, + }; + } + if (message.includes("uk_chiaveConsegna")) { + return { + status: 409, + error: "Chiave consegna già registrata", + fields: { chiaveConsegna: "Chiave consegna già registrata" }, + }; + } + return { status: 409, error: "Valore duplicato" }; + } + + if (err.code === "ER_SIGNAL_EXCEPTION" || err.errno === 1644) { + if (message.includes("DataRitiro")) { + return { + status: 400, + error: "Data ritiro non valida", + fields: { dataRitiro: "Data ritiro non può essere anteriore alla data odierna" }, + }; + } + if (message.includes("DataConsegna")) { + return { + status: 400, + error: "Data consegna non valida", + fields: { dataConsegna: "Data consegna non può essere anteriore alla data odierna" }, + }; + } + } + + if (err.code === "ER_ROW_IS_REFERENCED_2") { + return { + status: 409, + error: "Impossibile eliminare: esistono riferimenti collegati", + }; + } + + if (err.code === "ER_NO_REFERENCED_ROW_2") { + if (message.includes("fk_consegna_cliente")) { + return { + status: 400, + error: "Cliente non valido", + fields: { clienteId: "Cliente non trovato" }, + }; + } + return { status: 400, error: "Riferimento non valido" }; + } + + return null; +} diff --git a/src/index.ts b/src/index.ts index 290ecf4..e27cc9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,35 @@ -import cors from "cors"; +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 clientiRouter from "./routes/clienti.js"; +import consegneRouter from "./routes/consegne.js"; +import meRouter from "./routes/me.js"; +import utentiRouter from "./routes/utenti.js"; +import verificaConsegnaRouter from "./routes/verifica-consegna.js"; dotenv.config(); const app = express(); -const port = Number(process.env.PORT) || 3005; +const port = Number(process.env.PORT ?? 3005); -app.use(cors({ origin: process.env.FRONTEND_URL })); +app.set("trust proxy", 1); +app.use(corsMiddleware); app.use(express.json()); +app.options(/.*/, corsMiddleware); -app.get("/health", (_req, res) => { +app.get("/api/health", (_req, res) => { res.json({ status: "ok" }); }); -app.listen(port, () => { - console.log(`Server listening on port ${port}`); +app.use("/api/verifica-consegna", verificaConsegnaRouter); +app.use("/api/auth", ExpressAuth(authConfig)); +app.use("/api/me", meRouter); +app.use("/api/utenti", utentiRouter); +app.use("/api/clienti", clientiRouter); +app.use("/api/consegne", consegneRouter); + +app.listen(port, "0.0.0.0", () => { + console.log(`Server in ascolto sulla porta ${port}`); }); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 3cd1bb0..ab90c7c 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,6 +1,7 @@ import { getSession } from "@auth/express"; import type { NextFunction, Request, Response } from "express"; import { authConfig } from "../auth/config.js"; +import type { Ruolo } from "../types/index.js"; export async function requireAuth(req: Request, res: Response, next: NextFunction) { const session = await getSession(req, authConfig); @@ -17,8 +18,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio next(); } -export function requireAdmin(req: Request, res: Response, next: NextFunction) { - if (!req.user?.ruolo) { +export function requireAmministratore(req: Request, res: Response, next: NextFunction) { + if (req.user?.ruolo !== "Amministratore") { res.status(403).json({ error: "Operazione riservata agli amministratori" }); return; } @@ -32,7 +33,7 @@ declare global { id: number; email: string; name: string; - ruolo: string; + ruolo: Ruolo; }; } } diff --git a/src/routes/clienti.ts b/src/routes/clienti.ts new file mode 100644 index 0000000..10d08e7 --- /dev/null +++ b/src/routes/clienti.ts @@ -0,0 +1,219 @@ +import { Router } from "express"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import pool from "../db/pool.js"; +import { mapDbError } from "../errors/db.js"; +import { requireAuth } from "../middleware/auth.js"; +import { + validateClienteCreate, + validateClienteUpdate, + type ClienteInput, +} from "../validation/cliente.js"; +import { validateBody } from "../validation/middleware.js"; + +const router = Router(); + +interface ClienteRow extends RowDataPacket { + ClienteID: number; + Nome: string; + Cognome: string; + Via: string; + Comune: string; + Provincia: string; + Telefono: string; + Email: string; + Note: string | null; +} + +const SELECT_FIELDS = + "ClienteID, Nome, Cognome, Via, Comune, Provincia, Telefono, Email, Note"; + +function toClienteResponse(row: ClienteRow) { + return { + id: row.ClienteID, + nome: row.Nome, + cognome: row.Cognome, + via: row.Via, + comune: row.Comune, + provincia: row.Provincia, + telefono: row.Telefono, + email: row.Email, + note: row.Note, + }; +} + +async function findClienteById(id: number): Promise { + const [rows] = await pool.execute( + `SELECT ${SELECT_FIELDS} FROM cliente WHERE ClienteID = ?`, + [id] + ); + return rows[0]; +} + +router.use(requireAuth); + +router.get("/", async (_req, res) => { + const [rows] = await pool.execute( + `SELECT ${SELECT_FIELDS} FROM cliente ORDER BY Cognome, Nome` + ); + res.json(rows.map(toClienteResponse)); +}); + +router.get("/:id", async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const cliente = await findClienteById(id); + if (!cliente) { + res.status(404).json({ error: "Cliente non trovato" }); + return; + } + + res.json(toClienteResponse(cliente)); +}); + +router.post("/", validateBody(validateClienteCreate), async (_req, res) => { + const body = res.locals.validatedBody as ClienteInput; + + try { + const [result] = await pool.execute( + `INSERT INTO cliente + (Nome, Cognome, Via, Comune, Provincia, Telefono, Email, Note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + body.nome, + body.cognome, + body.via, + body.comune, + body.provincia, + body.telefono, + body.email, + body.note, + ] + ); + + const created = await findClienteById(result.insertId); + if (!created) { + res.status(500).json({ error: "Errore durante la creazione del cliente" }); + return; + } + + res.status(201).json(toClienteResponse(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", validateBody(validateClienteUpdate), async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findClienteById(id); + if (!existing) { + res.status(404).json({ error: "Cliente non trovato" }); + return; + } + + const updates = res.locals.validatedBody as Partial; + const setClauses: string[] = []; + const values: (string | null)[] = []; + + if (updates.nome !== undefined) { + setClauses.push("Nome = ?"); + values.push(updates.nome); + } + if (updates.cognome !== undefined) { + setClauses.push("Cognome = ?"); + values.push(updates.cognome); + } + if (updates.via !== undefined) { + setClauses.push("Via = ?"); + values.push(updates.via); + } + if (updates.comune !== undefined) { + setClauses.push("Comune = ?"); + values.push(updates.comune); + } + if (updates.provincia !== undefined) { + setClauses.push("Provincia = ?"); + values.push(updates.provincia); + } + if (updates.telefono !== undefined) { + setClauses.push("Telefono = ?"); + values.push(updates.telefono); + } + if (updates.email !== undefined) { + setClauses.push("Email = ?"); + values.push(updates.email); + } + if (updates.note !== undefined) { + setClauses.push("Note = ?"); + values.push(updates.note); + } + + try { + await pool.execute( + `UPDATE cliente SET ${setClauses.join(", ")} WHERE ClienteID = ?`, + [...values, id] + ); + + const updated = await findClienteById(id); + if (!updated) { + res.status(500).json({ error: "Errore durante l'aggiornamento del cliente" }); + return; + } + + res.json(toClienteResponse(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", async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findClienteById(id); + if (!existing) { + res.status(404).json({ error: "Cliente non trovato" }); + return; + } + + try { + await pool.execute("DELETE FROM cliente WHERE ClienteID = ?", [id]); + res.status(204).send(); + } catch (error) { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ error: mapped.error }); + return; + } + throw error; + } +}); + +export default router; diff --git a/src/routes/consegne.ts b/src/routes/consegne.ts new file mode 100644 index 0000000..3b7d7d9 --- /dev/null +++ b/src/routes/consegne.ts @@ -0,0 +1,220 @@ +import { Router } from "express"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import pool from "../db/pool.js"; +import { mapDbError } from "../errors/db.js"; +import { requireAuth } from "../middleware/auth.js"; +import { validateBody } from "../validation/middleware.js"; +import { + validateConsegnaCreate, + validateConsegnaUpdate, + type ConsegnaInput, +} from "../validation/consegna.js"; + +const router = Router(); + +interface ConsegnaRow extends RowDataPacket { + ConsegnaID: number; + ClienteID: number; + DataRitiro: Date | string; + DataConsegna: Date | string; + Stato: string; + ChiaveConsegna: string; + ClienteNome: string; + ClienteCognome: string; +} + +const SELECT_BASE = ` + SELECT + c.ConsegnaID, + c.ClienteID, + c.DataRitiro, + c.DataConsegna, + c.Stato, + c.ChiaveConsegna, + cl.Nome AS ClienteNome, + cl.Cognome AS ClienteCognome + FROM consegna c + INNER JOIN cliente cl ON cl.ClienteID = c.ClienteID +`; + +function formatDate(value: Date | string): string { + if (value instanceof Date) { + return value.toISOString().slice(0, 10); + } + return value.toString().slice(0, 10); +} + +function toConsegnaResponse(row: ConsegnaRow) { + return { + id: row.ConsegnaID, + clienteId: row.ClienteID, + dataRitiro: formatDate(row.DataRitiro), + dataConsegna: formatDate(row.DataConsegna), + stato: row.Stato, + chiaveConsegna: row.ChiaveConsegna, + clienteNome: row.ClienteNome, + clienteCognome: row.ClienteCognome, + }; +} + +async function findConsegnaById(id: number): Promise { + const [rows] = await pool.execute( + `${SELECT_BASE} WHERE c.ConsegnaID = ?`, + [id] + ); + return rows[0]; +} + +router.use(requireAuth); + +router.get("/", async (_req, res) => { + const [rows] = await pool.execute( + `${SELECT_BASE} ORDER BY c.DataConsegna DESC, c.DataRitiro DESC` + ); + res.json(rows.map(toConsegnaResponse)); +}); + +router.get("/:id", async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const consegna = await findConsegnaById(id); + if (!consegna) { + res.status(404).json({ error: "Consegna non trovata" }); + return; + } + + res.json(toConsegnaResponse(consegna)); +}); + +router.post("/", validateBody(validateConsegnaCreate), async (_req, res) => { + const body = res.locals.validatedBody as ConsegnaInput; + + try { + const [result] = await pool.execute( + `INSERT INTO consegna + (ClienteID, DataRitiro, DataConsegna, Stato, ChiaveConsegna) + VALUES (?, ?, ?, ?, ?)`, + [ + body.clienteId, + body.dataRitiro, + body.dataConsegna, + body.stato, + body.chiaveConsegna, + ] + ); + + const created = await findConsegnaById(result.insertId); + if (!created) { + res.status(500).json({ error: "Errore durante la creazione della consegna" }); + return; + } + + res.status(201).json(toConsegnaResponse(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", validateBody(validateConsegnaUpdate), async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findConsegnaById(id); + if (!existing) { + res.status(404).json({ error: "Consegna non trovata" }); + return; + } + + const updates = res.locals.validatedBody as Partial; + const setClauses: string[] = []; + const values: (string | number)[] = []; + + if (updates.dataRitiro !== undefined) { + setClauses.push("DataRitiro = ?"); + values.push(updates.dataRitiro); + } + if (updates.dataConsegna !== undefined) { + setClauses.push("DataConsegna = ?"); + values.push(updates.dataConsegna); + } + if (updates.stato !== undefined) { + setClauses.push("Stato = ?"); + values.push(updates.stato); + } + if (updates.chiaveConsegna !== undefined) { + setClauses.push("ChiaveConsegna = ?"); + values.push(updates.chiaveConsegna); + } + if (updates.clienteId !== undefined) { + setClauses.push("ClienteID = ?"); + values.push(updates.clienteId); + } + + try { + await pool.execute( + `UPDATE consegna SET ${setClauses.join(", ")} WHERE ConsegnaID = ?`, + [...values, id] + ); + + const updated = await findConsegnaById(id); + if (!updated) { + res.status(500).json({ error: "Errore durante l'aggiornamento della consegna" }); + return; + } + + res.json(toConsegnaResponse(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", async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findConsegnaById(id); + if (!existing) { + res.status(404).json({ error: "Consegna non trovata" }); + return; + } + + try { + await pool.execute("DELETE FROM consegna WHERE ConsegnaID = ?", [id]); + res.status(204).send(); + } catch (error) { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ error: mapped.error }); + return; + } + throw error; + } +}); + +export default router; diff --git a/src/routes/me.ts b/src/routes/me.ts new file mode 100644 index 0000000..daab7aa --- /dev/null +++ b/src/routes/me.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { requireAuth } from "../middleware/auth.js"; + +const router = Router(); + +router.get("/", requireAuth, (req, res) => { + res.json({ + id: req.user!.id, + email: req.user!.email, + name: req.user!.name, + ruolo: req.user!.ruolo, + }); +}); + +export default router; diff --git a/src/routes/utenti.ts b/src/routes/utenti.ts new file mode 100644 index 0000000..5ba240f --- /dev/null +++ b/src/routes/utenti.ts @@ -0,0 +1,195 @@ +import bcrypt from "bcryptjs"; +import { Router } from "express"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import pool from "../db/pool.js"; +import { mapDbError } from "../errors/db.js"; +import { requireAmministratore, requireAuth } from "../middleware/auth.js"; +import type { Ruolo } from "../types/index.js"; +import { validateBody } from "../validation/middleware.js"; +import { + validateUtenteCreate, + validateUtenteUpdate, + type UtenteCreateInput, + type UtenteUpdateInput, +} from "../validation/utente.js"; + +const router = Router(); + +interface UtenteRow extends RowDataPacket { + UtenteID: number; + Nome: string; + Cognome: string; + Email: string; + Ruolo: Ruolo; +} + +function toUtenteResponse(row: UtenteRow) { + return { + id: row.UtenteID, + nome: row.Nome, + cognome: row.Cognome, + email: row.Email, + ruolo: row.Ruolo, + }; +} + +async function findUtenteById(id: number): Promise { + const [rows] = await pool.execute( + "SELECT UtenteID, Nome, Cognome, Email, Ruolo FROM utente WHERE UtenteID = ?", + [id] + ); + return rows[0]; +} + +router.use(requireAuth, requireAmministratore); + +router.get("/", async (_req, res) => { + const [rows] = await pool.execute( + "SELECT UtenteID, Nome, Cognome, Email, Ruolo FROM utente ORDER BY Cognome, Nome" + ); + res.json(rows.map(toUtenteResponse)); +}); + +router.get("/:id", async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const utente = await findUtenteById(id); + if (!utente) { + res.status(404).json({ error: "Utente non trovato" }); + return; + } + + res.json(toUtenteResponse(utente)); +}); + +router.post("/", validateBody(validateUtenteCreate), async (_req, res) => { + const body = res.locals.validatedBody as UtenteCreateInput; + + try { + const passwordHash = await bcrypt.hash(body.password, 10); + const [result] = await pool.execute( + "INSERT INTO utente (Nome, Cognome, Email, PasswordHash, Ruolo) VALUES (?, ?, ?, ?, ?)", + [body.nome, body.cognome, body.email, passwordHash, body.ruolo] + ); + + const created = await findUtenteById(result.insertId); + if (!created) { + res.status(500).json({ error: "Errore durante la creazione dell'utente" }); + return; + } + + res.status(201).json(toUtenteResponse(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", validateBody(validateUtenteUpdate), async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + const existing = await findUtenteById(id); + if (!existing) { + res.status(404).json({ error: "Utente non trovato" }); + return; + } + + const updates = res.locals.validatedBody as UtenteUpdateInput; + const setClauses: string[] = []; + const values: (string | number)[] = []; + + if (updates.nome !== undefined) { + setClauses.push("Nome = ?"); + values.push(updates.nome); + } + if (updates.cognome !== undefined) { + setClauses.push("Cognome = ?"); + values.push(updates.cognome); + } + if (updates.email !== undefined) { + setClauses.push("Email = ?"); + values.push(updates.email); + } + if (updates.ruolo !== undefined) { + setClauses.push("Ruolo = ?"); + values.push(updates.ruolo); + } + if (updates.password) { + const passwordHash = await bcrypt.hash(updates.password, 10); + setClauses.push("PasswordHash = ?"); + values.push(passwordHash); + } + + try { + await pool.execute( + `UPDATE utente SET ${setClauses.join(", ")} WHERE UtenteID = ?`, + [...values, id] + ); + + const updated = await findUtenteById(id); + if (!updated) { + res.status(500).json({ error: "Errore durante l'aggiornamento dell'utente" }); + return; + } + + res.json(toUtenteResponse(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", async (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: "ID non valido" }); + return; + } + + if (req.user?.id === id) { + res.status(400).json({ error: "Non puoi eliminare il tuo account utente" }); + return; + } + + const existing = await findUtenteById(id); + if (!existing) { + res.status(404).json({ error: "Utente non trovato" }); + return; + } + + try { + await pool.execute("DELETE FROM utente WHERE UtenteID = ?", [id]); + res.status(204).send(); + } catch (error) { + const mapped = mapDbError(error); + if (mapped) { + res.status(mapped.status).json({ error: mapped.error }); + return; + } + throw error; + } +}); + +export default router; diff --git a/src/routes/verifica-consegna.ts b/src/routes/verifica-consegna.ts new file mode 100644 index 0000000..42ae06c --- /dev/null +++ b/src/routes/verifica-consegna.ts @@ -0,0 +1,81 @@ +import { Router } from "express"; +import type { RowDataPacket } from "mysql2"; +import pool from "../db/pool.js"; +import { CONSEGNA_CONSTRAINTS } from "../validation/constraints.js"; +import { + normalizeString, + validateMaxLength, + validateRequired, + type FieldErrors, +} from "../validation/primitives.js"; + +const router = Router(); + +interface VerificaRow extends RowDataPacket { + DataRitiro: Date | string; + DataConsegna: Date | string; + Stato: string; + ClienteNome: string; + ClienteCognome: string; + Comune: string; +} + +function formatDate(value: Date | string): string { + if (value instanceof Date) { + return value.toISOString().slice(0, 10); + } + return value.toString().slice(0, 10); +} + +router.get("/", async (req, res) => { + const chiave = normalizeString(req.query.chiave).toUpperCase(); + const errors: FieldErrors = {}; + + validateRequired(errors, "chiave", chiave, "La chiave consegna è obbligatoria"); + validateMaxLength( + errors, + "chiave", + chiave, + CONSEGNA_CONSTRAINTS.chiaveConsegna.maxLength, + "Chiave consegna" + ); + + if (Object.keys(errors).length > 0) { + res.status(400).json({ + error: Object.values(errors)[0], + fields: errors, + }); + return; + } + + const [rows] = await pool.execute( + `SELECT + c.DataRitiro, + c.DataConsegna, + c.Stato, + cl.Nome AS ClienteNome, + cl.Cognome AS ClienteCognome, + cl.Comune + FROM consegna c + INNER JOIN cliente cl ON cl.ClienteID = c.ClienteID + WHERE c.ChiaveConsegna = ?`, + [chiave] + ); + + const consegna = rows[0]; + if (!consegna) { + res.status(404).json({ error: "Consegna non trovata" }); + return; + } + + res.json({ + dataRitiro: formatDate(consegna.DataRitiro), + dataConsegna: formatDate(consegna.DataConsegna), + stato: consegna.Stato, + clienteNome: consegna.ClienteNome, + clienteCognome: consegna.ClienteCognome, + comune: consegna.Comune, + }); +}); + +export default router; diff --git a/src/validation/cliente.ts b/src/validation/cliente.ts new file mode 100644 index 0000000..80d737b --- /dev/null +++ b/src/validation/cliente.ts @@ -0,0 +1,251 @@ +//============================================== +// File: cliente.ts +// Validazione dati cliente. +// author: "villari.andrea@libero.it" +// version: "1.0.0 2026-06-18" +//============================================== + +import { CLIENTE_CONSTRAINTS } from "./constraints.js"; +import { + buildResult, + normalizeEmail, + normalizeString, + validateEmailFormat, + validateMaxLength, + validateRequired, + type FieldErrors, + type ValidationResult, +} from "./primitives.js"; + +export interface ClienteInput { + nome: string; + cognome: string; + via: string; + comune: string; + provincia: string; + telefono: string; + email: string; + note: string | null; +} + +export function normalizeClienteInput(raw: Record): ClienteInput { + const noteRaw = raw.note; + return { + nome: normalizeString(raw.nome), + cognome: normalizeString(raw.cognome), + via: normalizeString(raw.via), + comune: normalizeString(raw.comune), + provincia: normalizeString(raw.provincia).toUpperCase(), + telefono: normalizeString(raw.telefono), + email: normalizeEmail(raw.email), + note: + noteRaw === null || noteRaw === undefined || noteRaw === "" + ? null + : normalizeString(noteRaw), + }; +} + +function validateProvincia(errors: FieldErrors, provincia: string): void { + if (!provincia) return; + if (provincia.length !== CLIENTE_CONSTRAINTS.Provincia.maxLength) { + errors.provincia = "Provincia deve essere di 2 caratteri"; + } +} + +function validateClienteFields( + data: ClienteInput, + errors: FieldErrors, + options: { requireAll: boolean } +): void { + if (options.requireAll || data.nome !== undefined) { + validateRequired(errors, "nome", data.nome, "Nome obbligatorio"); + validateMaxLength( + errors, + "nome", + data.nome, + CLIENTE_CONSTRAINTS.nome.maxLength, + "Nome" + ); + } + + if (options.requireAll || data.cognome !== undefined) { + validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio"); + validateMaxLength( + errors, + "cognome", + data.cognome, + CLIENTE_CONSTRAINTS.cognome.maxLength, + "Cognome" + ); + } + + if (options.requireAll || data.via !== undefined) { + validateRequired(errors, "via", data.via, "Via obbligatoria"); + validateMaxLength( + errors, + "via", + data.via, + CLIENTE_CONSTRAINTS.via.maxLength, + "Via" + ); + } + + if (options.requireAll || data.comune !== undefined) { + validateRequired(errors, "comune", data.comune, "Comune obbligatorio"); + validateMaxLength( + errors, + "comune", + data.comune, + CLIENTE_CONSTRAINTS.Comune.maxLength, + "Comune" + ); + } + + if (options.requireAll || data.provincia !== undefined) { + validateRequired(errors, "provincia", data.provincia, "Provincia obbligatoria"); + validateMaxLength( + errors, + "provincia", + data.provincia, + CLIENTE_CONSTRAINTS.Provincia.maxLength, + "Provincia" + ); + validateProvincia(errors, data.provincia); + } + + if (options.requireAll || data.telefono !== undefined) { + validateRequired(errors, "telefono", data.telefono, "Telefono obbligatorio"); + validateMaxLength( + errors, + "telefono", + data.telefono, + CLIENTE_CONSTRAINTS.telefono.maxLength, + "Telefono" + ); + } + + if (options.requireAll || data.email !== undefined) { + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + CLIENTE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + } +} + +export function validateClienteCreate( + raw: Record +): ValidationResult { + const data = normalizeClienteInput(raw); + const errors: FieldErrors = {}; + validateClienteFields(data, errors, { requireAll: true }); + return buildResult(data, errors); +} + +export function validateClienteUpdate( + raw: Record +): ValidationResult> { + const errors: FieldErrors = {}; + const data: Partial = {}; + + if ("nome" in raw) { + data.nome = normalizeString(raw.nome); + validateRequired(errors, "nome", data.nome, "Nome obbligatorio"); + validateMaxLength( + errors, + "nome", + data.nome, + CLIENTE_CONSTRAINTS.nome.maxLength, + "Nome" + ); + } + + if ("cognome" in raw) { + data.cognome = normalizeString(raw.cognome); + validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio"); + validateMaxLength( + errors, + "cognome", + data.cognome, + CLIENTE_CONSTRAINTS.cognome.maxLength, + "Cognome" + ); + } + + if ("via" in raw) { + data.via = normalizeString(raw.via); + validateRequired(errors, "via", data.via, "Via obbligatoria"); + validateMaxLength( + errors, + "via", + data.via, + CLIENTE_CONSTRAINTS.via.maxLength, + "Via" + ); + } + + if ("comune" in raw) { + data.comune = normalizeString(raw.comune); + validateRequired(errors, "comune", data.comune, "Comune obbligatorio"); + validateMaxLength( + errors, + "comune", + data.comune, + CLIENTE_CONSTRAINTS.Comune.maxLength, + "Comune" + ); + } + + if ("provincia" in raw) { + data.provincia = normalizeString(raw.provincia).toUpperCase(); + validateRequired(errors, "provincia", data.provincia, "Provincia obbligatoria"); + validateMaxLength( + errors, + "provincia", + data.provincia, + CLIENTE_CONSTRAINTS.Provincia.maxLength, + "Provincia" + ); + validateProvincia(errors, data.provincia); + } + + if ("telefono" in raw) { + data.telefono = normalizeString(raw.telefono); + validateRequired(errors, "telefono", data.telefono, "Telefono obbligatorio"); + validateMaxLength( + errors, + "telefono", + data.telefono, + CLIENTE_CONSTRAINTS.telefono.maxLength, + "Telefono" + ); + } + + if ("email" in raw) { + data.email = normalizeEmail(raw.email); + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + CLIENTE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + } + + if ("note" in raw) { + data.note = + raw.note === null || raw.note === "" ? null : normalizeString(raw.note); + } + + if (Object.keys(data).length === 0) { + errors._form = "Nessun campo da aggiornare"; + } + + return buildResult(data, errors); +} diff --git a/src/validation/consegna.ts b/src/validation/consegna.ts new file mode 100644 index 0000000..c997fc3 --- /dev/null +++ b/src/validation/consegna.ts @@ -0,0 +1,189 @@ +//================================================ +// File: consegna.ts +// Validazione dati consegna. +// author: "villari.andrea@libero.it" +// version: "1.0.0 2026-06-18" +//================================================ + +import type { StatoConsegna } from "../types/index.js"; +import { CONSEGNA_CONSTRAINTS } from "./constraints.js"; +import { + buildResult, + normalizePositiveInt, + normalizeString, + validateDate, + validateEnum, + validateMaxLength, + validatePositiveInt, + validateRequired, + type FieldErrors, + type ValidationResult, +} from "./primitives.js"; + +export interface ConsegnaInput { + dataRitiro: string; + dataConsegna: string; + stato: StatoConsegna; + chiaveConsegna: string; + clienteId: number; +} + +function todayAtMidnight(): Date { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return today; +} + +function validateDateNotRetroactive( + errors: FieldErrors, + field: string, + value: string, + label: string +): void { + if (!value || errors[field]) return; + + const parsed = new Date(`${value}T00:00:00`); + if (parsed < todayAtMidnight()) { + errors[field] = `${label} non può essere anteriore alla data odierna`; + } +} + +export function normalizeConsegnaInput(raw: Record): ConsegnaInput { + return { + dataRitiro: normalizeString(raw.dataRitiro), + dataConsegna: normalizeString(raw.dataConsegna), + stato: normalizeString(raw.stato || "in deposito") as StatoConsegna, + chiaveConsegna: normalizeString(raw.chiaveConsegna).toUpperCase(), + clienteId: normalizePositiveInt(raw.clienteId), + }; +} + +function validateConsegnaFields( + raw: Record, + data: ConsegnaInput, + errors: FieldErrors, + options: { requireAll: boolean } +): void { + if (options.requireAll || "dataRitiro" in raw) { + validateRequired(errors, "dataRitiro", data.dataRitiro, "Data ritiro obbligatoria"); + validateDate(errors, "dataRitiro", data.dataRitiro, "Data ritiro"); + validateDateNotRetroactive(errors, "dataRitiro", data.dataRitiro, "Data ritiro"); + } + + if (options.requireAll || "dataConsegna" in raw) { + validateRequired( + errors, + "dataConsegna", + data.dataConsegna, + "Data consegna obbligatoria" + ); + validateDate(errors, "dataConsegna", data.dataConsegna, "Data consegna"); + validateDateNotRetroactive(errors, "dataConsegna", data.dataConsegna, "Data consegna"); + } + + if (options.requireAll || "stato" in raw) { + validateRequired(errors, "stato", data.stato, "Stato obbligatorio"); + validateEnum( + errors, + "stato", + data.stato, + CONSEGNA_CONSTRAINTS.stato.allowedValues, + "Stato" + ); + } + + if (options.requireAll || "chiaveConsegna" in raw) { + validateRequired( + errors, + "chiaveConsegna", + data.chiaveConsegna, + "Chiave consegna obbligatoria" + ); + validateMaxLength( + errors, + "chiaveConsegna", + data.chiaveConsegna, + CONSEGNA_CONSTRAINTS.chiaveConsegna.maxLength, + "Chiave consegna" + ); + } + + if (options.requireAll || "clienteId" in raw) { + validatePositiveInt(errors, "clienteId", data.clienteId, "Cliente", options.requireAll); + } +} + +export function validateConsegnaCreate( + raw: Record +): ValidationResult { + const data = normalizeConsegnaInput(raw); + const errors: FieldErrors = {}; + validateConsegnaFields(raw, data, errors, { requireAll: true }); + return buildResult(data, errors); +} + +export function validateConsegnaUpdate( + raw: Record +): ValidationResult> { + const errors: FieldErrors = {}; + const data: Partial = {}; + + if ("dataRitiro" in raw) { + data.dataRitiro = normalizeString(raw.dataRitiro); + validateRequired(errors, "dataRitiro", data.dataRitiro, "Data ritiro obbligatoria"); + validateDate(errors, "dataRitiro", data.dataRitiro, "Data ritiro"); + validateDateNotRetroactive(errors, "dataRitiro", data.dataRitiro, "Data ritiro"); + } + + if ("dataConsegna" in raw) { + data.dataConsegna = normalizeString(raw.dataConsegna); + validateRequired( + errors, + "dataConsegna", + data.dataConsegna, + "Data consegna obbligatoria" + ); + validateDate(errors, "dataConsegna", data.dataConsegna, "Data consegna"); + validateDateNotRetroactive(errors, "dataConsegna", data.dataConsegna, "Data consegna"); + } + + if ("stato" in raw) { + data.stato = normalizeString(raw.stato) as StatoConsegna; + validateRequired(errors, "stato", data.stato, "Stato obbligatorio"); + validateEnum( + errors, + "stato", + data.stato, + CONSEGNA_CONSTRAINTS.stato.allowedValues, + "Stato" + ); + } + + if ("chiaveConsegna" in raw) { + data.chiaveConsegna = normalizeString(raw.chiaveConsegna).toUpperCase(); + validateRequired( + errors, + "chiaveConsegna", + data.chiaveConsegna, + "Chiave consegna obbligatoria" + ); + validateMaxLength( + errors, + "chiaveConsegna", + data.chiaveConsegna, + CONSEGNA_CONSTRAINTS.chiaveConsegna.maxLength, + "Chiave consegna" + ); + } + + if ("clienteId" in raw) { + data.clienteId = normalizePositiveInt(raw.clienteId); + validatePositiveInt(errors, "clienteId", data.clienteId, "Cliente"); + } + + if (Object.keys(data).length === 0) { + errors._form = "Nessun campo da aggiornare"; + } + + return buildResult(data, errors); +} diff --git a/src/validation/constraints.ts b/src/validation/constraints.ts index abc5035..11afc47 100644 --- a/src/validation/constraints.ts +++ b/src/validation/constraints.ts @@ -19,8 +19,8 @@ export const UTENTE_CONSTRAINTS = { nome: { maxLength: 100, required: true }, cognome: { maxLength: 100, required: true }, via: {maxLength: 100, required: true}, - Comune: {maxLength: 100, required: true}, - Provincia: {maxLength: 2, required: true}, + comune: {maxLength: 100, required: true}, + provincia: {maxLength: 2, required: true}, telefono: { maxLength: 30, required: true }, email: { maxLength: 255, required: true }, note: { required: false }, @@ -34,7 +34,7 @@ export const UTENTE_CONSTRAINTS = { allowedValues: ["da ritirare", "in consegna", "in giacenza", "in deposito"], required: true, }, - chiaveConsegna: { maxLength: 8, required: true }, + chiaveconsegna: { maxLength: 8, required: true }, clienteId: { required: true }, } as const; \ No newline at end of file diff --git a/src/validation/middleware.ts b/src/validation/middleware.ts new file mode 100644 index 0000000..94fa95d --- /dev/null +++ b/src/validation/middleware.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from "express"; +import type { ValidationResult } from "./primitives.js"; + +export function validateBody( + validator: (body: Record) => ValidationResult +): RequestHandler { + return (req, res, next) => { + const result = validator(req.body as Record); + + if (!result.success) { + res.status(400).json({ + error: "Validazione fallita", + fields: result.errors, + }); + return; + } + + res.locals.validatedBody = result.data; + next(); + }; +} diff --git a/src/validation/utente.ts b/src/validation/utente.ts index 16d13a8..4779dc0 100644 --- a/src/validation/utente.ts +++ b/src/validation/utente.ts @@ -136,7 +136,7 @@ export function validateUtenteCreate( } -export function validateUTENTEUpdate( +export function validateUtenteUpdate( raw: Record ): ValidationResult { const errors: FieldErrors = {};