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
+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 {};