diff --git a/application/controllers/OrgRoleController.php b/application/controllers/OrgRoleController.php
new file mode 100644
index 0000000..dd68f06
--- /dev/null
+++ b/application/controllers/OrgRoleController.php
@@ -0,0 +1,326 @@
+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;
+ }
+ }
+ $attach = function (array $node) use (&$attach, $childrenByParent) {
+ $kids = $childrenByParent[(int) $node['id']] ?? [];
+ $node['children'] = array_map($attach, $kids);
+ return $node;
+ };
+ return array_map($attach, $roots);
+ }
+}
diff --git a/docs/sql/041_org_roles.sql b/docs/sql/041_org_roles.sql
new file mode 100644
index 0000000..8676fbd
--- /dev/null
+++ b/docs/sql/041_org_roles.sql
@@ -0,0 +1,39 @@
+-- ============================================================================
+-- Migration 041 — A4 Fase 4.1: Organigramma (org_roles)
+-- ----------------------------------------------------------------------------
+-- Introduce l'organigramma org-wide: ruoli/responsabilita con gerarchia
+-- (parent_role_id self-FK), titolare (holder_user_id) e il nodo distinto degli
+-- organi di amministrazione/direttivi (is_governance_body) richiesto dall'Art.23
+-- D.Lgs. 138/2024 (responsabilita degli organi di gestione) e da GV.RR-02
+-- (ruoli, responsabilita e poteri stabiliti e comunicati).
+--
+-- ADDITIVA: nessuna tabella esistente modificata. isms_roles (SoA SGSI) resta
+-- invariato e potra in futuro linkare org_roles via una colonna opzionale.
+--
+-- IDEMPOTENTE: CREATE TABLE IF NOT EXISTS. Applicare con la STESSA connessione
+-- PDO dell'app (host MySQL servito da php-fpm), NON sul DB secondario:
+-- docker exec nis2-app php /var/www/nis2-agile/scripts/migrate-a4.php
+--
+-- Design: docs/DESIGN_A4_RELATIONAL.md (sez. 2 entita, sez. 4 ancoraggio, sez. 7 fasi).
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS org_roles (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ organization_id INT NOT NULL,
+ role_name VARCHAR(150) NOT NULL,
+ parent_role_id INT NULL, -- gerarchia organigramma (self-FK)
+ holder_user_id INT NULL, -- titolare del ruolo (utente dell org)
+ is_governance_body TINYINT(1) NOT NULL DEFAULT 0, -- nodo organi amministrazione/direttivi (Art.23)
+ description TEXT NULL, -- responsabilita e poteri del ruolo (GV.RR-02)
+ sort_order INT NOT NULL DEFAULT 0, -- ordinamento tra fratelli
+ created_by INT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
+ FOREIGN KEY (parent_role_id) REFERENCES org_roles(id) ON DELETE SET NULL,
+ FOREIGN KEY (holder_user_id) REFERENCES users(id) ON DELETE SET NULL,
+ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
+ INDEX idx_org_roles_org (organization_id),
+ INDEX idx_org_roles_parent (parent_role_id),
+ INDEX idx_org_roles_holder (holder_user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html
index cbc14bb..3ff476b 100644
--- a/public/_app-bi-demo.html
+++ b/public/_app-bi-demo.html
@@ -70,9 +70,9 @@
-
-
-
+
+
+
-
+
+
@@ -163,9 +163,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
+
@@ -328,8 +328,8 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
+
+
-
+
+
@@ -158,8 +158,8 @@
bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts');
}
-
-
+
+
-
+
+
@@ -180,8 +180,8 @@
bootstrap.loadFonts('../vendor/bootstrap-italia/dist/fonts');
}
-
-
+
+
-
-
-
+
+
+
+
-
+
+
@@ -165,9 +165,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
+
@@ -377,9 +377,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
+
-
-
-
+
+
+
-
+
+
@@ -393,8 +393,8 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
+
+
-
+
+
@@ -154,9 +154,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
-
+
+
@@ -1152,9 +1152,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
+
@@ -362,9 +362,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
+
-
+
+
@@ -195,9 +195,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+