[FEAT] Gestione documentale ISMS — ciclo di vita ISO 27001 cl.7.5 + export Word (v1.24.0)

Ogni documento del Modello SGSI ha un ciclo di vita completo Bozza->In revisione->Approvato->Pubblicato->Archiviato con tracciamento (revisore/approvatore/pubblicatore, date, entrata in vigore, prossimo riesame) e storico versioni (snapshot). Download Word .doc modificabile per documento.
- mig.060: stati published/archived + 10 colonne tracciamento + tabella isms_document_versions
- IsmsModelController: submit/approve/publish/reject/archive/newVersion/versions/exportWord + listDocuments arricchito
- index.php: 8 route documents/{id}/(submit|approve|publish|reject|archive|newVersion|versions|word)
- isms.js: UI badge stato + pulsanti transizione + download Word + storico (IT/EN inline); cache-buster
- help.js: sezione 'Ciclo di vita dei documenti (cl.7.5)'
- 31 documenti di Nuova Agile pubblicati v1.0 (build_nuova_agile_p6_publish)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-20 13:40:21 +02:00
co-authored by Claude Opus 4.8
parent 94e3a4293e
commit 68694a0f38
9 changed files with 416 additions and 11 deletions
@@ -0,0 +1,36 @@
<?php
/**
* build_nuova_agile_p6_publish.php — Pubblica i documenti SGSI di Nuova Agile (ciclo di vita).
* approvato -> pubblicato (in vigore) con traccia approvazione/pubblicazione + snapshot versione. IDEMPOTENTE.
*/
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();
function ex(PDO $p,string $s,array $a=[]):void{$st=$p->prepare($s);$st->execute($a);}
function one(PDO $p,string $s,array $a=[]){$st=$p->prepare($s);$st->execute($a);return $st->fetch(PDO::FETCH_ASSOC);}
function all(PDO $p,string $s,array $a=[]){$st=$p->prepare($s);$st->execute($a);return $st->fetchAll(PDO::FETCH_ASSOC);}
$NOW='2026-06-20 13:30:00'; $TODAY='2026-06-20'; $NEXT='2027-06-20'; $ORGID=996003; $RSGSI=326; $PRES=103;
$m=one($pdo,"SELECT id FROM isms_models WHERE organization_id=?",[$ORGID]); $modelId=(int)$m['id'];
$docs=all($pdo,"SELECT id,version,status,body_html FROM isms_documents WHERE isms_model_id=? AND status='approved'",[$modelId]);
$pub=0;
foreach($docs as $d){
$v=$d['version']?:'1.0';
// snapshot pubblicazione (evita doppioni)
$exsnap=one($pdo,"SELECT id FROM isms_document_versions WHERE document_id=? AND version=? AND status='published'",[$d['id'],$v]);
if(!$exsnap){
ex($pdo,"INSERT INTO isms_document_versions (document_id,isms_model_id,organization_id,version,status,body_html,change_note,created_by,created_at)
VALUES (?,?,?,?, 'published', ?, ?, ?, ?)",
[$d['id'],$modelId,$ORGID,$v,$d['body_html'],'Pubblicazione versione '.$v.' (set documentale iniziale)',$PRES,$NOW]);
}
ex($pdo,"UPDATE isms_documents SET status='published',
approved_by=?, approved_at=?, reviewed_by=?, reviewed_at=?,
published_by=?, published_at=?, effective_date=?, next_review_date=?, updated_at=?
WHERE id=?",
[$RSGSI,$NOW,$RSGSI,$NOW,$PRES,$NOW,$TODAY,$NEXT,$NOW,$d['id']]);
$pub++;
}
$tot=one($pdo,"SELECT COUNT(*) c FROM isms_documents WHERE isms_model_id=? AND status='published'",[$modelId])['c'];
echo "Pubblicati ora: $pub. Totale documenti PUBBLICATI: $tot / ".one($pdo,"SELECT COUNT(*) c FROM isms_documents WHERE isms_model_id=?",[$modelId])['c']."\n";
echo "FASE 6 OK\n";
@@ -0,0 +1,56 @@
<?php
/**
* migrate_060_isms_doc_lifecycle.php — Ciclo di vita documentale ISMS (ISO 27001 cl.7.5).
* Estende isms_documents (stati pubblicato/archiviato + tracciamento + riesame) + tabella versioni.
* IDEMPOTENTE: ALTER guardati da information_schema, CREATE TABLE IF NOT EXISTS.
*/
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();
$db = $pdo->query('SELECT DATABASE()')->fetchColumn();
function col_exists(PDO $p,string $db,string $t,string $c):bool{
$s=$p->prepare("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND COLUMN_NAME=?");
$s->execute([$db,$t,$c]); return (bool)$s->fetchColumn();
}
function add_col(PDO $p,string $db,string $t,string $c,string $ddl):void{
if(col_exists($p,$db,$t,$c)){ echo " = $t.$c\n"; return; }
$p->exec("ALTER TABLE `$t` ADD COLUMN $ddl"); echo " + $t.$c\n";
}
echo "Migrazione 060 — ciclo di vita documenti ISMS (db=$db)\n";
/* 1) Estensione enum stato (idempotente: MODIFY) */
$pdo->exec("ALTER TABLE isms_documents MODIFY COLUMN status ENUM('draft','review','approved','published','archived') NOT NULL DEFAULT 'draft'");
echo " enum status -> draft/review/approved/published/archived\n";
/* 2) Colonne di tracciamento del ciclo di vita */
add_col($pdo,$db,'isms_documents','reviewed_by', "reviewed_by INT NULL");
add_col($pdo,$db,'isms_documents','reviewed_at', "reviewed_at DATETIME NULL");
add_col($pdo,$db,'isms_documents','approved_by', "approved_by INT NULL");
add_col($pdo,$db,'isms_documents','approved_at', "approved_at DATETIME NULL");
add_col($pdo,$db,'isms_documents','published_by', "published_by INT NULL");
add_col($pdo,$db,'isms_documents','published_at', "published_at DATETIME NULL");
add_col($pdo,$db,'isms_documents','archived_at', "archived_at DATETIME NULL");
add_col($pdo,$db,'isms_documents','review_note', "review_note VARCHAR(500) NULL");
add_col($pdo,$db,'isms_documents','effective_date',"effective_date DATE NULL");
add_col($pdo,$db,'isms_documents','next_review_date',"next_review_date DATE NULL");
/* 3) Tabella storico versioni (snapshot ad ogni approvazione/pubblicazione) */
$pdo->exec("CREATE TABLE IF NOT EXISTS isms_document_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
document_id INT NOT NULL,
isms_model_id INT NOT NULL,
organization_id INT NOT NULL,
version VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
body_html LONGTEXT NULL,
change_note VARCHAR(255) NULL,
created_by INT NULL,
created_at DATETIME NULL,
INDEX idx_docver_doc (document_id),
INDEX idx_docver_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
echo " tabella isms_document_versions OK\n";
echo "FATTO. Prossima mig=061.\n";
+170 -2
View File
@@ -287,8 +287,13 @@ class IsmsModelController extends BaseController
$this->requireOrgAccess();
$model = $this->requireModel();
$rows = Database::fetchAll(
"SELECT id, doc_type, title, status, ai_generated, version, updated_at
FROM isms_documents WHERE isms_model_id = ? ORDER BY updated_at DESC",
"SELECT d.id, d.doc_type, d.title, d.status, d.ai_generated, d.version, d.updated_at,
d.approved_at, d.published_at, d.next_review_date, d.effective_date, d.review_note,
ua.full_name AS approved_by_name, up.full_name AS published_by_name
FROM isms_documents d
LEFT JOIN users ua ON ua.id = d.approved_by
LEFT JOIN users up ON up.id = d.published_by
WHERE d.isms_model_id = ? ORDER BY d.id",
[$model['id']]
);
$this->jsonSuccess(['documents' => $rows]);
@@ -338,6 +343,169 @@ class IsmsModelController extends BaseController
$this->jsonSuccess(['id' => $id], 'Documento aggiornato');
}
/* ───────── Ciclo di vita documentale (ISO 27001 cl.7.5) ───────── */
private function requireDocument(int $id): array
{
$model = $this->requireModel();
$doc = Database::fetchOne('SELECT * FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]);
if (!$doc) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); }
return $doc;
}
private function bumpVersion(string $v, bool $major = false): string
{
$parts = explode('.', preg_replace('/[^0-9.]/', '', $v ?: '1.0'));
$maj = (int) ($parts[0] ?? 1); $min = (int) ($parts[1] ?? 0);
if ($major) { $maj++; $min = 0; } else { $min++; }
return $maj . '.' . $min;
}
private function snapshotDocument(array $doc, string $changeNote): void
{
Database::insert('isms_document_versions', [
'document_id' => $doc['id'],
'isms_model_id' => $doc['isms_model_id'],
'organization_id' => $doc['organization_id'],
'version' => $doc['version'] ?? '1.0',
'status' => $doc['status'],
'body_html' => $doc['body_html'] ?? null,
'change_note' => $changeNote,
'created_by' => $this->getCurrentUserId(),
'created_at' => date('Y-m-d H:i:s'),
]);
}
/** POST /api/isms/documents/{id}/submit bozza -> in revisione */
public function submitDocument(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$doc = $this->requireDocument($id);
if ($doc['status'] !== 'draft') { $this->jsonError('Solo una bozza può essere inviata in revisione', 409, 'BAD_STATE'); }
Database::update('isms_documents', ['status' => 'review', 'review_note' => null, 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]);
$this->logAudit('isms_document_submitted', 'isms_document', $id, []);
$this->jsonSuccess(['id' => $id, 'status' => 'review'], 'Documento inviato in revisione');
}
/** POST /api/isms/documents/{id}/approve in revisione -> approvato */
public function approveDocument(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$doc = $this->requireDocument($id);
if (!in_array($doc['status'], ['review', 'draft'], true)) { $this->jsonError('Stato non valido per l\'approvazione', 409, 'BAD_STATE'); }
$now = date('Y-m-d H:i:s');
Database::update('isms_documents', [
'status' => 'approved', 'approved_by' => $this->getCurrentUserId(), 'approved_at' => $now,
'reviewed_by' => $this->getCurrentUserId(), 'reviewed_at' => $now, 'review_note' => null, 'updated_at' => $now,
], 'id = ?', [$id]);
$this->logAudit('isms_document_approved', 'isms_document', $id, ['version' => $doc['version']]);
$this->jsonSuccess(['id' => $id, 'status' => 'approved'], 'Documento approvato');
}
/** POST /api/isms/documents/{id}/publish approvato -> pubblicato (in vigore) */
public function publishDocument(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$doc = $this->requireDocument($id);
if ($doc['status'] !== 'approved') { $this->jsonError('Solo un documento approvato può essere pubblicato', 409, 'BAD_STATE'); }
$now = date('Y-m-d H:i:s'); $today = date('Y-m-d'); $nextReview = date('Y-m-d', strtotime('+1 year'));
$this->snapshotDocument(array_merge($doc, ['status' => 'published']), 'Pubblicazione versione ' . ($doc['version'] ?? '1.0'));
Database::update('isms_documents', [
'status' => 'published', 'published_by' => $this->getCurrentUserId(), 'published_at' => $now,
'effective_date' => $today, 'next_review_date' => $nextReview, 'updated_at' => $now,
], 'id = ?', [$id]);
$this->logAudit('isms_document_published', 'isms_document', $id, ['version' => $doc['version']]);
$this->jsonSuccess(['id' => $id, 'status' => 'published'], 'Documento pubblicato (in vigore)');
}
/** POST /api/isms/documents/{id}/reject in revisione -> bozza (con nota) */
public function rejectDocument(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$doc = $this->requireDocument($id);
if ($doc['status'] !== 'review') { $this->jsonError('Solo un documento in revisione può essere rimandato in bozza', 409, 'BAD_STATE'); }
$note = trim((string) ($this->getJsonBody()['note'] ?? ''));
Database::update('isms_documents', ['status' => 'draft', 'review_note' => ($note !== '' ? $note : 'Rimandato in bozza dal revisore'), 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]);
$this->logAudit('isms_document_rejected', 'isms_document', $id, ['note' => $note]);
$this->jsonSuccess(['id' => $id, 'status' => 'draft'], 'Documento rimandato in bozza');
}
/** POST /api/isms/documents/{id}/archive pubblicato -> archiviato */
public function archiveDocument(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$doc = $this->requireDocument($id);
if ($doc['status'] !== 'published') { $this->jsonError('Solo un documento pubblicato può essere archiviato', 409, 'BAD_STATE'); }
$now = date('Y-m-d H:i:s');
Database::update('isms_documents', ['status' => 'archived', 'archived_at' => $now, 'updated_at' => $now], 'id = ?', [$id]);
$this->logAudit('isms_document_archived', 'isms_document', $id, []);
$this->jsonSuccess(['id' => $id, 'status' => 'archived'], 'Documento archiviato');
}
/** POST /api/isms/documents/{id}/newVersion pubblicato/archiviato -> nuova bozza */
public function newVersionDocument(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$doc = $this->requireDocument($id);
if (!in_array($doc['status'], ['published', 'archived', 'approved'], true)) { $this->jsonError('Nuova versione possibile solo da documento approvato/pubblicato/archiviato', 409, 'BAD_STATE'); }
$major = !empty($this->getJsonBody()['major']);
$newV = $this->bumpVersion($doc['version'] ?? '1.0', $major);
$this->snapshotDocument($doc, 'Apertura nuova versione ' . $newV . ' (dalla ' . ($doc['version'] ?? '1.0') . ')');
Database::update('isms_documents', [
'status' => 'draft', 'version' => $newV,
'approved_by' => null, 'approved_at' => null, 'reviewed_by' => null, 'reviewed_at' => null,
'published_by' => null, 'published_at' => null, 'archived_at' => null, 'review_note' => null, 'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$id]);
$this->logAudit('isms_document_new_version', 'isms_document', $id, ['version' => $newV]);
$this->jsonSuccess(['id' => $id, 'status' => 'draft', 'version' => $newV], 'Nuova versione ' . $newV . ' in bozza');
}
/** GET /api/isms/documents/{id}/versions */
public function documentVersions(int $id): void
{
$this->requireOrgAccess();
$this->requireDocument($id);
$rows = Database::fetchAll(
'SELECT v.id, v.version, v.status, v.change_note, v.created_at, u.full_name AS by_name
FROM isms_document_versions v LEFT JOIN users u ON u.id = v.created_by
WHERE v.document_id = ? ORDER BY v.id DESC', [$id]);
$this->jsonSuccess(['versions' => $rows]);
}
/** GET /api/isms/documents/{id}/word — scarica .doc (Word, modificabile) */
public function exportDocumentWord(int $id): void
{
$this->requireOrgAccess();
$doc = $this->requireDocument($id);
$org = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$doc['organization_id']]);
$orgName = htmlspecialchars((string) ($org['name'] ?? ''));
$statusLabel = [
'draft' => 'Bozza', 'review' => 'In revisione', 'approved' => 'Approvato',
'published' => 'Pubblicato', 'archived' => 'Archiviato',
][$doc['status']] ?? $doc['status'];
$meta = $orgName . ' &mdash; Versione ' . htmlspecialchars((string) ($doc['version'] ?? '1.0')) . ' &mdash; Stato: ' . $statusLabel;
if (!empty($doc['approved_at'])) $meta .= ' &mdash; Approvato il ' . date('d/m/Y', strtotime($doc['approved_at']));
if (!empty($doc['published_at'])) $meta .= ' &mdash; In vigore dal ' . date('d/m/Y', strtotime($doc['published_at']));
if (!empty($doc['next_review_date'])) $meta .= ' &mdash; Prossimo riesame: ' . date('d/m/Y', strtotime($doc['next_review_date']));
$title = htmlspecialchars((string) $doc['title']);
$body = $doc['body_html'] ?? '';
$html = "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>"
. "<head><meta charset='utf-8'><title>$title</title>"
. "<style>body{font-family:Calibri,Arial,sans-serif;font-size:11pt;color:#1b1b1b;line-height:1.4;}"
. "h1{font-size:18pt;color:#0066CC;}h2{font-size:14pt;color:#0066CC;}h3{font-size:12pt;color:#17324d;}"
. "table{border-collapse:collapse;width:100%;}td,th{border:1px solid #999;padding:5px;font-size:10pt;}th{background:#eef4fb;}"
. ".meta{color:#555;font-size:9pt;border-bottom:1px solid #ccc;padding-bottom:6px;margin-bottom:12px;}</style></head><body>"
. "<h1>$title</h1><p class='meta'>$meta</p>$body</body></html>";
$slug = trim(preg_replace('/[^A-Za-z0-9]+/', '_', (string) $doc['title']), '_');
$fname = $slug . '_v' . ($doc['version'] ?? '1.0') . '.doc';
header('Content-Type: application/msword; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $fname . '"');
echo $html;
exit;
}
/**
* POST /api/isms/documents/ai-generate
* Body: { doc_type, title? } - genera una bozza con AI (grounding fonti certe).