Add scouthub-eventi-be
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { healthRouter } from './routes/health.routes';
|
||||
import { eventiRouter } from './routes/eventi.routes';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
|
||||
export const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(cors());
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use(eventiRouter);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ message: 'not found' });
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { HttpError } from '../errors';
|
||||
import { AuthContext } from './auth.types';
|
||||
|
||||
// Lancia 403 se l'utente non appartiene alla branca indicata. Chi ha ruolo
|
||||
// capo-gruppo (realm o sull'organizzazione attiva del token, gia' confluiti in
|
||||
// user.roles da verifyToken) salta il controllo: ha accesso in scrittura a
|
||||
// tutte le branche del proprio gruppo.
|
||||
export function assertBrancaAccess(user: AuthContext, brancaId: string): void {
|
||||
if (user.roles.includes('capo-gruppo')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAccess = user.branche.some((branca) => branca.toLowerCase() === brancaId.toLowerCase());
|
||||
if (!hasAccess) {
|
||||
throw new HttpError(403, `Accesso alla branca '${brancaId}' non consentito`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface AuthContext {
|
||||
userId: string;
|
||||
email: string | null;
|
||||
orgId: string | null;
|
||||
roles: string[];
|
||||
// Branche dell'utente, ricavate dal claim "groups" del token (path normalizzati,
|
||||
// senza lo slash iniziale: es. "Lupetti", "Capi"). Vedi assertBrancaAccess.
|
||||
branche: string[];
|
||||
}
|
||||
|
||||
// Contesto delle chiamate machine-to-machine (client credentials), popolato da
|
||||
// verifyServiceToken. Distinto da AuthContext: non rappresenta un utente reale.
|
||||
export interface ServiceAuthContext {
|
||||
clientId: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
export function extractBearerToken(req: Request): string | null {
|
||||
const header = req.headers.authorization;
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import jwksClient from 'jwks-rsa';
|
||||
import { env } from '../config/env';
|
||||
|
||||
// Client JWKS condiviso tra verifyToken e verifyServiceToken: stesso realm,
|
||||
// stessa chiave pubblica di firma.
|
||||
export const jwksClientInstance = jwksClient({
|
||||
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// Da usare dopo verifyToken su tutte le route utente (mai insieme a verifyServiceToken):
|
||||
// richiede che il token porti un'organizzazione attiva. L'org_id da usare per filtrare
|
||||
// le query e' sempre req.auth.orgId, mai un org_id letto da params/query/body.
|
||||
export function requireOrgId(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.auth?.orgId) {
|
||||
res.status(403).json({ message: 'Nessuna organizzazione attiva sul token' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import { jwksClientInstance } from './jwks-client';
|
||||
import { extractBearerToken } from './extract-bearer-token';
|
||||
import { env } from '../config/env';
|
||||
import { ServiceAuthContext } from './auth.types';
|
||||
|
||||
interface ServiceTokenPayload extends JwtPayload {
|
||||
// "azp" (authorized party) e' il client_id del client che ha ottenuto il token
|
||||
// via client credentials grant: e' il modo standard per riconoscere quale
|
||||
// servizio sta chiamando in una chiamata machine-to-machine.
|
||||
azp?: string;
|
||||
}
|
||||
|
||||
// Middleware da usare al posto di (non insieme a) verifyToken sulle route
|
||||
// machine-to-machine, ad es. POST /eventi/:id/risorse chiamata da altri backend
|
||||
// (scouthub-attivita-be, scouthub-magazzino-be, ...) con le proprie credenziali
|
||||
// di servizio. Il client chiamante deve essere elencato in
|
||||
// KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS.
|
||||
export async function verifyServiceToken(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token) {
|
||||
res.status(401).json({ message: 'Token mancante' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (!decoded || !decoded.header.kid) {
|
||||
throw new Error("Header del token privo di 'kid'");
|
||||
}
|
||||
|
||||
const signingKey = await jwksClientInstance.getSigningKey(decoded.header.kid);
|
||||
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
throw new Error('Payload del token non valido');
|
||||
}
|
||||
|
||||
const clientId = (payload as ServiceTokenPayload).azp;
|
||||
if (!clientId || !env.keycloak.authorizedServiceClients.includes(clientId)) {
|
||||
res.status(403).json({ message: 'Client di servizio non autorizzato' });
|
||||
return;
|
||||
}
|
||||
|
||||
const service: ServiceAuthContext = { clientId };
|
||||
req.service = service;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ message: 'Token non valido' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import { jwksClientInstance } from './jwks-client';
|
||||
import { extractBearerToken } from './extract-bearer-token';
|
||||
import { AuthContext } from './auth.types';
|
||||
|
||||
interface KeycloakTokenPayload extends JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
realm_access?: { roles?: string[] };
|
||||
// Claim iniettato dalla feature "organizations" di Keycloak: mappa alias
|
||||
// organizzazione -> { id, roles dell'utente in quella organizzazione }.
|
||||
// Un token porta al piu' un'organizzazione attiva per volta (vedi anche
|
||||
// scouthub-magazzino-be/src/auth/verify-token.middleware.ts).
|
||||
organization?: Record<string, { id: string; roles?: string[] }>;
|
||||
// scouthub-attivita-be non espone ad oggi un claim branca dedicato nel token
|
||||
// (vedi src/middlewares/authenticate.ts di quel progetto): finche' non verra'
|
||||
// introdotto, la branca/i gruppi dell'utente sono ricavati dal claim standard
|
||||
// "groups" di Keycloak, con path del tipo "/Lupetti", "/Capi".
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
function normalizeGroupPath(path: string): string {
|
||||
return path.replace(/^\//, '');
|
||||
}
|
||||
|
||||
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
|
||||
const realmRoles = payload.realm_access?.roles ?? [];
|
||||
const [organization] = payload.organization ? Object.values(payload.organization) : [];
|
||||
const orgRoles = organization?.roles ?? [];
|
||||
const groups = payload.groups ?? [];
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
email: payload.email ?? null,
|
||||
orgId: organization?.id ?? null,
|
||||
roles: Array.from(new Set([...realmRoles, ...orgRoles])),
|
||||
branche: groups.map(normalizeGroupPath),
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyToken(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token) {
|
||||
res.status(401).json({ message: 'Token mancante' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (!decoded || !decoded.header.kid) {
|
||||
throw new Error("Header del token privo di 'kid'");
|
||||
}
|
||||
|
||||
const signingKey = await jwksClientInstance.getSigningKey(decoded.header.kid);
|
||||
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
throw new Error('Payload del token non valido');
|
||||
}
|
||||
|
||||
req.auth = buildAuthContext(payload as KeycloakTokenPayload);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ message: 'Token non valido' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Variabile d'ambiente mancante: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseClientList(value: string | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((clientId) => clientId.trim())
|
||||
.filter((clientId) => clientId.length > 0);
|
||||
}
|
||||
|
||||
export const env = {
|
||||
port: Number(process.env.PORT) || 8084,
|
||||
databaseUrl: requireEnv('DATABASE_URL'),
|
||||
keycloak: {
|
||||
baseUrl: requireEnv('KEYCLOAK_BASE_URL'),
|
||||
realm: requireEnv('KEYCLOAK_REALM'),
|
||||
eventiClientId: requireEnv('KEYCLOAK_EVENTI_CLIENT_ID'),
|
||||
eventiClientSecret: requireEnv('KEYCLOAK_EVENTI_CLIENT_SECRET'),
|
||||
// client_id (claim "azp") dei client di servizio autorizzati a chiamare le route
|
||||
// machine-to-machine (es. POST /eventi/:id/risorse). Vuoto di default: nessun
|
||||
// client e' autorizzato finche' non viene esplicitamente configurato.
|
||||
authorizedServiceClients: parseClientList(process.env.KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
AggiornaEventoInput,
|
||||
CreaEventoInput,
|
||||
CreaRisorsaCollegataInput,
|
||||
aggiornaEvento,
|
||||
creaEvento,
|
||||
creaRisorsaCollegata,
|
||||
eliminaEvento,
|
||||
getDettaglioEvento,
|
||||
getVistaAnno,
|
||||
getVistaMese,
|
||||
} from '../services/eventi.service';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
interface EventoBody {
|
||||
titolo?: unknown;
|
||||
descrizione?: unknown;
|
||||
dataInizio?: unknown;
|
||||
dataFine?: unknown;
|
||||
tipo?: unknown;
|
||||
location?: unknown;
|
||||
brancaId?: unknown;
|
||||
parentId?: unknown;
|
||||
}
|
||||
|
||||
function parseDate(value: unknown, field: string): Date {
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, `Il campo '${field}' è obbligatorio ed è una stringa in formato data`);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new HttpError(400, `Il campo '${field}' non è una data valida`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOptionalString(value: unknown, field: string): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, `Il campo '${field}', se presente, deve essere una stringa`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRequiredNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new HttpError(400, `Il campo '${field}' è obbligatorio ed è una stringa non vuota`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalNonEmptyString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new HttpError(400, `Il campo '${field}', se presente, deve essere una stringa non vuota`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseCreateBody(body: EventoBody): CreaEventoInput {
|
||||
return {
|
||||
titolo: parseRequiredNonEmptyString(body.titolo, 'titolo'),
|
||||
descrizione: parseOptionalString(body.descrizione, 'descrizione') ?? null,
|
||||
dataInizio: parseDate(body.dataInizio, 'dataInizio'),
|
||||
dataFine: parseDate(body.dataFine, 'dataFine'),
|
||||
tipo: parseRequiredNonEmptyString(body.tipo, 'tipo'),
|
||||
location: parseOptionalString(body.location, 'location') ?? null,
|
||||
brancaId: parseOptionalNonEmptyString(body.brancaId, 'brancaId'),
|
||||
parentId: parseOptionalNonEmptyString(body.parentId, 'parentId'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseUpdateBody(body: EventoBody): AggiornaEventoInput {
|
||||
const input: AggiornaEventoInput = {};
|
||||
|
||||
if (body.titolo !== undefined) {
|
||||
input.titolo = parseRequiredNonEmptyString(body.titolo, 'titolo');
|
||||
}
|
||||
if (body.tipo !== undefined) {
|
||||
input.tipo = parseRequiredNonEmptyString(body.tipo, 'tipo');
|
||||
}
|
||||
if (body.descrizione !== undefined) {
|
||||
input.descrizione = parseOptionalString(body.descrizione, 'descrizione') ?? null;
|
||||
}
|
||||
if (body.location !== undefined) {
|
||||
input.location = parseOptionalString(body.location, 'location') ?? null;
|
||||
}
|
||||
if (body.dataInizio !== undefined) {
|
||||
input.dataInizio = parseDate(body.dataInizio, 'dataInizio');
|
||||
}
|
||||
if (body.dataFine !== undefined) {
|
||||
input.dataFine = parseDate(body.dataFine, 'dataFine');
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function parseAnno(value: unknown, field: string): number {
|
||||
if (typeof value !== 'string' || !/^\d{4}$/.test(value)) {
|
||||
throw new HttpError(400, `Il campo '${field}' è obbligatorio ed è un anno a 4 cifre (es. 2026)`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function parseMese(value: unknown): { anno: number; mese: number } {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}$/.test(value)) {
|
||||
throw new HttpError(400, "Il campo 'mese' è obbligatorio nel formato YYYY-MM (es. 2026-09)");
|
||||
}
|
||||
const [annoStr, meseStr] = value.split('-');
|
||||
const mese = Number(meseStr);
|
||||
if (mese < 1 || mese > 12) {
|
||||
throw new HttpError(400, "Il campo 'mese' contiene un mese non valido (01-12)");
|
||||
}
|
||||
return { anno: Number(annoStr), mese };
|
||||
}
|
||||
|
||||
// Viste di sola lettura su GET /eventi (senza :id), trasversali a tutte le
|
||||
// branche dell'organizzazione: l'org è sempre quella del token (req.auth.orgId),
|
||||
// mai un orgId letto dalla querystring.
|
||||
export async function getEventiVista(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const orgId = req.auth!.orgId!;
|
||||
|
||||
if (req.query.vista === 'anno') {
|
||||
const anno = parseAnno(req.query.anno, 'anno');
|
||||
res.status(200).json(await getVistaAnno(orgId, anno));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.query.vista === 'mese') {
|
||||
const { anno, mese } = parseMese(req.query.mese);
|
||||
const brancaId = typeof req.query.brancaId === 'string' ? req.query.brancaId : undefined;
|
||||
res.status(200).json(await getVistaMese(orgId, anno, mese, brancaId));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new HttpError(400, "Il parametro 'vista' è obbligatorio e deve valere 'anno' o 'mese'");
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const input = parseCreateBody(req.body ?? {});
|
||||
const evento = await creaEvento(req.auth!, input);
|
||||
res.status(201).json(evento);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const evento = await getDettaglioEvento(req.params.id, req.auth!);
|
||||
res.status(200).json(evento);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function putEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const input = parseUpdateBody(req.body ?? {});
|
||||
const evento = await aggiornaEvento(req.params.id, req.auth!, input);
|
||||
res.status(200).json(evento);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
await eliminaEvento(req.params.id, req.auth!);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
interface RisorsaCollegataBody {
|
||||
tipoRisorsa?: unknown;
|
||||
risorsaId?: unknown;
|
||||
servizioOrigine?: unknown;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
function parseRisorsaCollegataBody(body: RisorsaCollegataBody): CreaRisorsaCollegataInput {
|
||||
return {
|
||||
tipoRisorsa: parseRequiredNonEmptyString(body.tipoRisorsa, 'tipoRisorsa'),
|
||||
risorsaId: parseRequiredNonEmptyString(body.risorsaId, 'risorsaId'),
|
||||
servizioOrigine: parseRequiredNonEmptyString(body.servizioOrigine, 'servizioOrigine'),
|
||||
metadata:
|
||||
body.metadata === undefined
|
||||
? undefined
|
||||
: body.metadata === null
|
||||
? Prisma.JsonNull
|
||||
: (body.metadata as Prisma.InputJsonValue),
|
||||
};
|
||||
}
|
||||
|
||||
// Route machine-to-machine (protetta da verifyServiceToken, non da verifyToken):
|
||||
// req.auth non è popolato qui, solo req.service.
|
||||
export async function postRisorsaCollegata(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const input = parseRisorsaCollegataBody(req.body ?? {});
|
||||
const risorsa = await creaRisorsaCollegata(req.params.id, input);
|
||||
res.status(201).json(risorsa);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { healthService } from '../services/health.service';
|
||||
|
||||
export async function getHealth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
await healthService.checkDatabase();
|
||||
res.json({ status: 'ok', database: 'up' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = global.__prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.__prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class HttpError extends Error {
|
||||
statusCode: number;
|
||||
|
||||
constructor(statusCode: number, message: string) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void {
|
||||
const hasStatusCode =
|
||||
typeof err === 'object' && err !== null && 'statusCode' in err && typeof (err as { statusCode?: unknown }).statusCode === 'number';
|
||||
const statusCode = hasStatusCode ? (err as { statusCode: number }).statusCode : 500;
|
||||
const message = err instanceof Error && err.message ? err.message : 'Errore interno del server';
|
||||
|
||||
res.status(statusCode).json({ message });
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Evento, Prisma, RisorsaCollegata } from '@prisma/client';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
const includeEvento = {
|
||||
children: true,
|
||||
risorseCollegate: true,
|
||||
} satisfies Prisma.EventoInclude;
|
||||
|
||||
export type EventoConDettagli = Prisma.EventoGetPayload<{ include: typeof includeEvento }>;
|
||||
|
||||
export interface CreateEventoData {
|
||||
orgId: string;
|
||||
brancaId: string;
|
||||
parentId: string | null;
|
||||
titolo: string;
|
||||
descrizione: string | null;
|
||||
dataInizio: Date;
|
||||
dataFine: Date;
|
||||
tipo: string;
|
||||
location: string | null;
|
||||
creatoDa: string;
|
||||
}
|
||||
|
||||
export interface UpdateEventoData {
|
||||
titolo?: string;
|
||||
descrizione?: string | null;
|
||||
dataInizio?: Date;
|
||||
dataFine?: Date;
|
||||
tipo?: string;
|
||||
location?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRisorsaCollegataData {
|
||||
eventoId: string;
|
||||
tipoRisorsa: string;
|
||||
risorsaId: string;
|
||||
servizioOrigine: string;
|
||||
metadata?: Prisma.InputJsonValue | typeof Prisma.JsonNull;
|
||||
}
|
||||
|
||||
export class EventiRepository {
|
||||
// id + orgId nella stessa where: un evento di un'altra org risulta semplicemente
|
||||
// "non trovato", mai un 403 che ne rivela l'esistenza.
|
||||
findByIdAndOrg(id: string, orgId: string): Promise<EventoConDettagli | null> {
|
||||
return prisma.evento.findFirst({ where: { id, orgId }, include: includeEvento });
|
||||
}
|
||||
|
||||
// Nessun filtro per org: usata dalle chiamate machine-to-machine (POST /eventi/:id/risorse),
|
||||
// che non sono legate a un'organizzazione utente.
|
||||
findById(id: string): Promise<Evento | null> {
|
||||
return prisma.evento.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
create(data: CreateEventoData): Promise<EventoConDettagli> {
|
||||
return prisma.evento.create({ data, include: includeEvento });
|
||||
}
|
||||
|
||||
update(id: string, data: UpdateEventoData): Promise<EventoConDettagli> {
|
||||
return prisma.evento.update({ where: { id }, data, include: includeEvento });
|
||||
}
|
||||
|
||||
// Nessuna cancellazione esplicita di figli/risorse collegate: se ne occupa il
|
||||
// vincolo ON DELETE CASCADE definito in migration su evento.parent_id e
|
||||
// risorsa_collegata.evento_id.
|
||||
remove(id: string): Promise<void> {
|
||||
return prisma.evento.delete({ where: { id } }).then(() => undefined);
|
||||
}
|
||||
|
||||
// Solo eventi radice (parent_id null) la cui data_inizio/data_fine intersecano
|
||||
// [da, a]: usata dalla vista "anno" per l'aggregato leggero per giorno.
|
||||
findRadiceNellIntervallo(orgId: string, da: Date, a: Date): Promise<Evento[]> {
|
||||
return prisma.evento.findMany({
|
||||
where: { orgId, parentId: null, dataInizio: { lte: a }, dataFine: { gte: da } },
|
||||
orderBy: { dataInizio: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// Radice e figli la cui data_inizio/data_fine intersecano [da, a]: usata dalla
|
||||
// vista "mese", lettura trasversale a tutte le branche salvo filtro esplicito.
|
||||
findNellIntervallo(orgId: string, da: Date, a: Date, brancaId?: string): Promise<Evento[]> {
|
||||
return prisma.evento.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
dataInizio: { lte: a },
|
||||
dataFine: { gte: da },
|
||||
...(brancaId ? { brancaId } : {}),
|
||||
},
|
||||
orderBy: { dataInizio: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
createRisorsaCollegata(data: CreateRisorsaCollegataData): Promise<RisorsaCollegata> {
|
||||
return prisma.risorsaCollegata.create({ data });
|
||||
}
|
||||
}
|
||||
|
||||
export const eventiRepository = new EventiRepository();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
export class HealthRepository {
|
||||
async ping(): Promise<void> {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
}
|
||||
}
|
||||
|
||||
export const healthRepository = new HealthRepository();
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import { verifyToken } from '../auth/verify-token.middleware';
|
||||
import { verifyServiceToken } from '../auth/verify-service-token.middleware';
|
||||
import { requireOrgId } from '../auth/require-org-id.middleware';
|
||||
import {
|
||||
deleteEvento,
|
||||
getEvento,
|
||||
getEventiVista,
|
||||
postEvento,
|
||||
postRisorsaCollegata,
|
||||
putEvento,
|
||||
} from '../controllers/eventi.controller';
|
||||
|
||||
export const eventiRouter = Router();
|
||||
|
||||
eventiRouter.post('/eventi', verifyToken, requireOrgId, postEvento);
|
||||
// Viste "anno"/"mese": sola lettura, trasversali a tutte le branche dell'org.
|
||||
eventiRouter.get('/eventi', verifyToken, requireOrgId, getEventiVista);
|
||||
eventiRouter.get('/eventi/:id', verifyToken, requireOrgId, getEvento);
|
||||
eventiRouter.put('/eventi/:id', verifyToken, requireOrgId, putEvento);
|
||||
eventiRouter.delete('/eventi/:id', verifyToken, requireOrgId, deleteEvento);
|
||||
// Machine-to-machine: verifyServiceToken invece di verifyToken/requireOrgId.
|
||||
eventiRouter.post('/eventi/:id/risorse', verifyServiceToken, postRisorsaCollegata);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { getHealth } from '../controllers/health.controller';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
healthRouter.get('/health', getHealth);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { app } from './app';
|
||||
import { env } from './config/env';
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`Server avviato su porta ${env.port}`);
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuthContext } from '../auth/auth.types';
|
||||
import { assertBrancaAccess } from '../auth/assert-branca-access';
|
||||
import { EventoConDettagli, eventiRepository } from '../repositories/eventi.repository';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
export interface EventoView {
|
||||
id: string;
|
||||
orgId: string;
|
||||
brancaId: string;
|
||||
parentId: string | null;
|
||||
titolo: string;
|
||||
descrizione: string | null;
|
||||
dataInizio: Date;
|
||||
dataFine: Date;
|
||||
tipo: string;
|
||||
location: string | null;
|
||||
creatoDa: string;
|
||||
creatoIl: Date;
|
||||
}
|
||||
|
||||
export interface RisorsaCollegataView {
|
||||
id: string;
|
||||
tipoRisorsa: string;
|
||||
risorsaId: string;
|
||||
servizioOrigine: string;
|
||||
metadata: unknown;
|
||||
creatoIl: Date;
|
||||
}
|
||||
|
||||
export interface EventoDettaglioView extends EventoView {
|
||||
figli: EventoView[];
|
||||
risorseCollegate: RisorsaCollegataView[];
|
||||
}
|
||||
|
||||
function toEventoView(evento: Omit<EventoConDettagli, 'children' | 'risorseCollegate'>): EventoView {
|
||||
return {
|
||||
id: evento.id,
|
||||
orgId: evento.orgId,
|
||||
brancaId: evento.brancaId,
|
||||
parentId: evento.parentId,
|
||||
titolo: evento.titolo,
|
||||
descrizione: evento.descrizione,
|
||||
dataInizio: evento.dataInizio,
|
||||
dataFine: evento.dataFine,
|
||||
tipo: evento.tipo,
|
||||
location: evento.location,
|
||||
creatoDa: evento.creatoDa,
|
||||
creatoIl: evento.creatoIl,
|
||||
};
|
||||
}
|
||||
|
||||
function toDettaglioView(evento: EventoConDettagli): EventoDettaglioView {
|
||||
return {
|
||||
...toEventoView(evento),
|
||||
figli: evento.children.map(toEventoView),
|
||||
risorseCollegate: evento.risorseCollegate.map((r) => ({
|
||||
id: r.id,
|
||||
tipoRisorsa: r.tipoRisorsa,
|
||||
risorsaId: r.risorsaId,
|
||||
servizioOrigine: r.servizioOrigine,
|
||||
metadata: r.metadata,
|
||||
creatoIl: r.creatoIl,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function assertIntervalloValido(dataInizio: Date, dataFine: Date): void {
|
||||
if (dataFine < dataInizio) {
|
||||
throw new HttpError(400, "'dataFine' deve essere successiva o uguale a 'dataInizio'");
|
||||
}
|
||||
}
|
||||
|
||||
function assertContenutoNelParent(dataInizio: Date, dataFine: Date, parent: EventoConDettagli): void {
|
||||
if (dataInizio < parent.dataInizio || dataFine > parent.dataFine) {
|
||||
throw new HttpError(400, "Le date dell'evento devono essere contenute nell'intervallo del parent");
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreaEventoInput {
|
||||
titolo: string;
|
||||
descrizione: string | null;
|
||||
dataInizio: Date;
|
||||
dataFine: Date;
|
||||
tipo: string;
|
||||
location: string | null;
|
||||
brancaId?: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export async function creaEvento(user: AuthContext, input: CreaEventoInput): Promise<EventoView> {
|
||||
const orgId = user.orgId!;
|
||||
assertIntervalloValido(input.dataInizio, input.dataFine);
|
||||
|
||||
let brancaId: string;
|
||||
let parentId: string | null = null;
|
||||
|
||||
if (input.parentId) {
|
||||
const parent = await eventiRepository.findByIdAndOrg(input.parentId, orgId);
|
||||
if (!parent) {
|
||||
throw new HttpError(400, 'Il parent indicato non esiste o non appartiene alla tua organizzazione');
|
||||
}
|
||||
if (parent.parentId) {
|
||||
throw new HttpError(400, 'profondità massima superata');
|
||||
}
|
||||
assertContenutoNelParent(input.dataInizio, input.dataFine, parent);
|
||||
|
||||
brancaId = parent.brancaId;
|
||||
parentId = parent.id;
|
||||
} else {
|
||||
if (!input.brancaId) {
|
||||
throw new HttpError(400, "Il campo 'brancaId' è obbligatorio per un evento senza parent");
|
||||
}
|
||||
brancaId = input.brancaId;
|
||||
}
|
||||
|
||||
assertBrancaAccess(user, brancaId);
|
||||
|
||||
const evento = await eventiRepository.create({
|
||||
orgId,
|
||||
brancaId,
|
||||
parentId,
|
||||
titolo: input.titolo,
|
||||
descrizione: input.descrizione,
|
||||
dataInizio: input.dataInizio,
|
||||
dataFine: input.dataFine,
|
||||
tipo: input.tipo,
|
||||
location: input.location,
|
||||
creatoDa: user.userId,
|
||||
});
|
||||
|
||||
return toEventoView(evento);
|
||||
}
|
||||
|
||||
export async function getDettaglioEvento(id: string, user: AuthContext): Promise<EventoDettaglioView> {
|
||||
const evento = await eventiRepository.findByIdAndOrg(id, user.orgId!);
|
||||
if (!evento) {
|
||||
throw new HttpError(404, 'Evento non trovato');
|
||||
}
|
||||
return toDettaglioView(evento);
|
||||
}
|
||||
|
||||
// Viste di sola lettura, trasversali a tutte le branche dell'organizzazione
|
||||
// (nessuna assertBrancaAccess): visibili a qualunque utente autenticato dell'org.
|
||||
|
||||
export interface EventoGiornoView {
|
||||
titolo: string;
|
||||
tipo: string;
|
||||
brancaId: string;
|
||||
}
|
||||
|
||||
export interface VistaAnnoGiornoView {
|
||||
data: string;
|
||||
conteggio: number;
|
||||
eventi: EventoGiornoView[];
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function getVistaAnno(orgId: string, anno: number): Promise<VistaAnnoGiornoView[]> {
|
||||
const inizioAnno = new Date(Date.UTC(anno, 0, 1));
|
||||
const fineAnno = new Date(Date.UTC(anno, 11, 31, 23, 59, 59, 999));
|
||||
|
||||
const eventi = await eventiRepository.findRadiceNellIntervallo(orgId, inizioAnno, fineAnno);
|
||||
|
||||
const perGiorno = new Map<string, EventoGiornoView[]>();
|
||||
|
||||
for (const evento of eventi) {
|
||||
const inizioEffettivo = evento.dataInizio < inizioAnno ? inizioAnno : evento.dataInizio;
|
||||
const fineEffettiva = evento.dataFine > fineAnno ? fineAnno : evento.dataFine;
|
||||
|
||||
const cursor = new Date(
|
||||
Date.UTC(inizioEffettivo.getUTCFullYear(), inizioEffettivo.getUTCMonth(), inizioEffettivo.getUTCDate()),
|
||||
);
|
||||
const fineGiorno = new Date(
|
||||
Date.UTC(fineEffettiva.getUTCFullYear(), fineEffettiva.getUTCMonth(), fineEffettiva.getUTCDate()),
|
||||
);
|
||||
|
||||
while (cursor <= fineGiorno) {
|
||||
const chiave = isoDate(cursor);
|
||||
const lista = perGiorno.get(chiave) ?? [];
|
||||
lista.push({ titolo: evento.titolo, tipo: evento.tipo, brancaId: evento.brancaId });
|
||||
perGiorno.set(chiave, lista);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(perGiorno.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([data, eventiGiorno]) => ({ data, conteggio: eventiGiorno.length, eventi: eventiGiorno }));
|
||||
}
|
||||
|
||||
export async function getVistaMese(
|
||||
orgId: string,
|
||||
anno: number,
|
||||
mese: number,
|
||||
brancaId: string | undefined,
|
||||
): Promise<EventoView[]> {
|
||||
const inizioMese = new Date(Date.UTC(anno, mese - 1, 1));
|
||||
// Giorno 0 del mese successivo == ultimo giorno del mese richiesto.
|
||||
const fineMese = new Date(Date.UTC(anno, mese, 0, 23, 59, 59, 999));
|
||||
|
||||
const eventi = await eventiRepository.findNellIntervallo(orgId, inizioMese, fineMese, brancaId);
|
||||
return eventi.map(toEventoView);
|
||||
}
|
||||
|
||||
export interface AggiornaEventoInput {
|
||||
titolo?: string;
|
||||
descrizione?: string | null;
|
||||
dataInizio?: Date;
|
||||
dataFine?: Date;
|
||||
tipo?: string;
|
||||
location?: string | null;
|
||||
}
|
||||
|
||||
export async function aggiornaEvento(id: string, user: AuthContext, input: AggiornaEventoInput): Promise<EventoView> {
|
||||
const orgId = user.orgId!;
|
||||
const evento = await eventiRepository.findByIdAndOrg(id, orgId);
|
||||
if (!evento) {
|
||||
throw new HttpError(404, 'Evento non trovato');
|
||||
}
|
||||
|
||||
assertBrancaAccess(user, evento.brancaId);
|
||||
|
||||
const nuovaDataInizio = input.dataInizio ?? evento.dataInizio;
|
||||
const nuovaDataFine = input.dataFine ?? evento.dataFine;
|
||||
const dateModificate = input.dataInizio !== undefined || input.dataFine !== undefined;
|
||||
|
||||
if (dateModificate) {
|
||||
assertIntervalloValido(nuovaDataInizio, nuovaDataFine);
|
||||
|
||||
if (evento.parentId) {
|
||||
const parent = await eventiRepository.findByIdAndOrg(evento.parentId, orgId);
|
||||
if (parent) {
|
||||
assertContenutoNelParent(nuovaDataInizio, nuovaDataFine, parent);
|
||||
}
|
||||
}
|
||||
|
||||
const figlioFuoriIntervallo = evento.children.some(
|
||||
(figlio) => figlio.dataInizio < nuovaDataInizio || figlio.dataFine > nuovaDataFine,
|
||||
);
|
||||
if (figlioFuoriIntervallo) {
|
||||
throw new HttpError(400, 'Il nuovo intervallo deve continuare a contenere tutti gli eventi figli');
|
||||
}
|
||||
}
|
||||
|
||||
const aggiornato = await eventiRepository.update(id, {
|
||||
...(input.titolo !== undefined ? { titolo: input.titolo } : {}),
|
||||
...(input.descrizione !== undefined ? { descrizione: input.descrizione } : {}),
|
||||
...(input.dataInizio !== undefined ? { dataInizio: input.dataInizio } : {}),
|
||||
...(input.dataFine !== undefined ? { dataFine: input.dataFine } : {}),
|
||||
...(input.tipo !== undefined ? { tipo: input.tipo } : {}),
|
||||
...(input.location !== undefined ? { location: input.location } : {}),
|
||||
});
|
||||
|
||||
return toEventoView(aggiornato);
|
||||
}
|
||||
|
||||
export async function eliminaEvento(id: string, user: AuthContext): Promise<void> {
|
||||
const evento = await eventiRepository.findByIdAndOrg(id, user.orgId!);
|
||||
if (!evento) {
|
||||
throw new HttpError(404, 'Evento non trovato');
|
||||
}
|
||||
|
||||
assertBrancaAccess(user, evento.brancaId);
|
||||
|
||||
await eventiRepository.remove(id);
|
||||
}
|
||||
|
||||
// Chiamata machine-to-machine (POST /eventi/:id/risorse, protetta da
|
||||
// verifyServiceToken): nessun AuthContext utente, nessun assertBrancaAccess.
|
||||
// Nessuna lettura pubblica su risorsa_collegata: la lettura per l'utente finale
|
||||
// passa da GET /eventi/:id (toDettaglioView sopra).
|
||||
export interface CreaRisorsaCollegataInput {
|
||||
tipoRisorsa: string;
|
||||
risorsaId: string;
|
||||
servizioOrigine: string;
|
||||
metadata?: Prisma.InputJsonValue | typeof Prisma.JsonNull;
|
||||
}
|
||||
|
||||
export async function creaRisorsaCollegata(
|
||||
eventoId: string,
|
||||
input: CreaRisorsaCollegataInput,
|
||||
): Promise<RisorsaCollegataView> {
|
||||
const evento = await eventiRepository.findById(eventoId);
|
||||
if (!evento) {
|
||||
throw new HttpError(404, 'Evento non trovato');
|
||||
}
|
||||
|
||||
const risorsa = await eventiRepository.createRisorsaCollegata({
|
||||
eventoId,
|
||||
tipoRisorsa: input.tipoRisorsa,
|
||||
risorsaId: input.risorsaId,
|
||||
servizioOrigine: input.servizioOrigine,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
id: risorsa.id,
|
||||
tipoRisorsa: risorsa.tipoRisorsa,
|
||||
risorsaId: risorsa.risorsaId,
|
||||
servizioOrigine: risorsa.servizioOrigine,
|
||||
metadata: risorsa.metadata,
|
||||
creatoIl: risorsa.creatoIl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { healthRepository } from '../repositories/health.repository';
|
||||
|
||||
export class HealthService {
|
||||
async checkDatabase(): Promise<boolean> {
|
||||
await healthRepository.ping();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const healthService = new HealthService();
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { AuthContext, ServiceAuthContext } from '../auth/auth.types';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: AuthContext;
|
||||
service?: ServiceAuthContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user