nis2-agile/application/controllers/NormativeController.php
DevEnv nis2-agile 9ccf2a72b5 [FIX] Database::execute() → Database::query() in 5 controller
Database non ha metodo execute() — corretto in:
InviteController, ServicesController, WebhookController,
NormativeController, WhistleblowingController.
Causa del HTTP 500 su tutti gli endpoint /api/invites/*.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 16:49:58 +01:00

256 lines
9.3 KiB
PHP

<?php
/**
* NIS2 Agile - Normative Controller
*
* Feed aggiornamenti normativi NIS2/ACN/DORA con ACK tracciato per audit.
* Le organizzazioni devono documentare la presa visione degli aggiornamenti
* normativi per dimostrare compliance continuativa.
*
* Endpoint:
* GET /api/normative/list → lista aggiornamenti (filtrabili)
* GET /api/normative/{id} → dettaglio aggiornamento
* POST /api/normative/{id}/ack → conferma presa visione (con note)
* GET /api/normative/pending → aggiornamenti non ancora ACK dall'org
* GET /api/normative/stats → statistiche ACK per dashboard
* POST /api/normative/create → crea aggiornamento (solo super_admin)
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/WebhookService.php';
class NormativeController extends BaseController
{
/**
* GET /api/normative/list
*/
public function list(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$conditions = ['nu.is_published = 1'];
$params = [];
if ($this->hasParam('source')) {
$conditions[] = 'nu.source = ?';
$params[] = $this->getParam('source');
}
if ($this->hasParam('impact')) {
$conditions[] = 'nu.impact_level = ?';
$params[] = $this->getParam('impact');
}
if ($this->hasParam('action_required')) {
$conditions[] = 'nu.action_required = ?';
$params[] = (int)$this->getParam('action_required');
}
$where = implode(' AND ', $conditions);
$updates = Database::fetchAll(
"SELECT nu.*,
na.acknowledged_at,
na.acknowledged_by,
u.full_name as ack_by_name
FROM normative_updates nu
LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ?
LEFT JOIN users u ON u.id = na.acknowledged_by
WHERE {$where}
ORDER BY
CASE nu.impact_level WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 ELSE 5 END,
nu.published_at DESC",
array_merge([$orgId], $params)
);
// Decodifica affected_domains
foreach ($updates as &$u) {
$u['affected_domains'] = json_decode($u['affected_domains'] ?? '[]', true) ?? [];
$u['is_acknowledged'] = !empty($u['acknowledged_at']);
}
unset($u);
$this->jsonSuccess(['updates' => $updates, 'total' => count($updates)]);
}
/**
* GET /api/normative/{id}
*/
public function get(int $id): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$update = Database::fetchOne(
'SELECT nu.*,
na.acknowledged_at, na.acknowledged_by, na.notes as ack_notes,
u.full_name as ack_by_name
FROM normative_updates nu
LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ?
LEFT JOIN users u ON u.id = na.acknowledged_by
WHERE nu.id = ? AND nu.is_published = 1',
[$orgId, $id]
);
if (!$update) {
$this->jsonError('Aggiornamento non trovato', 404, 'NOT_FOUND');
}
$update['affected_domains'] = json_decode($update['affected_domains'] ?? '[]', true) ?? [];
$update['is_acknowledged'] = !empty($update['acknowledged_at']);
$this->jsonSuccess($update);
}
/**
* POST /api/normative/{id}/ack
* Documenta la presa visione dell'aggiornamento normativo.
*/
public function acknowledge(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$orgId = $this->getCurrentOrgId();
$update = Database::fetchOne(
'SELECT id, title, impact_level FROM normative_updates WHERE id = ? AND is_published = 1',
[$id]
);
if (!$update) { $this->jsonError('Aggiornamento non trovato', 404, 'NOT_FOUND'); }
// Verifica se già ACK
$existing = Database::fetchOne(
'SELECT id FROM normative_ack WHERE normative_update_id = ? AND organization_id = ?',
[$id, $orgId]
);
$notes = trim($this->getParam('notes', ''));
if ($existing) {
// Aggiorna note se già ACK
Database::query(
'UPDATE normative_ack SET notes = ?, acknowledged_by = ?, acknowledged_at = NOW()
WHERE normative_update_id = ? AND organization_id = ?',
[$notes ?: null, $this->getCurrentUserId(), $id, $orgId]
);
} else {
Database::insert('normative_ack', [
'normative_update_id' => $id,
'organization_id' => $orgId,
'acknowledged_by' => $this->getCurrentUserId(),
'notes' => $notes ?: null,
]);
}
$this->logAudit('normative_acknowledged', 'normative_update', $id, [
'title' => $update['title'],
'impact' => $update['impact_level'],
]);
// Dispatch webhook
try {
(new WebhookService())->dispatch($orgId, 'normative.update', [
'id' => $update['id'],
'title' => $update['title'],
'impact_level' => $update['impact_level'],
'acknowledged' => true,
'acknowledged_at' => date('c'),
]);
} catch (Throwable $e) {
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
}
$this->jsonSuccess(['acknowledged_at' => date('c')], 'Presa visione registrata');
}
/**
* GET /api/normative/pending
* Aggiornamenti non ancora ACK dall'organizzazione corrente.
*/
public function pending(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$pending = Database::fetchAll(
'SELECT nu.id, nu.title, nu.source, nu.impact_level, nu.action_required,
nu.effective_date, nu.published_at
FROM normative_updates nu
LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ?
WHERE nu.is_published = 1 AND na.id IS NULL
ORDER BY
CASE nu.impact_level WHEN \'critical\' THEN 1 WHEN \'high\' THEN 2 WHEN \'medium\' THEN 3 ELSE 4 END,
nu.published_at DESC',
[$orgId]
);
$this->jsonSuccess([
'pending' => $pending,
'count' => count($pending),
'critical_count' => count(array_filter($pending, fn($u) => $u['impact_level'] === 'critical')),
]);
}
/**
* GET /api/normative/stats
*/
public function stats(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$total = Database::fetchOne('SELECT COUNT(*) as n FROM normative_updates WHERE is_published = 1');
$acked = Database::fetchOne(
'SELECT COUNT(*) as n FROM normative_ack WHERE organization_id = ?',
[$orgId]
);
$ackRate = $total['n'] > 0 ? round(($acked['n'] / $total['n']) * 100) : 0;
$bySource = Database::fetchAll(
'SELECT nu.source,
COUNT(nu.id) as total,
COUNT(na.id) as acknowledged
FROM normative_updates nu
LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ?
WHERE nu.is_published = 1
GROUP BY nu.source',
[$orgId]
);
$this->jsonSuccess([
'total_updates' => (int)$total['n'],
'acknowledged' => (int)$acked['n'],
'pending' => (int)$total['n'] - (int)$acked['n'],
'ack_rate' => $ackRate,
'by_source' => $bySource,
]);
}
/**
* POST /api/normative/create
* Solo super_admin può pubblicare nuovi aggiornamenti normativi.
*/
public function create(): void
{
$this->requireSuperAdmin();
$this->validateRequired(['title', 'source', 'summary', 'impact_level']);
$domains = $this->getParam('affected_domains', []);
if (is_string($domains)) $domains = json_decode($domains, true) ?? [];
$id = Database::insert('normative_updates', [
'title' => trim($this->getParam('title')),
'source' => $this->getParam('source'),
'source_label' => $this->getParam('source_label'),
'reference' => $this->getParam('reference'),
'summary' => trim($this->getParam('summary')),
'content' => $this->getParam('content'),
'impact_level' => $this->getParam('impact_level'),
'affected_domains'=> json_encode(array_values($domains)),
'action_required' => $this->getParam('action_required', 0) ? 1 : 0,
'effective_date' => $this->getParam('effective_date') ?: null,
'url' => $this->getParam('url') ?: null,
'is_published' => 1,
]);
$this->jsonSuccess(['id' => $id], 'Aggiornamento normativo pubblicato', 201);
}
}