diff --git a/dist/index.js b/dist/index.js index 5019620..0a48415 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,23 +1,31 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const express_1 = __importDefault(require("express")); -const cors_1 = __importDefault(require("cors")); -const dotenv_1 = __importDefault(require("dotenv")); -dotenv_1.default.config(); -const app = (0, express_1.default)(); +import { ExpressAuth } from "@auth/express"; +import cors from "cors"; +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(); +const app = express(); const port = Number(process.env.PORT) || 3003; app.set("trust proxy", 1); -app.use((0, cors_1.default)({ - origin: process.env.FRONTEND_URL ?? "https://provapraticafrontend.andreavillari.it", +app.use(cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:5173", credentials: true, })); -app.use(express_1.default.json()); +app.use(express.json()); app.get("/api/health", (_req, res) => { 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", () => { console.log(`Backend in ascolto sulla porta ${port}`); }); diff --git a/dist/index.js.map b/dist/index.js.map index 2584bde..6124ba5 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/initdb/02_seed.sql b/initdb/02_seed.sql new file mode 100644 index 0000000..379280b --- /dev/null +++ b/initdb/02_seed.sql @@ -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); diff --git a/package.json b/package.json index 6625d1d..ffde6e9 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "provapraticabackend", "version": "1.0.0", "main": "index.js", - "type": "commonjs", + "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", diff --git a/src/auth/config.ts b/src/auth/config.ts new file mode 100644 index 0000000..61688eb --- /dev/null +++ b/src/auth/config.ts @@ -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( + "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; + }, + }, +}; diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..66b3380 --- /dev/null +++ b/src/db/pool.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index cb4f01c..04fcbc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,13 @@ -import express from "express"; +import { ExpressAuth } from "@auth/express"; import cors from "cors"; 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(); @@ -8,16 +15,26 @@ const app = express(); const port = Number(process.env.PORT) || 3003; app.set("trust proxy", 1); -app.use(cors({ - origin: process.env.FRONTEND_URL ?? "https://provapraticafrontend.andreavillari.it", - credentials: true, -})); +app.use( + cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:5173", + credentials: true, + }) +); app.use(express.json()); app.get("/api/health", (_req, res) => { 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", () => { console.log(`Backend in ascolto sulla porta ${port}`); -}); \ No newline at end of file +}); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..85ea8b1 --- /dev/null +++ b/src/middleware/auth.ts @@ -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; + }; + } + } +} diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts new file mode 100644 index 0000000..2238c58 --- /dev/null +++ b/src/middleware/errorHandler.ts @@ -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" }); +} diff --git a/src/routes/categorie.ts b/src/routes/categorie.ts new file mode 100644 index 0000000..93e3f22 --- /dev/null +++ b/src/routes/categorie.ts @@ -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; diff --git a/src/routes/rimborsi.ts b/src/routes/rimborsi.ts new file mode 100644 index 0000000..11fef00 --- /dev/null +++ b/src/routes/rimborsi.ts @@ -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; diff --git a/src/routes/statistiche.ts b/src/routes/statistiche.ts new file mode 100644 index 0000000..9208b26 --- /dev/null +++ b/src/routes/statistiche.ts @@ -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; diff --git a/src/routes/utenti.ts b/src/routes/utenti.ts new file mode 100644 index 0000000..bd7cf8a --- /dev/null +++ b/src/routes/utenti.ts @@ -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; diff --git a/src/services/rimborsiService.ts b/src/services/rimborsiService.ts new file mode 100644 index 0000000..a8f7a95 --- /dev/null +++ b/src/services/rimborsiService.ts @@ -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 { + const { where, params } = buildFilters(filtri, ruolo, userId); + const [rows] = await pool.execute( + `${SELECT_BASE} ${where} ORDER BY r.data_inserimento DESC`, + params + ); + return rows.map(mapRow); +} + +export async function getRimborsoById(id: number): Promise { + const [rows] = await pool.execute( + `${SELECT_BASE} WHERE r.richiesta_id = ?`, + [id] + ); + return rows[0] ? mapRow(rows[0]) : null; +} + +export async function categoriaExists(id: number): Promise { + const [rows] = await pool.execute( + "SELECT 1 FROM categorie_spesa WHERE categoria_id = ?", + [id] + ); + return rows.length > 0; +} + +export function validateRichiestaBody( + body: Record, + 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 { + const [result] = await pool.execute( + `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 { + 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 { + const [result] = await pool.execute( + "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 { + const [result] = await pool.execute( + `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 { + const [result] = await pool.execute( + `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 { + const [result] = await pool.execute( + `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( + "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( + "SELECT utente_id, nome, cognome FROM utenti WHERE ruolo = 'DIPENDENTE' ORDER BY cognome, nome" + ); + return rows as { utente_id: number; nome: string; cognome: string }[]; +} diff --git a/src/services/statisticheService.ts b/src/services/statisticheService.ts new file mode 100644 index 0000000..a49650c --- /dev/null +++ b/src/services/statisticheService.ts @@ -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 { + 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( + `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), + })); +} diff --git a/src/services/utentiService.ts b/src/services/utentiService.ts new file mode 100644 index 0000000..e8803f5 --- /dev/null +++ b/src/services/utentiService.ts @@ -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 { + const [rows] = await pool.execute( + "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 { + const passwordHash = await bcrypt.hash(data.password, 10); + const [result] = await pool.execute( + `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 | 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; +} diff --git a/src/types/auth.d.ts b/src/types/auth.d.ts new file mode 100644 index 0000000..267c8a4 --- /dev/null +++ b/src/types/auth.d.ts @@ -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; + } +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..550abfb --- /dev/null +++ b/src/types/index.ts @@ -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; +} diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..1476199 --- /dev/null +++ b/src/utils/format.ts @@ -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, + }; +}