diff --git a/application/controllers/DashboardController.php b/application/controllers/DashboardController.php index 6651aac..a3967d1 100644 --- a/application/controllers/DashboardController.php +++ b/application/controllers/DashboardController.php @@ -150,6 +150,90 @@ class DashboardController extends BaseController ]); } + /** + * GET /api/dashboard/sectorBenchmark + * Benchmark settoriale ANONIMIZZATO (P2): confronta lo score di compliance + * dell'organizzazione con la media/distribuzione del proprio settore sulla + * rete multi-tenant. k-anonymity: aggregati solo se >= MIN_PEERS organizzazioni. + */ + public function sectorBenchmark(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + $MIN_PEERS = 3; + + $org = Database::fetchOne('SELECT sector FROM organizations WHERE id = ?', [$orgId]); + if (!$org) { + $this->jsonError('Organizzazione non trovata', 404, 'NOT_FOUND'); + } + $sector = $org['sector']; + + $mine = Database::fetchOne( + "SELECT overall_score FROM assessments WHERE organization_id = ? AND status='completed' + ORDER BY completed_at DESC LIMIT 1", + [$orgId] + ); + $myScore = $mine ? (float) $mine['overall_score'] : null; + + $rows = Database::fetchAll( + "SELECT a.overall_score + FROM assessments a + JOIN ( + SELECT organization_id, MAX(completed_at) mx + FROM assessments WHERE status='completed' + GROUP BY organization_id + ) last ON last.organization_id = a.organization_id AND last.mx = a.completed_at + JOIN organizations o ON o.id = a.organization_id + WHERE o.is_active = 1 AND o.sector = ? AND a.status='completed' AND a.overall_score IS NOT NULL", + [$sector] + ); + $scores = array_map(fn($r) => (float) $r['overall_score'], $rows); + $n = count($scores); + + if ($n < $MIN_PEERS) { + $this->jsonSuccess([ + 'sector' => $sector, + 'sufficient_data' => false, + 'min_peers_required' => $MIN_PEERS, + 'peers_available' => $n, + 'my_score' => $myScore, + 'message' => 'Dati insufficienti per un benchmark anonimo (servono almeno ' . $MIN_PEERS . ' organizzazioni nel settore).', + ]); + return; + } + + sort($scores); + $avg = array_sum($scores) / $n; + $median = $n % 2 ? $scores[intdiv($n, 2)] : ($scores[$n / 2 - 1] + $scores[$n / 2]) / 2; + $p25 = $scores[(int) floor(0.25 * ($n - 1))]; + $p75 = $scores[(int) floor(0.75 * ($n - 1))]; + + $percentile = null; $position = null; + if ($myScore !== null) { + $below = count(array_filter($scores, fn($s) => $s < $myScore)); + $percentile = (int) round($below / $n * 100); + $position = $myScore >= $p75 ? 'top_quartile' + : ($myScore >= $median ? 'above_median' + : ($myScore >= $p25 ? 'below_median' : 'bottom_quartile')); + } + + $this->jsonSuccess([ + 'sector' => $sector, + 'sufficient_data' => true, + 'peers' => $n, + 'my_score' => $myScore !== null ? round($myScore, 1) : null, + 'sector_average' => round($avg, 1), + 'sector_median' => round($median, 1), + 'sector_min' => round($scores[0], 1), + 'sector_max' => round($scores[$n - 1], 1), + 'sector_p25' => round($p25, 1), + 'sector_p75' => round($p75, 1), + 'my_percentile' => $percentile, + 'my_position' => $position, + 'delta_vs_average' => $myScore !== null ? round($myScore - $avg, 1) : null, + ]); + } + /** * GET /api/dashboard/upcoming-deadlines */ diff --git a/application/controllers/PolicyController.php b/application/controllers/PolicyController.php index 0f1254a..5290b2c 100644 --- a/application/controllers/PolicyController.php +++ b/application/controllers/PolicyController.php @@ -121,9 +121,24 @@ class PolicyController extends BaseController $this->logAudit('policy_approved', 'policy', $id); + $policy = Database::fetchOne('SELECT * FROM policies WHERE id = ?', [$id]); + + // Snapshot versione per storico/diff/attestation (P3) + try { + Database::insert('policy_versions', [ + 'policy_id' => $id, + 'organization_id' => $this->getCurrentOrgId(), + 'version' => $policy['version'] ?? '1.0', + 'content' => $policy['content'] ?? null, + 'change_note' => $this->getParam('change_note') ?: 'Approvazione', + 'created_by' => $this->getCurrentUserId(), + ]); + } catch (Throwable $e) { + error_log('[POLICY_VER] ' . $e->getMessage()); + } + // Dispatch webhook policy.approved try { - $policy = Database::fetchOne('SELECT * FROM policies WHERE id = ?', [$id]); (new WebhookService())->dispatch( $this->getCurrentOrgId(), 'policy.approved', @@ -181,4 +196,154 @@ class PolicyController extends BaseController $this->jsonSuccess($templates); } + + // ══════════════════════════════════════════════════════════════════════ + // ATTESTATION + VERSIONING (P3) + // ══════════════════════════════════════════════════════════════════════ + + /** POST /api/policies/{id}/attest — dipendente prende visione (versione corrente). */ + public function attest(int $id): void + { + $this->requireOrgAccess(); + $policy = Database::fetchOne( + "SELECT id, version, status FROM policies WHERE id = ? AND organization_id = ? AND deleted_at IS NULL", + [$id, $this->getCurrentOrgId()] + ); + if (!$policy) { + $this->jsonError('Policy non trovata', 404, 'NOT_FOUND'); + } + if ($policy['status'] !== 'approved') { + $this->jsonError('Si puo attestare solo una policy approvata', 422, 'NOT_APPROVED'); + } + $version = $policy['version'] ?? '1.0'; + + $existing = Database::fetchOne( + 'SELECT id FROM policy_attestations WHERE policy_id = ? AND user_id = ? AND version = ?', + [$id, $this->getCurrentUserId(), $version] + ); + if ($existing) { + $this->jsonSuccess(['already' => true, 'version' => $version], 'Presa visione gia registrata'); + return; + } + + Database::insert('policy_attestations', [ + 'policy_id' => $id, + 'organization_id' => $this->getCurrentOrgId(), + 'user_id' => $this->getCurrentUserId(), + 'version' => $version, + 'ip_address' => $this->attClientIp(), + ]); + $this->logAudit('policy_attested', 'policy', $id, ['version' => $version]); + $this->jsonSuccess(['version' => $version], 'Presa visione registrata', 201); + } + + /** GET /api/policies/{id}/attestations — copertura % presa visione (versione corrente). */ + public function attestations(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member', 'auditor']); + $policy = Database::fetchOne( + "SELECT id, title, version FROM policies WHERE id = ? AND organization_id = ? AND deleted_at IS NULL", + [$id, $this->getCurrentOrgId()] + ); + if (!$policy) { + $this->jsonError('Policy non trovata', 404, 'NOT_FOUND'); + } + $version = $policy['version'] ?? '1.0'; + + $members = Database::fetchAll( + 'SELECT u.id, u.full_name, u.email FROM user_organizations uo + JOIN users u ON u.id = uo.user_id + WHERE uo.organization_id = ? AND u.is_active = 1', + [$this->getCurrentOrgId()] + ); + $attested = Database::fetchAll( + 'SELECT user_id, attested_at FROM policy_attestations WHERE policy_id = ? AND version = ?', + [$id, $version] + ); + $attMap = []; + foreach ($attested as $a) { $attMap[(int) $a['user_id']] = $a['attested_at']; } + + $list = array_map(fn($m) => [ + 'user_id' => (int) $m['id'], + 'full_name' => $m['full_name'], + 'email' => $m['email'], + 'attested' => isset($attMap[(int) $m['id']]), + 'attested_at' => $attMap[(int) $m['id']] ?? null, + ], $members); + + $total = count($members); + $done = count(array_filter($list, fn($x) => $x['attested'])); + $this->jsonSuccess([ + 'policy_id' => $id, + 'version' => $version, + 'total_members' => $total, + 'attested' => $done, + 'coverage_percent' => $total ? (int) round($done * 100 / $total) : 0, + 'members' => $list, + ]); + } + + /** GET /api/policies/{id}/versions — storico versioni. */ + public function versions(int $id): void + { + $this->requireOrgAccess(); + $rows = Database::fetchAll( + 'SELECT id, version, change_note, created_by, created_at FROM policy_versions + WHERE policy_id = ? AND organization_id = ? ORDER BY created_at DESC', + [$id, $this->getCurrentOrgId()] + ); + $this->jsonSuccess(['versions' => $rows]); + } + + /** GET /api/policies/{id}/diff?from={vId}&to={vId} — diff line-by-line. */ + public function diff(int $id): void + { + $this->requireOrgAccess(); + $fromId = (int) $this->getParam('from'); + $toId = (int) $this->getParam('to'); + $a = Database::fetchOne('SELECT version, content FROM policy_versions WHERE id = ? AND policy_id = ? AND organization_id = ?', [$fromId, $id, $this->getCurrentOrgId()]); + $b = Database::fetchOne('SELECT version, content FROM policy_versions WHERE id = ? AND policy_id = ? AND organization_id = ?', [$toId, $id, $this->getCurrentOrgId()]); + if (!$a || !$b) { + $this->jsonError('Versione non trovata', 404, 'NOT_FOUND'); + } + $linesA = preg_split('/\r\n|\r|\n/', (string) $a['content']); + $linesB = preg_split('/\r\n|\r|\n/', (string) $b['content']); + $setA = array_count_values(array_map('trim', $linesA)); + $setB = array_count_values(array_map('trim', $linesB)); + $added = []; $removed = []; + foreach ($linesB as $l) { $t = trim($l); if ($t !== '' && empty($setA[$t])) $added[] = $l; } + foreach ($linesA as $l) { $t = trim($l); if ($t !== '' && empty($setB[$t])) $removed[] = $l; } + $this->jsonSuccess([ + 'from' => ['id' => $fromId, 'version' => $a['version']], + 'to' => ['id' => $toId, 'version' => $b['version']], + 'added' => $added, + 'removed' => $removed, + 'summary' => ['added' => count($added), 'removed' => count($removed)], + ]); + } + + /** GET /api/policies/pending-attestations — policy approvate non ancora attestate dall'utente. */ + public function pendingAttestations(): void + { + $this->requireOrgAccess(); + $rows = Database::fetchAll( + "SELECT p.id, p.title, p.category, p.version, p.approved_at + FROM policies p + WHERE p.organization_id = ? AND p.status = 'approved' AND p.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM policy_attestations a + WHERE a.policy_id = p.id AND a.user_id = ? AND a.version = p.version + ) + ORDER BY p.approved_at DESC", + [$this->getCurrentOrgId(), $this->getCurrentUserId()] + ); + $this->jsonSuccess(['pending' => $rows, 'count' => count($rows)]); + } + + private function attClientIp(): ?string + { + $fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; + if ($fwd) { return trim(explode(',', $fwd)[0]); } + return $_SERVER['REMOTE_ADDR'] ?? null; + } } diff --git a/public/index.php b/public/index.php index 406dd3e..00b2939 100644 --- a/public/index.php +++ b/public/index.php @@ -252,17 +252,33 @@ $actionMap = [ 'POST:{id}/approve' => 'approve', 'POST:aiGenerate' => 'aiGeneratePolicy', 'GET:templates' => 'getTemplates', + // Attestation + versioning (P3) + 'GET:pendingAttestations' => 'pendingAttestations', + 'GET:pending-attestations' => 'pendingAttestations', + 'POST:{id}/attest' => 'attest', + 'GET:{id}/attestations' => 'attestations', + 'GET:{id}/versions' => 'versions', + 'GET:{id}/diff' => 'diff', ], // ── SupplyChainController ─────────────────────── 'supply-chain' => [ 'GET:list' => 'list', 'POST:create' => 'create', + 'GET:riskOverview' => 'riskOverview', + // Self-assessment fornitori (P3) + 'GET:publicQuestionnaire' => 'publicQuestionnaire', // NO AUTH (token) + 'GET:public-questionnaire' => 'publicQuestionnaire', // NO AUTH (token) + 'POST:submitPublicQuestionnaire' => 'submitPublicQuestionnaire', // NO AUTH (token) + 'POST:submit-public-questionnaire' => 'submitPublicQuestionnaire', // NO AUTH (token) + 'POST:{id}/sendQuestionnaire' => 'sendQuestionnaire', + 'POST:{id}/send-questionnaire' => 'sendQuestionnaire', + 'GET:{id}/questionnaireStatus' => 'questionnaireStatus', + 'GET:{id}/questionnaire-status' => 'questionnaireStatus', + 'POST:{id}/assess' => 'assessSupplier', 'GET:{id}' => 'get', 'PUT:{id}' => 'update', 'DELETE:{id}' => 'delete', - 'POST:{id}/assess' => 'assessSupplier', - 'GET:riskOverview' => 'riskOverview', ], // ── TrainingController ──────────────────────────