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::execute( '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); } }