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