requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $rows = Database::fetchAll( 'SELECT r.id, r.organization_id, r.role_name, r.parent_role_id, r.holder_user_id, r.is_governance_body, r.description, r.sort_order, r.created_at, r.updated_at, u.full_name AS holder_name, u.email AS holder_email, p.role_name AS parent_role_name FROM org_roles r LEFT JOIN users u ON u.id = r.holder_user_id LEFT JOIN org_roles p ON p.id = r.parent_role_id WHERE r.organization_id = ? ORDER BY r.sort_order, r.role_name', [$orgId] ); $roles = array_map([$this, 'normalizeRow'], $rows); $this->jsonSuccess([ 'roles' => $roles, 'tree' => $this->buildTree($roles), 'governance_count' => count(array_filter($roles, fn($r) => $r['is_governance_body'])), 'total' => count($roles), ]); } /** * GET /api/org-roles/assignableUsers * Membri dell'org corrente (per la select del titolare). */ public function assignableUsers(): void { $this->requireOrgAccess(); $users = Database::fetchAll( 'SELECT u.id, u.full_name, u.email, uo.role AS org_role FROM user_organizations uo JOIN users u ON u.id = uo.user_id WHERE uo.organization_id = ? AND u.is_active = 1 ORDER BY u.full_name', [$this->getCurrentOrgId()] ); $this->jsonSuccess($users); } /** * GET /api/org-roles/{id} */ public function get(int $id): void { $this->requireOrgAccess(); $role = $this->fetchRoleOrFail($id); $this->jsonSuccess($this->normalizeRow($role)); } /** * POST /api/org-roles/create * body: {role_name, parent_role_id?, holder_user_id?, is_governance_body?, description?, sort_order?} */ public function create(): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $name = trim((string) $this->getParam('role_name', '')); if ($name === '') { $this->jsonError('Il nome del ruolo e obbligatorio', 422, 'ROLE_NAME_REQUIRED'); } if (mb_strlen($name) > 150) { $this->jsonError('Il nome del ruolo supera 150 caratteri', 422, 'ROLE_NAME_TOO_LONG'); } $parentId = $this->validateParent($this->getParam('parent_role_id'), null); $holderId = $this->validateHolder($this->getParam('holder_user_id')); $id = Database::insert('org_roles', [ 'organization_id' => $orgId, 'role_name' => $name, 'parent_role_id' => $parentId, 'holder_user_id' => $holderId, 'is_governance_body' => $this->getParam('is_governance_body') ? 1 : 0, 'description' => $this->nullableText($this->getParam('description')), 'sort_order' => (int) $this->getParam('sort_order', 0), 'created_by' => $this->getCurrentUserId(), ]); $this->logAudit('org_role_created', 'org_role', $id, [ 'role_name' => $name, 'is_governance_body' => $this->getParam('is_governance_body') ? 1 : 0, ]); $role = $this->fetchRoleOrFail($id); $this->jsonSuccess($this->normalizeRow($role), 'Ruolo creato', 201); } /** * PUT /api/org-roles/{id} */ public function update(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $this->fetchRoleOrFail($id); // anti-IDOR: deve appartenere all'org corrente $updates = []; if ($this->hasParam('role_name')) { $name = trim((string) $this->getParam('role_name', '')); if ($name === '') { $this->jsonError('Il nome del ruolo e obbligatorio', 422, 'ROLE_NAME_REQUIRED'); } if (mb_strlen($name) > 150) { $this->jsonError('Il nome del ruolo supera 150 caratteri', 422, 'ROLE_NAME_TOO_LONG'); } $updates['role_name'] = $name; } if ($this->hasParam('parent_role_id')) { $updates['parent_role_id'] = $this->validateParent($this->getParam('parent_role_id'), $id); } if ($this->hasParam('holder_user_id')) { $updates['holder_user_id'] = $this->validateHolder($this->getParam('holder_user_id')); } if ($this->hasParam('is_governance_body')) { $updates['is_governance_body'] = $this->getParam('is_governance_body') ? 1 : 0; } if ($this->hasParam('description')) { $updates['description'] = $this->nullableText($this->getParam('description')); } if ($this->hasParam('sort_order')) { $updates['sort_order'] = (int) $this->getParam('sort_order', 0); } if (empty($updates)) { $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); } Database::update('org_roles', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('org_role_updated', 'org_role', $id, $updates); $role = $this->fetchRoleOrFail($id); $this->jsonSuccess($this->normalizeRow($role), 'Ruolo aggiornato'); } /** * DELETE /api/org-roles/{id} * Bloccato se il ruolo ha figli (vanno prima riassegnati/spostati). */ public function delete(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $this->fetchRoleOrFail($id); $childCount = Database::count( 'org_roles', 'parent_role_id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if ($childCount > 0) { $this->jsonError( 'Il ruolo ha ' . $childCount . ' ruoli subordinati: riassegnali o spostali prima di eliminarlo', 409, 'ROLE_HAS_CHILDREN' ); } Database::delete('org_roles', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('org_role_deleted', 'org_role', $id, null); $this->jsonSuccess(null, 'Ruolo eliminato'); } // ═══════════════════════════════════════════════════════════════════════ // PRIVATI // ═══════════════════════════════════════════════════════════════════════ /** Recupera il ruolo solo se appartiene all'org corrente, altrimenti 404. */ private function fetchRoleOrFail(int $id): array { $role = Database::fetchOne( 'SELECT * FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$role) { $this->jsonError('Ruolo non trovato', 404, 'ROLE_NOT_FOUND'); } return $role; } /** * Valida il parent: deve appartenere all'org corrente e non creare cicli. * $selfId = id del ruolo in modifica (null in create). Ritorna int|null. */ private function validateParent($parentRaw, ?int $selfId): ?int { if ($parentRaw === null || $parentRaw === '' || (int) $parentRaw === 0) { return null; // ruolo radice } $parentId = (int) $parentRaw; if ($selfId !== null && $parentId === $selfId) { $this->jsonError('Un ruolo non puo essere padre di se stesso', 422, 'ROLE_SELF_PARENT'); } $parent = Database::fetchOne( 'SELECT id, parent_role_id FROM org_roles WHERE id = ? AND organization_id = ?', [$parentId, $this->getCurrentOrgId()] ); if (!$parent) { $this->jsonError('Ruolo padre non valido per questa organizzazione', 422, 'INVALID_PARENT'); } // Anti-ciclo: risali la catena del padre proposto; se incontri $selfId, e un ciclo. if ($selfId !== null) { $cursor = $parent['parent_role_id'] !== null ? (int) $parent['parent_role_id'] : null; $guard = 0; while ($cursor !== null && $guard++ < 1000) { if ($cursor === $selfId) { $this->jsonError('Gerarchia non valida: si creerebbe un ciclo', 422, 'ROLE_CYCLE'); } $row = Database::fetchOne( 'SELECT parent_role_id FROM org_roles WHERE id = ? AND organization_id = ?', [$cursor, $this->getCurrentOrgId()] ); $cursor = ($row && $row['parent_role_id'] !== null) ? (int) $row['parent_role_id'] : null; } } return $parentId; } /** Valida il titolare: deve essere membro dell'org corrente. Ritorna int|null. */ private function validateHolder($holderRaw): ?int { if ($holderRaw === null || $holderRaw === '' || (int) $holderRaw === 0) { return null; // ruolo vacante } $holderId = (int) $holderRaw; $member = Database::fetchOne( 'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ?', [$holderId, $this->getCurrentOrgId()] ); if (!$member) { $this->jsonError('Il titolare deve essere un membro dell organizzazione', 422, 'HOLDER_NOT_MEMBER'); } return $holderId; } private function nullableText($v): ?string { if ($v === null) { return null; } $v = trim((string) $v); return $v === '' ? null : $v; } /** Normalizza tipi per l'output JSON. */ private function normalizeRow(array $r): array { $r['id'] = (int) $r['id']; $r['organization_id'] = (int) $r['organization_id']; $r['parent_role_id'] = $r['parent_role_id'] !== null ? (int) $r['parent_role_id'] : null; $r['holder_user_id'] = $r['holder_user_id'] !== null ? (int) $r['holder_user_id'] : null; $r['is_governance_body'] = (bool) $r['is_governance_body']; $r['sort_order'] = (int) ($r['sort_order'] ?? 0); return $r; } /** * Costruisce l'albero gerarchico da una lista flat. I ruoli con parent NULL * o con parent assente dall'insieme sono radici (robusto a ON DELETE SET NULL). */ private function buildTree(array $rows): array { $ids = []; foreach ($rows as $r) { $ids[(int) $r['id']] = true; } $childrenByParent = []; $roots = []; foreach ($rows as $r) { $pid = $r['parent_role_id']; if ($pid !== null && isset($ids[(int) $pid])) { $childrenByParent[(int) $pid][] = $r; } else { $roots[] = $r; } } // Guardia anti-ciclo: se parent_role_id formasse un ciclo (dato legacy/corrotto), // la ricorsione andrebbe in loop infinito → hang del backend e "blocco" della pagina. // Portiamo la catena di antenati e non ridiscendiamo su un id già visto. $attach = function (array $node, array $ancestry) use (&$attach, $childrenByParent) { $id = (int) $node['id']; $ancestry[$id] = true; $children = []; foreach ($childrenByParent[$id] ?? [] as $kid) { if (isset($ancestry[(int) $kid['id']])) { continue; // ciclo: interrompi questo ramo invece di ricorrere all'infinito } $children[] = $attach($kid, $ancestry); } $node['children'] = $children; return $node; }; return array_map(static fn(array $r) => $attach($r, []), $roots); } }