From 1b65321ffbc083f67f500485b6cb49a5b6686ec0 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Fri, 24 Jul 2026 15:13:13 +0200 Subject: [PATCH] [FEAT] Documenti istituzionali dashboard: backend + mig 065 (ticket #499) Card 'Documenti istituzionali' resa funzionale (era stato vuoto onesto). - mig 065: tabella institutional_documents (per-org, descrizione obbligatoria) - InstitutionalDocsController: list/save/delete (org-scoped, org_admin+compliance_manager) - 5 voci standard sempre presenti: Codice Etico, Mission, Vision, Statuto, Piano Sanzionatorio - registrato in index.php controllerMap/actionMap Co-Authored-By: Claude Opus 4.8 --- .../migrate_065_institutional_documents.php | 27 +++++ .../InstitutionalDocsController.php | 112 ++++++++++++++++++ docs/sql/065_institutional_documents.sql | 19 +++ public/index.php | 8 ++ 4 files changed, 166 insertions(+) create mode 100644 application/cli/migrate_065_institutional_documents.php create mode 100644 application/controllers/InstitutionalDocsController.php create mode 100644 docs/sql/065_institutional_documents.sql diff --git a/application/cli/migrate_065_institutional_documents.php b/application/cli/migrate_065_institutional_documents.php new file mode 100644 index 0000000..4b08c67 --- /dev/null +++ b/application/cli/migrate_065_institutional_documents.php @@ -0,0 +1,27 @@ +exec("CREATE TABLE IF NOT EXISTS institutional_documents ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + doc_key VARCHAR(60) NULL, + title VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + file_url VARCHAR(500) NULL, + is_standard TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + created_by INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_instdoc_org_key (organization_id, doc_key), + INDEX idx_instdoc_org (organization_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); +echo " + institutional_documents OK\n"; +echo "Migrazione 065 — documenti istituzionali OK. Prossima mig=066.\n"; diff --git a/application/controllers/InstitutionalDocsController.php b/application/controllers/InstitutionalDocsController.php new file mode 100644 index 0000000..ffbbd5d --- /dev/null +++ b/application/controllers/InstitutionalDocsController.php @@ -0,0 +1,112 @@ + 'codice_etico', 'label' => 'Codice Etico'], + ['key' => 'mission', 'label' => 'Mission'], + ['key' => 'vision', 'label' => 'Vision'], + ['key' => 'statuto', 'label' => 'Statuto'], + ['key' => 'piano_sanzionatorio', 'label' => 'Piano Sanzionatorio'], + ]; + + public function list(): void + { + $this->requireAuth(); + $orgId = $this->getCurrentOrgId(); + if (!$orgId) { $this->jsonError('Nessuna organizzazione selezionata', 400, 'NO_ORG'); } + + $rows = Database::fetchAll( + "SELECT id, doc_key, title, description, file_url, is_standard, sort_order, updated_at + FROM institutional_documents + WHERE organization_id = ? + ORDER BY is_standard DESC, sort_order ASC, id ASC", + [$orgId] + ); + $this->jsonSuccess(['docs' => $rows, 'standards' => self::STANDARDS]); + } + + public function save(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['title', 'description']); + $orgId = $this->getCurrentOrgId(); + $userId = $this->getCurrentUserId(); + + $title = trim((string) $this->getParam('title')); + $desc = trim((string) $this->getParam('description')); + if ($title === '' || $desc === '') { + $this->jsonError('Titolo e descrizione sono obbligatori', 422, 'MISSING_FIELDS'); + } + $fileUrl = $this->getParam('file_url'); + $fileUrl = ($fileUrl !== null && trim((string) $fileUrl) !== '') ? trim((string) $fileUrl) : null; + + $id = (int) ($this->getParam('id') ?? 0); + $docKey = $this->getParam('doc_key'); + $stdKeys = array_column(self::STANDARDS, 'key'); + $isStandard = ($docKey !== null && in_array($docKey, $stdKeys, true)) ? 1 : 0; + if (!$isStandard) { $docKey = null; } // le voci custom non hanno doc_key + + // Trova riga esistente: per id, oppure per (org, doc_key) se voce standard. + $existing = null; + if ($id > 0) { + $existing = Database::fetchOne( + 'SELECT id FROM institutional_documents WHERE id=? AND organization_id=?', [$id, $orgId]); + if (!$existing) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); } + } elseif ($isStandard) { + $existing = Database::fetchOne( + 'SELECT id FROM institutional_documents WHERE organization_id=? AND doc_key=?', [$orgId, $docKey]); + } + + if ($existing) { + Database::update('institutional_documents', [ + 'title' => $title, + 'description' => $desc, + 'file_url' => $fileUrl, + ], 'id=? AND organization_id=?', [(int) $existing['id'], $orgId]); + $savedId = (int) $existing['id']; + } else { + $savedId = Database::insert('institutional_documents', [ + 'organization_id' => $orgId, + 'doc_key' => $docKey, + 'title' => $title, + 'description' => $desc, + 'file_url' => $fileUrl, + 'is_standard' => $isStandard, + 'created_by' => $userId, + ]); + } + $this->jsonSuccess(['id' => $savedId], 'Documento salvato'); + } + + public function delete(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $orgId = $this->getCurrentOrgId(); + $row = Database::fetchOne( + 'SELECT id FROM institutional_documents WHERE id=? AND organization_id=?', [$id, $orgId]); + if (!$row) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); } + Database::delete('institutional_documents', 'id=? AND organization_id=?', [$id, $orgId]); + $this->jsonSuccess(null, 'Documento eliminato'); + } +} diff --git a/docs/sql/065_institutional_documents.sql b/docs/sql/065_institutional_documents.sql new file mode 100644 index 0000000..01c1276 --- /dev/null +++ b/docs/sql/065_institutional_documents.sql @@ -0,0 +1,19 @@ +-- 065_institutional_documents.sql — Documenti istituzionali per-org (dashboard). +-- Ticket #499 (Simon Fattori, super_admin): ad ogni documento una DESCRIZIONE obbligatoria +-- inserita dall'utente + 5 voci standard sempre presenti (Codice Etico, Mission, Vision, +-- Statuto, Piano Sanzionatorio). Additiva e idempotente. +CREATE TABLE IF NOT EXISTS institutional_documents ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + doc_key VARCHAR(60) NULL, -- codice_etico|mission|vision|statuto|piano_sanzionatorio; NULL = voce custom + title VARCHAR(200) NOT NULL, + description TEXT NOT NULL, -- obbligatoria (richiesta punto 1 del ticket) + file_url VARCHAR(500) NULL, + is_standard TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + created_by INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_instdoc_org_key (organization_id, doc_key), + INDEX idx_instdoc_org (organization_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/index.php b/public/index.php index 14f8237..5d3b503 100644 --- a/public/index.php +++ b/public/index.php @@ -115,6 +115,7 @@ $controllerMap = [ 'invites' => 'InviteController', 'webhooks' => 'WebhookController', 'discovery-connectors' => 'DiscoveryConnectorController', // Connettori discovery rete/cloud → auto-popola Inventario + 'institutional-docs' => 'InstitutionalDocsController', // Documenti istituzionali per-org (dashboard) — ticket #499 'whistleblowing'=> 'WhistleblowingController', 'normative' => 'NormativeController', 'cross-analysis' => 'CrossAnalysisController', @@ -712,6 +713,13 @@ $actionMap = [ 'POST:{id}/rotateKey' => 'rotateKey', ], + // ── InstitutionalDocsController — Documenti istituzionali dashboard (ticket #499) ── + 'institutional-docs' => [ + 'GET:list' => 'list', + 'POST:save' => 'save', + 'DELETE:{id}' => 'delete', + ], + // ── BrandingController — White-label firm (Fase 5 / G16) ── 'branding' => [ 'GET:current' => 'getCurrent',