generazine backend
All checks were successful
Deploy to VPS / build (push) Successful in 17s
Deploy to VPS / deploy (push) Successful in 29s

This commit is contained in:
AV77web 2026-06-09 13:01:53 +02:00
parent 712d4add3c
commit 3c2694d607
19 changed files with 896 additions and 21 deletions

34
dist/index.js vendored
View file

@ -1,23 +1,31 @@
"use strict"; import { ExpressAuth } from "@auth/express";
var __importDefault = (this && this.__importDefault) || function (mod) { import cors from "cors";
return (mod && mod.__esModule) ? mod : { "default": mod }; import dotenv from "dotenv";
}; import express from "express";
Object.defineProperty(exports, "__esModule", { value: true }); import { authConfig } from "./auth/config.js";
const express_1 = __importDefault(require("express")); import { errorHandler } from "./middleware/errorHandler.js";
const cors_1 = __importDefault(require("cors")); import categorieRouter from "./routes/categorie.js";
const dotenv_1 = __importDefault(require("dotenv")); import rimborsiRouter from "./routes/rimborsi.js";
dotenv_1.default.config(); import statisticheRouter from "./routes/statistiche.js";
const app = (0, express_1.default)(); import utentiRouter from "./routes/utenti.js";
dotenv.config();
const app = express();
const port = Number(process.env.PORT) || 3003; const port = Number(process.env.PORT) || 3003;
app.set("trust proxy", 1); app.set("trust proxy", 1);
app.use((0, cors_1.default)({ app.use(cors({
origin: process.env.FRONTEND_URL ?? "https://provapraticafrontend.andreavillari.it", origin: process.env.FRONTEND_URL ?? "http://localhost:5173",
credentials: true, credentials: true,
})); }));
app.use(express_1.default.json()); app.use(express.json());
app.get("/api/health", (_req, res) => { app.get("/api/health", (_req, res) => {
res.json({ status: "ok" }); res.json({ status: "ok" });
}); });
app.use("/api/auth/*", ExpressAuth(authConfig));
app.use("/api/utenti", utentiRouter);
app.use("/api/categorie-spesa", categorieRouter);
app.use("/api/rimborsi", rimborsiRouter);
app.use("/api/statistiche", statisticheRouter);
app.use(errorHandler);
app.listen(port, "0.0.0.0", () => { app.listen(port, "0.0.0.0", () => {
console.log(`Backend in ascolto sulla porta ${port}`); console.log(`Backend in ascolto sulla porta ${port}`);
}); });

2
dist/index.js.map vendored
View file

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,sDAA8B;AAC9B,gDAAwB;AACxB,oDAA4B;AAE5B,gBAAM,CAAC,MAAM,EAAE,CAAC;AAEhB,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;AAE9C,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC;AAC1B,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,EAAC;IACX,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,+CAA+C;IACnF,WAAW,EAAE,IAAI;CAClB,CAAC,CAAC,CAAC;AACJ,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;IACnC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AAC7B,CAAC,CAAC,CAAC;AAEH,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE;IAC/B,OAAO,CAAC,GAAG,CAAC,kCAAkC,IAAI,EAAE,CAAC,CAAC;AACxD,CAAC,CAAC,CAAC"} {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,OAAO,MAAM,SAAS,CAAC;AAC9B,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,eAAe,MAAM,uBAAuB,CAAC;AACpD,OAAO,cAAc,MAAM,sBAAsB,CAAC;AAClD,OAAO,iBAAiB,MAAM,yBAAyB,CAAC;AACxD,OAAO,YAAY,MAAM,oBAAoB,CAAC;AAE9C,MAAM,CAAC,MAAM,EAAE,CAAC;AAEhB,MAAM,GAAG,GAAG,OAAO,EAAE,CAAC;AACtB,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;AAE9C,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC;AAC1B,GAAG,CAAC,GAAG,CACL,IAAI,CAAC;IACH,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,uBAAuB;IAC3D,WAAW,EAAE,IAAI;CAClB,CAAC,CACH,CAAC;AACF,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;IACnC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AAC7B,CAAC,CAAC,CAAC;AAEH,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,WAAW,CAAC,UAAU,CAAC,CAAC,CAAC;AAChD,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,YAAY,CAAC,CAAC;AACrC,GAAG,CAAC,GAAG,CAAC,sBAAsB,EAAE,eAAe,CAAC,CAAC;AACjD,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,cAAc,CAAC,CAAC;AACzC,GAAG,CAAC,GAAG,CAAC,kBAAkB,EAAE,iBAAiB,CAAC,CAAC;AAE/C,GAAG,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;AAEtB,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE;IAC/B,OAAO,CAAC,GAAG,CAAC,kCAAkC,IAAI,EAAE,CAAC,CAAC;AACxD,CAAC,CAAC,CAAC"}

