Files
nis2-agile/application/controllers/ReviewScheduleController.php
DevEnv nis2-agileandClaude Opus 4.8 79ca72fba9 [FEAT] ISO-readiness: Audit interni (§9.2) + Riesame Direzione (§9.3) + Calendario scadenze (mig.055-056, v1.22.0)
Rilascio unico (3 moduli) costruito in flotta parallela + verifica avversariale.

MODULO A — Audit interni (ISO 27001 §9.2, mig.055): internal_audits + internal_audit_items;
codice AUD-NNN; checklist pre-popolata (clausole 4-10 + Annex A applicabili dal SoA);
esiti per riga; apertura non conformità collegata a NCR/CAPA (source polimorfico);
report HTML stampabile. InternalAuditController + internal-audits.html.

MODULO B — Riesame di Direzione (ISO 27001 §9.3, mig.056): management_reviews +
management_review_decisions; codice RD-AAAA-NN; INPUT aggregati automaticamente dai
moduli (gather: NC/CAPA, audit interni, rischi+trattamenti, KPI/score, obiettivi, formazione,
stakeholder, normative, scadenze), congelati nello snapshot all'approvazione; decisioni;
verbale stampabile. ManagementReviewController + management-review.html.

MODULO C — Calendario unico scadenze: aggregatore SOLA LETTURA (nessuna migrazione) di tutte
le scadenze (incidenti/policy/rischi/NC-CAPA/formazione/stakeholder/audit/riesame/review_schedule),
griglia mensile + lista + filtri + deep-link. CalendarController + calendario.html.

Integrazione: router (3 controller+actionMap), api.js, sidebar V2+legacy, help.js (3 sezioni),
i18n (IT+EN), review_schedule ENUM += internal_audit. v1.22.0 + sw cache + cache-buster 20260630.

Verifica flotta (28 agenti, 4 dim + avversariale): 9 finding confermati, TUTTI corretti —
MAJOR gatherRisks (risk_treatments.organization_id inesistente → JOIN risks); nextCode numerico
(no dup >99/anno); footer report audit con etichetta "ISO buona prassi, non obbligo"; help 24→25
clausole; ARIA tab/calendario; focus modali; tasti su celle calendario; rimossi helper api morti.

Smoke prod OK (calendario 18 eventi, audit AUD-001 25 item + report + audit→NCR + audit→calendario,
riesame gather/decisione/approve/report, gatherRisks ora available); org 151 ripulita. Additivo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:48:49 +02:00

492 lines
20 KiB
PHP

