Add scouthub-eventi-be
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
PORT=8003
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub_eventi?schema=public
|
||||||
|
|
||||||
|
KEYCLOAK_BASE_URL=http://localhost:6999
|
||||||
|
KEYCLOAK_REALM=scouthub
|
||||||
|
KEYCLOAK_EVENTI_CLIENT_ID=scouthub-eventi-be
|
||||||
|
KEYCLOAK_EVENTI_CLIENT_SECRET=CAMBIA-QUESTO-SECRET-IN-UN-VAULT
|
||||||
|
|
||||||
|
# client_id (claim "azp") dei client di servizio autorizzati a chiamare le route
|
||||||
|
# machine-to-machine (es. POST /eventi/:id/risorse), separati da virgola.
|
||||||
|
KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS=scouthub-eventi-be
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
/.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 8084
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# scouthub-eventi-be
|
||||||
|
|
||||||
|
Backend Node.js/TypeScript per la gestione degli **eventi** Scouthub, pensato per affiancare
|
||||||
|
`scouthub-home-be`, `scouthub-attivita-be` e `scouthub-magazzino-be` nello stesso ecosistema.
|
||||||
|
|
||||||
|
> Stato attuale: schema Prisma (`Evento`/`RisorsaCollegata`), autenticazione, CRUD eventi
|
||||||
|
> (`POST/GET/PUT/DELETE /eventi`, viste `anno`/`mese`) e route machine-to-machine
|
||||||
|
> (`POST /eventi/:id/risorse`) implementati.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Node.js ≥ 20, TypeScript
|
||||||
|
- Express
|
||||||
|
- Prisma ORM su PostgreSQL (database dedicato `scouthub_eventi`)
|
||||||
|
- Autenticazione JWT via Keycloak, stesso realm di `scouthub-home-be`/`scouthub-attivita-be`/`scouthub-magazzino-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
|
||||||
|
config/ # lettura e validazione delle variabili d'ambiente
|
||||||
|
db/ # istanza condivisa di PrismaClient
|
||||||
|
auth/ # verifica JWT Keycloak (utente e machine-to-machine), assertBrancaAccess
|
||||||
|
middleware/ # error handler
|
||||||
|
```
|
||||||
|
|
||||||
|
Flusso delle richieste: `routes -> controller -> service -> repository (Prisma)`.
|
||||||
|
|
||||||
|
## Autenticazione
|
||||||
|
|
||||||
|
- `src/auth/verify-token.middleware.ts` (`verifyToken`) valida il Bearer token utente contro il
|
||||||
|
JWKS del realm Keycloak (`${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`)
|
||||||
|
e popola `req.auth` con `userId` (claim `sub`), `orgId` (claim `organization`, stessa struttura
|
||||||
|
usata da `scouthub-magazzino-be`/`scouthub-home-be`), `roles` (ruoli realm + ruoli
|
||||||
|
sull'organizzazione attiva) e `branche` (claim `groups`, path normalizzati senza lo slash
|
||||||
|
iniziale, es. `"/Lupetti"` -> `"Lupetti"`). `scouthub-attivita-be` non espone ad oggi un claim
|
||||||
|
branca dedicato: se in futuro venisse introdotto un claim piu' specifico, allineare qui.
|
||||||
|
- `src/auth/verify-service-token.middleware.ts` (`verifyServiceToken`) valida invece un token
|
||||||
|
ottenuto via client credentials grant (client di servizio, non un utente reale): controlla che
|
||||||
|
il claim `azp` (client_id del chiamante) sia incluso in `KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS` e
|
||||||
|
popola `req.service.clientId`. Da usare al posto di (non insieme a) `verifyToken`, solo sulle
|
||||||
|
route machine-to-machine come `POST /eventi/:id/risorse`.
|
||||||
|
- `src/auth/assert-branca-access.ts` (`assertBrancaAccess(user, brancaId)`) lancia un
|
||||||
|
`HttpError` 403 se l'utente non appartiene alla branca indicata; chi ha ruolo `capo-gruppo`
|
||||||
|
(sull'organizzazione attiva del token) salta il controllo.
|
||||||
|
- `src/auth/require-org-id.middleware.ts` (`requireOrgId`) da usare dopo `verifyToken` su tutte
|
||||||
|
le route utente: rifiuta la richiesta se il token non porta un'organizzazione attiva.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
Tutte le route sotto richiedono `verifyToken` + `requireOrgId` (Bearer JWT utente con
|
||||||
|
organizzazione attiva).
|
||||||
|
|
||||||
|
- `POST /eventi` — crea un evento per l'org/branca dell'utente corrente (`assertBrancaAccess`).
|
||||||
|
- Body: `titolo`, `tipo`, `dataInizio`, `dataFine` (obbligatori), `descrizione`, `location`
|
||||||
|
(opzionali), e **uno tra** `brancaId` (evento di primo livello) o `parentId` (evento figlio).
|
||||||
|
- Se `parentId` è presente: il parent deve esistere nella stessa org, non deve avere a sua
|
||||||
|
volta un `parentId` (max 2 livelli di annidamento, altrimenti `400 "profondità massima
|
||||||
|
superata"`), e l'intervallo `dataInizio`/`dataFine` del figlio deve essere contenuto in
|
||||||
|
quello del parent (altrimenti `400`). `orgId`/`brancaId` del figlio vengono presi dal
|
||||||
|
parent, mai dal body.
|
||||||
|
- `GET /eventi?vista=anno&anno=2026` — vista di sola lettura, **trasversale a tutte le
|
||||||
|
branche** dell'org (nessuna `assertBrancaAccess`): per ogni giorno dell'anno con almeno un
|
||||||
|
evento radice (`parentId` nullo) che lo intersechi, restituisce `{ data, conteggio, eventi:
|
||||||
|
[{ titolo, tipo, brancaId }] }`. Un evento che copre più giorni compare su ciascuno di essi.
|
||||||
|
Nessuna descrizione/risorsa collegata in questa vista (aggregato leggero per il calendario).
|
||||||
|
- `GET /eventi?vista=mese&mese=2026-09[&brancaId=...]` — vista di sola lettura, trasversale a
|
||||||
|
tutte le branche salvo filtro esplicito con `brancaId`: restituisce tutti gli eventi (radice e
|
||||||
|
figli) la cui `dataInizio`/`dataFine` intersecano il mese richiesto, con dettaglio completo
|
||||||
|
(incluso orario). L'intersezione è calcolata come `dataInizio <= fineMese AND dataFine >=
|
||||||
|
inizioMese`, quindi un evento iniziato il mese prima e finito dentro il mese richiesto compare
|
||||||
|
comunque nel risultato.
|
||||||
|
- `GET /eventi/:id` — dettaglio completo dell'evento (isolato per org, trasversale a tutte le
|
||||||
|
branche), con l'array `figli` (eventi di 2° livello) e `risorseCollegate`.
|
||||||
|
- `PUT /eventi/:id` — aggiorna un evento a cui l'utente ha accesso di branca
|
||||||
|
(`assertBrancaAccess`). Se si modificano `dataInizio`/`dataFine`: se l'evento è un figlio, il
|
||||||
|
nuovo intervallo deve restare contenuto in quello del parent; se l'evento ha figli, il nuovo
|
||||||
|
intervallo deve continuare a contenerli tutti (altrimenti `400` in entrambi i casi).
|
||||||
|
- `DELETE /eventi/:id` — elimina un evento a cui l'utente ha accesso di branca
|
||||||
|
(`assertBrancaAccess`). Figli e risorse collegate vengono rimossi automaticamente dal vincolo
|
||||||
|
`ON DELETE CASCADE` a livello di database, non da logica applicativa.
|
||||||
|
|
||||||
|
### Machine-to-machine
|
||||||
|
|
||||||
|
- `POST /eventi/:id/risorse` — protetta da `verifyServiceToken` (non da `verifyToken`): chiamata
|
||||||
|
da altri backend Scouthub con le proprie credenziali di servizio, non da un utente. Body:
|
||||||
|
`tipoRisorsa`, `risorsaId`, `servizioOrigine` (obbligatori), `metadata` (opzionale, JSON
|
||||||
|
libero). Verifica solo che l'evento esista (404 altrimenti, nessun controllo di org/branca:
|
||||||
|
una chiamata di servizio non è legata a un'organizzazione utente) e crea la
|
||||||
|
`RisorsaCollegata`. Nessun endpoint di lettura dedicato su questa tabella: la lettura per
|
||||||
|
l'utente finale passa da `GET /eventi/:id` (campo `risorseCollegate`).
|
||||||
|
|
||||||
|
## Variabili d'ambiente
|
||||||
|
|
||||||
|
Vedi `.env.example`. Copiarlo in `.env` e valorizzare:
|
||||||
|
|
||||||
|
| Variabile | Descrizione |
|
||||||
|
|---|---|
|
||||||
|
| `PORT` | Porta HTTP del servizio (default `8084`) |
|
||||||
|
| `DATABASE_URL` | Connection string Postgres (schema/database `scouthub_eventi`) |
|
||||||
|
| `KEYCLOAK_BASE_URL` | Base URL del server Keycloak |
|
||||||
|
| `KEYCLOAK_REALM` | Realm Keycloak (`scouthub`) |
|
||||||
|
| `KEYCLOAK_EVENTI_CLIENT_ID` | Client Keycloak dedicato a questo servizio |
|
||||||
|
| `KEYCLOAK_EVENTI_CLIENT_SECRET` | Secret del client sopra |
|
||||||
|
| `KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS` | Client_id (claim `azp`), separati da virgola, autorizzati sulle route machine-to-machine |
|
||||||
|
|
||||||
|
## Avvio in locale
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate deploy # applica le migration sul database scouthub_eventi
|
||||||
|
npm run dev # avvia con ts-node-dev su http://localhost:8084
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tests/integration/eventi.endpoint.test.ts` — test end-to-end sull'app Express con Prisma
|
||||||
|
mockato (`jest.mock('../../src/db/prisma')`) e JWT firmati al volo, verificati contro un JWKS
|
||||||
|
fittizio (`nock`): profondità massima di annidamento, contenimento date figlio/parent,
|
||||||
|
isolamento branca (incl. bypass `capo-gruppo`), intersezione mensile, machine-to-machine
|
||||||
|
(client autorizzato, client non in whitelist, nessun token, token utente su route di servizio),
|
||||||
|
401/403/404.
|
||||||
|
- `tests/integration/evento-cascade.db.test.ts` — **unico test che usa una connessione Prisma
|
||||||
|
reale** (non mockata): verifica che eliminare un evento padre elimini a cascata, a livello di
|
||||||
|
database, i suoi figli e le risorse collegate (vincolo `ON DELETE CASCADE` della migration).
|
||||||
|
Richiede un Postgres raggiungibile con lo schema già migrato (di default punta al database di
|
||||||
|
sviluppo locale `scouthub_eventi`); fallisce se il database non è raggiungibile.
|
||||||
@@ -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-eventi-be",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Backend Node.js/TypeScript per la gestione degli eventi 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,42 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "evento" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"org_id" TEXT NOT NULL,
|
||||||
|
"branca_id" TEXT NOT NULL,
|
||||||
|
"parent_id" TEXT,
|
||||||
|
"titolo" TEXT NOT NULL,
|
||||||
|
"descrizione" TEXT,
|
||||||
|
"data_inizio" TIMESTAMP(3) NOT NULL,
|
||||||
|
"data_fine" TIMESTAMP(3) NOT NULL,
|
||||||
|
"tipo" TEXT NOT NULL,
|
||||||
|
"location" TEXT,
|
||||||
|
"creato_da" TEXT NOT NULL,
|
||||||
|
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "evento_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "risorsa_collegata" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"evento_id" TEXT NOT NULL,
|
||||||
|
"tipo_risorsa" TEXT NOT NULL,
|
||||||
|
"risorsa_id" TEXT NOT NULL,
|
||||||
|
"servizio_origine" TEXT NOT NULL,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"creato_il" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "risorsa_collegata_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "evento_org_id_branca_id_data_inizio_idx" ON "evento"("org_id", "branca_id", "data_inizio");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "evento_parent_id_idx" ON "evento"("parent_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "evento" ADD CONSTRAINT "evento_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "evento"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "risorsa_collegata" ADD CONSTRAINT "risorsa_collegata_evento_id_fkey" FOREIGN KEY ("evento_id") REFERENCES "evento"("id") ON DELETE CASCADE 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,46 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Evento {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String @map("org_id")
|
||||||
|
brancaId String @map("branca_id")
|
||||||
|
parentId String? @map("parent_id")
|
||||||
|
titolo String
|
||||||
|
descrizione String?
|
||||||
|
dataInizio DateTime @map("data_inizio")
|
||||||
|
dataFine DateTime @map("data_fine")
|
||||||
|
tipo String
|
||||||
|
location String?
|
||||||
|
creatoDa String @map("creato_da")
|
||||||
|
creatoIl DateTime @default(now()) @map("creato_il")
|
||||||
|
|
||||||
|
parent Evento? @relation("EventoParent", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
children Evento[] @relation("EventoParent")
|
||||||
|
|
||||||
|
risorseCollegate RisorsaCollegata[]
|
||||||
|
|
||||||
|
@@index([orgId, brancaId, dataInizio])
|
||||||
|
@@index([parentId])
|
||||||
|
@@map("evento")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RisorsaCollegata {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
eventoId String @map("evento_id")
|
||||||
|
tipoRisorsa String @map("tipo_risorsa")
|
||||||
|
risorsaId String @map("risorsa_id")
|
||||||
|
servizioOrigine String @map("servizio_origine")
|
||||||
|
metadata Json?
|
||||||
|
creatoIl DateTime @default(now()) @map("creato_il")
|
||||||
|
|
||||||
|
evento Evento @relation(fields: [eventoId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("risorsa_collegata")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { healthRouter } from './routes/health.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(eventiRouter);
|
||||||
|
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ message: 'not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { HttpError } from '../errors';
|
||||||
|
import { AuthContext } from './auth.types';
|
||||||
|
|
||||||
|
// Lancia 403 se l'utente non appartiene alla branca indicata. Chi ha ruolo
|
||||||
|
// capo-gruppo (realm o sull'organizzazione attiva del token, gia' confluiti in
|
||||||
|
// user.roles da verifyToken) salta il controllo: ha accesso in scrittura a
|
||||||
|
// tutte le branche del proprio gruppo.
|
||||||
|
export function assertBrancaAccess(user: AuthContext, brancaId: string): void {
|
||||||
|
if (user.roles.includes('capo-gruppo')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAccess = user.branche.some((branca) => branca.toLowerCase() === brancaId.toLowerCase());
|
||||||
|
if (!hasAccess) {
|
||||||
|
throw new HttpError(403, `Accesso alla branca '${brancaId}' non consentito`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface AuthContext {
|
||||||
|
userId: string;
|
||||||
|
email: string | null;
|
||||||
|
orgId: string | null;
|
||||||
|
roles: string[];
|
||||||
|
// Branche dell'utente, ricavate dal claim "groups" del token (path normalizzati,
|
||||||
|
// senza lo slash iniziale: es. "Lupetti", "Capi"). Vedi assertBrancaAccess.
|
||||||
|
branche: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contesto delle chiamate machine-to-machine (client credentials), popolato da
|
||||||
|
// verifyServiceToken. Distinto da AuthContext: non rappresenta un utente reale.
|
||||||
|
export interface ServiceAuthContext {
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import jwksClient from 'jwks-rsa';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
|
||||||
|
// Client JWKS condiviso tra verifyToken e verifyServiceToken: stesso realm,
|
||||||
|
// stessa chiave pubblica di firma.
|
||||||
|
export const jwksClientInstance = jwksClient({
|
||||||
|
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
|
||||||
|
cache: true,
|
||||||
|
rateLimit: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
// Da usare dopo verifyToken su tutte le route utente (mai insieme a verifyServiceToken):
|
||||||
|
// richiede che il token porti un'organizzazione attiva. L'org_id da usare per filtrare
|
||||||
|
// le query e' sempre req.auth.orgId, mai un org_id letto da params/query/body.
|
||||||
|
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,52 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||||
|
import { jwksClientInstance } from './jwks-client';
|
||||||
|
import { extractBearerToken } from './extract-bearer-token';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
import { ServiceAuthContext } from './auth.types';
|
||||||
|
|
||||||
|
interface ServiceTokenPayload extends JwtPayload {
|
||||||
|
// "azp" (authorized party) e' il client_id del client che ha ottenuto il token
|
||||||
|
// via client credentials grant: e' il modo standard per riconoscere quale
|
||||||
|
// servizio sta chiamando in una chiamata machine-to-machine.
|
||||||
|
azp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware da usare al posto di (non insieme a) verifyToken sulle route
|
||||||
|
// machine-to-machine, ad es. POST /eventi/:id/risorse chiamata da altri backend
|
||||||
|
// (scouthub-attivita-be, scouthub-magazzino-be, ...) con le proprie credenziali
|
||||||
|
// di servizio. Il client chiamante deve essere elencato in
|
||||||
|
// KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS.
|
||||||
|
export async function verifyServiceToken(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 jwksClientInstance.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');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = (payload as ServiceTokenPayload).azp;
|
||||||
|
if (!clientId || !env.keycloak.authorizedServiceClients.includes(clientId)) {
|
||||||
|
res.status(403).json({ message: 'Client di servizio non autorizzato' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service: ServiceAuthContext = { clientId };
|
||||||
|
req.service = service;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ message: 'Token non valido' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||||
|
import { jwksClientInstance } from './jwks-client';
|
||||||
|
import { extractBearerToken } from './extract-bearer-token';
|
||||||
|
import { AuthContext } from './auth.types';
|
||||||
|
|
||||||
|
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 piu' un'organizzazione attiva per volta (vedi anche
|
||||||
|
// scouthub-magazzino-be/src/auth/verify-token.middleware.ts).
|
||||||
|
organization?: Record<string, { id: string; roles?: string[] }>;
|
||||||
|
// scouthub-attivita-be non espone ad oggi un claim branca dedicato nel token
|
||||||
|
// (vedi src/middlewares/authenticate.ts di quel progetto): finche' non verra'
|
||||||
|
// introdotto, la branca/i gruppi dell'utente sono ricavati dal claim standard
|
||||||
|
// "groups" di Keycloak, con path del tipo "/Lupetti", "/Capi".
|
||||||
|
groups?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGroupPath(path: string): string {
|
||||||
|
return path.replace(/^\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
|
||||||
|
const realmRoles = payload.realm_access?.roles ?? [];
|
||||||
|
const [organization] = payload.organization ? Object.values(payload.organization) : [];
|
||||||
|
const orgRoles = organization?.roles ?? [];
|
||||||
|
const groups = payload.groups ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
email: payload.email ?? null,
|
||||||
|
orgId: organization?.id ?? null,
|
||||||
|
roles: Array.from(new Set([...realmRoles, ...orgRoles])),
|
||||||
|
branche: groups.map(normalizeGroupPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 jwksClientInstance.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,33 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClientList(value: string | undefined): string[] {
|
||||||
|
return (value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((clientId) => clientId.trim())
|
||||||
|
.filter((clientId) => clientId.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
port: Number(process.env.PORT) || 8084,
|
||||||
|
databaseUrl: requireEnv('DATABASE_URL'),
|
||||||
|
keycloak: {
|
||||||
|
baseUrl: requireEnv('KEYCLOAK_BASE_URL'),
|
||||||
|
realm: requireEnv('KEYCLOAK_REALM'),
|
||||||
|
eventiClientId: requireEnv('KEYCLOAK_EVENTI_CLIENT_ID'),
|
||||||
|
eventiClientSecret: requireEnv('KEYCLOAK_EVENTI_CLIENT_SECRET'),
|
||||||
|
// client_id (claim "azp") dei client di servizio autorizzati a chiamare le route
|
||||||
|
// machine-to-machine (es. POST /eventi/:id/risorse). Vuoto di default: nessun
|
||||||
|
// client e' autorizzato finche' non viene esplicitamente configurato.
|
||||||
|
authorizedServiceClients: parseClientList(process.env.KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
AggiornaEventoInput,
|
||||||
|
CreaEventoInput,
|
||||||
|
CreaRisorsaCollegataInput,
|
||||||
|
aggiornaEvento,
|
||||||
|
creaEvento,
|
||||||
|
creaRisorsaCollegata,
|
||||||
|
eliminaEvento,
|
||||||
|
getDettaglioEvento,
|
||||||
|
getVistaAnno,
|
||||||
|
getVistaMese,
|
||||||
|
} from '../services/eventi.service';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
|
interface EventoBody {
|
||||||
|
titolo?: unknown;
|
||||||
|
descrizione?: unknown;
|
||||||
|
dataInizio?: unknown;
|
||||||
|
dataFine?: unknown;
|
||||||
|
tipo?: unknown;
|
||||||
|
location?: unknown;
|
||||||
|
brancaId?: unknown;
|
||||||
|
parentId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value: unknown, field: string): Date {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new HttpError(400, `Il campo '${field}' è obbligatorio ed è una stringa in formato data`);
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw new HttpError(400, `Il campo '${field}' non è una data valida`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalString(value: unknown, field: string): string | null | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new HttpError(400, `Il campo '${field}', se presente, deve essere una stringa`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRequiredNonEmptyString(value: unknown, field: string): string {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
throw new HttpError(400, `Il campo '${field}' è obbligatorio ed è una stringa non vuota`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalNonEmptyString(value: unknown, field: string): string | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
throw new HttpError(400, `Il campo '${field}', se presente, deve essere una stringa non vuota`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCreateBody(body: EventoBody): CreaEventoInput {
|
||||||
|
return {
|
||||||
|
titolo: parseRequiredNonEmptyString(body.titolo, 'titolo'),
|
||||||
|
descrizione: parseOptionalString(body.descrizione, 'descrizione') ?? null,
|
||||||
|
dataInizio: parseDate(body.dataInizio, 'dataInizio'),
|
||||||
|
dataFine: parseDate(body.dataFine, 'dataFine'),
|
||||||
|
tipo: parseRequiredNonEmptyString(body.tipo, 'tipo'),
|
||||||
|
location: parseOptionalString(body.location, 'location') ?? null,
|
||||||
|
brancaId: parseOptionalNonEmptyString(body.brancaId, 'brancaId'),
|
||||||
|
parentId: parseOptionalNonEmptyString(body.parentId, 'parentId'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUpdateBody(body: EventoBody): AggiornaEventoInput {
|
||||||
|
const input: AggiornaEventoInput = {};
|
||||||
|
|
||||||
|
if (body.titolo !== undefined) {
|
||||||
|
input.titolo = parseRequiredNonEmptyString(body.titolo, 'titolo');
|
||||||
|
}
|
||||||
|
if (body.tipo !== undefined) {
|
||||||
|
input.tipo = parseRequiredNonEmptyString(body.tipo, 'tipo');
|
||||||
|
}
|
||||||
|
if (body.descrizione !== undefined) {
|
||||||
|
input.descrizione = parseOptionalString(body.descrizione, 'descrizione') ?? null;
|
||||||
|
}
|
||||||
|
if (body.location !== undefined) {
|
||||||
|
input.location = parseOptionalString(body.location, 'location') ?? null;
|
||||||
|
}
|
||||||
|
if (body.dataInizio !== undefined) {
|
||||||
|
input.dataInizio = parseDate(body.dataInizio, 'dataInizio');
|
||||||
|
}
|
||||||
|
if (body.dataFine !== undefined) {
|
||||||
|
input.dataFine = parseDate(body.dataFine, 'dataFine');
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnno(value: unknown, field: string): number {
|
||||||
|
if (typeof value !== 'string' || !/^\d{4}$/.test(value)) {
|
||||||
|
throw new HttpError(400, `Il campo '${field}' è obbligatorio ed è un anno a 4 cifre (es. 2026)`);
|
||||||
|
}
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMese(value: unknown): { anno: number; mese: number } {
|
||||||
|
if (typeof value !== 'string' || !/^\d{4}-\d{2}$/.test(value)) {
|
||||||
|
throw new HttpError(400, "Il campo 'mese' è obbligatorio nel formato YYYY-MM (es. 2026-09)");
|
||||||
|
}
|
||||||
|
const [annoStr, meseStr] = value.split('-');
|
||||||
|
const mese = Number(meseStr);
|
||||||
|
if (mese < 1 || mese > 12) {
|
||||||
|
throw new HttpError(400, "Il campo 'mese' contiene un mese non valido (01-12)");
|
||||||
|
}
|
||||||
|
return { anno: Number(annoStr), mese };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viste di sola lettura su GET /eventi (senza :id), trasversali a tutte le
|
||||||
|
// branche dell'organizzazione: l'org è sempre quella del token (req.auth.orgId),
|
||||||
|
// mai un orgId letto dalla querystring.
|
||||||
|
export async function getEventiVista(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const orgId = req.auth!.orgId!;
|
||||||
|
|
||||||
|
if (req.query.vista === 'anno') {
|
||||||
|
const anno = parseAnno(req.query.anno, 'anno');
|
||||||
|
res.status(200).json(await getVistaAnno(orgId, anno));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.vista === 'mese') {
|
||||||
|
const { anno, mese } = parseMese(req.query.mese);
|
||||||
|
const brancaId = typeof req.query.brancaId === 'string' ? req.query.brancaId : undefined;
|
||||||
|
res.status(200).json(await getVistaMese(orgId, anno, mese, brancaId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpError(400, "Il parametro 'vista' è obbligatorio e deve valere 'anno' o 'mese'");
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const input = parseCreateBody(req.body ?? {});
|
||||||
|
const evento = await creaEvento(req.auth!, 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!);
|
||||||
|
res.status(200).json(evento);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const input = parseUpdateBody(req.body ?? {});
|
||||||
|
const evento = await aggiornaEvento(req.params.id, req.auth!, input);
|
||||||
|
res.status(200).json(evento);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await eliminaEvento(req.params.id, req.auth!);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RisorsaCollegataBody {
|
||||||
|
tipoRisorsa?: unknown;
|
||||||
|
risorsaId?: unknown;
|
||||||
|
servizioOrigine?: unknown;
|
||||||
|
metadata?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRisorsaCollegataBody(body: RisorsaCollegataBody): CreaRisorsaCollegataInput {
|
||||||
|
return {
|
||||||
|
tipoRisorsa: parseRequiredNonEmptyString(body.tipoRisorsa, 'tipoRisorsa'),
|
||||||
|
risorsaId: parseRequiredNonEmptyString(body.risorsaId, 'risorsaId'),
|
||||||
|
servizioOrigine: parseRequiredNonEmptyString(body.servizioOrigine, 'servizioOrigine'),
|
||||||
|
metadata:
|
||||||
|
body.metadata === undefined
|
||||||
|
? undefined
|
||||||
|
: body.metadata === null
|
||||||
|
? Prisma.JsonNull
|
||||||
|
: (body.metadata as Prisma.InputJsonValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route machine-to-machine (protetta da verifyServiceToken, non da verifyToken):
|
||||||
|
// req.auth non è popolato qui, solo req.service.
|
||||||
|
export async function postRisorsaCollegata(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const input = parseRisorsaCollegataBody(req.body ?? {});
|
||||||
|
const risorsa = await creaRisorsaCollegata(req.params.id, input);
|
||||||
|
res.status(201).json(risorsa);
|
||||||
|
} 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,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,97 @@
|
|||||||
|
import { Evento, Prisma, RisorsaCollegata } from '@prisma/client';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
const includeEvento = {
|
||||||
|
children: true,
|
||||||
|
risorseCollegate: true,
|
||||||
|
} satisfies Prisma.EventoInclude;
|
||||||
|
|
||||||
|
export type EventoConDettagli = Prisma.EventoGetPayload<{ include: typeof includeEvento }>;
|
||||||
|
|
||||||
|
export interface CreateEventoData {
|
||||||
|
orgId: string;
|
||||||
|
brancaId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
titolo: string;
|
||||||
|
descrizione: string | null;
|
||||||
|
dataInizio: Date;
|
||||||
|
dataFine: Date;
|
||||||
|
tipo: string;
|
||||||
|
location: string | null;
|
||||||
|
creatoDa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEventoData {
|
||||||
|
titolo?: string;
|
||||||
|
descrizione?: string | null;
|
||||||
|
dataInizio?: Date;
|
||||||
|
dataFine?: Date;
|
||||||
|
tipo?: string;
|
||||||
|
location?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRisorsaCollegataData {
|
||||||
|
eventoId: string;
|
||||||
|
tipoRisorsa: string;
|
||||||
|
risorsaId: string;
|
||||||
|
servizioOrigine: string;
|
||||||
|
metadata?: Prisma.InputJsonValue | typeof Prisma.JsonNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nessun filtro per org: usata dalle chiamate machine-to-machine (POST /eventi/:id/risorse),
|
||||||
|
// che non sono legate a un'organizzazione utente.
|
||||||
|
findById(id: string): Promise<Evento | null> {
|
||||||
|
return prisma.evento.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data: CreateEventoData): Promise<EventoConDettagli> {
|
||||||
|
return prisma.evento.create({ data, include: includeEvento });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, data: UpdateEventoData): Promise<EventoConDettagli> {
|
||||||
|
return prisma.evento.update({ where: { id }, data, include: includeEvento });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nessuna cancellazione esplicita di figli/risorse collegate: se ne occupa il
|
||||||
|
// vincolo ON DELETE CASCADE definito in migration su evento.parent_id e
|
||||||
|
// risorsa_collegata.evento_id.
|
||||||
|
remove(id: string): Promise<void> {
|
||||||
|
return prisma.evento.delete({ where: { id } }).then(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo eventi radice (parent_id null) la cui data_inizio/data_fine intersecano
|
||||||
|
// [da, a]: usata dalla vista "anno" per l'aggregato leggero per giorno.
|
||||||
|
findRadiceNellIntervallo(orgId: string, da: Date, a: Date): Promise<Evento[]> {
|
||||||
|
return prisma.evento.findMany({
|
||||||
|
where: { orgId, parentId: null, dataInizio: { lte: a }, dataFine: { gte: da } },
|
||||||
|
orderBy: { dataInizio: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radice e figli la cui data_inizio/data_fine intersecano [da, a]: usata dalla
|
||||||
|
// vista "mese", lettura trasversale a tutte le branche salvo filtro esplicito.
|
||||||
|
findNellIntervallo(orgId: string, da: Date, a: Date, brancaId?: string): Promise<Evento[]> {
|
||||||
|
return prisma.evento.findMany({
|
||||||
|
where: {
|
||||||
|
orgId,
|
||||||
|
dataInizio: { lte: a },
|
||||||
|
dataFine: { gte: da },
|
||||||
|
...(brancaId ? { brancaId } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { dataInizio: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createRisorsaCollegata(data: CreateRisorsaCollegataData): Promise<RisorsaCollegata> {
|
||||||
|
return prisma.risorsaCollegata.create({ data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,23 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { verifyToken } from '../auth/verify-token.middleware';
|
||||||
|
import { verifyServiceToken } from '../auth/verify-service-token.middleware';
|
||||||
|
import { requireOrgId } from '../auth/require-org-id.middleware';
|
||||||
|
import {
|
||||||
|
deleteEvento,
|
||||||
|
getEvento,
|
||||||
|
getEventiVista,
|
||||||
|
postEvento,
|
||||||
|
postRisorsaCollegata,
|
||||||
|
putEvento,
|
||||||
|
} from '../controllers/eventi.controller';
|
||||||
|
|
||||||
|
export const eventiRouter = Router();
|
||||||
|
|
||||||
|
eventiRouter.post('/eventi', verifyToken, requireOrgId, postEvento);
|
||||||
|
// Viste "anno"/"mese": sola lettura, trasversali a tutte le branche dell'org.
|
||||||
|
eventiRouter.get('/eventi', verifyToken, requireOrgId, getEventiVista);
|
||||||
|
eventiRouter.get('/eventi/:id', verifyToken, requireOrgId, getEvento);
|
||||||
|
eventiRouter.put('/eventi/:id', verifyToken, requireOrgId, putEvento);
|
||||||
|
eventiRouter.delete('/eventi/:id', verifyToken, requireOrgId, deleteEvento);
|
||||||
|
// Machine-to-machine: verifyServiceToken invece di verifyToken/requireOrgId.
|
||||||
|
eventiRouter.post('/eventi/:id/risorse', verifyServiceToken, postRisorsaCollegata);
|
||||||
@@ -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,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,308 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AuthContext } from '../auth/auth.types';
|
||||||
|
import { assertBrancaAccess } from '../auth/assert-branca-access';
|
||||||
|
import { EventoConDettagli, eventiRepository } from '../repositories/eventi.repository';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
|
|
||||||
|
export interface EventoView {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
brancaId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
titolo: string;
|
||||||
|
descrizione: string | null;
|
||||||
|
dataInizio: Date;
|
||||||
|
dataFine: Date;
|
||||||
|
tipo: string;
|
||||||
|
location: string | null;
|
||||||
|
creatoDa: string;
|
||||||
|
creatoIl: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RisorsaCollegataView {
|
||||||
|
id: string;
|
||||||
|
tipoRisorsa: string;
|
||||||
|
risorsaId: string;
|
||||||
|
servizioOrigine: string;
|
||||||
|
metadata: unknown;
|
||||||
|
creatoIl: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventoDettaglioView extends EventoView {
|
||||||
|
figli: EventoView[];
|
||||||
|
risorseCollegate: RisorsaCollegataView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEventoView(evento: Omit<EventoConDettagli, 'children' | 'risorseCollegate'>): EventoView {
|
||||||
|
return {
|
||||||
|
id: evento.id,
|
||||||
|
orgId: evento.orgId,
|
||||||
|
brancaId: evento.brancaId,
|
||||||
|
parentId: evento.parentId,
|
||||||
|
titolo: evento.titolo,
|
||||||
|
descrizione: evento.descrizione,
|
||||||
|
dataInizio: evento.dataInizio,
|
||||||
|
dataFine: evento.dataFine,
|
||||||
|
tipo: evento.tipo,
|
||||||
|
location: evento.location,
|
||||||
|
creatoDa: evento.creatoDa,
|
||||||
|
creatoIl: evento.creatoIl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDettaglioView(evento: EventoConDettagli): EventoDettaglioView {
|
||||||
|
return {
|
||||||
|
...toEventoView(evento),
|
||||||
|
figli: evento.children.map(toEventoView),
|
||||||
|
risorseCollegate: evento.risorseCollegate.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
tipoRisorsa: r.tipoRisorsa,
|
||||||
|
risorsaId: r.risorsaId,
|
||||||
|
servizioOrigine: r.servizioOrigine,
|
||||||
|
metadata: r.metadata,
|
||||||
|
creatoIl: r.creatoIl,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIntervalloValido(dataInizio: Date, dataFine: Date): void {
|
||||||
|
if (dataFine < dataInizio) {
|
||||||
|
throw new HttpError(400, "'dataFine' deve essere successiva o uguale a 'dataInizio'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContenutoNelParent(dataInizio: Date, dataFine: Date, parent: EventoConDettagli): void {
|
||||||
|
if (dataInizio < parent.dataInizio || dataFine > parent.dataFine) {
|
||||||
|
throw new HttpError(400, "Le date dell'evento devono essere contenute nell'intervallo del parent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreaEventoInput {
|
||||||
|
titolo: string;
|
||||||
|
descrizione: string | null;
|
||||||
|
dataInizio: Date;
|
||||||
|
dataFine: Date;
|
||||||
|
tipo: string;
|
||||||
|
location: string | null;
|
||||||
|
brancaId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function creaEvento(user: AuthContext, input: CreaEventoInput): Promise<EventoView> {
|
||||||
|
const orgId = user.orgId!;
|
||||||
|
assertIntervalloValido(input.dataInizio, input.dataFine);
|
||||||
|
|
||||||
|
let brancaId: string;
|
||||||
|
let parentId: string | null = null;
|
||||||
|
|
||||||
|
if (input.parentId) {
|
||||||
|
const parent = await eventiRepository.findByIdAndOrg(input.parentId, orgId);
|
||||||
|
if (!parent) {
|
||||||
|
throw new HttpError(400, 'Il parent indicato non esiste o non appartiene alla tua organizzazione');
|
||||||
|
}
|
||||||
|
if (parent.parentId) {
|
||||||
|
throw new HttpError(400, 'profondità massima superata');
|
||||||
|
}
|
||||||
|
assertContenutoNelParent(input.dataInizio, input.dataFine, parent);
|
||||||
|
|
||||||
|
brancaId = parent.brancaId;
|
||||||
|
parentId = parent.id;
|
||||||
|
} else {
|
||||||
|
if (!input.brancaId) {
|
||||||
|
throw new HttpError(400, "Il campo 'brancaId' è obbligatorio per un evento senza parent");
|
||||||
|
}
|
||||||
|
brancaId = input.brancaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertBrancaAccess(user, brancaId);
|
||||||
|
|
||||||
|
const evento = await eventiRepository.create({
|
||||||
|
orgId,
|
||||||
|
brancaId,
|
||||||
|
parentId,
|
||||||
|
titolo: input.titolo,
|
||||||
|
descrizione: input.descrizione,
|
||||||
|
dataInizio: input.dataInizio,
|
||||||
|
dataFine: input.dataFine,
|
||||||
|
tipo: input.tipo,
|
||||||
|
location: input.location,
|
||||||
|
creatoDa: user.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toEventoView(evento);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDettaglioEvento(id: string, user: AuthContext): Promise<EventoDettaglioView> {
|
||||||
|
const evento = await eventiRepository.findByIdAndOrg(id, user.orgId!);
|
||||||
|
if (!evento) {
|
||||||
|
throw new HttpError(404, 'Evento non trovato');
|
||||||
|
}
|
||||||
|
return toDettaglioView(evento);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viste di sola lettura, trasversali a tutte le branche dell'organizzazione
|
||||||
|
// (nessuna assertBrancaAccess): visibili a qualunque utente autenticato dell'org.
|
||||||
|
|
||||||
|
export interface EventoGiornoView {
|
||||||
|
titolo: string;
|
||||||
|
tipo: string;
|
||||||
|
brancaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VistaAnnoGiornoView {
|
||||||
|
data: string;
|
||||||
|
conteggio: number;
|
||||||
|
eventi: EventoGiornoView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVistaAnno(orgId: string, anno: number): Promise<VistaAnnoGiornoView[]> {
|
||||||
|
const inizioAnno = new Date(Date.UTC(anno, 0, 1));
|
||||||
|
const fineAnno = new Date(Date.UTC(anno, 11, 31, 23, 59, 59, 999));
|
||||||
|
|
||||||
|
const eventi = await eventiRepository.findRadiceNellIntervallo(orgId, inizioAnno, fineAnno);
|
||||||
|
|
||||||
|
const perGiorno = new Map<string, EventoGiornoView[]>();
|
||||||
|
|
||||||
|
for (const evento of eventi) {
|
||||||
|
const inizioEffettivo = evento.dataInizio < inizioAnno ? inizioAnno : evento.dataInizio;
|
||||||
|
const fineEffettiva = evento.dataFine > fineAnno ? fineAnno : evento.dataFine;
|
||||||
|
|
||||||
|
const cursor = new Date(
|
||||||
|
Date.UTC(inizioEffettivo.getUTCFullYear(), inizioEffettivo.getUTCMonth(), inizioEffettivo.getUTCDate()),
|
||||||
|
);
|
||||||
|
const fineGiorno = new Date(
|
||||||
|
Date.UTC(fineEffettiva.getUTCFullYear(), fineEffettiva.getUTCMonth(), fineEffettiva.getUTCDate()),
|
||||||
|
);
|
||||||
|
|
||||||
|
while (cursor <= fineGiorno) {
|
||||||
|
const chiave = isoDate(cursor);
|
||||||
|
const lista = perGiorno.get(chiave) ?? [];
|
||||||
|
lista.push({ titolo: evento.titolo, tipo: evento.tipo, brancaId: evento.brancaId });
|
||||||
|
perGiorno.set(chiave, lista);
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(perGiorno.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([data, eventiGiorno]) => ({ data, conteggio: eventiGiorno.length, eventi: eventiGiorno }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVistaMese(
|
||||||
|
orgId: string,
|
||||||
|
anno: number,
|
||||||
|
mese: number,
|
||||||
|
brancaId: string | undefined,
|
||||||
|
): Promise<EventoView[]> {
|
||||||
|
const inizioMese = new Date(Date.UTC(anno, mese - 1, 1));
|
||||||
|
// Giorno 0 del mese successivo == ultimo giorno del mese richiesto.
|
||||||
|
const fineMese = new Date(Date.UTC(anno, mese, 0, 23, 59, 59, 999));
|
||||||
|
|
||||||
|
const eventi = await eventiRepository.findNellIntervallo(orgId, inizioMese, fineMese, brancaId);
|
||||||
|
return eventi.map(toEventoView);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggiornaEventoInput {
|
||||||
|
titolo?: string;
|
||||||
|
descrizione?: string | null;
|
||||||
|
dataInizio?: Date;
|
||||||
|
dataFine?: Date;
|
||||||
|
tipo?: string;
|
||||||
|
location?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aggiornaEvento(id: string, user: AuthContext, input: AggiornaEventoInput): Promise<EventoView> {
|
||||||
|
const orgId = user.orgId!;
|
||||||
|
const evento = await eventiRepository.findByIdAndOrg(id, orgId);
|
||||||
|
if (!evento) {
|
||||||
|
throw new HttpError(404, 'Evento non trovato');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertBrancaAccess(user, evento.brancaId);
|
||||||
|
|
||||||
|
const nuovaDataInizio = input.dataInizio ?? evento.dataInizio;
|
||||||
|
const nuovaDataFine = input.dataFine ?? evento.dataFine;
|
||||||
|
const dateModificate = input.dataInizio !== undefined || input.dataFine !== undefined;
|
||||||
|
|
||||||
|
if (dateModificate) {
|
||||||
|
assertIntervalloValido(nuovaDataInizio, nuovaDataFine);
|
||||||
|
|
||||||
|
if (evento.parentId) {
|
||||||
|
const parent = await eventiRepository.findByIdAndOrg(evento.parentId, orgId);
|
||||||
|
if (parent) {
|
||||||
|
assertContenutoNelParent(nuovaDataInizio, nuovaDataFine, parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const figlioFuoriIntervallo = evento.children.some(
|
||||||
|
(figlio) => figlio.dataInizio < nuovaDataInizio || figlio.dataFine > nuovaDataFine,
|
||||||
|
);
|
||||||
|
if (figlioFuoriIntervallo) {
|
||||||
|
throw new HttpError(400, 'Il nuovo intervallo deve continuare a contenere tutti gli eventi figli');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggiornato = await eventiRepository.update(id, {
|
||||||
|
...(input.titolo !== undefined ? { titolo: input.titolo } : {}),
|
||||||
|
...(input.descrizione !== undefined ? { descrizione: input.descrizione } : {}),
|
||||||
|
...(input.dataInizio !== undefined ? { dataInizio: input.dataInizio } : {}),
|
||||||
|
...(input.dataFine !== undefined ? { dataFine: input.dataFine } : {}),
|
||||||
|
...(input.tipo !== undefined ? { tipo: input.tipo } : {}),
|
||||||
|
...(input.location !== undefined ? { location: input.location } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return toEventoView(aggiornato);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eliminaEvento(id: string, user: AuthContext): Promise<void> {
|
||||||
|
const evento = await eventiRepository.findByIdAndOrg(id, user.orgId!);
|
||||||
|
if (!evento) {
|
||||||
|
throw new HttpError(404, 'Evento non trovato');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertBrancaAccess(user, evento.brancaId);
|
||||||
|
|
||||||
|
await eventiRepository.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chiamata machine-to-machine (POST /eventi/:id/risorse, protetta da
|
||||||
|
// verifyServiceToken): nessun AuthContext utente, nessun assertBrancaAccess.
|
||||||
|
// Nessuna lettura pubblica su risorsa_collegata: la lettura per l'utente finale
|
||||||
|
// passa da GET /eventi/:id (toDettaglioView sopra).
|
||||||
|
export interface CreaRisorsaCollegataInput {
|
||||||
|
tipoRisorsa: string;
|
||||||
|
risorsaId: string;
|
||||||
|
servizioOrigine: string;
|
||||||
|
metadata?: Prisma.InputJsonValue | typeof Prisma.JsonNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function creaRisorsaCollegata(
|
||||||
|
eventoId: string,
|
||||||
|
input: CreaRisorsaCollegataInput,
|
||||||
|
): Promise<RisorsaCollegataView> {
|
||||||
|
const evento = await eventiRepository.findById(eventoId);
|
||||||
|
if (!evento) {
|
||||||
|
throw new HttpError(404, 'Evento non trovato');
|
||||||
|
}
|
||||||
|
|
||||||
|
const risorsa = await eventiRepository.createRisorsaCollegata({
|
||||||
|
eventoId,
|
||||||
|
tipoRisorsa: input.tipoRisorsa,
|
||||||
|
risorsaId: input.risorsaId,
|
||||||
|
servizioOrigine: input.servizioOrigine,
|
||||||
|
metadata: input.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: risorsa.id,
|
||||||
|
tipoRisorsa: risorsa.tipoRisorsa,
|
||||||
|
risorsaId: risorsa.risorsaId,
|
||||||
|
servizioOrigine: risorsa.servizioOrigine,
|
||||||
|
metadata: risorsa.metadata,
|
||||||
|
creatoIl: risorsa.creatoIl,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import { AuthContext, ServiceAuthContext } from '../auth/auth.types';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
auth?: AuthContext;
|
||||||
|
service?: ServiceAuthContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
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_EVENTI_CLIENT_ID = 'test-client';
|
||||||
|
process.env.KEYCLOAK_EVENTI_CLIENT_SECRET = 'test-secret';
|
||||||
|
process.env.KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS = 'test-service-client';
|
||||||
|
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_eventi_test';
|
||||||
|
|
||||||
|
const eventoFindFirst = jest.fn();
|
||||||
|
const eventoFindMany = jest.fn();
|
||||||
|
const eventoFindUnique = jest.fn();
|
||||||
|
const eventoCreate = jest.fn();
|
||||||
|
const eventoUpdate = jest.fn();
|
||||||
|
const eventoDelete = jest.fn();
|
||||||
|
const risorsaCollegataCreate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../src/db/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
evento: {
|
||||||
|
findFirst: (...args: unknown[]) => eventoFindFirst(...args),
|
||||||
|
findMany: (...args: unknown[]) => eventoFindMany(...args),
|
||||||
|
findUnique: (...args: unknown[]) => eventoFindUnique(...args),
|
||||||
|
create: (...args: unknown[]) => eventoCreate(...args),
|
||||||
|
update: (...args: unknown[]) => eventoUpdate(...args),
|
||||||
|
delete: (...args: unknown[]) => eventoDelete(...args),
|
||||||
|
},
|
||||||
|
risorsaCollegata: {
|
||||||
|
create: (...args: unknown[]) => risorsaCollegataCreate(...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 tokenFor(orgId: string, branche: string[], roles: string[] = []): string {
|
||||||
|
return signToken({
|
||||||
|
sub: 'user-1',
|
||||||
|
realm_access: { roles },
|
||||||
|
organization: { gruppo: { id: orgId, roles: [] } },
|
||||||
|
groups: branche.map((b) => `/${b}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token client-credentials (client di servizio): niente organization/groups, solo
|
||||||
|
// "azp" (client_id del chiamante), come emesso da Keycloak per un service account.
|
||||||
|
function serviceTokenFor(azp: string): string {
|
||||||
|
return signToken({ sub: `service-account-${azp}`, azp });
|
||||||
|
}
|
||||||
|
|
||||||
|
function evento(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'ev-1',
|
||||||
|
orgId: 'org-a',
|
||||||
|
brancaId: 'Lupetti',
|
||||||
|
parentId: null,
|
||||||
|
titolo: 'Uscita',
|
||||||
|
descrizione: null,
|
||||||
|
dataInizio: new Date('2026-08-01'),
|
||||||
|
dataFine: new Date('2026-08-10'),
|
||||||
|
tipo: 'campo',
|
||||||
|
location: null,
|
||||||
|
creatoDa: 'user-1',
|
||||||
|
creatoIl: new Date('2026-01-01'),
|
||||||
|
children: [] as unknown[],
|
||||||
|
risorseCollegate: [] as unknown[],
|
||||||
|
...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 — profondità massima (max 2 livelli)', () => {
|
||||||
|
test('rifiuta la creazione di un nipote (3° livello) con 400', async () => {
|
||||||
|
// Il "parent" indicato in request ha a sua volta un parentId: è già un
|
||||||
|
// figlio di 2° livello, quindi crearvi sotto un evento sarebbe un 3° livello.
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(evento({ id: 'ev-figlio', parentId: 'ev-nonno' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/eventi')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
|
||||||
|
.send({
|
||||||
|
titolo: 'Nipote',
|
||||||
|
dataInizio: '2026-08-02',
|
||||||
|
dataFine: '2026-08-03',
|
||||||
|
tipo: 'uscita',
|
||||||
|
parentId: 'ev-figlio',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toMatch(/profondità massima superata/);
|
||||||
|
expect(eventoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /eventi — contenimento date nel parent', () => {
|
||||||
|
test('rifiuta un figlio con date fuori dall\'intervallo del parent', async () => {
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(
|
||||||
|
evento({ id: 'ev-parent', parentId: null, dataInizio: new Date('2026-08-01'), dataFine: new Date('2026-08-10') }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/eventi')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
|
||||||
|
.send({
|
||||||
|
titolo: 'Figlio fuori data',
|
||||||
|
dataInizio: '2026-07-30',
|
||||||
|
dataFine: '2026-08-05',
|
||||||
|
tipo: 'uscita',
|
||||||
|
parentId: 'ev-parent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(eventoCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accetta un figlio con date contenute nell\'intervallo del parent', async () => {
|
||||||
|
const parent = evento({ id: 'ev-parent', parentId: null, dataInizio: new Date('2026-08-01'), dataFine: new Date('2026-08-10') });
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(parent);
|
||||||
|
eventoCreate.mockResolvedValueOnce(
|
||||||
|
evento({ id: 'ev-figlio', parentId: 'ev-parent', dataInizio: new Date('2026-08-02'), dataFine: new Date('2026-08-03') }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/eventi')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
|
||||||
|
.send({
|
||||||
|
titolo: 'Figlio ok',
|
||||||
|
dataInizio: '2026-08-02',
|
||||||
|
dataFine: '2026-08-03',
|
||||||
|
tipo: 'uscita',
|
||||||
|
parentId: 'ev-parent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(eventoCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ orgId: 'org-a', brancaId: 'Lupetti', parentId: 'ev-parent' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /eventi/:id — isolamento branca', () => {
|
||||||
|
test("un capo-unità di un'altra branca non può modificare l'evento (403)", async () => {
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(evento({ brancaId: 'Esploratori' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/eventi/ev-1')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'], ['capo-unita'])}`)
|
||||||
|
.send({ titolo: 'Nuovo titolo' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(eventoUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capo-gruppo può modificare un evento di qualunque branca della propria org', async () => {
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(evento({ brancaId: 'Esploratori' }));
|
||||||
|
eventoUpdate.mockResolvedValueOnce(evento({ brancaId: 'Esploratori', titolo: 'Nuovo titolo' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/eventi/ev-1')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', [], ['capo-gruppo'])}`)
|
||||||
|
.send({ titolo: 'Nuovo titolo' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(eventoUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un membro della stessa branca può modificare l\'evento', async () => {
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(evento({ brancaId: 'Lupetti' }));
|
||||||
|
eventoUpdate.mockResolvedValueOnce(evento({ brancaId: 'Lupetti', titolo: 'Nuovo titolo' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/eventi/ev-1')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'], ['capo-unita'])}`)
|
||||||
|
.send({ titolo: 'Nuovo titolo' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(eventoUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /eventi/:id — contenimento date su modifica', () => {
|
||||||
|
test('rifiuta la modifica delle date del parent se non contengono più un figlio esistente', async () => {
|
||||||
|
const figlio = evento({ id: 'ev-figlio', parentId: 'ev-1', dataInizio: new Date('2026-08-05'), dataFine: new Date('2026-08-06') });
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(evento({ id: 'ev-1', children: [figlio] }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/eventi/ev-1')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
|
||||||
|
.send({ dataInizio: '2026-08-01', dataFine: '2026-08-05' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(eventoUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rifiuta la modifica delle date di un figlio se escono dall\'intervallo del parent', async () => {
|
||||||
|
const figlio = evento({ id: 'ev-figlio', parentId: 'ev-parent', dataInizio: new Date('2026-08-02'), dataFine: new Date('2026-08-03') });
|
||||||
|
eventoFindFirst
|
||||||
|
.mockResolvedValueOnce(figlio) // fetch dell'evento da modificare
|
||||||
|
.mockResolvedValueOnce(evento({ id: 'ev-parent', dataInizio: new Date('2026-08-01'), dataFine: new Date('2026-08-10') })); // fetch del parent
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/eventi/ev-figlio')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
|
||||||
|
.send({ dataFine: '2026-08-15' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(eventoUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /eventi/:id', () => {
|
||||||
|
test('elimina un evento a cui l\'utente ha accesso', async () => {
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(evento());
|
||||||
|
eventoDelete.mockResolvedValueOnce(evento());
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/eventi/ev-1')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(eventoDelete).toHaveBeenCalledWith({ where: { id: 'ev-1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rifiuta l'eliminazione se l'utente non ha accesso alla branca (403)", async () => {
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(evento({ brancaId: 'Esploratori' }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/eventi/ev-1')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'], ['capo-unita'])}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(eventoDelete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 401 senza token', async () => {
|
||||||
|
const response = await request(app).delete('/eventi/ev-1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(eventoDelete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /eventi/:id', () => {
|
||||||
|
test('restituisce il dettaglio completo con figli e risorse collegate', async () => {
|
||||||
|
const figlio = evento({ id: 'ev-figlio', parentId: 'ev-1' });
|
||||||
|
const risorsa = {
|
||||||
|
id: 'r-1',
|
||||||
|
eventoId: 'ev-1',
|
||||||
|
tipoRisorsa: 'attivita',
|
||||||
|
risorsaId: 'att-1',
|
||||||
|
servizioOrigine: 'scouthub-attivita-be',
|
||||||
|
metadata: null,
|
||||||
|
creatoIl: new Date('2026-01-02'),
|
||||||
|
};
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(evento({ children: [figlio], risorseCollegate: [risorsa] }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/eventi/ev-1')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.figli).toHaveLength(1);
|
||||||
|
expect(response.body.figli[0].id).toBe('ev-figlio');
|
||||||
|
expect(response.body.risorseCollegate).toHaveLength(1);
|
||||||
|
expect(response.body.risorseCollegate[0]).toMatchObject({ id: 'r-1', tipoRisorsa: 'attivita' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un'altra org non può leggere l'evento (404)", async () => {
|
||||||
|
eventoFindFirst.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/eventi/ev-1')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-b', ['Lupetti'])}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /eventi?vista=anno', () => {
|
||||||
|
test('aggrega per giorno solo gli eventi radice, senza descrizione/risorse', async () => {
|
||||||
|
eventoFindMany.mockResolvedValueOnce([
|
||||||
|
evento({ id: 'ev-1', titolo: 'Campo estivo', dataInizio: new Date('2026-08-01'), dataFine: new Date('2026-08-02') }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/eventi?vista=anno&anno=2026')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(eventoFindMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: expect.objectContaining({ orgId: 'org-a', parentId: null }) }),
|
||||||
|
);
|
||||||
|
expect(response.body).toEqual([
|
||||||
|
{ data: '2026-08-01', conteggio: 1, eventi: [{ titolo: 'Campo estivo', tipo: 'campo', brancaId: 'Lupetti' }] },
|
||||||
|
{ data: '2026-08-02', conteggio: 1, eventi: [{ titolo: 'Campo estivo', tipo: 'campo', brancaId: 'Lupetti' }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 400 se manca l'anno", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/eventi?vista=anno')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(eventoFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /eventi?vista=mese — intersezione date', () => {
|
||||||
|
test('un evento che inizia il mese prima e finisce dentro il mese richiesto compare nel risultato', async () => {
|
||||||
|
// Evento a cavallo tra agosto e settembre: dataInizio nel mese precedente,
|
||||||
|
// dataFine dentro il mese richiesto (settembre 2026).
|
||||||
|
eventoFindMany.mockResolvedValueOnce([
|
||||||
|
evento({
|
||||||
|
id: 'ev-a-cavallo',
|
||||||
|
titolo: 'Campo a cavallo',
|
||||||
|
dataInizio: new Date('2026-08-28'),
|
||||||
|
dataFine: new Date('2026-09-03'),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/eventi?vista=mese&mese=2026-09')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(eventoFindMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
orgId: 'org-a',
|
||||||
|
dataInizio: { lte: new Date(Date.UTC(2026, 8, 30, 23, 59, 59, 999)) },
|
||||||
|
dataFine: { gte: new Date(Date.UTC(2026, 8, 1)) },
|
||||||
|
},
|
||||||
|
orderBy: { dataInizio: 'asc' },
|
||||||
|
});
|
||||||
|
expect(response.body).toHaveLength(1);
|
||||||
|
expect(response.body[0].id).toBe('ev-a-cavallo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applica il filtro brancaId quando presente', async () => {
|
||||||
|
eventoFindMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.get('/eventi?vista=mese&mese=2026-09&brancaId=Esploratori')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
|
||||||
|
|
||||||
|
expect(eventoFindMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: expect.objectContaining({ brancaId: 'Esploratori' }) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 400 se il formato di 'mese' non è YYYY-MM", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/eventi?vista=mese&mese=settembre-2026')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(eventoFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 400 se 'vista' non è valorizzato con anno o mese", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/eventi?vista=settimana')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(eventoFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /eventi/:id/risorse — machine-to-machine', () => {
|
||||||
|
test('crea la risorsa collegata con credenziali di servizio autorizzate', async () => {
|
||||||
|
eventoFindUnique.mockResolvedValueOnce(evento());
|
||||||
|
risorsaCollegataCreate.mockResolvedValueOnce({
|
||||||
|
id: 'r-1',
|
||||||
|
eventoId: 'ev-1',
|
||||||
|
tipoRisorsa: 'attivita',
|
||||||
|
risorsaId: 'att-1',
|
||||||
|
servizioOrigine: 'scouthub-attivita-be',
|
||||||
|
metadata: null,
|
||||||
|
creatoIl: new Date('2026-01-01'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/eventi/ev-1/risorse')
|
||||||
|
.set('Authorization', `Bearer ${serviceTokenFor('test-service-client')}`)
|
||||||
|
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(risorsaCollegataCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
eventoId: 'ev-1',
|
||||||
|
tipoRisorsa: 'attivita',
|
||||||
|
risorsaId: 'att-1',
|
||||||
|
servizioOrigine: 'scouthub-attivita-be',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 404 se l'evento non esiste", async () => {
|
||||||
|
eventoFindUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/eventi/ev-inesistente/risorse')
|
||||||
|
.set('Authorization', `Bearer ${serviceTokenFor('test-service-client')}`)
|
||||||
|
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(risorsaCollegataCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('risponde 401 senza alcun token', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/eventi/ev-1/risorse')
|
||||||
|
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(eventoFindUnique).not.toHaveBeenCalled();
|
||||||
|
expect(risorsaCollegataCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("risponde 403 se il client (azp) non è nella whitelist KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/eventi/ev-1/risorse')
|
||||||
|
.set('Authorization', `Bearer ${serviceTokenFor('client-non-autorizzato')}`)
|
||||||
|
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(eventoFindUnique).not.toHaveBeenCalled();
|
||||||
|
expect(risorsaCollegataCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un token utente valido (senza azp di un client di servizio) viene comunque respinto', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/eventi/ev-1/risorse')
|
||||||
|
.set('Authorization', `Bearer ${tokenFor('org-a', ['Lupetti'])}`)
|
||||||
|
.send({ tipoRisorsa: 'attivita', risorsaId: 'att-1', servizioOrigine: 'scouthub-attivita-be' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(risorsaCollegataCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// Test di integrazione reale (Prisma NON mockato): verifica il vincolo ON DELETE
|
||||||
|
// CASCADE definito nella migration fra evento.parent_id -> evento(id) e fra
|
||||||
|
// risorsa_collegata.evento_id -> evento(id). Un mock di Prisma non potrebbe
|
||||||
|
// verificare un comportamento che vive nel database, quindi qui si usa una
|
||||||
|
// connessione reale. Richiede un Postgres raggiungibile con lo schema di
|
||||||
|
// scouthub-eventi-be già migrato (`npx prisma migrate deploy`), es. il database
|
||||||
|
// locale di sviluppo scouthub_eventi.
|
||||||
|
process.env.DATABASE_URL =
|
||||||
|
process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/scouthub_eventi?schema=public';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cascade delete evento -> figli e risorse collegate (vincolo DB)', () => {
|
||||||
|
test('eliminare il parent elimina anche i figli e le risorse collegate', async () => {
|
||||||
|
const parent = await prisma.evento.create({
|
||||||
|
data: {
|
||||||
|
orgId: 'org-cascade-test',
|
||||||
|
brancaId: 'Lupetti',
|
||||||
|
titolo: 'Campo estivo',
|
||||||
|
dataInizio: new Date('2026-08-01'),
|
||||||
|
dataFine: new Date('2026-08-10'),
|
||||||
|
tipo: 'campo',
|
||||||
|
creatoDa: 'user-test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const figlio = await prisma.evento.create({
|
||||||
|
data: {
|
||||||
|
orgId: parent.orgId,
|
||||||
|
brancaId: parent.brancaId,
|
||||||
|
parentId: parent.id,
|
||||||
|
titolo: 'Uscita di un giorno',
|
||||||
|
dataInizio: new Date('2026-08-02'),
|
||||||
|
dataFine: new Date('2026-08-02'),
|
||||||
|
tipo: 'uscita',
|
||||||
|
creatoDa: 'user-test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const risorsa = await prisma.risorsaCollegata.create({
|
||||||
|
data: {
|
||||||
|
eventoId: parent.id,
|
||||||
|
tipoRisorsa: 'attivita',
|
||||||
|
risorsaId: 'att-1',
|
||||||
|
servizioOrigine: 'scouthub-attivita-be',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.evento.delete({ where: { id: parent.id } });
|
||||||
|
|
||||||
|
const figlioRimasto = await prisma.evento.findUnique({ where: { id: figlio.id } });
|
||||||
|
const risorsaRimasta = await prisma.risorsaCollegata.findUnique({ where: { id: risorsa.id } });
|
||||||
|
|
||||||
|
expect(figlioRimasto).toBeNull();
|
||||||
|
expect(risorsaRimasta).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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