25
initdb/02_seed.sql Normal file
View file

@ -0,0 +1,25 @@
USE provapratica;
INSERT INTO categorie_spesa (descrizione) VALUES
('Trasferta'),
('Pasto'),
('Pedaggio'),
('Parcheggio'),
('Acquisto autorizzato');
-- Password per tutti: Password123!
INSERT INTO utenti (nome, cognome, email, password_hash, ruolo) VALUES
('Marco', 'Rossi', 'marco.rossi@azienda.it', '$2b$10$xJ9shj7EP1XZzcKztutcs.K4np19OVExwZcSGlBmS/5WTZSt9W.c6', 'DIPENDENTE'),
('Laura', 'Bianchi', 'laura.bianchi@azienda.it', '$2b$10$xJ9shj7EP1XZzcKztutcs.K4np19OVExwZcSGlBmS/5WTZSt9W.c6', 'DIPENDENTE'),
('Giulia', 'Verdi', 'giulia.verdi@azienda.it', '$2b$10$xJ9shj7EP1XZzcKztutcs.K4np19OVExwZcSGlBmS/5WTZSt9W.c6', 'DIPENDENTE'),
('Paolo', 'Admin', 'admin@azienda.it', '$2b$10$xJ9shj7EP1XZzcKztutcs.K4np19OVExwZcSGlBmS/5WTZSt9W.c6', 'RESPONSABILE_AMMINISTRATIVO');
INSERT INTO richieste_rimborso
(data_inserimento, data_spesa, categoria_id, importo, descrizione, riferimento_giustificativo, stato, dipendente_id, data_valutazione, responsabile_valutazione_id, motivazione_rifiuto, data_liquidazione)
VALUES
('2026-05-02 09:00:00', '2026-05-01', 1, 120.50, 'Trasferta Milano-Bologna', 'BIGL-001', 'IN_ATTESA', 1, NULL, NULL, NULL, NULL),
('2026-05-03 10:00:00', '2026-05-02', 2, 35.00, 'Pranzo con cliente', 'SCONTR-102', 'IN_ATTESA', 1, NULL, NULL, NULL, NULL),
('2026-05-05 11:00:00', '2026-05-04', 3, 18.50, 'Pedaggio autostrada A1', 'PED-445', 'APPROVATA', 2, '2026-05-06 14:00:00', 4, NULL, NULL),
('2026-05-07 08:30:00', '2026-05-06', 4, 12.00, 'Parcheggio centro storico', NULL, 'LIQUIDATA', 2, '2026-05-08 09:00:00', 4, NULL, '2026-05-10 16:00:00'),
('2026-05-08 15:00:00', '2026-05-07', 5, 45.00, 'Cancelleria per ufficio', 'FATT-778', 'RIFIUTATA', 3, '2026-05-09 10:00:00', 4, 'Spesa non autorizzata preventivamente', NULL),
('2026-05-10 09:00:00', '2026-05-09', 1, 89.00, 'Taxi aeroporto', 'TAXI-22', 'APPROVATA', 3, '2026-05-11 11:00:00', 4, NULL, NULL);

View file

@ -2,7 +2,7 @@
"name": "provapraticabackend", "name": "provapraticabackend",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"type": "commonjs", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",

61
src/auth/config.ts Normal file
View file

@ -0,0 +1,61 @@
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 { Ruolo, Utente } from "../types/index.js";
export const authConfig: ExpressAuthConfig = {
trustHost: true,
secret: process.env.AUTH_SECRET,
session: { strategy: "jwt" },
pages: { signIn: "/login" },
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const email = credentials?.email?.toString().trim().toLowerCase();
const password = credentials?.password?.toString();
if (!email || !password) return null;
const [rows] = await pool.execute<RowDataPacket[]>(
"SELECT utente_id, nome, cognome, email, password_hash, ruolo FROM utenti WHERE email = ?",
[email]
);
const user = rows[0] as Utente | undefined;
if (!user) return null;
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return null;
return {
id: String(user.utente_id),
email: user.email,
name: `${user.nome} ${user.cognome}`,
ruolo: user.ruolo as 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 as Ruolo;
}
return session;
},
},
};

