[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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ecb17e59f0
commit
1b65321ffb
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* migrate_065_institutional_documents.php — Documenti istituzionali per-org (dashboard). IDEMPOTENTE.
|
||||||
|
* Ticket #499. Esegui: docker exec nis2-app php /var/www/nis2-agile/application/cli/migrate_065_institutional_documents.php
|
||||||
|
*/
|
||||||
|
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
|
||||||
|
require_once __DIR__ . '/../config/env.php';
|
||||||
|
require_once __DIR__ . '/../config/database.php';
|
||||||
|
$pdo = Database::getInstance();
|
||||||
|
|
||||||
|
$pdo->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";
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - InstitutionalDocsController
|
||||||
|
*
|
||||||
|
* Documenti istituzionali per-organizzazione (card in dashboard.html).
|
||||||
|
* Ticket #499 (Simon Fattori, super_admin):
|
||||||
|
* 1. Ad ogni documento va inserita una DESCRIZIONE obbligatoria a opera dell'utente.
|
||||||
|
* 2. Oltre al tasto "aggiungi voce" ci sono 5 voci standard sempre presenti:
|
||||||
|
* Codice Etico, Mission, Vision, Statuto, Piano Sanzionatorio.
|
||||||
|
*
|
||||||
|
* Le 5 voci standard sono renderizzate sempre lato UI (STANDARDS); il backend salva
|
||||||
|
* una riga per (organization_id, doc_key) quando l'utente le compila.
|
||||||
|
*
|
||||||
|
* Endpoint:
|
||||||
|
* GET /api/institutional-docs/list lista (voci compilate + elenco standard)
|
||||||
|
* POST /api/institutional-docs/save upsert (title+description obbligatori)
|
||||||
|
* DELETE /api/institutional-docs/{id} elimina voce (org-scoped)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class InstitutionalDocsController extends BaseController
|
||||||
|
{
|
||||||
|
/** Le 5 voci standard sempre presenti (richiesta punto 2). */
|
||||||
|
private const STANDARDS = [
|
||||||
|
['key' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -115,6 +115,7 @@ $controllerMap = [
|
|||||||
'invites' => 'InviteController',
|
'invites' => 'InviteController',
|
||||||
'webhooks' => 'WebhookController',
|
'webhooks' => 'WebhookController',
|
||||||
'discovery-connectors' => 'DiscoveryConnectorController', // Connettori discovery rete/cloud → auto-popola Inventario
|
'discovery-connectors' => 'DiscoveryConnectorController', // Connettori discovery rete/cloud → auto-popola Inventario
|
||||||
|
'institutional-docs' => 'InstitutionalDocsController', // Documenti istituzionali per-org (dashboard) — ticket #499
|
||||||
'whistleblowing'=> 'WhistleblowingController',
|
'whistleblowing'=> 'WhistleblowingController',
|
||||||
'normative' => 'NormativeController',
|
'normative' => 'NormativeController',
|
||||||
'cross-analysis' => 'CrossAnalysisController',
|
'cross-analysis' => 'CrossAnalysisController',
|
||||||
@@ -712,6 +713,13 @@ $actionMap = [
|
|||||||
'POST:{id}/rotateKey' => 'rotateKey',
|
'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) ──
|
// ── BrandingController — White-label firm (Fase 5 / G16) ──
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'GET:current' => 'getCurrent',
|
'GET:current' => 'getCurrent',
|
||||||
|
|||||||
Reference in New Issue
Block a user