[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
+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).