14
src/db/pool.ts Normal file
View file

@ -0,0 +1,14 @@
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 ?? "provapratica",
user: process.env.DB_USER ?? "provapratica_user",
password: process.env.DB_PASSWORD ?? "",
waitForConnections: true,
connectionLimit: 10,
dateStrings: false,
});
export default pool;

View file

@ -1,6 +1,13 @@
import express from "express"; import { ExpressAuth } from "@auth/express";
import cors from "cors"; import cors from "cors";
import dotenv from "dotenv"; import dotenv from "dotenv";
import express from "express";
import { authConfig } from "./auth/config.js";
import { errorHandler } from "./middleware/errorHandler.js";
import categorieRouter from "./routes/categorie.js";
import rimborsiRouter from "./routes/rimborsi.js";
import statisticheRouter from "./routes/statistiche.js";
import utentiRouter from "./routes/utenti.js";
dotenv.config(); dotenv.config();
@ -8,16 +15,26 @@ const app = express();
const port = Number(process.env.PORT) || 3003; const port = Number(process.env.PORT) || 3003;
app.set("trust proxy", 1); app.set("trust proxy", 1);
app.use(cors({ app.use(
origin: process.env.FRONTEND_URL ?? "https://provapraticafrontend.andreavillari.it", cors({
credentials: true, origin: process.env.FRONTEND_URL ?? "http://localhost:5173",
})); credentials: true,
})
);
app.use(express.json()); app.use(express.json());
app.get("/api/health", (_req, res) => { app.get("/api/health", (_req, res) => {
res.json({ status: "ok" }); res.json({ status: "ok" });
}); });
app.use("/api/auth/*", ExpressAuth(authConfig));
app.use("/api/utenti", utentiRouter);
app.use("/api/categorie-spesa", categorieRouter);
app.use("/api/rimborsi", rimborsiRouter);
app.use("/api/statistiche", statisticheRouter);
app.use(errorHandler);
app.listen(port, "0.0.0.0", () => { app.listen(port, "0.0.0.0", () => {
console.log(`Backend in ascolto sulla porta ${port}`); console.log(`Backend in ascolto sulla porta ${port}`);
}); });

42
src/middleware/auth.ts Normal file
View file

@ -0,0 +1,42 @@
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 requireRole(...ruoli: Ruolo[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !ruoli.includes(req.user.ruolo)) {
res.status(403).json({ error: "Operazione non consentita per il ruolo corrente" });
return;
}
next();
};
}
declare global {
namespace Express {
interface Request {
user?: {
id: number;
email: string;
name: string;
ruolo: Ruolo;
};
}
}
}

View file

@ -0,0 +1,6 @@
import type { NextFunction, Request, Response } from "express";
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) {
console.error(err);
res.status(500).json({ error: "Errore interno del server" });
}

17
src/routes/categorie.ts Normal file
View file

@ -0,0 +1,17 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth.js";
import { listCategorie } from "../services/rimborsiService.js";
const router = Router();
router.get("/", requireAuth, async (_req, res) => {
const categorie = await listCategorie();
res.json(
categorie.map((c) => ({
id: c.categoria_id,
descrizione: c.descrizione,
}))
);
});
export default router;

168
src/routes/rimborsi.ts Normal file
View file

