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
+7
View File
@@ -0,0 +1,7 @@
PORT=8002
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub_magazzino?schema=public
KEYCLOAK_BASE_URL=http://localhost:6999
KEYCLOAK_REALM=scouthub
KEYCLOAK_MAGAZZINO_CLIENT_ID=scouthub-magazzino-be
KEYCLOAK_MAGAZZINO_CLIENT_SECRET=CAMBIA-QUESTO-SECRET-IN-UN-VAULT
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.env
*.log
coverage/
/magazzino-be.iml
/.idea/
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
EXPOSE 8083
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
+89
View File
@@ -0,0 +1,89 @@
README.# scouthub-magazzino-be
Backend Node.js/TypeScript per la gestione del **magazzino** Scouthub (materiale scout in dotazione
ai gruppi), pensato per affiancare `scouthub-home-be` e `scouthub-attivita-be` nello stesso ecosistema.
> Stato attuale: schema dati e middleware di autenticazione pronti; espone solo l'healthcheck,
> nessuna route di dominio (liste/magazzino/eventi) ancora implementata.
## Stack
- Node.js ≥ 20, TypeScript
- Express
- Prisma ORM su PostgreSQL (database dedicato `scouthub_magazzino`)
- Autenticazione JWT via Keycloak, stesso realm di `scouthub-home-be`/`scouthub-attivita-be`
## Struttura a livelli
```
src/
routes/ # definizione degli endpoint Express
controllers/ # gestione request/response, delega alla service layer
services/ # business logic
repositories/ # accesso ai dati tramite Prisma
auth/ # verifica JWT Keycloak e guard sui ruoli/org_id
config/ # lettura e validazione delle variabili d'ambiente
db/ # istanza condivisa di PrismaClient
middleware/ # error handler
```
Flusso delle richieste: `routes -> controller -> service -> repository (Prisma)`.
## Autenticazione e multi-tenancy (org_id)
- `src/auth/verify-token.middleware.ts` valida il Bearer token contro il JWKS del realm Keycloak
(`${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`) e popola
`req.auth` con `userId`, `email`, `orgId` (dal claim `organization` iniettato da Keycloak
Organizations) e `roles` (ruoli realm + ruoli sull'organizzazione attiva).
- `src/auth/require-org-id.middleware.ts` da usare dopo `verifyToken` su **tutte** le route
private (liste, magazzino, eventi): rifiuta la richiesta se il token non porta
un'organizzazione attiva. Le query verso il database devono sempre filtrare per
`req.auth.orgId`, mai per un `org_id` letto da params/query/body della richiesta.
- `src/auth/require-moderatore.middleware.ts` da usare solo sulle route di moderazione del
catalogo materiali (richiede il ruolo realm `moderatore`).
## Variabili d'ambiente
Vedi `.env.example`. Copiarlo in `.env` e valorizzare:
| Variabile | Descrizione |
|---|---|
| `PORT` | Porta HTTP del servizio (default `8083`) |
| `DATABASE_URL` | Connection string Postgres (schema/database `scouthub_magazzino`) |
| `KEYCLOAK_BASE_URL` | Base URL del server Keycloak |
| `KEYCLOAK_REALM` | Realm Keycloak (`scouthub`) |
| `KEYCLOAK_MAGAZZINO_CLIENT_ID` | Client Keycloak dedicato a questo servizio |
| `KEYCLOAK_MAGAZZINO_CLIENT_SECRET` | Secret del client sopra |
## Avvio in locale
```bash
npm install
npx prisma generate
npx prisma migrate deploy # applica le migration sul database scouthub_magazzino
npm run dev # avvia con ts-node-dev su http://localhost:8083
```
## Build e avvio in produzione
```bash
npm run build
npm start
```
## Healthcheck
```
GET /health
```
Risponde `{ "status": "ok", "database": "up" }` se il servizio e la connessione al database sono
funzionanti.
## Test
```bash
npm test
```
Nessun test presente al momento (scaffold); il comando gira con `--passWithNoTests`.
+14
View File
@@ -0,0 +1,14 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['<rootDir>/tests/unit/**/*.test.ts', '<rootDir>/tests/integration/**/*.test.ts'],
testTimeout: 15000,
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.jest.json' }],
},
};
export default config;
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "scouthub-magazzino-be",
"version": "1.0.0",
"private": true,
"description": "Backend Node.js/TypeScript per la gestione del magazzino Scouthub, integrato con Keycloak",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"test": "jest --runInBand --passWithNoTests",
"test:watch": "jest --watch --runInBand"
},
"dependencies": {
"@prisma/client": "^5.16.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^3.2.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.14.12",
"@types/supertest": "^7.2.1",
"jest": "^29.7.0",
"nock": "^13.5.4",
"prisma": "^5.16.1",
"supertest": "^7.2.2",
"ts-jest": "^29.2.3",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.4"
}
}
@@ -0,0 +1,125 @@
-- CreateEnum
CREATE TYPE "stato_materiale" AS ENUM ('proposto', 'approvato', 'rifiutato');
-- CreateEnum
CREATE TYPE "stato_magazzino_voce" AS ENUM ('buono', 'da_riparare', 'mancante');
-- CreateTable
CREATE TABLE "materiale" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"categoria" TEXT NOT NULL,
"unita_misura" TEXT NOT NULL,
"stato" "stato_materiale" NOT NULL DEFAULT 'proposto',
"proposto_da_org_id" TEXT NOT NULL,
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "materiale_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tipo_evento" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
CONSTRAINT "tipo_evento_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_modello" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"tipo_evento_id" TEXT NOT NULL,
"pubblica" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "lista_modello_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_modello_voce" (
"lista_modello_id" TEXT NOT NULL,
"materiale_id" TEXT NOT NULL,
"quantita" INTEGER NOT NULL,
CONSTRAINT "lista_modello_voce_pkey" PRIMARY KEY ("lista_modello_id","materiale_id")
);
-- CreateTable
CREATE TABLE "lista" (
"id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"creata_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lista_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lista_voce" (
"lista_id" TEXT NOT NULL,
"materiale_id" TEXT NOT NULL,
"quantita" INTEGER NOT NULL,
CONSTRAINT "lista_voce_pkey" PRIMARY KEY ("lista_id","materiale_id")
);
-- CreateTable
CREATE TABLE "magazzino_voce" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"materiale_id" TEXT NOT NULL,
"quantita_posseduta" INTEGER NOT NULL,
"stato" "stato_magazzino_voce" NOT NULL,
"posizione" TEXT,
"note" TEXT,
CONSTRAINT "magazzino_voce_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "evento" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"lista_id" TEXT NOT NULL,
"data" TIMESTAMP(3) NOT NULL,
CONSTRAINT "evento_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "evento_check" (
"evento_id" TEXT NOT NULL,
"materiale_id" TEXT NOT NULL,
"portato" BOOLEAN NOT NULL DEFAULT false,
"note" TEXT,
CONSTRAINT "evento_check_pkey" PRIMARY KEY ("evento_id","materiale_id")
);
-- AddForeignKey
ALTER TABLE "lista_modello" ADD CONSTRAINT "lista_modello_tipo_evento_id_fkey" FOREIGN KEY ("tipo_evento_id") REFERENCES "tipo_evento"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_lista_modello_id_fkey" FOREIGN KEY ("lista_modello_id") REFERENCES "lista_modello"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_modello_voce" ADD CONSTRAINT "lista_modello_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lista_voce" ADD CONSTRAINT "lista_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "magazzino_voce" ADD CONSTRAINT "magazzino_voce_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "evento" ADD CONSTRAINT "evento_lista_id_fkey" FOREIGN KEY ("lista_id") REFERENCES "lista"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "evento_check" ADD CONSTRAINT "evento_check_evento_id_fkey" FOREIGN KEY ("evento_id") REFERENCES "evento"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "evento_check" ADD CONSTRAINT "evento_check_materiale_id_fkey" FOREIGN KEY ("materiale_id") REFERENCES "materiale"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+138
View File
@@ -0,0 +1,138 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum StatoMateriale {
proposto
approvato
rifiutato
@@map("stato_materiale")
}
enum StatoMagazzinoVoce {
buono
da_riparare
mancante
@@map("stato_magazzino_voce")
}
model Materiale {
id String @id @default(uuid())
nome String
categoria String
unitaMisura String @map("unita_misura")
stato StatoMateriale @default(proposto)
propostoDaOrgId String @map("proposto_da_org_id")
creatoIl DateTime @default(now()) @map("creato_il")
listaModelloVoci ListaModelloVoce[]
listaVoci ListaVoce[]
magazzinoVoci MagazzinoVoce[]
eventoCheck EventoCheck[]
@@map("materiale")
}
model TipoEvento {
id String @id @default(uuid())
nome String
listeModello ListaModello[]
@@map("tipo_evento")
}
model ListaModello {
id String @id @default(uuid())
nome String
tipoEventoId String @map("tipo_evento_id")
pubblica Boolean @default(true)
tipoEvento TipoEvento @relation(fields: [tipoEventoId], references: [id])
voci ListaModelloVoce[]
@@map("lista_modello")
}
model ListaModelloVoce {
listaModelloId String @map("lista_modello_id")
materialeId String @map("materiale_id")
quantita Int
listaModello ListaModello @relation(fields: [listaModelloId], references: [id])
materiale Materiale @relation(fields: [materialeId], references: [id])
@@id([listaModelloId, materialeId])
@@map("lista_modello_voce")
}
model Lista {
id String @id @default(uuid())
nome String
orgId String @map("org_id")
creataIl DateTime @default(now()) @map("creata_il")
voci ListaVoce[]
eventi Evento[]
@@map("lista")
}
model ListaVoce {
listaId String @map("lista_id")
materialeId String @map("materiale_id")
quantita Int
lista Lista @relation(fields: [listaId], references: [id])
materiale Materiale @relation(fields: [materialeId], references: [id])
@@id([listaId, materialeId])
@@map("lista_voce")
}
model MagazzinoVoce {
id String @id @default(uuid())
orgId String @map("org_id")
materialeId String @map("materiale_id")
quantitaPosseduta Int @map("quantita_posseduta")
stato StatoMagazzinoVoce
posizione String?
note String?
materiale Materiale @relation(fields: [materialeId], references: [id])
@@map("magazzino_voce")
}
model Evento {
id String @id @default(uuid())
orgId String @map("org_id")
nome String
listaId String @map("lista_id")
data DateTime
lista Lista @relation(fields: [listaId], references: [id])
check EventoCheck[]
@@map("evento")
}
model EventoCheck {
eventoId String @map("evento_id")
materialeId String @map("materiale_id")
portato Boolean @default(false)
note String?
evento Evento @relation(fields: [eventoId], references: [id])
materiale Materiale @relation(fields: [materialeId], references: [id])
@@id([eventoId, materialeId])
@@map("evento_check")
}
+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;
}
@@ -0,0 +1,254 @@
import { generateKeyPairSync } from 'crypto';
import request from 'supertest';
import nock from 'nock';
import jwt from 'jsonwebtoken';
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
process.env.KEYCLOAK_REALM = 'scouthub';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
const eventoFindFirst = jest.fn();
const eventoCreate = jest.fn();
const eventoCheckUpsert = jest.fn();
const listaFindFirst = jest.fn();
const magazzinoVoceFindMany = jest.fn();
jest.mock('../../src/db/prisma', () => ({
prisma: {
evento: {
findFirst: (...args: unknown[]) => eventoFindFirst(...args),
create: (...args: unknown[]) => eventoCreate(...args),
},
eventoCheck: {
upsert: (...args: unknown[]) => eventoCheckUpsert(...args),
},
lista: {
findFirst: (...args: unknown[]) => listaFindFirst(...args),
},
magazzinoVoce: {
findMany: (...args: unknown[]) => magazzinoVoceFindMany(...args),
},
},
}));
import { app } from '../../src/app';
const KEYCLOAK_HOST = 'http://keycloak.test';
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
const KID = 'test-kid';
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function tokenOrg(orgId: string): string {
return signToken({
sub: 'user-1',
realm_access: { roles: ['censito'] },
organization: { gruppo: { id: orgId, roles: [] } },
});
}
function materiale(id: string, nome: string, unitaMisura: string) {
return { id, nome, categoria: 'x', unitaMisura, stato: 'approvato', propostoDaOrgId: 'org-seed', creatoIl: new Date() };
}
// Evento con lista a due voci (Tenda x2, Torcia x4): un materiale è tracciato
// in magazzino, l'altro no (deve risultare quantitaPosseduta: 0 di default).
function eventoConDettagli(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'ev-1',
orgId: 'org-a',
nome: 'Campo estivo 2026',
listaId: 'l-1',
data: new Date('2026-08-01'),
lista: {
id: 'l-1',
nome: 'Kit campo estivo',
orgId: 'org-a',
creataIl: new Date('2026-01-01'),
voci: [
{ listaId: 'l-1', materialeId: 'm-1', quantita: 2, materiale: materiale('m-1', 'Tenda', 'pz') },
{ listaId: 'l-1', materialeId: 'm-2', quantita: 4, materiale: materiale('m-2', 'Torcia', 'pz') },
],
},
check: [{ eventoId: 'ev-1', materialeId: 'm-1', portato: true, note: 'controllata' }],
...overrides,
};
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
});
});
afterAll(() => {
nock.cleanAll();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /eventi', () => {
test("crea l'evento per l'org corrente se la lista appartiene alla stessa org", async () => {
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Kit', orgId: 'org-a', creataIl: new Date(), voci: [] });
eventoCreate.mockResolvedValueOnce(eventoConDettagli({ check: [] }));
magazzinoVoceFindMany.mockResolvedValueOnce([]);
const response = await request(app)
.post('/eventi')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Campo estivo 2026', listaId: 'l-1', data: '2026-08-01' });
expect(response.status).toBe(201);
expect(listaFindFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'l-1', orgId: 'org-a' } }),
);
expect(eventoCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ orgId: 'org-a', listaId: 'l-1' }) }),
);
});
test('risponde 400 se la lista non esiste o appartiene a un\'altra org', async () => {
listaFindFirst.mockResolvedValueOnce(null);
const response = await request(app)
.post('/eventi')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Campo estivo 2026', listaId: 'l-di-unaltra-org', data: '2026-08-01' });
expect(response.status).toBe(400);
expect(eventoCreate).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/eventi').send({ nome: 'x', listaId: 'l-1', data: '2026-08-01' });
expect(response.status).toBe(401);
expect(eventoCreate).not.toHaveBeenCalled();
});
});
describe('GET /eventi/:id — join lista <-> magazzino', () => {
test("un'org non può leggere un evento di un'altra org", async () => {
eventoFindFirst.mockResolvedValueOnce(null);
const response = await request(app).get('/eventi/ev-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
expect(response.status).toBe(404);
expect(eventoFindFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'ev-1', orgId: 'org-b' } }),
);
expect(magazzinoVoceFindMany).not.toHaveBeenCalled();
});
test('combina, per ogni voce della lista, quantità posseduta in magazzino e stato di check', async () => {
eventoFindFirst.mockResolvedValueOnce(eventoConDettagli());
// Solo m-1 è tracciato in magazzino (5 posseduti); m-2 non ha alcuna riga.
magazzinoVoceFindMany.mockResolvedValueOnce([{ materialeId: 'm-1', quantitaPosseduta: 5 }]);
const response = await request(app).get('/eventi/ev-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(200);
expect(magazzinoVoceFindMany).toHaveBeenCalledWith({
where: { orgId: 'org-a', materialeId: { in: ['m-1', 'm-2'] } },
select: { materialeId: true, quantitaPosseduta: true },
});
expect(response.body).toEqual({
id: 'ev-1',
orgId: 'org-a',
nome: 'Campo estivo 2026',
listaId: 'l-1',
data: '2026-08-01T00:00:00.000Z',
voci: [
{
materialeId: 'm-1',
nome: 'Tenda',
unitaMisura: 'pz',
quantitaRichiesta: 2,
quantitaPosseduta: 5,
portato: true,
note: 'controllata',
},
{
materialeId: 'm-2',
nome: 'Torcia',
unitaMisura: 'pz',
quantitaRichiesta: 4,
quantitaPosseduta: 0,
portato: false,
note: null,
},
],
});
});
test('risponde 401 senza token', async () => {
const response = await request(app).get('/eventi/ev-1');
expect(response.status).toBe(401);
expect(eventoFindFirst).not.toHaveBeenCalled();
});
});
describe('PATCH /eventi/:id/check', () => {
test('aggiorna portato/note per una voce e restituisce il dettaglio aggiornato', async () => {
eventoFindFirst
.mockResolvedValueOnce(eventoConDettagli({ check: [] })) // ownership check dentro aggiornaCheckEvento
.mockResolvedValueOnce(eventoConDettagli()); // rilettura per la response
eventoCheckUpsert.mockResolvedValueOnce({ eventoId: 'ev-1', materialeId: 'm-1', portato: true, note: 'controllata' });
magazzinoVoceFindMany.mockResolvedValue([{ materialeId: 'm-1', quantitaPosseduta: 5 }]);
const response = await request(app)
.patch('/eventi/ev-1/check')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ voci: [{ materialeId: 'm-1', portato: true, note: 'controllata' }] });
expect(response.status).toBe(200);
expect(eventoCheckUpsert).toHaveBeenCalledWith({
where: { eventoId_materialeId: { eventoId: 'ev-1', materialeId: 'm-1' } },
create: { eventoId: 'ev-1', materialeId: 'm-1', portato: true, note: 'controllata' },
update: { portato: true, note: 'controllata' },
});
expect(response.body.voci[0]).toMatchObject({ materialeId: 'm-1', portato: true, note: 'controllata' });
});
test('rifiuta un materialeId che non appartiene alla lista collegata (400)', async () => {
eventoFindFirst.mockResolvedValueOnce(eventoConDettagli());
const response = await request(app)
.patch('/eventi/ev-1/check')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ voci: [{ materialeId: 'm-estraneo', portato: true }] });
expect(response.status).toBe(400);
expect(eventoCheckUpsert).not.toHaveBeenCalled();
});
test("un'org non può aggiornare il check di un evento di un'altra org", async () => {
eventoFindFirst.mockResolvedValueOnce(null);
const response = await request(app)
.patch('/eventi/ev-1/check')
.set('Authorization', `Bearer ${tokenOrg('org-b')}`)
.send({ voci: [{ materialeId: 'm-1', portato: true }] });
expect(response.status).toBe(404);
expect(eventoCheckUpsert).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).patch('/eventi/ev-1/check').send({ voci: [{ materialeId: 'm-1', portato: true }] });
expect(response.status).toBe(401);
expect(eventoCheckUpsert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,314 @@
import { generateKeyPairSync } from 'crypto';
import request from 'supertest';
import nock from 'nock';
import jwt from 'jsonwebtoken';
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
process.env.KEYCLOAK_REALM = 'scouthub';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
const listaFindMany = jest.fn();
const listaFindFirst = jest.fn();
const listaCreate = jest.fn();
const listaUpdate = jest.fn();
const listaDelete = jest.fn();
const listaVoceDeleteMany = jest.fn();
const listaModelloFindUnique = jest.fn();
const listaModelloUpdate = jest.fn();
const listaModelloVoceDeleteMany = jest.fn();
// $transaction condiviso da entrambi i repository (liste e liste-modello): il tx
// espone gli stessi metodi mockati usati fuori transazione, così i test possono
// asserire su un'unica lista di chiamate indipendentemente dal fatto che passino
// per una transazione o meno.
const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) =>
callback({
lista: { update: listaUpdate, delete: listaDelete },
listaVoce: { deleteMany: listaVoceDeleteMany },
listaModello: { update: listaModelloUpdate },
listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany },
}),
);
jest.mock('../../src/db/prisma', () => ({
prisma: {
lista: {
findMany: (...args: unknown[]) => listaFindMany(...args),
findFirst: (...args: unknown[]) => listaFindFirst(...args),
create: (...args: unknown[]) => listaCreate(...args),
},
listaVoce: {
deleteMany: (...args: unknown[]) => listaVoceDeleteMany(...args),
},
listaModello: {
findUnique: (...args: unknown[]) => listaModelloFindUnique(...args),
update: (...args: unknown[]) => listaModelloUpdate(...args),
},
listaModelloVoce: {
deleteMany: (...args: unknown[]) => listaModelloVoceDeleteMany(...args),
},
$transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])),
},
}));
import { app } from '../../src/app';
const KEYCLOAK_HOST = 'http://keycloak.test';
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
const KID = 'test-kid';
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function tokenOrg(orgId: string): string {
return signToken({
sub: 'user-1',
realm_access: { roles: ['censito'] },
organization: { gruppo: { id: orgId, roles: [] } },
});
}
function adminCatalogoToken(): string {
return signToken({
sub: 'admin-1',
realm_access: { roles: ['moderatore'] },
organization: { 'gruppo-omega': { id: 'org-9', roles: [] } },
});
}
function materialeJoin(id: string, nome: string, unitaMisura: string) {
return { id, nome, categoria: 'x', unitaMisura, stato: 'approvato', propostoDaOrgId: 'org-seed', creatoIl: new Date() };
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
});
});
afterAll(() => {
nock.cleanAll();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /liste', () => {
test('restituisce solo le liste dell\'org corrente, ricavata dal token', async () => {
listaFindMany.mockResolvedValueOnce([]);
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(listaFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { orgId: 'org-a' } }),
);
});
test('org diverse ottengono query filtrate su org_id diversi', async () => {
listaFindMany.mockResolvedValue([]);
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
await request(app).get('/liste').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
expect(listaFindMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { orgId: 'org-a' } }));
expect(listaFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ where: { orgId: 'org-b' } }));
});
test('risponde 401 senza token', async () => {
const response = await request(app).get('/liste');
expect(response.status).toBe(401);
expect(listaFindMany).not.toHaveBeenCalled();
});
});
describe('POST /liste', () => {
test('crea una lista vuota per l\'org corrente, ignorando un org_id eventualmente inviato dal client', async () => {
listaCreate.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista vuota', orgId: 'org-a', creataIl: new Date(), voci: [] });
const response = await request(app)
.post('/liste')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Lista vuota', orgId: 'org-spoofed' });
expect(response.status).toBe(201);
expect(listaCreate).toHaveBeenCalledWith({
data: { nome: 'Lista vuota', orgId: 'org-a', voci: { create: [] } },
include: { voci: { include: { materiale: true } } },
});
});
});
describe('POST /liste/da-modello/:listaModelloId', () => {
test('copia nome e voci dalla lista modello, senza salvare alcun riferimento verso di essa', async () => {
const vociModello = [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }];
listaModelloFindUnique.mockResolvedValueOnce({
id: 'lm-1',
nome: 'Kit campo estivo',
tipoEventoId: 't-1',
pubblica: true,
voci: vociModello,
});
listaCreate.mockResolvedValueOnce({
id: 'l-2',
nome: 'Kit campo estivo',
orgId: 'org-a',
creataIl: new Date(),
voci: [{ materialeId: 'm-1', quantita: 2, materiale: materialeJoin('m-1', 'Tenda', 'pz') }],
});
const response = await request(app)
.post('/liste/da-modello/lm-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(201);
expect(JSON.stringify(response.body)).not.toMatch(/listaModello/i);
const callArg = listaCreate.mock.calls[0][0];
expect(callArg.data).toEqual({
nome: 'Kit campo estivo',
orgId: 'org-a',
voci: { create: [{ materialeId: 'm-1', quantita: 2 }] },
});
// Le voci passate a create sono un array nuovo con valori copiati, non lo
// stesso array (né gli stessi oggetti) restituiti dalla lista modello.
expect(callArg.data.voci.create).not.toBe(vociModello);
});
test('risponde 404 se la lista modello non esiste', async () => {
listaModelloFindUnique.mockResolvedValueOnce(null);
const response = await request(app)
.post('/liste/da-modello/inesistente')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(404);
expect(listaCreate).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/liste/da-modello/lm-1');
expect(response.status).toBe(401);
expect(listaCreate).not.toHaveBeenCalled();
});
});
describe('PUT /liste/:id — isolamento tra org', () => {
test('un\'org non può modificare una lista di un\'altra org (risponde 404, non 403, per non rivelarne l\'esistenza)', async () => {
// La query combina sempre id + orgId: una lista di un'altra org non viene trovata.
listaFindFirst.mockResolvedValueOnce(null);
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-b')}`)
.send({ nome: 'Nome modificato' });
expect(response.status).toBe(404);
expect(listaFindFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'l-1', orgId: 'org-b' } }),
);
expect(listaUpdate).not.toHaveBeenCalled();
});
test('l\'org proprietaria può modificare la propria lista', async () => {
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Vecchio nome', orgId: 'org-a', creataIl: new Date(), voci: [] });
listaUpdate.mockResolvedValueOnce({ id: 'l-1', nome: 'Nuovo nome', orgId: 'org-a', creataIl: new Date(), voci: [] });
const response = await request(app)
.put('/liste/l-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Nuovo nome' });
expect(response.status).toBe(200);
expect(listaUpdate).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'l-1' }, data: expect.objectContaining({ nome: 'Nuovo nome' }) }),
);
});
test('risponde 401 senza token', async () => {
const response = await request(app).put('/liste/l-1').send({ nome: 'x' });
expect(response.status).toBe(401);
expect(listaFindFirst).not.toHaveBeenCalled();
});
});
describe('DELETE /liste/:id — isolamento tra org', () => {
test('un\'org non può eliminare una lista di un\'altra org', async () => {
listaFindFirst.mockResolvedValueOnce(null);
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
expect(response.status).toBe(404);
expect(listaDelete).not.toHaveBeenCalled();
});
test('l\'org proprietaria può eliminare la propria lista', async () => {
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Lista', orgId: 'org-a', creataIl: new Date(), voci: [] });
listaDelete.mockResolvedValueOnce({ id: 'l-1' });
const response = await request(app).delete('/liste/l-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(204);
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-1' } });
expect(listaDelete).toHaveBeenCalledWith({ where: { id: 'l-1' } });
});
});
describe('indipendenza tra lista modello e lista forkata', () => {
test('aggiornare la lista modello originale non tocca in alcun modo le tabelle della lista privata forkata', async () => {
listaModelloUpdate.mockResolvedValueOnce({
id: 'lm-1',
nome: 'Kit aggiornato',
tipoEventoId: 't-1',
pubblica: true,
voci: [],
});
const response = await request(app)
.put('/liste-modello/lm-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ nome: 'Kit aggiornato', voci: [] });
expect(response.status).toBe(200);
expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } });
expect(listaModelloUpdate).toHaveBeenCalled();
// Nessuna chiamata sulle tabelle delle liste private: le due entità sono
// completamente disgiunte dopo il fork.
expect(listaVoceDeleteMany).not.toHaveBeenCalled();
expect(listaUpdate).not.toHaveBeenCalled();
expect(listaDelete).not.toHaveBeenCalled();
});
test('aggiornare la lista privata forkata non tocca in alcun modo le tabelle della lista modello originale', async () => {
listaFindFirst.mockResolvedValueOnce({ id: 'l-2', nome: 'Kit campo estivo', orgId: 'org-a', creataIl: new Date(), voci: [] });
listaUpdate.mockResolvedValueOnce({
id: 'l-2',
nome: 'Kit campo estivo (personalizzato)',
orgId: 'org-a',
creataIl: new Date(),
voci: [],
});
const response = await request(app)
.put('/liste/l-2')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ nome: 'Kit campo estivo (personalizzato)', voci: [] });
expect(response.status).toBe(200);
expect(listaVoceDeleteMany).toHaveBeenCalledWith({ where: { listaId: 'l-2' } });
expect(listaUpdate).toHaveBeenCalled();
expect(listaModelloVoceDeleteMany).not.toHaveBeenCalled();
expect(listaModelloUpdate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,252 @@
import { generateKeyPairSync } from 'crypto';
import request from 'supertest';
import nock from 'nock';
import jwt from 'jsonwebtoken';
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
process.env.KEYCLOAK_REALM = 'scouthub';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
const listaModelloFindMany = jest.fn();
const listaModelloCreate = jest.fn();
// tx espone gli stessi metodi usati dal repository dentro $transaction; i test su
// update/delete forniscono un tx dedicato via mockImplementationOnce.
const listaModelloVoceDeleteMany = jest.fn();
const listaModelloUpdate = jest.fn();
const listaModelloDelete = jest.fn();
const transactionFn = jest.fn(async (callback: (tx: unknown) => unknown) =>
callback({
listaModelloVoce: { deleteMany: listaModelloVoceDeleteMany },
listaModello: { update: listaModelloUpdate, delete: listaModelloDelete },
}),
);
jest.mock('../../src/db/prisma', () => ({
prisma: {
listaModello: {
findMany: (...args: unknown[]) => listaModelloFindMany(...args),
create: (...args: unknown[]) => listaModelloCreate(...args),
},
$transaction: (...args: unknown[]) => transactionFn(...(args as [(tx: unknown) => unknown])),
},
}));
import { app } from '../../src/app';
const KEYCLOAK_HOST = 'http://keycloak.test';
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
const KID = 'test-kid';
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function utenteToken(): string {
return signToken({
sub: 'user-1',
realm_access: { roles: ['censito'] },
organization: { 'gruppo-alfa': { id: 'org-1', roles: [] } },
});
}
function adminCatalogoToken(): string {
return signToken({
sub: 'admin-1',
realm_access: { roles: ['moderatore'] },
organization: { 'gruppo-omega': { id: 'org-9', roles: [] } },
});
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
});
});
afterAll(() => {
nock.cleanAll();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /liste-modello', () => {
test('è pubblico e restituisce le liste con le voci (materiale + quantità)', async () => {
listaModelloFindMany.mockResolvedValueOnce([
{
id: 'lm-1',
nome: 'Kit campo estivo',
tipoEventoId: 't-1',
pubblica: true,
voci: [{ materialeId: 'm-1', quantita: 2, materiale: { id: 'm-1', nome: 'Tenda', unitaMisura: 'pz' } }],
},
]);
const response = await request(app).get('/liste-modello');
expect(response.status).toBe(200);
expect(response.body).toEqual([
{
id: 'lm-1',
nome: 'Kit campo estivo',
tipoEventoId: 't-1',
voci: [{ materialeId: 'm-1', nome: 'Tenda', unitaMisura: 'pz', quantita: 2 }],
},
]);
expect(listaModelloFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { pubblica: true } }),
);
});
test('filtra per tipoEventoId quando richiesto', async () => {
listaModelloFindMany.mockResolvedValueOnce([]);
await request(app).get('/liste-modello').query({ tipoEventoId: 't-1' });
expect(listaModelloFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { pubblica: true, tipoEventoId: 't-1' } }),
);
});
});
describe('POST /liste-modello', () => {
const body = { nome: 'Kit bivacco', tipoEventoId: 't-2', voci: [{ materialeId: 'm-1', quantita: 3 }] };
test('un utente normale non può creare una lista modello', async () => {
const response = await request(app)
.post('/liste-modello')
.set('Authorization', `Bearer ${utenteToken()}`)
.send(body);
expect(response.status).toBe(403);
expect(listaModelloCreate).not.toHaveBeenCalled();
});
test('un moderatore può creare una lista modello, sempre pubblica', async () => {
listaModelloCreate.mockResolvedValueOnce({
id: 'lm-2',
nome: 'Kit bivacco',
tipoEventoId: 't-2',
pubblica: true,
voci: [{ materialeId: 'm-1', quantita: 3, materiale: { id: 'm-1', nome: 'Corda', unitaMisura: 'm' } }],
});
const response = await request(app)
.post('/liste-modello')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send(body);
expect(response.status).toBe(201);
expect(listaModelloCreate).toHaveBeenCalledWith({
data: {
nome: 'Kit bivacco',
tipoEventoId: 't-2',
pubblica: true,
voci: { create: [{ materialeId: 'm-1', quantita: 3 }] },
},
include: { voci: { include: { materiale: true } } },
});
});
test('ignora un eventuale pubblica:false inviato dal client, resta sempre true', async () => {
listaModelloCreate.mockResolvedValueOnce({
id: 'lm-3',
nome: 'Kit bivacco',
tipoEventoId: 't-2',
pubblica: true,
voci: [],
});
await request(app)
.post('/liste-modello')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ ...body, voci: [], pubblica: false });
expect(listaModelloCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ pubblica: true }) }),
);
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/liste-modello').send(body);
expect(response.status).toBe(401);
expect(listaModelloCreate).not.toHaveBeenCalled();
});
});
describe('PUT /liste-modello/:id', () => {
test('un utente normale non può modificare una lista modello', async () => {
const response = await request(app)
.put('/liste-modello/lm-1')
.set('Authorization', `Bearer ${utenteToken()}`)
.send({ nome: 'Kit aggiornato' });
expect(response.status).toBe(403);
expect(listaModelloUpdate).not.toHaveBeenCalled();
});
test('un moderatore può modificare nome e voci di una lista modello', async () => {
listaModelloUpdate.mockResolvedValueOnce({
id: 'lm-1',
nome: 'Kit aggiornato',
tipoEventoId: 't-1',
pubblica: true,
voci: [{ materialeId: 'm-2', quantita: 1, materiale: { id: 'm-2', nome: 'Torcia', unitaMisura: 'pz' } }],
});
const response = await request(app)
.put('/liste-modello/lm-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ nome: 'Kit aggiornato', voci: [{ materialeId: 'm-2', quantita: 1 }] });
expect(response.status).toBe(200);
expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } });
expect(listaModelloUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'lm-1' },
data: expect.objectContaining({
nome: 'Kit aggiornato',
voci: { create: [{ materialeId: 'm-2', quantita: 1 }] },
}),
}),
);
});
});
describe('DELETE /liste-modello/:id', () => {
test('un utente normale non può eliminare una lista modello', async () => {
const response = await request(app).delete('/liste-modello/lm-1').set('Authorization', `Bearer ${utenteToken()}`);
expect(response.status).toBe(403);
expect(listaModelloDelete).not.toHaveBeenCalled();
});
test('un moderatore può eliminare una lista modello (e le sue voci)', async () => {
listaModelloDelete.mockResolvedValueOnce({ id: 'lm-1' });
const response = await request(app)
.delete('/liste-modello/lm-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(204);
expect(listaModelloVoceDeleteMany).toHaveBeenCalledWith({ where: { listaModelloId: 'lm-1' } });
expect(listaModelloDelete).toHaveBeenCalledWith({ where: { id: 'lm-1' } });
});
test('risponde 401 senza token', async () => {
const response = await request(app).delete('/liste-modello/lm-1');
expect(response.status).toBe(401);
expect(listaModelloDelete).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,304 @@
import { generateKeyPairSync } from 'crypto';
import request from 'supertest';
import nock from 'nock';
import jwt from 'jsonwebtoken';
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
process.env.KEYCLOAK_REALM = 'scouthub';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
const magazzinoVoceFindMany = jest.fn();
const magazzinoVoceFindFirst = jest.fn();
const magazzinoVoceCreate = jest.fn();
const magazzinoVoceUpdate = jest.fn();
const magazzinoVoceDelete = jest.fn();
const materialeFindUnique = jest.fn();
jest.mock('../../src/db/prisma', () => ({
prisma: {
magazzinoVoce: {
findMany: (...args: unknown[]) => magazzinoVoceFindMany(...args),
findFirst: (...args: unknown[]) => magazzinoVoceFindFirst(...args),
create: (...args: unknown[]) => magazzinoVoceCreate(...args),
update: (...args: unknown[]) => magazzinoVoceUpdate(...args),
delete: (...args: unknown[]) => magazzinoVoceDelete(...args),
},
materiale: {
findUnique: (...args: unknown[]) => materialeFindUnique(...args),
},
},
}));
import { app } from '../../src/app';
const KEYCLOAK_HOST = 'http://keycloak.test';
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
const KID = 'test-kid';
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function tokenOrg(orgId: string): string {
return signToken({
sub: 'user-1',
realm_access: { roles: ['censito'] },
organization: { gruppo: { id: orgId, roles: [] } },
});
}
function materialeApprovato(id = 'm-1') {
return {
id,
nome: 'Tenda canadese',
categoria: 'campeggio',
unitaMisura: 'pz',
stato: 'approvato',
propostoDaOrgId: 'org-seed',
creatoIl: new Date(),
};
}
function voceConMateriale(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'mv-1',
orgId: 'org-a',
materialeId: 'm-1',
quantitaPosseduta: 3,
stato: 'buono',
posizione: 'scaffale A',
note: null,
materiale: materialeApprovato(),
...overrides,
};
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
});
});
afterAll(() => {
nock.cleanAll();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /magazzino', () => {
test('restituisce l\'inventario dell\'org corrente, con nome/categoria del materiale', async () => {
magazzinoVoceFindMany.mockResolvedValueOnce([voceConMateriale()]);
const response = await request(app).get('/magazzino').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(200);
expect(magazzinoVoceFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { orgId: 'org-a' } }),
);
expect(response.body).toEqual([
{
id: 'mv-1',
orgId: 'org-a',
materialeId: 'm-1',
materialeNome: 'Tenda canadese',
materialeCategoria: 'campeggio',
quantitaPosseduta: 3,
stato: 'buono',
posizione: 'scaffale A',
note: null,
},
]);
});
test('org diverse ottengono query filtrate su org_id diversi', async () => {
magazzinoVoceFindMany.mockResolvedValue([]);
await request(app).get('/magazzino').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
await request(app).get('/magazzino').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
expect(magazzinoVoceFindMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: { orgId: 'org-a' } }));
expect(magazzinoVoceFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ where: { orgId: 'org-b' } }));
});
test('risponde 401 senza token', async () => {
const response = await request(app).get('/magazzino');
expect(response.status).toBe(401);
expect(magazzinoVoceFindMany).not.toHaveBeenCalled();
});
});
describe('POST /magazzino', () => {
test('aggiunge una voce per l\'org corrente se il materiale è approvato', async () => {
materialeFindUnique.mockResolvedValueOnce(materialeApprovato());
magazzinoVoceCreate.mockResolvedValueOnce(voceConMateriale());
const response = await request(app)
.post('/magazzino')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ materialeId: 'm-1', quantitaPosseduta: 3, stato: 'buono', posizione: 'scaffale A' });
expect(response.status).toBe(201);
expect(magazzinoVoceCreate).toHaveBeenCalledWith({
data: {
materialeId: 'm-1',
quantitaPosseduta: 3,
stato: 'buono',
posizione: 'scaffale A',
note: undefined,
orgId: 'org-a',
},
include: { materiale: true },
});
});
test('ignora un eventuale org_id inviato dal client, usa sempre quello del token', async () => {
materialeFindUnique.mockResolvedValueOnce(materialeApprovato());
magazzinoVoceCreate.mockResolvedValueOnce(voceConMateriale());
await request(app)
.post('/magazzino')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ materialeId: 'm-1', quantitaPosseduta: 3, stato: 'buono', orgId: 'org-spoofed' });
expect(magazzinoVoceCreate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ orgId: 'org-a' }) }),
);
});
test('risponde 400 e invita a proporre il materiale se non esiste nel catalogo', async () => {
materialeFindUnique.mockResolvedValueOnce(null);
const response = await request(app)
.post('/magazzino')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ materialeId: 'inesistente', quantitaPosseduta: 1, stato: 'buono' });
expect(response.status).toBe(400);
expect(response.body.message).toMatch(/POST \/materiali\/proposte/);
expect(magazzinoVoceCreate).not.toHaveBeenCalled();
});
test('risponde 400 se il materiale esiste ma non è ancora approvato', async () => {
materialeFindUnique.mockResolvedValueOnce({ ...materialeApprovato(), stato: 'proposto' });
const response = await request(app)
.post('/magazzino')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ materialeId: 'm-1', quantitaPosseduta: 1, stato: 'buono' });
expect(response.status).toBe(400);
expect(response.body.message).toMatch(/POST \/materiali\/proposte/);
expect(magazzinoVoceCreate).not.toHaveBeenCalled();
});
test('risponde 400 se lo stato non è uno dei valori validi', async () => {
const response = await request(app)
.post('/magazzino')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ materialeId: 'm-1', quantitaPosseduta: 1, stato: 'ottimo' });
expect(response.status).toBe(400);
expect(materialeFindUnique).not.toHaveBeenCalled();
expect(magazzinoVoceCreate).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/magazzino').send({ materialeId: 'm-1', quantitaPosseduta: 1, stato: 'buono' });
expect(response.status).toBe(401);
expect(magazzinoVoceCreate).not.toHaveBeenCalled();
});
});
describe('PUT /magazzino/:id — isolamento tra org', () => {
test('un\'org non può modificare una voce di un\'altra org (404, non 403)', async () => {
magazzinoVoceFindFirst.mockResolvedValueOnce(null);
const response = await request(app)
.put('/magazzino/mv-1')
.set('Authorization', `Bearer ${tokenOrg('org-b')}`)
.send({ quantitaPosseduta: 5 });
expect(response.status).toBe(404);
expect(magazzinoVoceFindFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'mv-1', orgId: 'org-b' } }),
);
expect(magazzinoVoceUpdate).not.toHaveBeenCalled();
});
test('l\'org proprietaria può modificare la propria voce', async () => {
magazzinoVoceFindFirst.mockResolvedValueOnce(voceConMateriale());
magazzinoVoceUpdate.mockResolvedValueOnce(voceConMateriale({ quantitaPosseduta: 5 }));
const response = await request(app)
.put('/magazzino/mv-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ quantitaPosseduta: 5 });
expect(response.status).toBe(200);
expect(response.body.quantitaPosseduta).toBe(5);
expect(magazzinoVoceUpdate).toHaveBeenCalledWith({
where: { id: 'mv-1' },
data: { quantitaPosseduta: 5 },
include: { materiale: true },
});
});
test('se si cambia materialeId, valida di nuovo che sia approvato', async () => {
magazzinoVoceFindFirst.mockResolvedValueOnce(voceConMateriale());
materialeFindUnique.mockResolvedValueOnce({ ...materialeApprovato('m-2'), stato: 'proposto' });
const response = await request(app)
.put('/magazzino/mv-1')
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
.send({ materialeId: 'm-2' });
expect(response.status).toBe(400);
expect(response.body.message).toMatch(/POST \/materiali\/proposte/);
expect(magazzinoVoceUpdate).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).put('/magazzino/mv-1').send({ quantitaPosseduta: 1 });
expect(response.status).toBe(401);
expect(magazzinoVoceFindFirst).not.toHaveBeenCalled();
});
});
describe('DELETE /magazzino/:id — isolamento tra org', () => {
test('un\'org non può eliminare una voce di un\'altra org', async () => {
magazzinoVoceFindFirst.mockResolvedValueOnce(null);
const response = await request(app).delete('/magazzino/mv-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
expect(response.status).toBe(404);
expect(magazzinoVoceDelete).not.toHaveBeenCalled();
});
test('l\'org proprietaria può eliminare la propria voce', async () => {
magazzinoVoceFindFirst.mockResolvedValueOnce(voceConMateriale());
magazzinoVoceDelete.mockResolvedValueOnce({ id: 'mv-1' });
const response = await request(app).delete('/magazzino/mv-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
expect(response.status).toBe(204);
expect(magazzinoVoceDelete).toHaveBeenCalledWith({ where: { id: 'mv-1' } });
});
test('risponde 401 senza token', async () => {
const response = await request(app).delete('/magazzino/mv-1');
expect(response.status).toBe(401);
expect(magazzinoVoceDelete).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,312 @@
import { generateKeyPairSync } from 'crypto';
import request from 'supertest';
import nock from 'nock';
import jwt from 'jsonwebtoken';
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
process.env.KEYCLOAK_REALM = 'scouthub';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
const materialeFindMany = jest.fn();
const materialeFindUnique = jest.fn();
const materialeCreate = jest.fn();
const materialeUpdate = jest.fn();
jest.mock('../../src/db/prisma', () => ({
prisma: {
materiale: {
findMany: (...args: unknown[]) => materialeFindMany(...args),
findUnique: (...args: unknown[]) => materialeFindUnique(...args),
create: (...args: unknown[]) => materialeCreate(...args),
update: (...args: unknown[]) => materialeUpdate(...args),
},
},
}));
import { app } from '../../src/app';
const KEYCLOAK_HOST = 'http://keycloak.test';
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
const KID = 'test-kid';
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function utenteToken(orgId = 'org-1'): string {
return signToken({
sub: 'user-1',
realm_access: { roles: ['censito'] },
organization: { 'gruppo-alfa': { id: orgId, roles: [] } },
});
}
function adminCatalogoToken(orgId = 'org-9'): string {
return signToken({
sub: 'admin-1',
realm_access: { roles: ['moderatore'] },
organization: { 'gruppo-omega': { id: orgId, roles: [] } },
});
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
});
});
afterAll(() => {
nock.cleanAll();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /materiali', () => {
test('non richiede autenticazione e restituisce solo i materiali approvati', async () => {
materialeFindMany.mockResolvedValueOnce([
{
id: 'm-1',
nome: 'Tenda canadese',
categoria: 'campeggio',
unitaMisura: 'pz',
stato: 'approvato',
propostoDaOrgId: 'org-x',
creatoIl: new Date('2026-01-01'),
},
]);
const response = await request(app).get('/materiali');
expect(response.status).toBe(200);
expect(response.body).toEqual([{ id: 'm-1', nome: 'Tenda canadese', categoria: 'campeggio', unitaMisura: 'pz' }]);
expect(materialeFindMany).toHaveBeenCalledWith({
where: { stato: 'approvato' },
orderBy: { nome: 'asc' },
});
});
test('filtra per categoria quando richiesto', async () => {
materialeFindMany.mockResolvedValueOnce([]);
await request(app).get('/materiali').query({ categoria: 'cucina' });
expect(materialeFindMany).toHaveBeenCalledWith({
where: { stato: 'approvato', categoria: 'cucina' },
orderBy: { nome: 'asc' },
});
});
test('il catalogo pubblico esclude sempre le proposte non approvate, anche forzando uno stato via query string', async () => {
materialeFindMany.mockResolvedValueOnce([]);
await request(app).get('/materiali').query({ stato: 'proposto' });
// Il filtro "stato" non è un parametro accettato: la query verso il database
// continua a chiedere solo lo stato "approvato".
expect(materialeFindMany).toHaveBeenCalledWith({
where: { stato: 'approvato' },
orderBy: { nome: 'asc' },
});
});
});
describe('POST /materiali/proposte', () => {
test('salva la proposta con stato "proposto" e proposto_da_org_id preso dal token', async () => {
materialeCreate.mockResolvedValueOnce({
id: 'm-2',
nome: 'Fornello a gas',
categoria: 'cucina',
unitaMisura: 'pz',
stato: 'proposto',
propostoDaOrgId: 'org-1',
creatoIl: new Date('2026-01-02'),
});
const response = await request(app)
.post('/materiali/proposte')
.set('Authorization', `Bearer ${utenteToken('org-1')}`)
.send({ nome: 'Fornello a gas', categoria: 'cucina', unitaMisura: 'pz' });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({ propostoDaOrgId: 'org-1', stato: 'proposto' });
expect(materialeCreate).toHaveBeenCalledWith({
data: { nome: 'Fornello a gas', categoria: 'cucina', unitaMisura: 'pz', propostoDaOrgId: 'org-1', stato: 'proposto' },
});
});
test('ignora un eventuale proposto_da_org_id inviato dal client, usa sempre quello del token', async () => {
materialeCreate.mockResolvedValueOnce({
id: 'm-3',
nome: 'Piccone',
categoria: 'attrezzi',
unitaMisura: 'pz',
stato: 'proposto',
propostoDaOrgId: 'org-1',
creatoIl: new Date('2026-01-03'),
});
await request(app)
.post('/materiali/proposte')
.set('Authorization', `Bearer ${utenteToken('org-1')}`)
.send({ nome: 'Piccone', categoria: 'attrezzi', unitaMisura: 'pz', propostoDaOrgId: 'org-spoofed' });
expect(materialeCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ propostoDaOrgId: 'org-1' }),
});
});
test('risponde 401 senza token', async () => {
const response = await request(app)
.post('/materiali/proposte')
.send({ nome: 'Fornello', categoria: 'cucina', unitaMisura: 'pz' });
expect(response.status).toBe(401);
expect(materialeCreate).not.toHaveBeenCalled();
});
test("risponde 400 se manca un campo obbligatorio", async () => {
const response = await request(app)
.post('/materiali/proposte')
.set('Authorization', `Bearer ${utenteToken()}`)
.send({ nome: 'Fornello' });
expect(response.status).toBe(400);
expect(materialeCreate).not.toHaveBeenCalled();
});
});
describe('GET /materiali/proposte', () => {
test('un utente normale non può accedere', async () => {
const response = await request(app).get('/materiali/proposte').set('Authorization', `Bearer ${utenteToken()}`);
expect(response.status).toBe(403);
expect(materialeFindMany).not.toHaveBeenCalled();
});
test('un moderatore vede tutte le proposte pending', async () => {
materialeFindMany.mockResolvedValueOnce([
{
id: 'm-4',
nome: 'Corda',
categoria: 'attrezzi',
unitaMisura: 'm',
stato: 'proposto',
propostoDaOrgId: 'org-7',
creatoIl: new Date('2026-01-04'),
},
]);
const response = await request(app).get('/materiali/proposte').set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(200);
expect(materialeFindMany).toHaveBeenCalledWith({
where: { stato: 'proposto' },
orderBy: { creatoIl: 'asc' },
});
expect(response.body).toEqual([
expect.objectContaining({ id: 'm-4', propostoDaOrgId: 'org-7', stato: 'proposto' }),
]);
});
test('risponde 401 senza token', async () => {
const response = await request(app).get('/materiali/proposte');
expect(response.status).toBe(401);
expect(materialeFindMany).not.toHaveBeenCalled();
});
});
describe('PATCH /materiali/proposte/:id', () => {
test('un utente normale non può decidere una proposta', async () => {
const response = await request(app)
.patch('/materiali/proposte/m-1')
.set('Authorization', `Bearer ${utenteToken()}`)
.send({ decisione: 'approvato' });
expect(response.status).toBe(403);
expect(materialeUpdate).not.toHaveBeenCalled();
});
test('un moderatore può approvare una proposta pending', async () => {
materialeFindUnique.mockResolvedValueOnce({ id: 'm-1', stato: 'proposto' });
materialeUpdate.mockResolvedValueOnce({
id: 'm-1',
nome: 'Tenda',
categoria: 'campeggio',
unitaMisura: 'pz',
stato: 'approvato',
propostoDaOrgId: 'org-1',
creatoIl: new Date('2026-01-01'),
});
const response = await request(app)
.patch('/materiali/proposte/m-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ decisione: 'approvato' });
expect(response.status).toBe(200);
expect(response.body.stato).toBe('approvato');
expect(materialeUpdate).toHaveBeenCalledWith({ where: { id: 'm-1' }, data: { stato: 'approvato' } });
});
test('un moderatore può rifiutare una proposta pending', async () => {
materialeFindUnique.mockResolvedValueOnce({ id: 'm-2', stato: 'proposto' });
materialeUpdate.mockResolvedValueOnce({
id: 'm-2',
nome: 'Zaino',
categoria: 'equipaggiamento',
unitaMisura: 'pz',
stato: 'rifiutato',
propostoDaOrgId: 'org-2',
creatoIl: new Date('2026-01-01'),
});
const response = await request(app)
.patch('/materiali/proposte/m-2')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ decisione: 'rifiutato' });
expect(response.status).toBe(200);
expect(response.body.stato).toBe('rifiutato');
expect(materialeUpdate).toHaveBeenCalledWith({ where: { id: 'm-2' }, data: { stato: 'rifiutato' } });
});
test('risponde 404 se la proposta non esiste', async () => {
materialeFindUnique.mockResolvedValueOnce(null);
const response = await request(app)
.patch('/materiali/proposte/inesistente')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ decisione: 'approvato' });
expect(response.status).toBe(404);
expect(materialeUpdate).not.toHaveBeenCalled();
});
test('risponde 409 se la proposta è già stata decisa', async () => {
materialeFindUnique.mockResolvedValueOnce({ id: 'm-1', stato: 'approvato' });
const response = await request(app)
.patch('/materiali/proposte/m-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ decisione: 'rifiutato' });
expect(response.status).toBe(409);
expect(materialeUpdate).not.toHaveBeenCalled();
});
test('risponde 401 senza token', async () => {
const response = await request(app).patch('/materiali/proposte/m-1').send({ decisione: 'approvato' });
expect(response.status).toBe(401);
expect(materialeUpdate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,161 @@
import { generateKeyPairSync } from 'crypto';
import request from 'supertest';
import nock from 'nock';
import jwt from 'jsonwebtoken';
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
process.env.KEYCLOAK_REALM = 'scouthub';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client';
process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
const tipoEventoFindMany = jest.fn();
const tipoEventoCreate = jest.fn();
const tipoEventoUpdate = jest.fn();
const tipoEventoDelete = jest.fn();
jest.mock('../../src/db/prisma', () => ({
prisma: {
tipoEvento: {
findMany: (...args: unknown[]) => tipoEventoFindMany(...args),
create: (...args: unknown[]) => tipoEventoCreate(...args),
update: (...args: unknown[]) => tipoEventoUpdate(...args),
delete: (...args: unknown[]) => tipoEventoDelete(...args),
},
},
}));
import { app } from '../../src/app';
const KEYCLOAK_HOST = 'http://keycloak.test';
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
const KID = 'test-kid';
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
function signToken(payload: object): string {
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
}
function utenteToken(): string {
return signToken({
sub: 'user-1',
realm_access: { roles: ['censito'] },
organization: { 'gruppo-alfa': { id: 'org-1', roles: [] } },
});
}
function adminCatalogoToken(): string {
return signToken({
sub: 'admin-1',
realm_access: { roles: ['moderatore'] },
organization: { 'gruppo-omega': { id: 'org-9', roles: [] } },
});
}
beforeAll(() => {
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
});
});
afterAll(() => {
nock.cleanAll();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /tipi-evento', () => {
test('è pubblico e restituisce la lista', async () => {
tipoEventoFindMany.mockResolvedValueOnce([{ id: 't-1', nome: 'Campo estivo' }]);
const response = await request(app).get('/tipi-evento');
expect(response.status).toBe(200);
expect(response.body).toEqual([{ id: 't-1', nome: 'Campo estivo' }]);
});
});
describe('POST /tipi-evento', () => {
test('un utente normale non può creare un tipo evento', async () => {
const response = await request(app)
.post('/tipi-evento')
.set('Authorization', `Bearer ${utenteToken()}`)
.send({ nome: 'Bivacco' });
expect(response.status).toBe(403);
expect(tipoEventoCreate).not.toHaveBeenCalled();
});
test('un moderatore può creare un tipo evento', async () => {
tipoEventoCreate.mockResolvedValueOnce({ id: 't-2', nome: 'Bivacco' });
const response = await request(app)
.post('/tipi-evento')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ nome: 'Bivacco' });
expect(response.status).toBe(201);
expect(tipoEventoCreate).toHaveBeenCalledWith({ data: { nome: 'Bivacco' } });
});
test('risponde 401 senza token', async () => {
const response = await request(app).post('/tipi-evento').send({ nome: 'Bivacco' });
expect(response.status).toBe(401);
expect(tipoEventoCreate).not.toHaveBeenCalled();
});
});
describe('PUT /tipi-evento/:id', () => {
test('un utente normale non può modificare un tipo evento', async () => {
const response = await request(app)
.put('/tipi-evento/t-1')
.set('Authorization', `Bearer ${utenteToken()}`)
.send({ nome: 'Uscita di branco' });
expect(response.status).toBe(403);
expect(tipoEventoUpdate).not.toHaveBeenCalled();
});
test('un moderatore può modificare un tipo evento', async () => {
tipoEventoUpdate.mockResolvedValueOnce({ id: 't-1', nome: 'Uscita di branco' });
const response = await request(app)
.put('/tipi-evento/t-1')
.set('Authorization', `Bearer ${adminCatalogoToken()}`)
.send({ nome: 'Uscita di branco' });
expect(response.status).toBe(200);
expect(tipoEventoUpdate).toHaveBeenCalledWith({ where: { id: 't-1' }, data: { nome: 'Uscita di branco' } });
});
});
describe('DELETE /tipi-evento/:id', () => {
test('un utente normale non può eliminare un tipo evento', async () => {
const response = await request(app).delete('/tipi-evento/t-1').set('Authorization', `Bearer ${utenteToken()}`);
expect(response.status).toBe(403);
expect(tipoEventoDelete).not.toHaveBeenCalled();
});
test('un moderatore può eliminare un tipo evento', async () => {
tipoEventoDelete.mockResolvedValueOnce({ id: 't-1', nome: 'Sede' });
const response = await request(app).delete('/tipi-evento/t-1').set('Authorization', `Bearer ${adminCatalogoToken()}`);
expect(response.status).toBe(204);
expect(tipoEventoDelete).toHaveBeenCalledWith({ where: { id: 't-1' } });
});
test('risponde 401 senza token', async () => {
const response = await request(app).delete('/tipi-evento/t-1');
expect(response.status).toBe(401);
expect(tipoEventoDelete).not.toHaveBeenCalled();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"declaration": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}