Add scouthub-home-be

This commit is contained in:
Lorenzo Sanesi
2026-07-23 19:36:55 +02:00
parent cfd2de4ca3
commit 566754f825
45 changed files with 8233 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import express from 'express';
import cors from 'cors';
import { healthRouter } from './routes/health.routes';
import { gruppiRouter } from './routes/gruppi.routes';
import { invitiRouter } from './routes/inviti.routes';
import { membriRouter } from './routes/membri.routes';
import { errorHandler } from './middleware/errorHandler';
export const app = express();
app.use(express.json());
app.use(cors());
app.use(healthRouter);
app.use(gruppiRouter);
app.use(invitiRouter);
app.use(membriRouter);
app.use((req, res) => {
res.status(404).json({ message: 'not found' });
});
app.use(errorHandler);
+23
View File
@@ -0,0 +1,23 @@
import dotenv from 'dotenv';
dotenv.config();
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Variabile d'ambiente mancante: ${name}`);
}
return value;
}
export const env = {
port: Number(process.env.PORT) || 8082,
databaseUrl: requireEnv('DATABASE_URL'),
frontendBaseUrl: requireEnv('FRONTEND_BASE_URL'),
keycloak: {
baseUrl: requireEnv('KEYCLOAK_BASE_URL'),
realm: requireEnv('KEYCLOAK_REALM'),
orgServiceClientId: requireEnv('KEYCLOAK_ORG_SERVICE_CLIENT_ID'),
orgServiceClientSecret: requireEnv('KEYCLOAK_ORG_SERVICE_CLIENT_SECRET'),
},
};
@@ -0,0 +1,42 @@
import { Request, Response, NextFunction } from 'express';
import { createGruppo } from '../services/gruppi.service';
import { HttpError } from '../errors';
interface PostGruppoBody {
nome?: unknown;
regione?: unknown;
ruoliDefault?: unknown;
}
function parseBody(body: PostGruppoBody): { nome: string; regione?: string; ruoliDefault?: string[] } {
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (body.regione !== undefined && typeof body.regione !== 'string') {
throw new HttpError(400, "Il campo 'regione', se presente, deve essere una stringa");
}
if (body.ruoliDefault !== undefined) {
const isArrayOfStrings = Array.isArray(body.ruoliDefault) && body.ruoliDefault.every((r) => typeof r === 'string');
if (!isArrayOfStrings) {
throw new HttpError(400, "Il campo 'ruoliDefault', se presente, deve essere un array di stringhe");
}
}
return {
nome: body.nome,
regione: body.regione as string | undefined,
ruoliDefault: body.ruoliDefault as string[] | undefined,
};
}
export async function postGruppo(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseBody(req.body ?? {});
const result = await createGruppo({ ...input, userId: req.auth!.userId });
res.status(201).json(result);
} 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,65 @@
import { Request, Response, NextFunction } from 'express';
import { creaInvito, getInvitoPubblico, accettaInvito } from '../services/inviti.service';
import { HttpError } from '../errors';
interface PostInvitoBody {
email?: unknown;
ruolo?: unknown;
}
function parseCreaInvitoBody(body: PostInvitoBody): { email: string; ruolo: string } {
if (typeof body.email !== 'string' || body.email.trim().length === 0) {
throw new HttpError(400, "Il campo 'email' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.ruolo !== 'string' || body.ruolo.trim().length === 0) {
throw new HttpError(400, "Il campo 'ruolo' è obbligatorio ed è una stringa non vuota");
}
return { email: body.email, ruolo: body.ruolo };
}
export async function postInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { orgId } = req.params;
// Un capo gruppo può invitare solo all'interno della propria organization.
if (req.auth?.organizationId !== orgId) {
throw new HttpError(403, "Non puoi invitare persone in un'organizzazione diversa dalla tua");
}
const { email, ruolo } = parseCreaInvitoBody(req.body ?? {});
const result = await creaInvito({ orgId, email, ruolo });
res.status(201).json(result);
} catch (err) {
next(err);
}
}
export async function getInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { token } = req.params;
const invito = await getInvitoPubblico(token);
if (!invito) {
throw new HttpError(404, 'Invito non trovato');
}
res.json(invito);
} catch (err) {
next(err);
}
}
export async function postAccettaInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { token } = req.params;
const result = await accettaInvito(token, {
userId: req.auth!.userId,
email: req.auth!.email,
});
res.status(200).json(result);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,50 @@
import { Request, Response, NextFunction } from 'express';
import { listaMembri, cambiaRuoloMembro, rimuoviMembro } from '../services/membri.service';
import { HttpError } from '../errors';
function checkOrgAccess(req: Request): string {
const { orgId } = req.params;
if (req.auth?.organizationId !== orgId) {
throw new HttpError(403, "Non puoi gestire membri di un'organizzazione diversa dalla tua");
}
return orgId;
}
export async function getMembri(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const orgId = checkOrgAccess(req);
const membri = await listaMembri(orgId);
res.json(membri);
} catch (err) {
next(err);
}
}
export async function putRuoloMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const orgId = checkOrgAccess(req);
const { userId } = req.params;
const { ruolo } = req.body ?? {};
if (typeof ruolo !== 'string' || ruolo.trim().length === 0) {
throw new HttpError(400, "Il campo 'ruolo' è obbligatorio ed è una stringa non vuota");
}
await cambiaRuoloMembro(orgId, userId, ruolo);
res.status(200).json({ userId, ruolo });
} catch (err) {
next(err);
}
}
export async function deleteMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const orgId = checkOrgAccess(req);
const { userId } = req.params;
await rimuoviMembro(orgId, userId);
res.status(204).send();
} 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,21 @@
export async function assignUserToGroup(userId: string, groupId: string): Promise<void> {
// TODO: PUT {adminBaseUrl}/users/{userId}/groups/{groupId}
throw new Error('Not implemented');
}
export async function removeUserFromGroup(userId: string, groupId: string): Promise<void> {
// TODO: DELETE {adminBaseUrl}/users/{userId}/groups/{groupId}
throw new Error('Not implemented');
}
export interface UserGroup {
groupId: string;
nome: string;
}
export async function getUserGroupsInOrganization(orgId: string, userId: string): Promise<UserGroup[]> {
// TODO: GET {adminBaseUrl}/users/{userId}/groups, filtrati ai gruppi che
// appartengono all'albero dei gruppi dell'organizzazione orgId (o endpoint
// dedicato, da verificare in base alla versione di Keycloak).
throw new Error('Not implemented');
}
@@ -0,0 +1,13 @@
import axios from 'axios';
import { env } from '../config/env';
import { getAccessToken } from './tokenManager';
export const keycloakAdminHttp = axios.create({
baseURL: `${env.keycloak.baseUrl}/admin/realms/${env.keycloak.realm}`,
});
keycloakAdminHttp.interceptors.request.use(async (config) => {
const token = await getAccessToken();
config.headers.set('Authorization', `Bearer ${token}`);
return config;
});
@@ -0,0 +1,12 @@
export { getAccessToken } from './tokenManager';
export { keycloakAdminHttp } from './httpClient';
export {
createOrganization,
createOrganizationGroup,
addMemberToOrganization,
listOrganizationMembers,
removeMemberFromOrganization,
} from './organizations';
export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups';
export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles';
export { findUserByEmail, createUser } from './users';
@@ -0,0 +1,101 @@
import axios from 'axios';
import { keycloakAdminHttp } from './httpClient';
// Le risposte 201 di Keycloak restituiscono l'id della risorsa creata solo
// nell'header Location (es. ".../organizations/3f2b...").
function extractIdFromLocation(location: string | undefined): string {
if (!location) {
throw new Error("Risposta Keycloak priva dell'header Location");
}
const id = location.split('/').pop();
if (!id) {
throw new Error(`Impossibile estrarre l'id dall'header Location: "${location}"`);
}
return id;
}
// L'alias dell'organizzazione è vincolato da Keycloak a un formato URL-safe:
// verificato contro un'istanza reale che uno spazio nel nome (comunissimo nei
// nomi di gruppo scout, es. "Milano 1") viene rifiutato con 400 "Empty Space
// not allowed", a differenza del campo "name" che accetta testo libero.
function toAlias(nome: string): string {
return nome
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export async function createOrganization(nome: string): Promise<{ orgId: string }> {
const response = await keycloakAdminHttp.post('/organizations', {
name: nome,
alias: toAlias(nome),
enabled: true,
domains: [],
});
return { orgId: extractIdFromLocation(response.headers.location) };
}
// Prefisso per il gruppo realm padre di ogni organizzazione. Keycloak riserva
// internamente un gruppo top-level il cui nome coincide con l'id
// dell'organizzazione (non elencato da GET /groups, ma verificato contro
// un'istanza reale: un POST /groups con quel nome esatto risponde comunque
// 409 "already exists"). Va quindi evitato un nome che collida con quello.
const GRUPPO_PADRE_PREFIX = 'org-';
export async function createOrganizationGroup(orgId: string, nomeGruppo: string): Promise<{ groupId: string }> {
// Le Organizations di Keycloak 26 non hanno un concetto nativo di "gruppo
// ruolo": usiamo un gruppo realm nidificato sotto un gruppo padre dedicato
// all'organizzazione, per isolare i gruppi ruolo di organizzazioni diverse
// anche se condividono lo stesso nome di ruolo (es. "Capi"). Il gruppo
// padre viene creato al primo ruolo e riusato per gli altri: la 409 sul
// secondo tentativo è quindi attesa, non un errore.
const nomeGruppoPadre = `${GRUPPO_PADRE_PREFIX}${orgId}`;
let parentGroupId: string;
try {
const parentResponse = await keycloakAdminHttp.post('/groups', { name: nomeGruppoPadre });
parentGroupId = extractIdFromLocation(parentResponse.headers.location);
} catch (err) {
if (!axios.isAxiosError(err) || err.response?.status !== 409) {
throw err;
}
const searchResponse = await keycloakAdminHttp.get<Array<{ id: string; name: string }>>('/groups', {
params: { search: nomeGruppoPadre, exact: true },
});
const existing = searchResponse.data.find((g) => g.name === nomeGruppoPadre);
if (!existing) {
throw new Error(`Gruppo padre "${nomeGruppoPadre}" segnalato come duplicato ma non trovato nella ricerca`);
}
parentGroupId = existing.id;
}
const childResponse = await keycloakAdminHttp.post(`/groups/${parentGroupId}/children`, { name: nomeGruppo });
return { groupId: extractIdFromLocation(childResponse.headers.location) };
}
export async function addMemberToOrganization(orgId: string, userId: string): Promise<void> {
// Verificato contro un'istanza Keycloak 26.7 reale: l'endpoint richiede
// Content-Type application/json con il solo id utente come stringa JSON nel
// body (non un oggetto, e non text/plain: entrambi rispondono 415).
await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), {
headers: { 'Content-Type': 'application/json' },
});
}
export interface OrganizationMember {
userId: string;
email: string;
}
export async function listOrganizationMembers(orgId: string): Promise<OrganizationMember[]> {
const response = await keycloakAdminHttp.get<Array<{ id: string; email: string }>>(
`/organizations/${orgId}/members`,
);
return response.data.map((member) => ({ userId: member.id, email: member.email }));
}
export async function removeMemberFromOrganization(orgId: string, userId: string): Promise<void> {
await keycloakAdminHttp.delete(`/organizations/${orgId}/members/${userId}`);
}
@@ -0,0 +1,20 @@
export async function assignRealmRoleToUser(userId: string, ruolo: string): Promise<void> {
// TODO: POST {adminBaseUrl}/users/{userId}/role-mappings/realm con il
// rappresentante del ruolo (richiede prima GET {adminBaseUrl}/roles/{ruolo}
// per ottenerne id e name).
throw new Error('Not implemented');
}
export async function removeRealmRoleFromUser(userId: string, ruolo: string): Promise<void> {
// TODO: DELETE {adminBaseUrl}/users/{userId}/role-mappings/realm con il
// rappresentante del ruolo.
throw new Error('Not implemented');
}
export async function getUserRealmRoles(userId: string): Promise<string[]> {
// TODO: GET {adminBaseUrl}/users/{userId}/role-mappings/realm. Il contratto
// di questa funzione è di restituire solo i ruoli scout "custom" (es. le
// voci di ruoliDefault create in POST /gruppi), escludendo i ruoli di
// default di Keycloak (offline_access, uma_authorization, ecc.).
throw new Error('Not implemented');
}
@@ -0,0 +1,60 @@
import axios from 'axios';
import { env } from '../config/env';
import { TokenResponse } from './types';
// Margine di sicurezza sotto il quale consideriamo il token "in scadenza"
// e ne richiediamo uno nuovo, invece di rischiare di usarne uno già scaduto
// a causa della latenza della chiamata Admin API successiva.
const EXPIRY_SAFETY_MARGIN_MS = 10_000;
interface CachedToken {
accessToken: string;
expiresAt: number;
}
let cachedToken: CachedToken | null = null;
let pendingRefresh: Promise<string> | null = null;
function tokenEndpoint(): string {
return `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/token`;
}
async function requestNewToken(): Promise<string> {
const response = await axios.post<TokenResponse>(
tokenEndpoint(),
new URLSearchParams({
grant_type: 'client_credentials',
client_id: env.keycloak.orgServiceClientId,
client_secret: env.keycloak.orgServiceClientSecret,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
const { access_token, expires_in } = response.data;
cachedToken = {
accessToken: access_token,
expiresAt: Date.now() + expires_in * 1000,
};
return cachedToken.accessToken;
}
export async function getAccessToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt - EXPIRY_SAFETY_MARGIN_MS > Date.now()) {
return cachedToken.accessToken;
}
// Se un rinnovo è già in corso, tutte le chiamate concorrenti aspettano
// lo stesso risultato invece di richiedere ciascuna un nuovo token.
if (!pendingRefresh) {
pendingRefresh = requestNewToken().finally(() => {
pendingRefresh = null;
});
}
return pendingRefresh;
}
// Esportata solo per i test: azzera lo stato in-memory del modulo tra un test e l'altro.
export function resetTokenCache(): void {
cachedToken = null;
pendingRefresh = null;
}
@@ -0,0 +1,9 @@
export interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
}
export interface KeycloakUserSummary {
id: string;
}
@@ -0,0 +1,13 @@
import { KeycloakUserSummary } from './types';
export async function findUserByEmail(email: string): Promise<KeycloakUserSummary | null> {
// TODO: GET {adminBaseUrl}/users?email={email}&exact=true, restituire il
// primo risultato mappato a { id } oppure null se l'array è vuoto.
throw new Error('Not implemented');
}
export async function createUser(email: string, datiProfilo: object): Promise<{ userId: string }> {
// TODO: POST {adminBaseUrl}/users con { email, ...datiProfilo, enabled: true },
// leggere l'id dall'header Location della risposta.
throw new Error('Not implemented');
}
@@ -0,0 +1,6 @@
export interface AuthContext {
userId: string;
email: string | null;
organizationId: string | null;
roles: string[];
}
@@ -0,0 +1,73 @@
import { Request, Response, NextFunction } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { env } from '../config/env';
import { AuthContext } from './auth.types';
const client = jwksClient({
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
cache: true,
rateLimit: true,
});
interface KeycloakTokenPayload extends JwtPayload {
sub: string;
email?: string;
realm_access?: { roles?: string[] };
// Claim iniettato dalla feature "organizations" di Keycloak: mappa alias
// organizzazione -> { id, roles dell'utente in quella organizzazione }.
// Un token porta al più un'organizzazione attiva per volta.
organization?: Record<string, { id: string; roles?: string[] }>;
}
function extractBearerToken(req: Request): string | null {
const header = req.headers.authorization;
if (!header) {
return null;
}
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return null;
}
return token;
}
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
const realmRoles = payload.realm_access?.roles ?? [];
const [organization] = payload.organization ? Object.values(payload.organization) : [];
const orgRoles = organization?.roles ?? [];
return {
userId: payload.sub,
email: payload.email ?? null,
organizationId: organization?.id ?? null,
roles: Array.from(new Set([...realmRoles, ...orgRoles])),
};
}
export async function authenticate(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = extractBearerToken(req);
if (!token) {
res.status(401).json({ message: 'Token mancante' });
return;
}
try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header.kid) {
throw new Error("Header del token privo di 'kid'");
}
const signingKey = await client.getSigningKey(decoded.header.kid);
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
if (typeof payload === 'string') {
throw new Error('Payload del token non valido');
}
req.auth = buildAuthContext(payload as KeycloakTokenPayload);
next();
} catch {
res.status(401).json({ message: 'Token non valido' });
}
}
@@ -0,0 +1,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,15 @@
import { Request, Response, NextFunction } from 'express';
export function requireRole(...ruoliAmmessi: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
const roles = req.auth?.roles ?? [];
const autorizzato = ruoliAmmessi.some((ruolo) => roles.includes(ruolo));
if (!autorizzato) {
res.status(403).json({ message: 'Ruolo non autorizzato' });
return;
}
next();
};
}
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate';
import { requireRole } from '../middleware/requireRole';
import { postGruppo } from '../controllers/gruppi.controller';
export const gruppiRouter = Router();
// TODO: "admin-centrale" è temporaneo, in attesa di definire i ruoli reali
// abilitati alla creazione di un nuovo gruppo scout.
gruppiRouter.post('/gruppi', authenticate, requireRole('admin-centrale'), postGruppo);
@@ -0,0 +1,6 @@
import { Router } from 'express';
import { getHealth } from '../controllers/health.controller';
export const healthRouter = Router();
healthRouter.get('/health', getHealth);
@@ -0,0 +1,12 @@
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate';
import { requireRole } from '../middleware/requireRole';
import { postInvito, getInvito, postAccettaInvito } from '../controllers/inviti.controller';
export const invitiRouter = Router();
invitiRouter.post('/gruppi/:orgId/inviti', authenticate, requireRole('capo-gruppo'), postInvito);
invitiRouter.get('/inviti/:token', getInvito);
invitiRouter.post('/inviti/:token/accetta', authenticate, postAccettaInvito);
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate';
import { requireRole } from '../middleware/requireRole';
import { getMembri, putRuoloMembro, deleteMembro } from '../controllers/membri.controller';
export const membriRouter = Router();
membriRouter.get('/gruppi/:orgId/membri', authenticate, requireRole('capo-gruppo'), getMembri);
membriRouter.put('/gruppi/:orgId/membri/:userId/ruolo', authenticate, requireRole('capo-gruppo'), putRuoloMembro);
membriRouter.delete('/gruppi/:orgId/membri/:userId', authenticate, requireRole('capo-gruppo'), deleteMembro);
+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,93 @@
import axios from 'axios';
import { prisma } from '../db/prisma';
import { createOrganization, createOrganizationGroup, addMemberToOrganization } from '../keycloak-admin';
import { HttpError } from '../errors';
export const RUOLI_DEFAULT = ['Capi', 'Aiuto capi', 'Censiti'];
export interface CreateGruppoInput {
nome: string;
userId: string;
regione?: string;
ruoliDefault?: string[];
}
export interface CreateGruppoResult {
orgId: string;
gruppiCreati: string[];
}
function isKeycloakConflict(err: unknown): boolean {
return axios.isAxiosError(err) && err.response?.status === 409;
}
export async function createGruppo(input: CreateGruppoInput): Promise<CreateGruppoResult> {
const ruoliDefault = input.ruoliDefault && input.ruoliDefault.length > 0 ? input.ruoliDefault : RUOLI_DEFAULT;
// Step 1: organizzazione Keycloak.
let orgId: string;
try {
({ orgId } = await createOrganization(input.nome));
} catch (err) {
if (isKeycloakConflict(err)) {
throw new HttpError(409, `Esiste già un gruppo scout con nome "${input.nome}"`);
}
console.error(
`[gruppi] STEP 1 (createOrganization) fallito per nome="${input.nome}". Nessuna risorsa creata: ` +
'il retry può ripartire dall\'inizio.',
err,
);
throw new HttpError(502, "Impossibile creare l'organizzazione su Keycloak");
}
// Step 2: un gruppo Keycloak per ciascun ruolo di default.
const gruppiCreati: string[] = [];
for (const ruolo of ruoliDefault) {
try {
await createOrganizationGroup(orgId, ruolo);
gruppiCreati.push(ruolo);
} catch (err) {
console.error(
`[gruppi] STEP 2 (createOrganizationGroup) fallito per orgId="${orgId}", ruolo="${ruolo}". ` +
`Gruppi già creati con successo su Keycloak: [${gruppiCreati.join(', ')}]. ` +
`L'organizzazione orgId="${orgId}" esiste già su Keycloak: per il retry manuale non richiamare ` +
'createOrganization, ma solo i gruppi ruolo mancanti seguiti dal salvataggio locale.',
err,
);
throw new HttpError(502, 'Impossibile creare uno dei gruppi ruolo su Keycloak');
}
}
// Step 3: il creatore diventa membro dell'organizzazione appena creata.
// Senza questo passaggio il claim "organization" non comparirebbe mai nel
// suo token, e la auth guard del FE lo rimanderebbe sempre su "crea gruppo"
// anche a creazione riuscita.
try {
await addMemberToOrganization(orgId, input.userId);
} catch (err) {
console.error(
`[gruppi] STEP 3 (addMemberToOrganization) fallito per orgId="${orgId}", userId="${input.userId}". ` +
`Organizzazione e gruppi [${gruppiCreati.join(', ')}] già creati su Keycloak: per il retry manuale ` +
`non richiamare le API di creazione, ma solo l'aggiunta del membro con orgId="${orgId}".`,
err,
);
throw new HttpError(502, "Impossibile aggiungere l'utente all'organizzazione su Keycloak");
}
// Step 4: riga locale in gruppo_scout.
try {
await prisma.gruppoScout.create({
data: { orgId, nome: input.nome, regione: input.regione },
});
} catch (err) {
console.error(
`[gruppi] STEP 4 (salvataggio locale gruppo_scout) fallito per orgId="${orgId}", nome="${input.nome}". ` +
`Organizzazione, gruppi [${gruppiCreati.join(', ')}] e membership già creati su Keycloak: per il ` +
`retry manuale non richiamare le API Keycloak, ma solo il salvataggio locale con orgId="${orgId}".`,
err,
);
throw new HttpError(500, 'Impossibile salvare il gruppo scout localmente');
}
return { orgId, gruppiCreati };
}
@@ -0,0 +1,10 @@
import { prisma } from '../db/prisma';
export class HealthService {
async checkDatabase(): Promise<boolean> {
await prisma.$queryRaw`SELECT 1`;
return true;
}
}
export const healthService = new HealthService();
@@ -0,0 +1,123 @@
import crypto from 'crypto';
import { prisma } from '../db/prisma';
import { addMemberToOrganization, assignUserToGroup, assignRealmRoleToUser } from '../keycloak-admin';
import { HttpError } from '../errors';
import { env } from '../config/env';
export const STATO_INVITO = {
PENDING: 'pending',
ACCETTATO: 'accettato',
} as const;
const DURATA_INVITO_MS = 7 * 24 * 60 * 60 * 1000;
export interface CreaInvitoInput {
orgId: string;
email: string;
ruolo: string;
}
export interface CreaInvitoResult {
invitoId: string;
scadenza: Date;
}
export async function creaInvito(input: CreaInvitoInput): Promise<CreaInvitoResult> {
const token = crypto.randomBytes(32).toString('hex');
const scadenza = new Date(Date.now() + DURATA_INVITO_MS);
const invito = await prisma.invito.create({
data: {
token,
email: input.email,
orgId: input.orgId,
ruolo: input.ruolo,
scadenza,
stato: STATO_INVITO.PENDING,
},
});
// TODO: sostituire con un vero invio email quando sarà disponibile un servizio dedicato.
console.log(`[inviti] invito per ${input.email} (ruolo "${input.ruolo}"): ${env.frontendBaseUrl}/inviti/${token}`);
return { invitoId: invito.id, scadenza };
}
export interface InvitoPubblico {
email: string;
nomeGruppo: string;
ruolo: string;
valido: boolean;
}
export async function getInvitoPubblico(token: string): Promise<InvitoPubblico | null> {
const invito = await prisma.invito.findUnique({
where: { token },
include: { gruppoScout: true },
});
if (!invito) {
return null;
}
const valido = invito.stato === STATO_INVITO.PENDING && invito.scadenza.getTime() > Date.now();
return {
email: invito.email,
nomeGruppo: invito.gruppoScout.nome,
ruolo: invito.ruolo,
valido,
};
}
export interface AccettaInvitoResult {
organizationId: string;
ruolo: string;
}
export async function accettaInvito(
token: string,
utente: { userId: string; email: string | null },
): Promise<AccettaInvitoResult> {
const invito = await prisma.invito.findUnique({ where: { token } });
if (!invito) {
throw new HttpError(404, 'Invito non trovato');
}
if (invito.stato !== STATO_INVITO.PENDING) {
throw new HttpError(409, "L'invito è già stato accettato");
}
if (invito.scadenza.getTime() <= Date.now()) {
throw new HttpError(410, 'Invito scaduto');
}
if (!utente.email || utente.email.toLowerCase() !== invito.email.toLowerCase()) {
throw new HttpError(403, "L'utente autenticato non corrisponde al destinatario dell'invito");
}
try {
await addMemberToOrganization(invito.orgId, utente.userId);
// TODO: "ruolo" è usato qui anche come identificativo del gruppo Keycloak,
// in attesa che createOrganizationGroup persista una mappa ruolo -> groupId
// reale da risolvere in questo punto.
await assignUserToGroup(utente.userId, invito.ruolo);
await assignRealmRoleToUser(utente.userId, invito.ruolo);
} catch (err) {
console.error(
`[inviti] fallita l'assegnazione Keycloak per l'accettazione dell'invito token="${token}", ` +
`userId="${utente.userId}", orgId="${invito.orgId}". Nessun cambio di stato locale: ` +
"l'invito resta 'pending' e l'utente può ritentare l'accettazione.",
err,
);
throw new HttpError(502, "Impossibile completare l'assegnazione su Keycloak");
}
await prisma.invito.update({
where: { token },
data: { stato: STATO_INVITO.ACCETTATO },
});
return { organizationId: invito.orgId, ruolo: invito.ruolo };
}
@@ -0,0 +1,74 @@
import {
listOrganizationMembers,
removeMemberFromOrganization,
assignUserToGroup,
removeUserFromGroup,
getUserGroupsInOrganization,
assignRealmRoleToUser,
removeRealmRoleFromUser,
getUserRealmRoles,
} from '../keycloak-admin';
import { HttpError } from '../errors';
export interface MembroOrganization {
userId: string;
email: string;
ruolo: string | null;
gruppoInterno: string | null;
}
export async function listaMembri(orgId: string): Promise<MembroOrganization[]> {
const membri = await listOrganizationMembers(orgId);
return Promise.all(
membri.map(async (membro) => {
const [gruppi, ruoli] = await Promise.all([
getUserGroupsInOrganization(orgId, membro.userId),
getUserRealmRoles(membro.userId),
]);
return {
userId: membro.userId,
email: membro.email,
gruppoInterno: gruppi[0]?.nome ?? null,
ruolo: ruoli[0] ?? null,
};
}),
);
}
export async function cambiaRuoloMembro(orgId: string, userId: string, nuovoRuolo: string): Promise<void> {
try {
const gruppiAttuali = await getUserGroupsInOrganization(orgId, userId);
for (const gruppo of gruppiAttuali) {
await removeUserFromGroup(userId, gruppo.groupId);
}
// TODO: come nel flusso di invito, "ruolo" è usato anche come
// identificativo del gruppo Keycloak, in attesa di una mappa
// ruolo -> groupId reale (vedi src/services/inviti.service.ts).
await assignUserToGroup(userId, nuovoRuolo);
const ruoliAttuali = await getUserRealmRoles(userId);
for (const ruolo of ruoliAttuali) {
await removeRealmRoleFromUser(userId, ruolo);
}
await assignRealmRoleToUser(userId, nuovoRuolo);
} catch (err) {
console.error(
`[membri] cambio ruolo fallito per orgId="${orgId}", userId="${userId}", nuovoRuolo="${nuovoRuolo}". ` +
"Lo stato su Keycloak potrebbe essere stato aggiornato solo parzialmente: verificare manualmente " +
'gruppo/ruoli correnti dell\'utente prima di ritentare.',
err,
);
throw new HttpError(502, "Impossibile completare l'aggiornamento del ruolo su Keycloak");
}
}
export async function rimuoviMembro(orgId: string, userId: string): Promise<void> {
try {
await removeMemberFromOrganization(orgId, userId);
} catch (err) {
console.error(`[membri] rimozione membro fallita per orgId="${orgId}", userId="${userId}".`, err);
throw new HttpError(502, "Impossibile rimuovere il membro dall'organizzazione su Keycloak");
}
}
+11
View File
@@ -0,0 +1,11 @@
import { AuthContext } from '../middleware/auth.types';
declare global {
namespace Express {
interface Request {
auth?: AuthContext;
}
}
}
export {};