@ -0,0 +1,168 @@
import { Router } from "express";
import { requireAuth, requireRole } from "../middleware/auth.js";
import {
approvaRimborso,
categoriaExists,
createRimborso,
deleteRimborso,
getRimborsoById,
liquidaRimborso,
listRimborsi,
rifiutaRimborso,
updateRimborso,
validateRichiestaBody,
} from "../services/rimborsiService.js";
import { formatRimborso } from "../utils/format.js";
import type { StatoRichiesta } from "../types/index.js";
const router = Router();
const STATI: StatoRichiesta[] = ["IN_ATTESA", "APPROVATA", "RIFIUTATA", "LIQUIDATA"];
router.use(requireAuth);
router.get("/", async (req, res) => {
const stato = req.query.stato?.toString() as StatoRichiesta | undefined;
if (stato && !STATI.includes(stato)) {
res.status(400).json({ error: "Stato non valido" });
return;
}
const rimborsi = await listRimborsi(
{
stato,
categoriaId: req.query.categoriaId ? Number(req.query.categoriaId) : undefined,
mese: req.query.mese?.toString(),
dipendenteId: req.query.dipendenteId ? Number(req.query.dipendenteId) : undefined,
},
req.user!.ruolo,
req.user!.id
);
res.json(rimborsi.map(formatRimborso));
});
router.get("/:id", async (req, res) => {
const id = Number(req.params.id);
const rimborso = await getRimborsoById(id);
if (!rimborso) {
res.status(404).json({ error: "Richiesta non trovata" });
return;
}
if (req.user!.ruolo === "DIPENDENTE" && rimborso.dipendente_id !== req.user!.id) {
res.status(403).json({ error: "Accesso non consentito" });
return;
}
res.json(formatRimborso(rimborso));
});
router.post("/", async (req, res) => {
if (req.user!.ruolo !== "DIPENDENTE") {
res.status(403).json({ error: "Solo i dipendenti possono creare richieste" });
return;
}
const { error, data } = validateRichiestaBody(req.body);
if (error || !data) {
res.status(400).json({ error });
return;
}
if (!(await categoriaExists(data.categoriaId))) {
res.status(400).json({ error: "Categoria non esistente" });
return;
}
const id = await createRimborso(req.user!.id, data);
const created = await getRimborsoById(id);
res.status(201).json(formatRimborso(created!));
});
router.put("/:id", async (req, res) => {
const id = Number(req.params.id);
const existing = await getRimborsoById(id);
if (!existing) {
res.status(404).json({ error: "Richiesta non trovata" });
return;
}
if (req.user!.ruolo === "DIPENDENTE" && existing.dipendente_id !== req.user!.id) {
res.status(403).json({ error: "Accesso non consentito" });
return;
}
if (existing.stato !== "IN_ATTESA") {
res.status(400).json({ error: "La richiesta non può essere modificata nello stato corrente" });
return;
}
const { error, data } = validateRichiestaBody(req.body);
if (error || !data) {
res.status(400).json({ error });
return;
}
if (!(await categoriaExists(data.categoriaId))) {
res.status(400).json({ error: "Categoria non esistente" });
return;
}
await updateRimborso(id, data);
const updated = await getRimborsoById(id);
res.json(formatRimborso(updated!));
});
router.delete("/:id", async (req, res) => {
const id = Number(req.params.id);
const existing = await getRimborsoById(id);
if (!existing) {
res.status(404).json({ error: "Richiesta non trovata" });
return;
}
if (req.user!.ruolo === "DIPENDENTE" && existing.dipendente_id !== req.user!.id) {
res.status(403).json({ error: "Accesso non consentito" });
return;
}
if (existing.stato !== "IN_ATTESA") {
res.status(400).json({ error: "La richiesta non può essere eliminata nello stato corrente" });
return;
}
const deleted = await deleteRimborso(id);
if (!deleted) {
res.status(400).json({ error: "Impossibile eliminare la richiesta" });
return;
}
res.json({ message: "Richiesta eliminata" });
});
router.put("/:id/approva", requireRole("RESPONSABILE_AMMINISTRATIVO"), async (req, res) => {
const id = Number(req.params.id);
const ok = await approvaRimborso(id, req.user!.id);
if (!ok) {
res.status(400).json({ error: "Impossibile approvare la richiesta" });
return;
}
const updated = await getRimborsoById(id);
res.json(formatRimborso(updated!));
});
router.put("/:id/rifiuta", requireRole("RESPONSABILE_AMMINISTRATIVO"), async (req, res) => {
const id = Number(req.params.id);
const motivazione = req.body.motivazione?.toString().trim() || null;
const ok = await rifiutaRimborso(id, req.user!.id, motivazione);
if (!ok) {
res.status(400).json({ error: "Impossibile rifiutare la richiesta" });
return;
}
const updated = await getRimborsoById(id);
res.json(formatRimborso(updated!));
});
router.put("/:id/liquida", requireRole("RESPONSABILE_AMMINISTRATIVO"), async (req, res) => {
const id = Number(req.params.id);
const ok = await liquidaRimborso(id);
if (!ok) {
res.status(400).json({ error: "Impossibile liquidare la richiesta" });
return;
}
const updated = await getRimborsoById(id);
res.json(formatRimborso(updated!));
});
export default router;

16
src/routes/statistiche.ts Normal file
View file

