iserimento validation auth cors types error db middleware e routes
This commit is contained in:
parent
53509490f3
commit
306ac68306
12 changed files with 984 additions and 0 deletions
81
src/auth/config.ts
Normal file
81
src/auth/config.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type { ExpressAuthConfig } from "@auth/express";
|
||||||
|
import Credentials from "@auth/express/providers/credentials";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import type { RowDataPacket } from "mysql2";
|
||||||
|
import pool from "../db/pool.js";
|
||||||
|
import type { Utente } from "../types/index.js";
|
||||||
|
import { validateLoginInput } from "../validation/utente.js";
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
|
const crossOriginCookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "none" as const,
|
||||||
|
secure: true,
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authConfig: ExpressAuthConfig = {
|
||||||
|
basePath: "/api/auth",
|
||||||
|
trustHost: true,
|
||||||
|
secret: process.env.AUTH_SECRET,
|
||||||
|
useSecureCookies: isProduction,
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
pages: { signIn: "/api/auth/error" },
|
||||||
|
cookies: isProduction
|
||||||
|
? {
|
||||||
|
sessionToken: { options: crossOriginCookieOptions },
|
||||||
|
csrfToken: { options: { ...crossOriginCookieOptions, httpOnly: false } },
|
||||||
|
callbackUrl: { options: crossOriginCookieOptions },
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
const validation = validateLoginInput(credentials ?? {});
|
||||||
|
|
||||||
|
if (!validation.success) return null;
|
||||||
|
|
||||||
|
const { email, password } = validation.data;
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT UtenteID, Nome, Cognome, Email, PasswordHash, Ruolo FROM utente WHERE Email = ?",
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
const utente = rows[0] as Utente | undefined;
|
||||||
|
if (!utente) return null;
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, utente.PasswordHash);
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(utente.UtenteID),
|
||||||
|
email: utente.Email,
|
||||||
|
name: `${utente.Nome} ${utente.Cognome}`,
|
||||||
|
ruolo: utente.Ruolo,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
token.ruolo = user.ruolo;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
session.user.ruolo = token.ruolo!;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
28
src/cors.ts
Normal file
28
src/cors.ts
Normal file
|
|
@ -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",
|
||||||
|
],
|
||||||
|
});
|
||||||
13
src/db/pool.ts
Normal file
13
src/db/pool.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default pool;
|
||||||
90
src/errors/db.ts
Normal file
90
src/errors/db.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
export interface DbErrorResponse {
|
||||||
|
status: number;
|
||||||
|
error: string;
|
||||||
|
fields?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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_CHECK_CONSTRAINT_VIOLATED") {
|
||||||
|
if (message.includes("chk_consegna_almeno_una_data")) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: "Inserire almeno una data di ritiro o di consegna",
|
||||||
|
fields: { _form: "Inserire almeno una data di ritiro o di consegna" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
40
src/middleware/auth.ts
Normal file
40
src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
res.status(401).json({ error: "Autenticazione richiesta" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.user = {
|
||||||
|
id: Number(session.user.id),
|
||||||
|
email: session.user.email,
|
||||||
|
name: session.user.name,
|
||||||
|
ruolo: session.user.ruolo,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAmministratore(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (req.user?.ruolo !== "Amministratore") {
|
||||||
|
res.status(403).json({ error: "Operazione riservata agli amministratori" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
ruolo: Ruolo;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/routes/utenti.ts
Normal file
195
src/routes/utenti.ts
Normal file
|
|
@ -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<UtenteRow | undefined> {
|
||||||
|
const [rows] = await pool.execute<UtenteRow[]>(
|
||||||
|
"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<UtenteRow[]>(
|
||||||
|
"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<ResultSetHeader>(
|
||||||
|
"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<ResultSetHeader>(
|
||||||
|
`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;
|
||||||
22
src/types/auth.d.ts
vendored
Normal file
22
src/types/auth.d.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { Ruolo } from "./index.js";
|
||||||
|
|
||||||
|
declare module "@auth/core/types" {
|
||||||
|
interface User {
|
||||||
|
ruolo?: Ruolo;
|
||||||
|
}
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
ruolo: Ruolo;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@auth/core/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id?: string;
|
||||||
|
ruolo?: Ruolo;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/types/index.ts
Normal file
35
src/types/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { CONSEGNA_CONSTRAINTS, UTENTE_CONSTRAINTS } from "../validation/constraints.js";
|
||||||
|
|
||||||
|
export type Ruolo = (typeof UTENTE_CONSTRAINTS.ruolo.allowedValues)[number];
|
||||||
|
|
||||||
|
export type StatoConsegna = (typeof CONSEGNA_CONSTRAINTS.stato.allowedValues)[number];
|
||||||
|
|
||||||
|
export interface Utente {
|
||||||
|
UtenteID: number;
|
||||||
|
Nome: string;
|
||||||
|
Cognome: string;
|
||||||
|
Email: string;
|
||||||
|
PasswordHash: string;
|
||||||
|
Ruolo: Ruolo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cliente {
|
||||||
|
ClienteID: number;
|
||||||
|
Nome: string;
|
||||||
|
Cognome: string;
|
||||||
|
Via: string;
|
||||||
|
Comune: string;
|
||||||
|
Provincia: string;
|
||||||
|
Telefono: string;
|
||||||
|
Email: string;
|
||||||
|
Note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Consegna {
|
||||||
|
ConsegnaID: number;
|
||||||
|
ClienteID: number;
|
||||||
|
DataRitiro: Date | null;
|
||||||
|
DataConsegna: Date | null;
|
||||||
|
Stato: StatoConsegna;
|
||||||
|
ChiaveConsegna: string;
|
||||||
|
}
|
||||||
40
src/validation/constraints.ts
Normal file
40
src/validation/constraints.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
//=========================================
|
||||||
|
// File: constraints.ts
|
||||||
|
// Vincoli derivati da initdb/01_schema.sql.
|
||||||
|
// author: "villari.andrea@libero.it"
|
||||||
|
// version: "1.0.0 2026-06-18"
|
||||||
|
//=========================================
|
||||||
|
|
||||||
|
export const UTENTE_CONSTRAINTS = {
|
||||||
|
nome: { maxLength: 100, required: true },
|
||||||
|
cognome: { maxLength: 100, required: true },
|
||||||
|
email: { maxLength: 255, required: true },
|
||||||
|
password: { minLength: 8, maxLength: 72, required: true },
|
||||||
|
ruolo: {allowedValues: ["Operatore", "Amministratore"],
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CLIENTE_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},
|
||||||
|
telefono: { maxLength: 30, required: true },
|
||||||
|
email: { maxLength: 255, required: true },
|
||||||
|
note: { required: false },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
|
export const CONSEGNA_CONSTRAINTS = {
|
||||||
|
dataRitiro: { required: false },
|
||||||
|
dataConsegna: { required: false },
|
||||||
|
stato: {
|
||||||
|
allowedValues: ["da ritirare", "in consegna", "in giacenza", "in deposito"],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
chiaveConsegna: { maxLength: 8, required: true },
|
||||||
|
clienteId: { required: true },
|
||||||
|
} as const;
|
||||||
|
|
||||||
21
src/validation/middleware.ts
Normal file
21
src/validation/middleware.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
import type { ValidationResult } from "./primitives.js";
|
||||||
|
|
||||||
|
export function validateBody<T>(
|
||||||
|
validator: (body: Record<string, unknown>) => ValidationResult<T>
|
||||||
|
): RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const result = validator(req.body as Record<string, unknown>);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: "Validazione fallita",
|
||||||
|
fields: result.errors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.validatedBody = result.data;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
199
src/validation/primitives.ts
Normal file
199
src/validation/primitives.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
//==========================================
|
||||||
|
// File: primitives.ts
|
||||||
|
// Funzioni di validazione dei dati prmitivi
|
||||||
|
// author: "villari.andrea@libero.it"
|
||||||
|
// version: "1.0.0 2026-06-18"
|
||||||
|
//==========================================
|
||||||
|
|
||||||
|
export type FieldErrors = Record<string, string>;
|
||||||
|
|
||||||
|
export type ValidationResult<T> =
|
||||||
|
| { success: true; data: T }
|
||||||
|
| { success: false; errors: FieldErrors };
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export function normalizeString(value: unknown): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEmail(value: unknown): string {
|
||||||
|
return normalizeString(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBoolean(value: unknown, defaultValue = false): boolean {
|
||||||
|
if (value === true || value === "true") return true;
|
||||||
|
if (value === false || value === "false") return false;
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDecimal(value: unknown): number {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
const normalized = value?.toString().trim().replace(",", ".") ?? "";
|
||||||
|
if (!normalized) return Number.NaN;
|
||||||
|
return Number(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePositiveInt(value: unknown): number {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTime(value: unknown): string {
|
||||||
|
const str = normalizeString(value);
|
||||||
|
if (!str) return "";
|
||||||
|
if (/^\d{2}:\d{2}$/.test(str)) return `${str}:00`;
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRequired(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: string,
|
||||||
|
message: string
|
||||||
|
): void {
|
||||||
|
if (!value) errors[field] = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMaxLength(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: string,
|
||||||
|
maxLength: number,
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
|
if (value.length > maxLength) {
|
||||||
|
errors[field] = `${label} deve contenere al massimo ${maxLength} caratteri`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMinLength(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: string,
|
||||||
|
minLength: number,
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
|
if (value && value.length < minLength) {
|
||||||
|
errors[field] = `${label} deve contenere almeno ${minLength} caratteri`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEmailFormat(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: string
|
||||||
|
): void {
|
||||||
|
if (value && !EMAIL_REGEX.test(value)) {
|
||||||
|
errors[field] = "Formato email non valido";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEnum(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: string,
|
||||||
|
allowedValues: readonly string[],
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
|
if (value && !allowedValues.includes(value)) {
|
||||||
|
errors[field] = `${label} non valido`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBoolean(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: unknown,
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
|
if (value === undefined || value === null || value === "") return;
|
||||||
|
if (
|
||||||
|
value !== true &&
|
||||||
|
value !== false &&
|
||||||
|
value !== "true" &&
|
||||||
|
value !== "false"
|
||||||
|
) {
|
||||||
|
errors[field] = `${label} non valido`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const TIME_REGEX = /^\d{2}:\d{2}:\d{2}$/;
|
||||||
|
|
||||||
|
export function validateDate(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: string,
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
|
if (!value) return;
|
||||||
|
if (!DATE_REGEX.test(value)) {
|
||||||
|
errors[field] = `${label} non valida`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = new Date(`${value}T00:00:00`);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
errors[field] = `${label} non valida`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTimeFormat(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: string,
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
|
if (!value) return;
|
||||||
|
if (!TIME_REGEX.test(value)) {
|
||||||
|
errors[field] = `${label} non valida`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePositiveInt(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: number,
|
||||||
|
label: string,
|
||||||
|
required = true
|
||||||
|
): void {
|
||||||
|
if (Number.isNaN(value) || value <= 0) {
|
||||||
|
errors[field] = required ? `${label} obbligatorio` : `${label} non valido`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTimeRange(
|
||||||
|
errors: FieldErrors,
|
||||||
|
startField: string,
|
||||||
|
endField: string,
|
||||||
|
start: string,
|
||||||
|
end: string
|
||||||
|
): void {
|
||||||
|
if (!start || !end) return;
|
||||||
|
if (start >= end) {
|
||||||
|
errors[endField] =
|
||||||
|
"L'ora di fine deve essere successiva all'ora di inizio";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDecimalPositive(
|
||||||
|
errors: FieldErrors,
|
||||||
|
field: string,
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
min = 0.01
|
||||||
|
): void {
|
||||||
|
if (value === undefined || value === null || value === "") return;
|
||||||
|
const num = typeof value === "number" ? value : Number(value);
|
||||||
|
if (Number.isNaN(num) || num < min) {
|
||||||
|
errors[field] = `${label} deve essere almeno ${min}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildResult<T>(data: T, errors: FieldErrors): ValidationResult<T> {
|
||||||
|
return Object.keys(errors).length > 0
|
||||||
|
? { success: false, errors }
|
||||||
|
: { success: true, data };
|
||||||
|
}
|
||||||
220
src/validation/utente.ts
Normal file
220
src/validation/utente.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import type { Ruolo } from "../types/index.js";
|
||||||
|
import { UTENTE_CONSTRAINTS } from "./constraints.js";
|
||||||
|
import {
|
||||||
|
buildResult,
|
||||||
|
normalizeEmail,
|
||||||
|
normalizeString,
|
||||||
|
validateEmailFormat,
|
||||||
|
validateEnum,
|
||||||
|
validateMaxLength,
|
||||||
|
validateMinLength,
|
||||||
|
validateRequired,
|
||||||
|
type FieldErrors,
|
||||||
|
type ValidationResult,
|
||||||
|
} from "./primitives.js";
|
||||||
|
|
||||||
|
export interface LoginInput {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtenteCreateInput {
|
||||||
|
nome: string;
|
||||||
|
cognome: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
ruolo: Ruolo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UtenteUpdateInput = Partial<UtenteCreateInput>;
|
||||||
|
|
||||||
|
export function normalizeLoginInput(raw: Record<string, unknown>): LoginInput {
|
||||||
|
return {
|
||||||
|
email: normalizeEmail(raw.email),
|
||||||
|
password: raw.password?.toString() ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLoginInput(
|
||||||
|
raw: Record<string, unknown>
|
||||||
|
): ValidationResult<LoginInput> {
|
||||||
|
const data = normalizeLoginInput(raw);
|
||||||
|
const errors: FieldErrors = {};
|
||||||
|
|
||||||
|
validateRequired(errors, "email", data.email, "Email obbligatoria");
|
||||||
|
validateMaxLength(
|
||||||
|
errors,
|
||||||
|
"email",
|
||||||
|
data.email,
|
||||||
|
UTENTE_CONSTRAINTS.email.maxLength,
|
||||||
|
"Email"
|
||||||
|
);
|
||||||
|
validateEmailFormat(errors, "email", data.email);
|
||||||
|
validateRequired(errors, "password", data.password, "Password obbligatoria");
|
||||||
|
validateMaxLength(
|
||||||
|
errors,
|
||||||
|
"password",
|
||||||
|
data.password,
|
||||||
|
UTENTE_CONSTRAINTS.password.maxLength,
|
||||||
|
"Password"
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildResult(data, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUtenteProfileFields(
|
||||||
|
data: UtenteCreateInput,
|
||||||
|
errors: FieldErrors
|
||||||
|
): void {
|
||||||
|
validateRequired(errors, "nome", data.nome, "Nome obbligatorio");
|
||||||
|
validateMaxLength(
|
||||||
|
errors,
|
||||||
|
"nome",
|
||||||
|
data.nome,
|
||||||
|
UTENTE_CONSTRAINTS.nome.maxLength,
|
||||||
|
"Nome"
|
||||||
|
);
|
||||||
|
validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio");
|
||||||
|
validateMaxLength(
|
||||||
|
errors,
|
||||||
|
"cognome",
|
||||||
|
data.cognome,
|
||||||
|
UTENTE_CONSTRAINTS.cognome.maxLength,
|
||||||
|
"Cognome"
|
||||||
|
);
|
||||||
|
validateRequired(errors, "email", data.email, "Email obbligatoria");
|
||||||
|
validateMaxLength(
|
||||||
|
errors,
|
||||||
|
"email",
|
||||||
|
data.email,
|
||||||
|
UTENTE_CONSTRAINTS.email.maxLength,
|
||||||
|
"Email"
|
||||||
|
);
|
||||||
|
validateEmailFormat(errors, "email", data.email);
|
||||||
|
|
||||||
|
validateRequired(errors, "password", data.password, "Password obbligatoria");
|
||||||
|
validateMinLength(
|
||||||
|
errors,
|
||||||
|
"password",
|
||||||
|
data.password,
|
||||||
|
UTENTE_CONSTRAINTS.password.minLength,
|
||||||
|
"Password"
|
||||||
|
);
|
||||||
|
validateMaxLength(
|
||||||
|
errors,
|
||||||
|
"password",
|
||||||
|
data.password,
|
||||||
|
UTENTE_CONSTRAINTS.password.maxLength,
|
||||||
|
"Password"
|
||||||
|
);
|
||||||
|
|
||||||
|
validateRequired(errors, "ruolo", data.ruolo, "Ruolo obbligatorio");
|
||||||
|
validateEnum(
|
||||||
|
errors,
|
||||||
|
"ruolo",
|
||||||
|
data.ruolo,
|
||||||
|
UTENTE_CONSTRAINTS.ruolo.allowedValues,
|
||||||
|
"Ruolo"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUtenteCreate(
|
||||||
|
raw: Record<string, unknown>
|
||||||
|
): ValidationResult<UtenteCreateInput> {
|
||||||
|
const data: UtenteCreateInput = {
|
||||||
|
nome: normalizeString(raw.nome),
|
||||||
|
cognome: normalizeString(raw.cognome),
|
||||||
|
email: normalizeEmail(raw.email),
|
||||||
|
password: raw.password?.toString() ?? "",
|
||||||
|
ruolo: normalizeString(raw.ruolo) as Ruolo,
|
||||||
|
};
|
||||||
|
const errors: FieldErrors = {};
|
||||||
|
|
||||||
|
validateUtenteProfileFields(data, errors);
|
||||||
|
|
||||||
|
return buildResult(data, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function validateUtenteUpdate(
|
||||||
|
raw: Record<string, unknown>
|
||||||
|
): ValidationResult<UtenteUpdateInput> {
|
||||||
|
const errors: FieldErrors = {};
|
||||||
|
const data: UtenteUpdateInput = {};
|
||||||
|
|
||||||
|
if ("nome" in raw) {
|
||||||
|
data.nome = normalizeString(raw.nome);
|
||||||
|
validateRequired(errors, "nome", data.nome, "Nome obbligatorio");
|
||||||
|
validateMaxLength(
|
||||||
|
errors,
|
||||||
|
"nome",
|
||||||
|
data.nome,
|
||||||
|
UTENTE_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,
|
||||||
|
UTENTE_CONSTRAINTS.cognome.maxLength,
|
||||||
|
"Cognome"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("email" in raw) {
|
||||||
|
data.email = normalizeEmail(raw.email);
|
||||||
|
validateRequired(errors, "email", data.email, "Email obbligatoria");
|
||||||
|
validateMaxLength(
|
||||||
|
errors,
|
||||||
|
"email",
|
||||||
|
data.email,
|
||||||
|
UTENTE_CONSTRAINTS.email.maxLength,
|
||||||
|
"Email"
|
||||||
|
);
|
||||||
|
validateEmailFormat(errors, "email", data.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("password" in raw) {
|
||||||
|
const password = raw.password?.toString() ?? "";
|
||||||
|
if (password) {
|
||||||
|
data.password = password;
|
||||||
|
validateMinLength(
|
||||||
|
errors,
|
||||||
|
"password",
|
||||||
|
password,
|
||||||
|
UTENTE_CONSTRAINTS.password.minLength,
|
||||||
|
"Password"
|
||||||
|
);
|
||||||
|
validateMaxLength(
|
||||||
|
errors,
|
||||||
|
"password",
|
||||||
|
password,
|
||||||
|
UTENTE_CONSTRAINTS.password.maxLength,
|
||||||
|
"Password"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("ruolo" in raw) {
|
||||||
|
data.ruolo = normalizeString(raw.ruolo) as Ruolo;
|
||||||
|
validateRequired(errors, "ruolo", data.ruolo, "Ruolo obbligatorio");
|
||||||
|
validateEnum(
|
||||||
|
errors,
|
||||||
|
"ruolo",
|
||||||
|
data.ruolo,
|
||||||
|
UTENTE_CONSTRAINTS.ruolo.allowedValues,
|
||||||
|
"Ruolo"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
errors._form = "Nessun campo da aggiornare";
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildResult(data, errors);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue