Add scouthub-magazzino-be
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user