@ -0,0 +1,16 @@
import { Router } from "express";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { getStatisticheRimborsi } from "../services/statisticheService.js";
const router = Router();
router.get("/rimborsi", requireAuth, requireRole("RESPONSABILE_AMMINISTRATIVO"), async (req, res) => {
const mese = req.query.mese?.toString();
const categoriaId = req.query.categoriaId ? Number(req.query.categoriaId) : undefined;
const dipendenteId = req.query.dipendenteId ? Number(req.query.dipendenteId) : undefined;
const stats = await getStatisticheRimborsi({ mese, categoriaId, dipendenteId });
res.json(stats);
});
export default router;

65
src/routes/utenti.ts Normal file
View file

@ -0,0 +1,65 @@
import { getSession } from "@auth/express";
import { Router } from "express";
import { authConfig } from "../auth/config.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { listDipendenti } from "../services/rimborsiService.js";
import { createUtente, findByEmail, validateRegistration } from "../services/utentiService.js";
import type { Ruolo } from "../types/index.js";
const router = Router();
router.post("/register", async (req, res) => {
const validationError = validateRegistration(req.body);
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
const existing = await findByEmail(req.body.email);
if (existing) {
res.status(409).json({ error: "Email già registrata" });
return;
}
const ruolo = (req.body.ruolo as Ruolo) ?? "DIPENDENTE";
const id = await createUtente({
nome: req.body.nome,
cognome: req.body.cognome,
email: req.body.email,
password: req.body.password,
ruolo,
});
res.status(201).json({
message: "Registrazione completata",
utente: { id, nome: req.body.nome.trim(), cognome: req.body.cognome.trim(), email: req.body.email.trim().toLowerCase(), ruolo },
});
});
router.get("/dipendenti", requireAuth, requireRole("RESPONSABILE_AMMINISTRATIVO"), async (_req, res) => {
const dipendenti = await listDipendenti();
res.json(
dipendenti.map((d) => ({
id: d.utente_id,
nome: d.nome,
cognome: d.cognome,
}))
);
});
router.get("/me", async (req, res) => {
const session = await getSession(req, authConfig);
if (!session?.user?.id) {
res.status(401).json({ error: "Non autenticato" });
return;
}
res.json({
id: Number(session.user.id),
email: session.user.email,
nome: session.user.name.split(" ")[0],
cognome: session.user.name.split(" ").slice(1).join(" "),
ruolo: session.user.ruolo,
});
});
export default router;

View file

