Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
492 lines
20 KiB
PHP
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',
|
|
];
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// 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++;
|
|
}
|
|
}
|