Add scouthub-magazzino-be

This commit is contained in:
Lorenzo Sanesi
2026-07-25 11:54:03 +02:00
parent 7f1d0d5ce3
commit 9ceb05cda2
58 changed files with 9711 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
import express from 'express';
import cors from 'cors';
import { healthRouter } from './routes/health.routes';
import { materialiRouter } from './routes/materiali.routes';
import { tipiEventoRouter } from './routes/tipiEvento.routes';
import { listeModelloRouter } from './routes/listeModello.routes';
import { listeRouter } from './routes/liste.routes';
import { magazzinoRouter } from './routes/magazzino.routes';
import { eventiRouter } from './routes/eventi.routes';
import { errorHandler } from './middleware/errorHandler';
export const app = express();
app.use(express.json());
app.use(cors());
app.use(healthRouter);
app.use(materialiRouter);
app.use(tipiEventoRouter);
app.use(listeModelloRouter);
app.use(listeRouter);
app.use(magazzinoRouter);
app.use(eventiRouter);
app.use((req, res) => {
res.status(404).json({ message: 'not found' });
});
app.use(errorHandler);
@@ -0,0 +1,6 @@
export interface AuthContext {
userId: string;
email: string | null;
orgId: string | null;
roles: string[];
}
@@ -0,0 +1,15 @@
import { Request, Response, NextFunction } from 'express';
// Guard per le route di moderazione (es. approvazione/rifiuto di un materiale
// proposto nel catalogo). Da usare dopo verifyToken, solo su quelle route: il
// possesso del ruolo realm moderatore non è richiesto altrove.
export function requireModeratore(req: Request, res: Response, next: NextFunction): void {
const roles = req.auth?.roles ?? [];
if (!roles.includes('moderatore')) {
res.status(403).json({ message: 'Ruolo moderatore richiesto' });
return;
}
next();
}
@@ -0,0 +1,14 @@
import { Request, Response, NextFunction } from 'express';
// Da usare dopo verifyToken su tutte le route private (liste, magazzino, eventi):
// richiede che il token porti un'organizzazione attiva. L'org_id da usare per
// filtrare le query è sempre req.auth.orgId, mai un org_id letto da
// params/query/body della richiesta.
export function requireOrgId(req: Request, res: Response, next: NextFunction): void {
if (!req.auth?.orgId) {
res.status(403).json({ message: "Nessuna organizzazione attiva sul token" });
return;
}
next();
}
@@ -0,0 +1,73 @@
import { Request, Response, NextFunction } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { env } from '../config/env';
import { AuthContext } from './auth.types';
const client = jwksClient({
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
cache: true,
rateLimit: true,
});
interface KeycloakTokenPayload extends JwtPayload {
sub: string;
email?: string;
realm_access?: { roles?: string[] };
// Claim iniettato dalla feature "organizations" di Keycloak: mappa alias
// organizzazione -> { id, roles dell'utente in quella organizzazione }.
// Un token porta al più un'organizzazione attiva per volta.
organization?: Record<string, { id: string; roles?: string[] }>;
}
function extractBearerToken(req: Request): string | null {
const header = req.headers.authorization;
if (!header) {
return null;
}
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return null;
}
return token;
}
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
const realmRoles = payload.realm_access?.roles ?? [];
const [organization] = payload.organization ? Object.values(payload.organization) : [];
const orgRoles = organization?.roles ?? [];
return {
userId: payload.sub,
email: payload.email ?? null,
orgId: organization?.id ?? null,
roles: Array.from(new Set([...realmRoles, ...orgRoles])),
};
}
export async function verifyToken(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = extractBearerToken(req);
if (!token) {
res.status(401).json({ message: 'Token mancante' });
return;
}
try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header.kid) {
throw new Error("Header del token privo di 'kid'");
}
const signingKey = await client.getSigningKey(decoded.header.kid);
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
if (typeof payload === 'string') {
throw new Error('Payload del token non valido');
}
req.auth = buildAuthContext(payload as KeycloakTokenPayload);
next();
} catch {
res.status(401).json({ message: 'Token non valido' });
}
}
+22
View File
@@ -0,0 +1,22 @@
import dotenv from 'dotenv';
dotenv.config();
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Variabile d'ambiente mancante: ${name}`);
}
return value;
}
export const env = {
port: Number(process.env.PORT) || 8083,
databaseUrl: requireEnv('DATABASE_URL'),
keycloak: {
baseUrl: requireEnv('KEYCLOAK_BASE_URL'),
realm: requireEnv('KEYCLOAK_REALM'),
magazzinoClientId: requireEnv('KEYCLOAK_MAGAZZINO_CLIENT_ID'),
magazzinoClientSecret: requireEnv('KEYCLOAK_MAGAZZINO_CLIENT_SECRET'),
},
};
@@ -0,0 +1,94 @@
import { Request, Response, NextFunction } from 'express';
import { AggiornaCheckInput, aggiornaCheckEvento, creaEvento, getDettaglioEvento } from '../services/eventi.service';
import { HttpError } from '../errors';
interface PostEventoBody {
nome?: unknown;
listaId?: unknown;
data?: unknown;
}
function parseData(value: unknown): Date {
if (typeof value !== 'string') {
throw new HttpError(400, "Il campo 'data' è obbligatorio ed è una stringa in formato data");
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new HttpError(400, "Il campo 'data' non è una data valida");
}
return parsed;
}
function parseCreateBody(body: PostEventoBody): { nome: string; listaId: string; data: Date } {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.listaId !== 'string' || body.listaId.trim().length === 0) {
throw new HttpError(400, "Il campo 'listaId' è obbligatorio ed è una stringa non vuota");
}
return { nome: body.nome, listaId: body.listaId, data: parseData(body.data) };
}
export async function postEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseCreateBody(req.body ?? {});
const evento = await creaEvento(req.auth!.orgId!, input);
res.status(201).json(evento);
} catch (err) {
next(err);
}
}
export async function getEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const evento = await getDettaglioEvento(req.params.id, req.auth!.orgId!);
res.status(200).json(evento);
} catch (err) {
next(err);
}
}
interface CheckVoceBody {
materialeId?: unknown;
portato?: unknown;
note?: unknown;
}
interface PatchCheckBody {
voci?: unknown;
}
function parseCheckBody(body: PatchCheckBody): AggiornaCheckInput[] {
if (!Array.isArray(body.voci) || body.voci.length === 0) {
throw new HttpError(400, "Il campo 'voci' è obbligatorio ed è un array non vuoto");
}
return body.voci.map((voce: CheckVoceBody) => {
if (typeof voce.materialeId !== 'string' || voce.materialeId.trim().length === 0) {
throw new HttpError(400, "Ogni voce deve avere un 'materialeId' valido");
}
if (voce.portato !== undefined && typeof voce.portato !== 'boolean') {
throw new HttpError(400, "Il campo 'portato', se presente, deve essere un booleano");
}
if (voce.note !== undefined && voce.note !== null && typeof voce.note !== 'string') {
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa o null");
}
return {
materialeId: voce.materialeId,
portato: voce.portato as boolean | undefined,
note: voce.note as string | null | undefined,
};
});
}
export async function patchEventoCheck(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const voci = parseCheckBody(req.body ?? {});
const evento = await aggiornaCheckEvento(req.params.id, req.auth!.orgId!, voci);
res.status(200).json(evento);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,11 @@
import { Request, Response, NextFunction } from 'express';
import { healthService } from '../services/health.service';
export async function getHealth(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await healthService.checkDatabase();
res.json({ status: 'ok', database: 'up' });
} catch (err) {
next(err);
}
}
@@ -0,0 +1,103 @@
import { Request, Response, NextFunction } from 'express';
import { ListaVoceInput } from '../repositories/liste.repository';
import {
aggiornaLista,
creaListaVuota,
eliminaLista,
forkListaDaModello,
listListePerOrg,
} from '../services/liste.service';
import { HttpError } from '../errors';
interface VoceBody {
materialeId?: unknown;
quantita?: unknown;
}
function parseVoci(voci: unknown): ListaVoceInput[] {
if (!Array.isArray(voci)) {
throw new HttpError(400, "Il campo 'voci' deve essere un array");
}
return voci.map((voce: VoceBody) => {
if (typeof voce.materialeId !== 'string' || voce.materialeId.trim().length === 0) {
throw new HttpError(400, "Ogni voce deve avere un 'materialeId' valido");
}
if (typeof voce.quantita !== 'number' || !Number.isInteger(voce.quantita) || voce.quantita <= 0) {
throw new HttpError(400, "Ogni voce deve avere una 'quantita' intera positiva");
}
return { materialeId: voce.materialeId, quantita: voce.quantita };
});
}
function parseNome(body: { nome?: unknown }): string {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
return body.nome;
}
// L'org di appartenenza è sempre req.auth.orgId (garantito da requireOrgId a
// monte): nessun org_id letto dal body/query del client viene mai usato qui.
export async function getListe(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const liste = await listListePerOrg(req.auth!.orgId!);
res.status(200).json(liste);
} catch (err) {
next(err);
}
}
export async function postLista(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const lista = await creaListaVuota(req.auth!.orgId!, nome);
res.status(201).json(lista);
} catch (err) {
next(err);
}
}
export async function postListaDaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const lista = await forkListaDaModello(req.auth!.orgId!, req.params.listaModelloId);
res.status(201).json(lista);
} catch (err) {
next(err);
}
}
interface PutListaBody {
nome?: unknown;
voci?: unknown;
}
function parseUpdateBody(body: PutListaBody): { nome?: string; voci?: ListaVoceInput[] } {
if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) {
throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota");
}
return {
nome: body.nome as string | undefined,
voci: body.voci !== undefined ? parseVoci(body.voci) : undefined,
};
}
export async function putLista(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseUpdateBody(req.body ?? {});
const lista = await aggiornaLista(req.params.id, req.auth!.orgId!, input);
res.status(200).json(lista);
} catch (err) {
next(err);
}
}
export async function deleteLista(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaLista(req.params.id, req.auth!.orgId!);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -0,0 +1,111 @@
import { Request, Response, NextFunction } from 'express';
import { ListaModelloVoceInput } from '../repositories/listeModello.repository';
import {
aggiornaListaModello,
creaListaModello,
eliminaListaModello,
listListeModello,
} from '../services/listeModello.service';
import { HttpError } from '../errors';
interface VoceBody {
materialeId?: unknown;
quantita?: unknown;
}
function parseVoci(voci: unknown): ListaModelloVoceInput[] {
if (!Array.isArray(voci)) {
throw new HttpError(400, "Il campo 'voci' deve essere un array");
}
return voci.map((voce: VoceBody) => {
if (typeof voce.materialeId !== 'string' || voce.materialeId.trim().length === 0) {
throw new HttpError(400, "Ogni voce deve avere un 'materialeId' valido");
}
if (typeof voce.quantita !== 'number' || !Number.isInteger(voce.quantita) || voce.quantita <= 0) {
throw new HttpError(400, "Ogni voce deve avere una 'quantita' intera positiva");
}
return { materialeId: voce.materialeId, quantita: voce.quantita };
});
}
interface PostListaModelloBody {
nome?: unknown;
tipoEventoId?: unknown;
voci?: unknown;
}
function parseCreateBody(body: PostListaModelloBody): { nome: string; tipoEventoId: string; voci: ListaModelloVoceInput[] } {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0) {
throw new HttpError(400, "Il campo 'tipoEventoId' è obbligatorio ed è una stringa non vuota");
}
return { nome: body.nome, tipoEventoId: body.tipoEventoId, voci: parseVoci(body.voci ?? []) };
}
interface PutListaModelloBody {
nome?: unknown;
tipoEventoId?: unknown;
voci?: unknown;
}
function parseUpdateBody(body: PutListaModelloBody): { nome?: string; tipoEventoId?: string; voci?: ListaModelloVoceInput[] } {
if (body.nome !== undefined && (typeof body.nome !== 'string' || body.nome.trim().length === 0)) {
throw new HttpError(400, "Il campo 'nome', se presente, deve essere una stringa non vuota");
}
if (body.tipoEventoId !== undefined && (typeof body.tipoEventoId !== 'string' || body.tipoEventoId.trim().length === 0)) {
throw new HttpError(400, "Il campo 'tipoEventoId', se presente, deve essere una stringa non vuota");
}
return {
nome: body.nome as string | undefined,
tipoEventoId: body.tipoEventoId as string | undefined,
voci: body.voci !== undefined ? parseVoci(body.voci) : undefined,
};
}
// Query pubblica: unico filtro accettato è "tipoEventoId". "pubblica" non è mai
// un parametro esposto al client: le liste modello sono per definizione pubbliche.
export async function getListeModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { tipoEventoId } = req.query;
const filtro = typeof tipoEventoId === 'string' && tipoEventoId.trim().length > 0 ? tipoEventoId : undefined;
const liste = await listListeModello(filtro);
res.status(200).json(liste);
} catch (err) {
next(err);
}
}
export async function postListaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseCreateBody(req.body ?? {});
const lista = await creaListaModello(input);
res.status(201).json(lista);
} catch (err) {
next(err);
}
}
export async function putListaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseUpdateBody(req.body ?? {});
const lista = await aggiornaListaModello(req.params.id, input);
res.status(200).json(lista);
} catch (err) {
next(err);
}
}
export async function deleteListaModello(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaListaModello(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -0,0 +1,121 @@
import { Request, Response, NextFunction } from 'express';
import { StatoMagazzinoVoce } from '@prisma/client';
import { AggiornaVoceInput, AggiungiVoceInput, aggiornaVoce, aggiungiVoce, eliminaVoce, listMagazzinoPerOrg } from '../services/magazzino.service';
import { HttpError } from '../errors';
const STATI_VALIDI = Object.values(StatoMagazzinoVoce);
function isStatoValido(value: unknown): value is StatoMagazzinoVoce {
return typeof value === 'string' && (STATI_VALIDI as string[]).includes(value);
}
interface PostVoceBody {
materialeId?: unknown;
quantitaPosseduta?: unknown;
stato?: unknown;
posizione?: unknown;
note?: unknown;
}
function parseCreateBody(body: PostVoceBody): AggiungiVoceInput {
if (typeof body.materialeId !== 'string' || body.materialeId.trim().length === 0) {
throw new HttpError(400, "Il campo 'materialeId' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.quantitaPosseduta !== 'number' || !Number.isInteger(body.quantitaPosseduta) || body.quantitaPosseduta < 0) {
throw new HttpError(400, "Il campo 'quantitaPosseduta' è obbligatorio ed è un intero >= 0");
}
if (!isStatoValido(body.stato)) {
throw new HttpError(400, `Il campo 'stato' deve valere uno tra: ${STATI_VALIDI.join(', ')}`);
}
if (body.posizione !== undefined && typeof body.posizione !== 'string') {
throw new HttpError(400, "Il campo 'posizione', se presente, deve essere una stringa");
}
if (body.note !== undefined && typeof body.note !== 'string') {
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa");
}
return {
materialeId: body.materialeId,
quantitaPosseduta: body.quantitaPosseduta,
stato: body.stato,
posizione: body.posizione as string | undefined,
note: body.note as string | undefined,
};
}
interface PutVoceBody {
materialeId?: unknown;
quantitaPosseduta?: unknown;
stato?: unknown;
posizione?: unknown;
note?: unknown;
}
function parseUpdateBody(body: PutVoceBody): AggiornaVoceInput {
if (body.materialeId !== undefined && (typeof body.materialeId !== 'string' || body.materialeId.trim().length === 0)) {
throw new HttpError(400, "Il campo 'materialeId', se presente, deve essere una stringa non vuota");
}
if (
body.quantitaPosseduta !== undefined &&
(typeof body.quantitaPosseduta !== 'number' || !Number.isInteger(body.quantitaPosseduta) || body.quantitaPosseduta < 0)
) {
throw new HttpError(400, "Il campo 'quantitaPosseduta', se presente, deve essere un intero >= 0");
}
if (body.stato !== undefined && !isStatoValido(body.stato)) {
throw new HttpError(400, `Il campo 'stato', se presente, deve valere uno tra: ${STATI_VALIDI.join(', ')}`);
}
if (body.posizione !== undefined && body.posizione !== null && typeof body.posizione !== 'string') {
throw new HttpError(400, "Il campo 'posizione', se presente, deve essere una stringa o null");
}
if (body.note !== undefined && body.note !== null && typeof body.note !== 'string') {
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa o null");
}
return {
materialeId: body.materialeId as string | undefined,
quantitaPosseduta: body.quantitaPosseduta as number | undefined,
stato: body.stato as StatoMagazzinoVoce | undefined,
posizione: body.posizione as string | null | undefined,
note: body.note as string | null | undefined,
};
}
// L'org di appartenenza è sempre req.auth.orgId (garantito da requireOrgId a
// monte): nessun org_id letto dal body/query del client viene mai usato qui.
export async function getMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const voci = await listMagazzinoPerOrg(req.auth!.orgId!);
res.status(200).json(voci);
} catch (err) {
next(err);
}
}
export async function postVoceMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseCreateBody(req.body ?? {});
const voce = await aggiungiVoce(req.auth!.orgId!, input);
res.status(201).json(voce);
} catch (err) {
next(err);
}
}
export async function putVoceMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseUpdateBody(req.body ?? {});
const voce = await aggiornaVoce(req.params.id, req.auth!.orgId!, input);
res.status(200).json(voce);
} catch (err) {
next(err);
}
}
export async function deleteVoceMagazzino(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaVoce(req.params.id, req.auth!.orgId!);
res.status(204).send();
} catch (err) {
next(err);
}
}
@@ -0,0 +1,84 @@
import { Request, Response, NextFunction } from 'express';
import {
DecisioneProposta,
decidiProposta,
listMaterialiApprovati,
listProposte,
proponiMateriale,
} from '../services/materiali.service';
import { HttpError } from '../errors';
interface PostPropostaBody {
nome?: unknown;
categoria?: unknown;
unitaMisura?: unknown;
}
function parsePropostaBody(body: PostPropostaBody): { nome: string; categoria: string; unitaMisura: string } {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.categoria !== 'string' || body.categoria.trim().length === 0) {
throw new HttpError(400, "Il campo 'categoria' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.unitaMisura !== 'string' || body.unitaMisura.trim().length === 0) {
throw new HttpError(400, "Il campo 'unitaMisura' è obbligatorio ed è una stringa non vuota");
}
return { nome: body.nome, categoria: body.categoria, unitaMisura: body.unitaMisura };
}
interface PatchPropostaBody {
decisione?: unknown;
}
function parseDecisioneBody(body: PatchPropostaBody): DecisioneProposta {
if (body.decisione !== 'approvato' && body.decisione !== 'rifiutato') {
throw new HttpError(400, "Il campo 'decisione' deve valere 'approvato' o 'rifiutato'");
}
return body.decisione;
}
// Query pubblica: unico filtro accettato dal client è "categoria". Lo stato
// non è mai un parametro esposto: il catalogo pubblico mostra solo i
// materiali con stato "approvato".
export async function getMaterialiPubblici(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { categoria } = req.query;
const filtro = typeof categoria === 'string' && categoria.trim().length > 0 ? categoria : undefined;
const materiali = await listMaterialiApprovati(filtro);
res.status(200).json(materiali);
} catch (err) {
next(err);
}
}
export async function postProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parsePropostaBody(req.body ?? {});
const proposta = await proponiMateriale({ ...input, orgId: req.auth!.orgId! });
res.status(201).json(proposta);
} catch (err) {
next(err);
}
}
export async function getProposte(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const proposte = await listProposte();
res.status(200).json(proposte);
} catch (err) {
next(err);
}
}
export async function patchProposta(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const decisione = parseDecisioneBody(req.body ?? {});
const proposta = await decidiProposta(req.params.id, decisione);
res.status(200).json(proposta);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,48 @@
import { Request, Response, NextFunction } from 'express';
import { aggiornaTipoEvento, creaTipoEvento, eliminaTipoEvento, listTipiEvento } from '../services/tipiEvento.service';
import { HttpError } from '../errors';
function parseNome(body: { nome?: unknown }): string {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
return body.nome;
}
export async function getTipiEvento(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tipiEvento = await listTipiEvento();
res.status(200).json(tipiEvento);
} catch (err) {
next(err);
}
}
export async function postTipoEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const tipoEvento = await creaTipoEvento(nome);
res.status(201).json(tipoEvento);
} catch (err) {
next(err);
}
}
export async function putTipoEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const nome = parseNome(req.body ?? {});
const tipoEvento = await aggiornaTipoEvento(req.params.id, nome);
res.status(200).json(tipoEvento);
} catch (err) {
next(err);
}
}
export async function deleteTipoEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await eliminaTipoEvento(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined;
}
export const prisma = global.__prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma;
}
+9
View File
@@ -0,0 +1,9 @@
export class HttpError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
this.name = 'HttpError';
}
}
@@ -0,0 +1,10 @@
import { Request, Response, NextFunction } from 'express';
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void {
const hasStatusCode =
typeof err === 'object' && err !== null && 'statusCode' in err && typeof (err as { statusCode?: unknown }).statusCode === 'number';
const statusCode = hasStatusCode ? (err as { statusCode: number }).statusCode : 500;
const message = err instanceof Error && err.message ? err.message : 'Errore interno del server';
res.status(statusCode).json({ message });
}
@@ -0,0 +1,48 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
const includeEvento = {
lista: { include: { voci: { include: { materiale: true } } } },
check: true,
} satisfies Prisma.EventoInclude;
export type EventoConDettagli = Prisma.EventoGetPayload<{ include: typeof includeEvento }>;
export interface CreateEventoData {
orgId: string;
nome: string;
listaId: string;
data: Date;
}
export interface UpsertCheckData {
portato?: boolean;
note?: string | null;
}
export class EventiRepository {
// id + orgId nella stessa where: un evento di un'altra org risulta
// semplicemente "non trovato", mai un 403 che ne rivela l'esistenza.
findByIdAndOrg(id: string, orgId: string): Promise<EventoConDettagli | null> {
return prisma.evento.findFirst({ where: { id, orgId }, include: includeEvento });
}
create(data: CreateEventoData): Promise<EventoConDettagli> {
return prisma.evento.create({ data, include: includeEvento });
}
upsertCheck(eventoId: string, materialeId: string, data: UpsertCheckData): Promise<void> {
return prisma.eventoCheck
.upsert({
where: { eventoId_materialeId: { eventoId, materialeId } },
create: { eventoId, materialeId, portato: data.portato ?? false, note: data.note ?? null },
update: {
...(data.portato !== undefined ? { portato: data.portato } : {}),
...(data.note !== undefined ? { note: data.note } : {}),
},
})
.then(() => undefined);
}
}
export const eventiRepository = new EventiRepository();
@@ -0,0 +1,9 @@
import { prisma } from '../db/prisma';
export class HealthRepository {
async ping(): Promise<void> {
await prisma.$queryRaw`SELECT 1`;
}
}
export const healthRepository = new HealthRepository();
@@ -0,0 +1,79 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
const includeVoci = {
voci: { include: { materiale: true } },
} satisfies Prisma.ListaInclude;
export type ListaConVoci = Prisma.ListaGetPayload<{ include: typeof includeVoci }>;
export interface ListaVoceInput {
materialeId: string;
quantita: number;
}
export interface CreateListaData {
nome: string;
orgId: string;
voci: ListaVoceInput[];
}
export interface UpdateListaData {
nome?: string;
voci?: ListaVoceInput[];
}
export class ListeRepository {
findAllByOrg(orgId: string): Promise<ListaConVoci[]> {
return prisma.lista.findMany({
where: { orgId },
include: includeVoci,
orderBy: { creataIl: 'desc' },
});
}
// id + orgId nella stessa where: una lista di un'altra org risulta
// semplicemente "non trovata", mai un 403 che ne rivela l'esistenza.
findByIdAndOrg(id: string, orgId: string): Promise<ListaConVoci | null> {
return prisma.lista.findFirst({ where: { id, orgId }, include: includeVoci });
}
create(data: CreateListaData): Promise<ListaConVoci> {
return prisma.lista.create({
data: {
nome: data.nome,
orgId: data.orgId,
voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) },
},
include: includeVoci,
});
}
update(id: string, data: UpdateListaData): Promise<ListaConVoci> {
return prisma.$transaction(async (tx) => {
if (data.voci) {
await tx.listaVoce.deleteMany({ where: { listaId: id } });
}
return tx.lista.update({
where: { id },
data: {
...(data.nome !== undefined ? { nome: data.nome } : {}),
...(data.voci
? { voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) } }
: {}),
},
include: includeVoci,
});
});
}
async delete(id: string): Promise<void> {
await prisma.$transaction(async (tx) => {
await tx.listaVoce.deleteMany({ where: { listaId: id } });
await tx.lista.delete({ where: { id } });
});
}
}
export const listeRepository = new ListeRepository();
@@ -0,0 +1,82 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
const includeVoci = {
voci: { include: { materiale: true } },
} satisfies Prisma.ListaModelloInclude;
export type ListaModelloConVoci = Prisma.ListaModelloGetPayload<{ include: typeof includeVoci }>;
export interface ListaModelloVoceInput {
materialeId: string;
quantita: number;
}
export interface CreateListaModelloData {
nome: string;
tipoEventoId: string;
voci: ListaModelloVoceInput[];
}
export interface UpdateListaModelloData {
nome?: string;
tipoEventoId?: string;
voci?: ListaModelloVoceInput[];
}
export class ListeModelloRepository {
// "pubblica" è sempre true per le liste modello: non è un filtro opzionale,
// è la condizione fissa che qualifica queste liste come tali.
findAll(tipoEventoId?: string): Promise<ListaModelloConVoci[]> {
return prisma.listaModello.findMany({
where: { pubblica: true, ...(tipoEventoId ? { tipoEventoId } : {}) },
include: includeVoci,
orderBy: { nome: 'asc' },
});
}
findById(id: string): Promise<ListaModelloConVoci | null> {
return prisma.listaModello.findUnique({ where: { id }, include: includeVoci });
}
create(data: CreateListaModelloData): Promise<ListaModelloConVoci> {
return prisma.listaModello.create({
data: {
nome: data.nome,
tipoEventoId: data.tipoEventoId,
pubblica: true,
voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) },
},
include: includeVoci,
});
}
update(id: string, data: UpdateListaModelloData): Promise<ListaModelloConVoci> {
return prisma.$transaction(async (tx) => {
if (data.voci) {
await tx.listaModelloVoce.deleteMany({ where: { listaModelloId: id } });
}
return tx.listaModello.update({
where: { id },
data: {
...(data.nome !== undefined ? { nome: data.nome } : {}),
...(data.tipoEventoId !== undefined ? { tipoEventoId: data.tipoEventoId } : {}),
...(data.voci
? { voci: { create: data.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) } }
: {}),
},
include: includeVoci,
});
});
}
async delete(id: string): Promise<void> {
await prisma.$transaction(async (tx) => {
await tx.listaModelloVoce.deleteMany({ where: { listaModelloId: id } });
await tx.listaModello.delete({ where: { id } });
});
}
}
export const listeModelloRepository = new ListeModelloRepository();
@@ -0,0 +1,69 @@
import { Prisma, StatoMagazzinoVoce } from '@prisma/client';
import { prisma } from '../db/prisma';
const includeMateriale = {
materiale: true,
} satisfies Prisma.MagazzinoVoceInclude;
export type MagazzinoVoceConMateriale = Prisma.MagazzinoVoceGetPayload<{ include: typeof includeMateriale }>;
export interface CreateMagazzinoVoceData {
orgId: string;
materialeId: string;
quantitaPosseduta: number;
stato: StatoMagazzinoVoce;
posizione?: string;
note?: string;
}
export interface UpdateMagazzinoVoceData {
materialeId?: string;
quantitaPosseduta?: number;
stato?: StatoMagazzinoVoce;
posizione?: string | null;
note?: string | null;
}
export interface QuantitaPosseduta {
materialeId: string;
quantitaPosseduta: number;
}
export class MagazzinoRepository {
findAllByOrg(orgId: string): Promise<MagazzinoVoceConMateriale[]> {
return prisma.magazzinoVoce.findMany({
where: { orgId },
include: includeMateriale,
orderBy: { id: 'asc' },
});
}
// id + orgId nella stessa where: una voce di un'altra org risulta
// semplicemente "non trovata", mai un 403 che ne rivela l'esistenza.
findByIdAndOrg(id: string, orgId: string): Promise<MagazzinoVoceConMateriale | null> {
return prisma.magazzinoVoce.findFirst({ where: { id, orgId }, include: includeMateriale });
}
create(data: CreateMagazzinoVoceData): Promise<MagazzinoVoceConMateriale> {
return prisma.magazzinoVoce.create({ data, include: includeMateriale });
}
update(id: string, data: UpdateMagazzinoVoceData): Promise<MagazzinoVoceConMateriale> {
return prisma.magazzinoVoce.update({ where: { id }, data, include: includeMateriale });
}
async delete(id: string): Promise<void> {
await prisma.magazzinoVoce.delete({ where: { id } });
}
// Usato per il join evento<->magazzino: quantità possedute dall'org per un
// sottoinsieme di materiali (quelli della lista collegata all'evento).
findQuantitaByOrgEMateriali(orgId: string, materialeIds: string[]): Promise<QuantitaPosseduta[]> {
return prisma.magazzinoVoce.findMany({
where: { orgId, materialeId: { in: materialeIds } },
select: { materialeId: true, quantitaPosseduta: true },
});
}
}
export const magazzinoRepository = new MagazzinoRepository();
@@ -0,0 +1,41 @@
import { Materiale, StatoMateriale } from '@prisma/client';
import { prisma } from '../db/prisma';
export interface CreateMaterialeData {
nome: string;
categoria: string;
unitaMisura: string;
propostoDaOrgId: string;
}
export class MaterialiRepository {
findApprovati(categoria?: string): Promise<Materiale[]> {
return prisma.materiale.findMany({
where: { stato: StatoMateriale.approvato, ...(categoria ? { categoria } : {}) },
orderBy: { nome: 'asc' },
});
}
findProposte(): Promise<Materiale[]> {
return prisma.materiale.findMany({
where: { stato: StatoMateriale.proposto },
orderBy: { creatoIl: 'asc' },
});
}
findById(id: string): Promise<Materiale | null> {
return prisma.materiale.findUnique({ where: { id } });
}
create(data: CreateMaterialeData): Promise<Materiale> {
return prisma.materiale.create({
data: { ...data, stato: StatoMateriale.proposto },
});
}
updateStato(id: string, stato: StatoMateriale): Promise<Materiale> {
return prisma.materiale.update({ where: { id }, data: { stato } });
}
}
export const materialiRepository = new MaterialiRepository();
@@ -0,0 +1,22 @@
import { TipoEvento } from '@prisma/client';
import { prisma } from '../db/prisma';
export class TipiEventoRepository {
findAll(): Promise<TipoEvento[]> {
return prisma.tipoEvento.findMany({ orderBy: { nome: 'asc' } });
}
create(nome: string): Promise<TipoEvento> {
return prisma.tipoEvento.create({ data: { nome } });
}
update(id: string, nome: string): Promise<TipoEvento> {
return prisma.tipoEvento.update({ where: { id }, data: { nome } });
}
async delete(id: string): Promise<void> {
await prisma.tipoEvento.delete({ where: { id } });
}
}
export const tipiEventoRepository = new TipiEventoRepository();
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireOrgId } from '../auth/require-org-id.middleware';
import { getEvento, patchEventoCheck, postEvento } from '../controllers/eventi.controller';
export const eventiRouter = Router();
eventiRouter.post('/eventi', verifyToken, requireOrgId, postEvento);
eventiRouter.get('/eventi/:id', verifyToken, requireOrgId, getEvento);
eventiRouter.patch('/eventi/:id/check', verifyToken, requireOrgId, patchEventoCheck);
@@ -0,0 +1,6 @@
import { Router } from 'express';
import { getHealth } from '../controllers/health.controller';
export const healthRouter = Router();
healthRouter.get('/health', getHealth);
@@ -0,0 +1,12 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireOrgId } from '../auth/require-org-id.middleware';
import { deleteLista, getListe, postLista, postListaDaModello, putLista } from '../controllers/liste.controller';
export const listeRouter = Router();
listeRouter.get('/liste', verifyToken, requireOrgId, getListe);
listeRouter.post('/liste', verifyToken, requireOrgId, postLista);
listeRouter.post('/liste/da-modello/:listaModelloId', verifyToken, requireOrgId, postListaDaModello);
listeRouter.put('/liste/:id', verifyToken, requireOrgId, putLista);
listeRouter.delete('/liste/:id', verifyToken, requireOrgId, deleteLista);
@@ -0,0 +1,16 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireModeratore } from '../auth/require-moderatore.middleware';
import {
deleteListaModello,
getListeModello,
postListaModello,
putListaModello,
} from '../controllers/listeModello.controller';
export const listeModelloRouter = Router();
listeModelloRouter.get('/liste-modello', getListeModello);
listeModelloRouter.post('/liste-modello', verifyToken, requireModeratore, postListaModello);
listeModelloRouter.put('/liste-modello/:id', verifyToken, requireModeratore, putListaModello);
listeModelloRouter.delete('/liste-modello/:id', verifyToken, requireModeratore, deleteListaModello);
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireOrgId } from '../auth/require-org-id.middleware';
import { deleteVoceMagazzino, getMagazzino, postVoceMagazzino, putVoceMagazzino } from '../controllers/magazzino.controller';
export const magazzinoRouter = Router();
magazzinoRouter.get('/magazzino', verifyToken, requireOrgId, getMagazzino);
magazzinoRouter.post('/magazzino', verifyToken, requireOrgId, postVoceMagazzino);
magazzinoRouter.put('/magazzino/:id', verifyToken, requireOrgId, putVoceMagazzino);
magazzinoRouter.delete('/magazzino/:id', verifyToken, requireOrgId, deleteVoceMagazzino);
@@ -0,0 +1,12 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireOrgId } from '../auth/require-org-id.middleware';
import { requireModeratore } from '../auth/require-moderatore.middleware';
import { getMaterialiPubblici, getProposte, patchProposta, postProposta } from '../controllers/materiali.controller';
export const materialiRouter = Router();
materialiRouter.get('/materiali', getMaterialiPubblici);
materialiRouter.post('/materiali/proposte', verifyToken, requireOrgId, postProposta);
materialiRouter.get('/materiali/proposte', verifyToken, requireModeratore, getProposte);
materialiRouter.patch('/materiali/proposte/:id', verifyToken, requireModeratore, patchProposta);
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { verifyToken } from '../auth/verify-token.middleware';
import { requireModeratore } from '../auth/require-moderatore.middleware';
import { deleteTipoEvento, getTipiEvento, postTipoEvento, putTipoEvento } from '../controllers/tipiEvento.controller';
export const tipiEventoRouter = Router();
tipiEventoRouter.get('/tipi-evento', getTipiEvento);
tipiEventoRouter.post('/tipi-evento', verifyToken, requireModeratore, postTipoEvento);
tipiEventoRouter.put('/tipi-evento/:id', verifyToken, requireModeratore, putTipoEvento);
tipiEventoRouter.delete('/tipi-evento/:id', verifyToken, requireModeratore, deleteTipoEvento);
+6
View File
@@ -0,0 +1,6 @@
import { app } from './app';
import { env } from './config/env';
app.listen(env.port, () => {
console.log(`Server avviato su porta ${env.port}`);
});
@@ -0,0 +1,109 @@
import { EventoConDettagli, eventiRepository } from '../repositories/eventi.repository';
import { listeRepository } from '../repositories/liste.repository';
import { magazzinoRepository } from '../repositories/magazzino.repository';
import { HttpError } from '../errors';
export interface EventoVoceView {
materialeId: string;
nome: string;
unitaMisura: string;
quantitaRichiesta: number;
quantitaPosseduta: number;
portato: boolean;
note: string | null;
}
export interface EventoDettaglioView {
id: string;
orgId: string;
nome: string;
listaId: string;
data: Date;
voci: EventoVoceView[];
}
// Join fra le voci della lista collegata all'evento e il magazzino dell'org:
// per ogni materiale della lista, quanto ne possiede l'org (0 se non tracciato)
// e lo stato di check (di default "non portato", nessuna nota) finché non
// viene aggiornato via PATCH /eventi/:id/check.
async function buildDettaglioView(evento: EventoConDettagli): Promise<EventoDettaglioView> {
const materialeIds = evento.lista.voci.map((v) => v.materialeId);
const quantitaPossedute =
materialeIds.length > 0 ? await magazzinoRepository.findQuantitaByOrgEMateriali(evento.orgId, materialeIds) : [];
const magazzinoByMateriale = new Map(quantitaPossedute.map((m) => [m.materialeId, m.quantitaPosseduta]));
const checkByMateriale = new Map(evento.check.map((c) => [c.materialeId, c]));
return {
id: evento.id,
orgId: evento.orgId,
nome: evento.nome,
listaId: evento.listaId,
data: evento.data,
voci: evento.lista.voci.map((v) => {
const check = checkByMateriale.get(v.materialeId);
return {
materialeId: v.materialeId,
nome: v.materiale.nome,
unitaMisura: v.materiale.unitaMisura,
quantitaRichiesta: v.quantita,
quantitaPosseduta: magazzinoByMateriale.get(v.materialeId) ?? 0,
portato: check?.portato ?? false,
note: check?.note ?? null,
};
}),
};
}
export interface CreaEventoInput {
nome: string;
listaId: string;
data: Date;
}
export async function creaEvento(orgId: string, input: CreaEventoInput): Promise<EventoDettaglioView> {
const lista = await listeRepository.findByIdAndOrg(input.listaId, orgId);
if (!lista) {
throw new HttpError(400, 'La lista indicata non esiste o non appartiene alla tua organizzazione');
}
const evento = await eventiRepository.create({ orgId, nome: input.nome, listaId: input.listaId, data: input.data });
return buildDettaglioView(evento);
}
export async function getDettaglioEvento(id: string, orgId: string): Promise<EventoDettaglioView> {
const evento = await eventiRepository.findByIdAndOrg(id, orgId);
if (!evento) {
throw new HttpError(404, 'Evento non trovato');
}
return buildDettaglioView(evento);
}
export interface AggiornaCheckInput {
materialeId: string;
portato?: boolean;
note?: string | null;
}
export async function aggiornaCheckEvento(
id: string,
orgId: string,
voci: AggiornaCheckInput[],
): Promise<EventoDettaglioView> {
const evento = await eventiRepository.findByIdAndOrg(id, orgId);
if (!evento) {
throw new HttpError(404, 'Evento non trovato');
}
const materialiDellaLista = new Set(evento.lista.voci.map((v) => v.materialeId));
for (const voce of voci) {
if (!materialiDellaLista.has(voce.materialeId)) {
throw new HttpError(400, `Il materiale ${voce.materialeId} non fa parte della lista collegata a questo evento`);
}
}
for (const voce of voci) {
await eventiRepository.upsertCheck(id, voce.materialeId, { portato: voce.portato, note: voce.note });
}
return getDettaglioEvento(id, orgId);
}
@@ -0,0 +1,10 @@
import { healthRepository } from '../repositories/health.repository';
export class HealthService {
async checkDatabase(): Promise<boolean> {
await healthRepository.ping();
return true;
}
}
export const healthService = new HealthService();
@@ -0,0 +1,89 @@
import { ListaConVoci, ListaVoceInput, listeRepository } from '../repositories/liste.repository';
import { listeModelloRepository } from '../repositories/listeModello.repository';
import { HttpError } from '../errors';
import { toHttpError } from '../utils/prisma-errors';
export interface ListaVoceView {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
}
export interface ListaView {
id: string;
nome: string;
orgId: string;
creataIl: Date;
voci: ListaVoceView[];
}
function toView(lista: ListaConVoci): ListaView {
return {
id: lista.id,
nome: lista.nome,
orgId: lista.orgId,
creataIl: lista.creataIl,
voci: lista.voci.map((v) => ({
materialeId: v.materialeId,
nome: v.materiale.nome,
unitaMisura: v.materiale.unitaMisura,
quantita: v.quantita,
})),
};
}
export async function listListePerOrg(orgId: string): Promise<ListaView[]> {
const liste = await listeRepository.findAllByOrg(orgId);
return liste.map(toView);
}
export async function creaListaVuota(orgId: string, nome: string): Promise<ListaView> {
const lista = await listeRepository.create({ nome, orgId, voci: [] });
return toView(lista);
}
// Fork: copia nome e voci correnti della lista modello in una nuova lista
// dell'org. Da qui in poi le due entità non hanno più alcun legame: nessun
// listaModelloId viene salvato sulla lista creata.
export async function forkListaDaModello(orgId: string, listaModelloId: string): Promise<ListaView> {
const modello = await listeModelloRepository.findById(listaModelloId);
if (!modello || !modello.pubblica) {
throw new HttpError(404, 'Lista modello non trovata');
}
const voci: ListaVoceInput[] = modello.voci.map((v) => ({ materialeId: v.materialeId, quantita: v.quantita }));
const lista = await listeRepository.create({ nome: modello.nome, orgId, voci });
return toView(lista);
}
async function assicuraListaDiOrg(id: string, orgId: string): Promise<void> {
const lista = await listeRepository.findByIdAndOrg(id, orgId);
if (!lista) {
throw new HttpError(404, 'Lista non trovata');
}
}
export interface AggiornaListaInput {
nome?: string;
voci?: ListaVoceInput[];
}
export async function aggiornaLista(id: string, orgId: string, input: AggiornaListaInput): Promise<ListaView> {
await assicuraListaDiOrg(id, orgId);
try {
const lista = await listeRepository.update(id, input);
return toView(lista);
} catch (err) {
throw toHttpError(err, 'Lista non trovata', 'Uno dei materiali indicati non esiste');
}
}
export async function eliminaLista(id: string, orgId: string): Promise<void> {
await assicuraListaDiOrg(id, orgId);
try {
await listeRepository.delete(id);
} catch (err) {
throw toHttpError(err, 'Lista non trovata', 'Impossibile eliminare la lista: è referenziata da un evento');
}
}
@@ -0,0 +1,81 @@
import {
CreateListaModelloData,
ListaModelloConVoci,
ListaModelloVoceInput,
UpdateListaModelloData,
listeModelloRepository,
} from '../repositories/listeModello.repository';
import { toHttpError } from '../utils/prisma-errors';
export interface ListaModelloVoceView {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
}
export interface ListaModelloView {
id: string;
nome: string;
tipoEventoId: string;
voci: ListaModelloVoceView[];
}
function toView(lista: ListaModelloConVoci): ListaModelloView {
return {
id: lista.id,
nome: lista.nome,
tipoEventoId: lista.tipoEventoId,
voci: lista.voci.map((v) => ({
materialeId: v.materialeId,
nome: v.materiale.nome,
unitaMisura: v.materiale.unitaMisura,
quantita: v.quantita,
})),
};
}
export async function listListeModello(tipoEventoId?: string): Promise<ListaModelloView[]> {
const liste = await listeModelloRepository.findAll(tipoEventoId);
return liste.map(toView);
}
export interface CreaListaModelloInput {
nome: string;
tipoEventoId: string;
voci: ListaModelloVoceInput[];
}
export async function creaListaModello(input: CreaListaModelloInput): Promise<ListaModelloView> {
const data: CreateListaModelloData = input;
try {
const lista = await listeModelloRepository.create(data);
return toView(lista);
} catch (err) {
throw toHttpError(err, 'Tipo evento non trovato', 'Uno dei materiali indicati non esiste');
}
}
export interface AggiornaListaModelloInput {
nome?: string;
tipoEventoId?: string;
voci?: ListaModelloVoceInput[];
}
export async function aggiornaListaModello(id: string, input: AggiornaListaModelloInput): Promise<ListaModelloView> {
const data: UpdateListaModelloData = input;
try {
const lista = await listeModelloRepository.update(id, data);
return toView(lista);
} catch (err) {
throw toHttpError(err, 'Lista modello non trovata', 'Riferimento non valido (tipo evento o materiale inesistente)');
}
}
export async function eliminaListaModello(id: string): Promise<void> {
try {
await listeModelloRepository.delete(id);
} catch (err) {
throw toHttpError(err, 'Lista modello non trovata', 'Impossibile eliminare la lista modello');
}
}
@@ -0,0 +1,109 @@
import { StatoMagazzinoVoce, StatoMateriale } from '@prisma/client';
import {
CreateMagazzinoVoceData,
MagazzinoVoceConMateriale,
UpdateMagazzinoVoceData,
magazzinoRepository,
} from '../repositories/magazzino.repository';
import { materialiRepository } from '../repositories/materiali.repository';
import { HttpError } from '../errors';
import { toHttpError } from '../utils/prisma-errors';
export interface MagazzinoVoceView {
id: string;
orgId: string;
materialeId: string;
materialeNome: string;
materialeCategoria: string;
quantitaPosseduta: number;
stato: StatoMagazzinoVoce;
posizione: string | null;
note: string | null;
}
function toView(voce: MagazzinoVoceConMateriale): MagazzinoVoceView {
return {
id: voce.id,
orgId: voce.orgId,
materialeId: voce.materialeId,
materialeNome: voce.materiale.nome,
materialeCategoria: voce.materiale.categoria,
quantitaPosseduta: voce.quantitaPosseduta,
stato: voce.stato,
posizione: voce.posizione,
note: voce.note,
};
}
// Non basta che materiale_id esista: deve essere nel catalogo pubblico
// approvato. Un materiale ancora "proposto" o "rifiutato" non può finire nel
// magazzino di un gruppo.
async function assicuraMaterialeApprovato(materialeId: string): Promise<void> {
const materiale = await materialiRepository.findById(materialeId);
if (!materiale || materiale.stato !== StatoMateriale.approvato) {
throw new HttpError(
400,
'Il materiale indicato non è nel catalogo pubblico approvato: proponilo prima tramite POST /materiali/proposte',
);
}
}
export function listMagazzinoPerOrg(orgId: string): Promise<MagazzinoVoceView[]> {
return magazzinoRepository.findAllByOrg(orgId).then((voci) => voci.map(toView));
}
export interface AggiungiVoceInput {
materialeId: string;
quantitaPosseduta: number;
stato: StatoMagazzinoVoce;
posizione?: string;
note?: string;
}
export async function aggiungiVoce(orgId: string, input: AggiungiVoceInput): Promise<MagazzinoVoceView> {
await assicuraMaterialeApprovato(input.materialeId);
const data: CreateMagazzinoVoceData = { ...input, orgId };
const voce = await magazzinoRepository.create(data);
return toView(voce);
}
async function assicuraVoceDiOrg(id: string, orgId: string): Promise<void> {
const voce = await magazzinoRepository.findByIdAndOrg(id, orgId);
if (!voce) {
throw new HttpError(404, 'Voce di magazzino non trovata');
}
}
export interface AggiornaVoceInput {
materialeId?: string;
quantitaPosseduta?: number;
stato?: StatoMagazzinoVoce;
posizione?: string | null;
note?: string | null;
}
export async function aggiornaVoce(id: string, orgId: string, input: AggiornaVoceInput): Promise<MagazzinoVoceView> {
await assicuraVoceDiOrg(id, orgId);
if (input.materialeId !== undefined) {
await assicuraMaterialeApprovato(input.materialeId);
}
const data: UpdateMagazzinoVoceData = input;
try {
const voce = await magazzinoRepository.update(id, data);
return toView(voce);
} catch (err) {
throw toHttpError(err, 'Voce di magazzino non trovata', 'Il materiale indicato non esiste');
}
}
export async function eliminaVoce(id: string, orgId: string): Promise<void> {
await assicuraVoceDiOrg(id, orgId);
try {
await magazzinoRepository.delete(id);
} catch (err) {
throw toHttpError(err, 'Voce di magazzino non trovata', 'Impossibile eliminare la voce di magazzino');
}
}
@@ -0,0 +1,84 @@
import { Materiale, StatoMateriale } from '@prisma/client';
import { materialiRepository } from '../repositories/materiali.repository';
import { HttpError } from '../errors';
export interface MaterialePubblico {
id: string;
nome: string;
categoria: string;
unitaMisura: string;
}
export interface MaterialeProposta {
id: string;
nome: string;
categoria: string;
unitaMisura: string;
stato: StatoMateriale;
propostoDaOrgId: string;
creatoIl: Date;
}
function toPubblico(materiale: Materiale): MaterialePubblico {
return {
id: materiale.id,
nome: materiale.nome,
categoria: materiale.categoria,
unitaMisura: materiale.unitaMisura,
};
}
function toProposta(materiale: Materiale): MaterialeProposta {
return {
id: materiale.id,
nome: materiale.nome,
categoria: materiale.categoria,
unitaMisura: materiale.unitaMisura,
stato: materiale.stato,
propostoDaOrgId: materiale.propostoDaOrgId,
creatoIl: materiale.creatoIl,
};
}
export async function listMaterialiApprovati(categoria?: string): Promise<MaterialePubblico[]> {
const materiali = await materialiRepository.findApprovati(categoria);
return materiali.map(toPubblico);
}
export interface ProponiMaterialeInput {
nome: string;
categoria: string;
unitaMisura: string;
orgId: string;
}
export async function proponiMateriale(input: ProponiMaterialeInput): Promise<MaterialeProposta> {
const materiale = await materialiRepository.create({
nome: input.nome,
categoria: input.categoria,
unitaMisura: input.unitaMisura,
propostoDaOrgId: input.orgId,
});
return toProposta(materiale);
}
export async function listProposte(): Promise<MaterialeProposta[]> {
const materiali = await materialiRepository.findProposte();
return materiali.map(toProposta);
}
export type DecisioneProposta = 'approvato' | 'rifiutato';
export async function decidiProposta(id: string, decisione: DecisioneProposta): Promise<MaterialeProposta> {
const materiale = await materialiRepository.findById(id);
if (!materiale) {
throw new HttpError(404, 'Proposta non trovata');
}
if (materiale.stato !== StatoMateriale.proposto) {
throw new HttpError(409, 'La proposta è già stata decisa');
}
const stato = decisione === 'approvato' ? StatoMateriale.approvato : StatoMateriale.rifiutato;
const aggiornato = await materialiRepository.updateStato(id, stato);
return toProposta(aggiornato);
}
@@ -0,0 +1,31 @@
import { TipoEvento } from '@prisma/client';
import { tipiEventoRepository } from '../repositories/tipiEvento.repository';
import { toHttpError } from '../utils/prisma-errors';
export function listTipiEvento(): Promise<TipoEvento[]> {
return tipiEventoRepository.findAll();
}
export function creaTipoEvento(nome: string): Promise<TipoEvento> {
return tipiEventoRepository.create(nome);
}
export async function aggiornaTipoEvento(id: string, nome: string): Promise<TipoEvento> {
try {
return await tipiEventoRepository.update(id, nome);
} catch (err) {
throw toHttpError(err, 'Tipo evento non trovato', 'Conflitto durante l\'aggiornamento del tipo evento');
}
}
export async function eliminaTipoEvento(id: string): Promise<void> {
try {
await tipiEventoRepository.delete(id);
} catch (err) {
throw toHttpError(
err,
'Tipo evento non trovato',
'Impossibile eliminare il tipo evento: è referenziato da almeno una lista modello',
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { AuthContext } from '../auth/auth.types';
declare global {
namespace Express {
interface Request {
auth?: AuthContext;
}
}
}
export {};
@@ -0,0 +1,16 @@
import { Prisma } from '@prisma/client';
import { HttpError } from '../errors';
// Converte gli errori noti di Prisma (vincoli FK, record non trovato) in HttpError
// con uno status code sensato, senza dover ripetere lo stesso catch ovunque.
export function toHttpError(err: unknown, notFoundMessage: string, conflictMessage: string): unknown {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') {
return new HttpError(404, notFoundMessage);
}
if (err.code === 'P2003') {
return new HttpError(409, conflictMessage);
}
}
return err;
}