@ -0,0 +1,225 @@
import type { ResultSetHeader, RowDataPacket } from "mysql2";
import pool from "../db/pool.js";
import type { RichiestaRimborsoDettaglio, Ruolo, StatoRichiesta } from "../types/index.js";
const SELECT_BASE = `
SELECT r.*,
c.descrizione AS categoria_descrizione,
d.nome AS dipendente_nome, d.cognome AS dipendente_cognome,
resp.nome AS responsabile_nome, resp.cognome AS responsabile_cognome
FROM richieste_rimborso r
JOIN categorie_spesa c ON c.categoria_id = r.categoria_id
JOIN utenti d ON d.utente_id = r.dipendente_id
LEFT JOIN utenti resp ON resp.utente_id = r.responsabile_valutazione_id
`;
function mapRow(row: RowDataPacket): RichiestaRimborsoDettaglio {
return {
richiesta_id: row.richiesta_id,
data_inserimento: row.data_inserimento,
data_spesa: row.data_spesa,
categoria_id: row.categoria_id,
importo: Number(row.importo),
descrizione: row.descrizione,
riferimento_giustificativo: row.riferimento_giustificativo,
stato: row.stato,
dipendente_id: row.dipendente_id,
data_valutazione: row.data_valutazione,
responsabile_valutazione_id: row.responsabile_valutazione_id,
motivazione_rifiuto: row.motivazione_rifiuto,
data_liquidazione: row.data_liquidazione,
categoria_descrizione: row.categoria_descrizione,
dipendente_nome: row.dipendente_nome,
dipendente_cognome: row.dipendente_cognome,
responsabile_nome: row.responsabile_nome,
responsabile_cognome: row.responsabile_cognome,
};
}
export interface FiltriRimborsi {
stato?: StatoRichiesta;
categoriaId?: number;
mese?: string;
dipendenteId?: number;
}
function buildFilters(
filtri: FiltriRimborsi,
ruolo: Ruolo,
userId: number
): { where: string; params: (string | number)[] } {
const clauses: string[] = [];
const params: (string | number)[] = [];
if (ruolo === "DIPENDENTE") {
clauses.push("r.dipendente_id = ?");
params.push(userId);
} else if (filtri.dipendenteId) {
clauses.push("r.dipendente_id = ?");
params.push(filtri.dipendenteId);
}
if (filtri.stato) {
clauses.push("r.stato = ?");
params.push(filtri.stato);
}
if (filtri.categoriaId) {
clauses.push("r.categoria_id = ?");
params.push(filtri.categoriaId);
}
if (filtri.mese && /^\d{4}-\d{2}$/.test(filtri.mese)) {
clauses.push("DATE_FORMAT(r.data_spesa, '%Y-%m') = ?");
params.push(filtri.mese);
}
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
return { where, params };
}
export async function listRimborsi(
filtri: FiltriRimborsi,
ruolo: Ruolo,
userId: number
): Promise<RichiestaRimborsoDettaglio[]> {
const { where, params } = buildFilters(filtri, ruolo, userId);
const [rows] = await pool.execute<RowDataPacket[]>(
`${SELECT_BASE} ${where} ORDER BY r.data_inserimento DESC`,
params
);
return rows.map(mapRow);
}
export async function getRimborsoById(id: number): Promise<RichiestaRimborsoDettaglio | null> {
const [rows] = await pool.execute<RowDataPacket[]>(
`${SELECT_BASE} WHERE r.richiesta_id = ?`,
[id]
);
return rows[0] ? mapRow(rows[0]) : null;
}
export async function categoriaExists(id: number): Promise<boolean> {
const [rows] = await pool.execute<RowDataPacket[]>(
"SELECT 1 FROM categorie_spesa WHERE categoria_id = ?",
[id]
);
return rows.length > 0;
}
export function validateRichiestaBody(
body: Record<string, unknown>,
isUpdate = false
): { error: string | null; data?: { dataSpesa: string; categoriaId: number; importo: number; descrizione: string; riferimentoGiustificativo: string | null } } {
const dataSpesa = body.dataSpesa?.toString().trim();
const categoriaId = Number(body.categoriaId);
const importo = Number(body.importo);
const descrizione = body.descrizione?.toString().trim();
const riferimento = body.riferimentoGiustificativo?.toString().trim() ?? null;
if (!isUpdate || dataSpesa !== undefined) {
if (!dataSpesa) return { error: "La data della spesa è obbligatoria" };
}
if (!isUpdate || body.categoriaId !== undefined) {
if (!categoriaId || Number.isNaN(categoriaId)) return { error: "Categoria non valida" };
}
if (!isUpdate || body.importo !== undefined) {
if (!importo || importo <= 0) return { error: "L'importo deve essere maggiore di zero" };
}
if (!isUpdate || body.descrizione !== undefined) {
if (!descrizione) return { error: "La descrizione è obbligatoria" };
}
if (riferimento !== null && riferimento !== "" && !riferimento.trim()) {
return { error: "Il riferimento al giustificativo non può essere composto solo da spazi" };
}
return {
error: null,
data: {
dataSpesa: dataSpesa!,
categoriaId,
importo,
descrizione: descrizione!,
riferimentoGiustificativo: riferimento || null,
},
};
}
export async function createRimborso(
dipendenteId: number,
data: { dataSpesa: string; categoriaId: number; importo: number; descrizione: string; riferimentoGiustificativo: string | null }
): Promise<number> {
const [result] = await pool.execute<ResultSetHeader>(
`INSERT INTO richieste_rimborso
(data_spesa, categoria_id, importo, descrizione, riferimento_giustificativo, stato, dipendente_id)
VALUES (?, ?, ?, ?, ?, 'IN_ATTESA', ?)`,
[data.dataSpesa, data.categoriaId, data.importo, data.descrizione, data.riferimentoGiustificativo, dipendenteId]
);
return result.insertId;
}
export async function updateRimborso(
id: number,
data: { dataSpesa: string; categoriaId: number; importo: number; descrizione: string; riferimentoGiustificativo: string | null }
): Promise<void> {
await pool.execute(
`UPDATE richieste_rimborso
SET data_spesa = ?, categoria_id = ?, importo = ?, descrizione = ?, riferimento_giustificativo = ?
WHERE richiesta_id = ? AND stato = 'IN_ATTESA'`,
[data.dataSpesa, data.categoriaId, data.importo, data.descrizione, data.riferimentoGiustificativo, id]
);
}
export async function deleteRimborso(id: number): Promise<boolean> {
const [result] = await pool.execute<ResultSetHeader>(
"DELETE FROM richieste_rimborso WHERE richiesta_id = ? AND stato = 'IN_ATTESA'",
[id]
);
return result.affectedRows > 0;
}
export async function approvaRimborso(id: number, responsabileId: number): Promise<boolean> {
const [result] = await pool.execute<ResultSetHeader>(
`UPDATE richieste_rimborso
SET stato = 'APPROVATA', data_valutazione = NOW(), responsabile_valutazione_id = ?, motivazione_rifiuto = NULL
WHERE richiesta_id = ? AND stato = 'IN_ATTESA'`,
[responsabileId, id]
);
return result.affectedRows > 0;
}
export async function rifiutaRimborso(
id: number,
responsabileId: number,
motivazione: string | null
): Promise<boolean> {
const [result] = await pool.execute<ResultSetHeader>(
`UPDATE richieste_rimborso
SET stato = 'RIFIUTATA', data_valutazione = NOW(), responsabile_valutazione_id = ?, motivazione_rifiuto = ?
WHERE richiesta_id = ? AND stato = 'IN_ATTESA'`,
[responsabileId, motivazione, id]
);
return result.affectedRows > 0;
}
export async function liquidaRimborso(id: number): Promise<boolean> {
const [result] = await pool.execute<ResultSetHeader>(
`UPDATE richieste_rimborso
SET stato = 'LIQUIDATA', data_liquidazione = NOW()
WHERE richiesta_id = ? AND stato = 'APPROVATA'`,
[id]
);
return result.affectedRows > 0;
}
export async function listCategorie(): Promise<{ categoria_id: number; descrizione: string }[]> {
const [rows] = await pool.execute<RowDataPacket[]>(
"SELECT categoria_id, descrizione FROM categorie_spesa ORDER BY descrizione"
);
return rows as { categoria_id: number; descrizione: string }[];
}
export async function listDipendenti(): Promise<{ utente_id: number; nome: string; cognome: string }[]> {
const [rows] = await pool.execute<RowDataPacket[]>(
"SELECT utente_id, nome, cognome FROM utenti WHERE ruolo = 'DIPENDENTE' ORDER BY cognome, nome"
);
return rows as { utente_id: number; nome: string; cognome: string }[];
}