<?php
/**
* NIS2 Agile - Scadenziario centralizzato delle revisioni (A4 Fase 4.4)
* ----------------------------------------------------------------------------
* Registro unico delle scadenze di revisione periodica: ruoli, competenze,
* inventario, procedure, rischi, fornitori, misure e voci custom. Lo STATO
* (ok/due/overdue) e' calcolato LIVE in base a next_review_date e NON ci si
* affida alla colonna persistita (computeStatus()).
*
* Multi-tenancy ancorata a getCurrentOrgId(): OGNI query filtra organization_id.
* Le letture passano requireOrgAccess(); le scritture richiedono
* org_admin/compliance_manager (super_admin bypassa; il demo guard gestisce il
* read-only/sandbox automaticamente in BaseController). Anti-IDOR: owner_role_id
* e' verificato come appartenente all'org (fetchRoleOrFail).
*
* sync(): upsert idempotente di voci dalle scadenze gia' presenti nel prodotto,
* tutte org-scoped, con anti-dup su (entity_type, entity_id):
* - policies.next_review_date -> entity_type='procedure'
* - compliance_controls.next_review_date -> entity_type='custom'
* - risk_treatments.due_date -> entity_type='risk' (JOIN risks: la
* tabella risk_treatments NON ha organization_id, si filtra via risks)
*
* Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md):
* - GV.PO-02: policy/procedure riesaminate periodicamente
* - GV.SC-07: sicurezza fornitori monitorata e rivista
* - PR.AT: formazione/sensibilizzazione ricorrenti
* - DE.CM: monitoraggio continuo
* - art. 24 D.Lgs. 138/2024: misure di gestione del rischio mantenute aggiornate
* Disclaimer: la periodicita' puntuale e' buona prassi dove non fissata da norma;
* strumento di supporto, non un parere legale.
*
* NOTE strutturali (verificate sul codice reale):
* - DB API: Database::query/fetchAll/fetchOne/insert/update/delete/count
* (NON esiste Database::execute).
* - Le AZIONI sono capa_actions (figlie di non_conformities): 4.4 non le usa.
*/
require_once __DIR__ . '/BaseController.php';
class ReviewScheduleController extends BaseController
{
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
private const ENTITY_TYPES = [
'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom', 'stakeholder_activity', 'internal_audit',
];
// ═══════════════════════════════════════════════════════════════════════
// LETTURA
// ═══════════════════════════════════════════════════════════════════════
/**
* GET /api/review-schedule/list
* Elenco scadenze dell'org, ordinate per prossima revisione. Lo stato e'
* ricalcolato LIVE (override del valore in colonna). Include il nome del
* ruolo owner e i contatori di sintesi.
*/
public function list(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$rows = Database::fetchAll(
'SELECT rs.id, rs.entity_type, rs.entity_id, rs.title, rs.owner_role_id,
rs.frequency_months, rs.last_reviewed_at, rs.next_review_date,
rs.notes, rs.created_at, rs.updated_at,
r.role_name AS owner_role_name
FROM review_schedule rs
LEFT JOIN org_roles r ON r.id = rs.owner_role_id
WHERE rs.organization_id = ?
ORDER BY rs.next_review_date ASC, rs.id ASC',
[$orgId]
);
$items = [];
$counts = ['ok' => 0, 'due' => 0, 'overdue' => 0];
foreach ($rows as $r) {
$status = $this->computeStatus($r['next_review_date']);
$counts[$status]++;
$items[] = [
'id' => (int) $r['id'],
'entity_type' => $r['entity_type'],
'entity_id' => $r['entity_id'] !== null ? (int) $r['entity_id'] : null,
'title' => $r['title'],
'owner_role_id' => $r['owner_role_id'] !== null ? (int) $r['owner_role_id'] : null,
'owner_role_name' => $r['owner_role_name'],
'frequency_months' => $r['frequency_months'] !== null ? (int) $r['frequency_months'] : null,
'last_reviewed_at' => $r['last_reviewed_at'],
'next_review_date' => $r['next_review_date'],
'status' => $status,
'notes' => $r['notes'],
'created_at' => $r['created_at'],
'updated_at' => $r['updated_at'],
];
}
$this->jsonSuccess([
'items' => $items,
'total' => count($items),
'ok' => $counts['ok'],
'due' => $counts['due'],
'overdue' => $counts['overdue'],
]);
}
// ═══════════════════════════════════════════════════════════════════════
// SCRITTURA
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/review-schedule/create
* Body: {title*, next_review_date*, entity_type?, entity_id?, owner_role_id?,
* frequency_months?, notes?}
*/
public function create(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$this->validateRequired(['title', 'next_review_date']);
$orgId = $this->getCurrentOrgId();
$type = $this->validateEntityType($this->getParam('entity_type', 'custom'));
$nextDate = $this->validateDate($this->getParam('next_review_date'));
$ownerId = $this->resolveOwnerRoleId($this->getParam('owner_role_id'));
$freq = $this->nullablePositiveInt($this->getParam('frequency_months'));
$entityId = $this->nullableInt($this->getParam('entity_id'));
$id = Database::insert('review_schedule', [
'organization_id' => $orgId,
'entity_type' => $type,
'entity_id' => $entityId,
'title' => trim((string) $this->getParam('title')),
'owner_role_id' => $ownerId,
'frequency_months' => $freq,
'last_reviewed_at' => null,
'next_review_date' => $nextDate,
'status' => $this->computeStatus($nextDate),
'notes' => $this->nullableText($this->getParam('notes')),
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('review_schedule_created', 'review_schedule', $id, [
'entity_type' => $type, 'next_review_date' => $nextDate,
]);
$this->jsonSuccess(['id' => $id], 'Voce di scadenziario creata', 201);
}
/**
* PUT /api/review-schedule/{id}
* Aggiorna solo i campi presenti nel body. Ricalcola lo stato se cambia la
* prossima revisione.
*/
public function update(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$row = $this->fetchEntryOrFail($id, $orgId);
$updates = [];
if ($this->hasParam('title')) {
$title = trim((string) $this->getParam('title'));
if ($title === '') {
$this->jsonError('Il titolo non puo essere vuoto', 422, 'INVALID_TITLE');
}
$updates['title'] = $title;
}
if ($this->hasParam('entity_type')) {
$updates['entity_type'] = $this->validateEntityType($this->getParam('entity_type'));
}
if ($this->hasParam('entity_id')) {
$updates['entity_id'] = $this->nullableInt($this->getParam('entity_id'));
}
if ($this->hasParam('owner_role_id')) {
$updates['owner_role_id'] = $this->resolveOwnerRoleId($this->getParam('owner_role_id'));
}
if ($this->hasParam('frequency_months')) {
$updates['frequency_months'] = $this->nullablePositiveInt($this->getParam('frequency_months'));
}
if ($this->hasParam('notes')) {
$updates['notes'] = $this->nullableText($this->getParam('notes'));
}
if ($this->hasParam('next_review_date')) {
$nextDate = $this->validateDate($this->getParam('next_review_date'));
$updates['next_review_date'] = $nextDate;
$updates['status'] = $this->computeStatus($nextDate);
}
if (empty($updates)) {
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_FIELDS');
}
Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
$this->logAudit('review_schedule_updated', 'review_schedule', $id, array_keys($updates));
$this->jsonSuccess(['id' => $id], 'Voce di scadenziario aggiornata');
}
/**
* DELETE /api/review-schedule/{id}
*/
public function delete(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$deleted = Database::delete('review_schedule', 'id = ? AND organization_id = ?', [$id, $orgId]);
if ($deleted === 0) {
$this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND');
}
$this->logAudit('review_schedule_deleted', 'review_schedule', $id, null);
$this->jsonSuccess(null, 'Voce di scadenziario eliminata');
}
/**
* POST /api/review-schedule/{id}/complete
* Segna la voce come revisionata oggi. Se ha una frequenza, avanza la
* prossima revisione di frequency_months a partire da oggi.
*/
public function complete(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$row = $this->fetchEntryOrFail($id, $orgId);
$today = date('Y-m-d');
$updates = ['last_reviewed_at' => $today];
$freq = $row['frequency_months'] !== null ? (int) $row['frequency_months'] : null;
if ($freq !== null && $freq > 0) {
$nextDate = date('Y-m-d', strtotime("+{$freq} months", strtotime($today)));
$updates['next_review_date'] = $nextDate;
$updates['status'] = $this->computeStatus($nextDate);
} else {
// Nessuna cadenza: la prossima revisione resta invariata, ricalcolo lo stato.
$updates['status'] = $this->computeStatus($row['next_review_date']);
$nextDate = $row['next_review_date'];
}
Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
$this->logAudit('review_schedule_completed', 'review_schedule', $id, [
'last_reviewed_at' => $today, 'next_review_date' => $nextDate,
]);
$this->jsonSuccess([
'id' => $id,
'last_reviewed_at' => $today,
'next_review_date' => $nextDate,
'status' => $updates['status'],
], 'Revisione registrata');
}
/**
* POST /api/review-schedule/sync
* Upsert idempotente dalle scadenze gia' presenti (policy / controlli /
* trattamenti rischio), tutte org-scoped. Anti-dup su (entity_type, entity_id):
* se la voce esiste e la data sorgente e' cambiata la aggiorna, altrimenti
* crea. Le voci create a mano (entity_type='custom', entity_id NULL) non
* vengono toccate.
*/
public function sync(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$userId = $this->getCurrentUserId();
$created = 0;
$updated = 0;
$skipped = 0;
// 1. Procedure (policies) con next_review_date impostata.
$policies = Database::fetchAll(
'SELECT id, title, next_review_date
FROM policies
WHERE organization_id = ? AND next_review_date IS NOT NULL AND deleted_at IS NULL',
[$orgId]
);
foreach ($policies as $p) {
$this->upsertSyncEntry(
$orgId, $userId, 'procedure', (int) $p['id'],
(string) $p['title'], (string) $p['next_review_date'],
'Sincronizzato da procedura/policy',
$created, $updated, $skipped
);
}
// 2. Controlli di compliance con next_review_date impostata (entity_type='custom').
$controls = Database::fetchAll(
'SELECT id, control_code, title, next_review_date
FROM compliance_controls
WHERE organization_id = ? AND next_review_date IS NOT NULL',
[$orgId]
);
foreach ($controls as $c) {
$label = trim(((string) ($c['control_code'] ?? '')) . ' — ' . ((string) ($c['title'] ?? '')));
$label = trim($label, ' —');
if ($label === '') {
$label = 'Controllo #' . (int) $c['id'];
}
$this->upsertSyncEntry(
$orgId, $userId, 'custom', (int) $c['id'],
$label, (string) $c['next_review_date'],
'Sincronizzato da controllo di compliance',
$created, $updated, $skipped
);
}
// 3. Trattamenti rischio (risk_treatments.due_date) — org via JOIN su risks
// (risk_treatments NON ha organization_id).
$treatments = Database::fetchAll(
'SELECT rt.id, rt.due_date, r.title
FROM risk_treatments rt
JOIN risks r ON r.id = rt.risk_id
WHERE r.organization_id = ? AND rt.due_date IS NOT NULL AND r.deleted_at IS NULL',
[$orgId]
);
foreach ($treatments as $t) {
$title = (string) ($t['title'] ?? '');
if ($title === '') {
$title = 'Rischio (trattamento #' . (int) $t['id'] . ')';
}
$this->upsertSyncEntry(
$orgId, $userId, 'risk', (int) $t['id'],
$title, (string) $t['due_date'],
'Sincronizzato da trattamento del rischio',
$created, $updated, $skipped
);
}
$this->logAudit('review_schedule_synced', 'review_schedule', null, [
'created' => $created, 'updated' => $updated, 'skipped' => $skipped,
]);
$this->jsonSuccess([
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
], 'Sincronizzazione completata');
}
// ═══════════════════════════════════════════════════════════════════════
// PRIVATI
// ═══════════════════════════════════════════════════════════════════════
/**
* Stato calcolato LIVE: scaduto (overdue) se la data e' passata; in scadenza
* (due) se entro 30 giorni; altrimenti ok. Confronto a granularita' giorno.
*/
private function computeStatus(string $nextDate): string
{
$next = strtotime($nextDate);
$today = strtotime(date('Y-m-d'));
if ($next === false) {
return 'ok';
}
if ($next < $today) {
return 'overdue';
}
if ($next <= strtotime('+30 days', $today)) {
return 'due';
}
return 'ok';
}
/** Voce di scadenziario org-scoped oppure 404 (anti-IDOR). */
private function fetchEntryOrFail(int $id, int $orgId): array
{
$row = Database::fetchOne(
'SELECT * FROM review_schedule WHERE id = ? AND organization_id = ?',
[$id, $orgId]
);
if (!$row) {
$this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND');
}
return $row;
}
/**
* owner_role_id opzionale: NULL/'' => null; altrimenti il ruolo deve
* appartenere all'org corrente (anti-IDOR), 404 se non trovato.
*/
private function resolveOwnerRoleId($raw): ?int
{
if ($raw === null || $raw === '' || (int) $raw === 0) {
return null;
}
$roleId = (int) $raw;
$exists = Database::count(
'org_roles', 'id = ? AND organization_id = ?', [$roleId, $this->getCurrentOrgId()]
);
if ($exists === 0) {
$this->jsonError('Ruolo owner non trovato in questa organizzazione', 404, 'ROLE_NOT_FOUND');
}
return $roleId;
}
private function validateEntityType($t): string
{
$t = strtolower((string) $t);
if ($t === '') {
return 'custom';
}
if (!in_array($t, self::ENTITY_TYPES, true)) {
$this->jsonError('Tipo entita non valido', 422, 'INVALID_ENTITY_TYPE');
}
return $t;
}
/** Valida data in formato Y-m-d (rifiuta valori non-data o impossibili). */
private function validateDate($raw): string
{
$d = trim((string) $raw);
$dt = DateTime::createFromFormat('Y-m-d', $d);
if (!$dt || $dt->format('Y-m-d') !== $d) {
$this->jsonError('Data non valida (atteso formato AAAA-MM-GG)', 422, 'INVALID_DATE');
}
return $d;
}
private function nullableInt($v): ?int
{
if ($v === null || $v === '') {
return null;
}
return (int) $v;
}
private function nullablePositiveInt($v): ?int
{
if ($v === null || $v === '') {
return null;
}
$i = (int) $v;
return $i > 0 ? $i : null;
}
private function nullableText($v): ?string
{
if ($v === null) {
return null;
}
$s = trim((string) $v);
return $s === '' ? null : $s;
}
/**
* Upsert idempotente di una voce di sync su (entity_type, entity_id):
* - non esiste -> insert (status calcolato, owner/frequenza null)
* - esiste con data diversa -> update next_review_date + status
* - esiste con stessa data -> skip
*/
private function upsertSyncEntry(
int $orgId, ?int $userId, string $type, int $entityId,
string $title, string $nextDate, string $note,
int &$created, int &$updated, int &$skipped
): void {
$existing = Database::fetchOne(
'SELECT id, next_review_date FROM review_schedule
WHERE organization_id = ? AND entity_type = ? AND entity_id = ?',
[$orgId, $type, $entityId]
);
if ($existing) {
if ((string) $existing['next_review_date'] !== $nextDate) {
Database::update('review_schedule', [
'next_review_date' => $nextDate,
'status' => $this->computeStatus($nextDate),
], 'id = ? AND organization_id = ?', [(int) $existing['id'], $orgId]);
$updated++;
} else {
$skipped++;
}
return;
}
Database::insert('review_schedule', [
'organization_id' => $orgId,
'entity_type' => $type,
'entity_id' => $entityId,
'title' => mb_substr($title, 0, 255),
'owner_role_id' => null,
'frequency_months' => null,
'last_reviewed_at' => null,
'next_review_date' => $nextDate,
'status' => $this->computeStatus($nextDate),
'notes' => $note,
'created_by' => $userId,
]);
$created++;
}
}