diff --git a/application/cli/build_nuova_agile_p6_publish.php b/application/cli/build_nuova_agile_p6_publish.php new file mode 100644 index 0000000..0a49208 --- /dev/null +++ b/application/cli/build_nuova_agile_p6_publish.php @@ -0,0 +1,36 @@ + 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"; diff --git a/application/cli/migrate_060_isms_doc_lifecycle.php b/application/cli/migrate_060_isms_doc_lifecycle.php new file mode 100644 index 0000000..cd2cf2a --- /dev/null +++ b/application/cli/migrate_060_isms_doc_lifecycle.php @@ -0,0 +1,56 @@ +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"; diff --git a/application/controllers/IsmsModelController.php b/application/controllers/IsmsModelController.php index b6a03bf..ebed7f3 100644 --- a/application/controllers/IsmsModelController.php +++ b/application/controllers/IsmsModelController.php @@ -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 . ' — Versione ' . htmlspecialchars((string) ($doc['version'] ?? '1.0')) . ' — Stato: ' . $statusLabel; + if (!empty($doc['approved_at'])) $meta .= ' — Approvato il ' . date('d/m/Y', strtotime($doc['approved_at'])); + if (!empty($doc['published_at'])) $meta .= ' — In vigore dal ' . date('d/m/Y', strtotime($doc['published_at'])); + if (!empty($doc['next_review_date'])) $meta .= ' — Prossimo riesame: ' . date('d/m/Y', strtotime($doc['next_review_date'])); + + $title = htmlspecialchars((string) $doc['title']); + $body = $doc['body_html'] ?? ''; + $html = "" + . "