View file

@ -0,0 +1,54 @@
import type { RowDataPacket } from "mysql2";
import pool from "../db/pool.js";
import type { StatisticaRimborso } from "../types/index.js";
export interface FiltriStatistiche {
mese?: string;
categoriaId?: number;
dipendenteId?: number;
}
export async function getStatisticheRimborsi(filtri: FiltriStatistiche): Promise<StatisticaRimborso[]> {
const clauses: string[] = [];
const params: (string | number)[] = [];
if (filtri.mese && /^\d{4}-\d{2}$/.test(filtri.mese)) {
clauses.push("DATE_FORMAT(r.data_spesa, '%Y-%m') = ?");
params.push(filtri.mese);
}
if (filtri.categoriaId) {
clauses.push("r.categoria_id = ?");
params.push(filtri.categoriaId);
}
if (filtri.dipendenteId) {
clauses.push("r.dipendente_id = ?");
params.push(filtri.dipendenteId);
}
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT
DATE_FORMAT(r.data_spesa, '%Y-%m') AS mese,
c.descrizione AS categoria,
COUNT(*) AS numeroRichieste,
SUM(r.importo) AS totaleRichiesto,
SUM(CASE WHEN r.stato IN ('APPROVATA', 'LIQUIDATA') THEN r.importo ELSE 0 END) AS totaleApprovato,
SUM(CASE WHEN r.stato = 'LIQUIDATA' THEN r.importo ELSE 0 END) AS totaleLiquidato
FROM richieste_rimborso r
JOIN categorie_spesa c ON c.categoria_id = r.categoria_id
${where}
GROUP BY DATE_FORMAT(r.data_spesa, '%Y-%m'), c.descrizione
ORDER BY mese DESC, categoria`,
params
);
return rows.map((row) => ({
mese: row.mese,
categoria: row.categoria,
numeroRichieste: Number(row.numeroRichieste),
totaleRichiesto: Number(row.totaleRichiesto),
totaleApprovato: Number(row.totaleApprovato),
totaleLiquidato: Number(row.totaleLiquidato),
}));
}

View file

@ -0,0 +1,54 @@
import bcrypt from "bcryptjs";
import type { ResultSetHeader, RowDataPacket } from "mysql2";
import pool from "../db/pool.js";
import type { Ruolo, Utente } from "../types/index.js";
export async function findByEmail(email: string): Promise<Utente | null> {
const [rows] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM utenti WHERE email = ?",
[email.toLowerCase().trim()]
);
return (rows[0] as Utente) ?? null;
}
export async function createUtente(data: {
nome: string;
cognome: string;
email: string;
password: string;
ruolo: Ruolo;
}): Promise<number> {
const passwordHash = await bcrypt.hash(data.password, 10);
const [result] = await pool.execute<ResultSetHeader>(
`INSERT INTO utenti (nome, cognome, email, password_hash, ruolo)
VALUES (?, ?, ?, ?, ?)`,
[
data.nome.trim(),
data.cognome.trim(),
data.email.toLowerCase().trim(),
passwordHash,
data.ruolo,
]
);
return result.insertId;
}
export function validateRegistration(body: Record<string, unknown>): string | null {
const nome = body.nome?.toString().trim();
const cognome = body.cognome?.toString().trim();
const email = body.email?.toString().trim().toLowerCase();
const password = body.password?.toString();
const confermaPassword = body.confermaPassword?.toString();
const ruolo = body.ruolo?.toString();
if (!nome) return "Il nome è obbligatorio";
if (!cognome) return "Il cognome è obbligatorio";
if (!email) return "L'email è obbligatoria";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Formato email non valido";
if (!password) return "La password è obbligatoria";
if (password !== confermaPassword) return "Le password non coincidono";
if (ruolo && ruolo !== "DIPENDENTE" && ruolo !== "RESPONSABILE_AMMINISTRATIVO") {
return "Ruolo non valido";
}
return null;
}

22
src/types/auth.d.ts vendored Normal file
View 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;
}
}

57
src/types/index.ts Normal file
View file

@ -0,0 +1,57 @@
export type Ruolo = "DIPENDENTE" | "RESPONSABILE_AMMINISTRATIVO";
export type StatoRichiesta = "IN_ATTESA" | "APPROVATA" | "RIFIUTATA" | "LIQUIDATA";
export interface Utente {
utente_id: number;
nome: string;
cognome: string;
email: string;
password_hash: string;
ruolo: Ruolo;
}
export interface UtentePublic {
id: number;
nome: string;
cognome: string;
email: string;
ruolo: Ruolo;
}
export interface CategoriaSpesa {
categoria_id: number;
descrizione: string;
}
export interface RichiestaRimborso {
richiesta_id: number;
data_inserimento: Date;
data_spesa: Date;
categoria_id: number;
importo: number;
descrizione: string;
riferimento_giustificativo: string | null;
stato: StatoRichiesta;
dipendente_id: number;
data_valutazione: Date | null;
responsabile_valutazione_id: number | null;
motivazione_rifiuto: string | null;
data_liquidazione: Date | null;
}
export interface RichiestaRimborsoDettaglio extends RichiestaRimborso {
categoria_descrizione: string;
dipendente_nome: string;
dipendente_cognome: string;
responsabile_nome: string | null;
responsabile_cognome: string | null;
}
export interface StatisticaRimborso {
mese: string;
categoria: string;
numeroRichieste: number;
totaleRichiesto: number;
totaleApprovato: number;
totaleLiquidato: number;
}

24
src/utils/format.ts Normal file
View file

@ -0,0 +1,24 @@
import type { RichiestaRimborsoDettaglio } from "../types/index.js";
export function formatRimborso(r: RichiestaRimborsoDettaglio) {
return {
id: r.richiesta_id,
dataInserimento: r.data_inserimento,
dataSpesa: r.data_spesa,
categoriaId: r.categoria_id,
categoria: r.categoria_descrizione,
importo: r.importo,
descrizione: r.descrizione,
riferimentoGiustificativo: r.riferimento_giustificativo,
stato: r.stato,
dipendenteId: r.dipendente_id,
dipendenteNome: `${r.dipendente_nome} ${r.dipendente_cognome}`,
dataValutazione: r.data_valutazione,
responsabileValutazioneId: r.responsabile_valutazione_id,
responsabileNome: r.responsabile_nome
? `${r.responsabile_nome} ${r.responsabile_cognome}`
: null,
motivazioneRifiuto: r.motivazione_rifiuto,
dataLiquidazione: r.data_liquidazione,
};
}