Add scouthub-eventi-be

This commit is contained in:
Lorenzo Sanesi
2026-07-25 11:59:25 +02:00
parent 9ceb05cda2
commit 7e0544c5ab
36 changed files with 7776 additions and 0 deletions
+11
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.env
*.log
coverage/
/.idea/
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
EXPOSE 8084
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
+152
View File
@@ -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.
+14
View File
@@ -0,0 +1,14 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['<rootDir>/tests/unit/**/*.test.ts', '<rootDir>/tests/integration/**/*.test.ts'],
testTimeout: 15000,
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.jest.json' }],
},
};
export default config;
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "scouthub-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"
+46
View File
@@ -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")
}
+19
View File
@@ -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`);
}
}
+15
View File
@@ -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' });
}
}
+33
View File
@@ -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);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined;
}
export const prisma = global.__prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma;
}
+9
View File
@@ -0,0 +1,9 @@
export class HttpError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
this.name = 'HttpError';
}
}
@@ -0,0 +1,10 @@
import { Request, Response, NextFunction } from 'express';
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void {
const hasStatusCode =
typeof err === 'object' && err !== null && 'statusCode' in err && typeof (err as { statusCode?: unknown }).statusCode === 'number';
const statusCode = hasStatusCode ? (err as { statusCode: number }).statusCode : 500;
const message = err instanceof Error && err.message ? err.message : 'Errore interno del server';
res.status(statusCode).json({ message });
}
@@ -0,0 +1,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);
+6
View File
@@ -0,0 +1,6 @@
import { app } from './app';
import { env } from './config/env';
app.listen(env.port, () => {
console.log(`Server avviato su porta ${env.port}`);
});
@@ -0,0 +1,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
View File
@@ -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();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"declaration": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}