requireAuth(); $this->requireCrossRole(); $question = trim($this->getParam('question', '')); if (empty($question)) { $this->jsonError('question è obbligatorio', 400, 'MISSING_QUESTION'); } if (strlen($question) > 1000) { $this->jsonError('Domanda troppo lunga (max 1000 caratteri)', 400, 'QUESTION_TOO_LONG'); } // Ottieni org_ids accessibili all'utente $orgIds = $this->getAccessibleOrgIds(); if (count($orgIds) < self::MIN_ORGS) { $this->jsonError( 'Analisi cross-org richiede almeno ' . self::MIN_ORGS . ' organizzazioni nel tuo portfolio (hai ' . count($orgIds) . ').', 422, 'INSUFFICIENT_ORGS' ); } // Costruisci dati aggregati (solo statistiche, nessun dato identificativo) $aggregated = $this->buildAggregatedData($orgIds); // Chiama AI try { $ai = new AIService(); $result = $ai->crossOrgAnalysis(count($orgIds), $aggregated, $question); } catch (RuntimeException $e) { $this->jsonError('Errore AI: ' . $e->getMessage(), 503, 'AI_ERROR'); } // Audit log obbligatorio (log query, NON i dati restituiti) $this->logCrossQuery($orgIds, $question, $result); $this->jsonSuccess([ 'result' => $result, 'org_count' => count($orgIds), 'context' => [ 'sectors' => array_keys($aggregated['by_sector'] ?? []), 'entity_types' => array_keys($aggregated['by_entity_type'] ?? []), ], ], 'Analisi completata'); } // ───────────────────────────────────────────────────────────────── // GET /api/cross-analysis/history // ───────────────────────────────────────────────────────────────── public function history(): void { $this->requireAuth(); $this->requireCrossRole(); $userId = $this->getCurrentUserId(); $limit = min((int)$this->getParam('limit', 20), 50); $rows = Database::fetchAll( "SELECT id, interaction_type, prompt_summary, response_summary, tokens_used, created_at FROM ai_interactions WHERE user_id = ? AND interaction_type = 'cross_org_analysis' ORDER BY created_at DESC LIMIT ?", [$userId, $limit] ); $this->jsonSuccess(['history' => $rows, 'total' => count($rows)]); } // ───────────────────────────────────────────────────────────────── // GET /api/cross-analysis/portfolio // Restituisce statistiche aggregate del portfolio (senza AI) // ───────────────────────────────────────────────────────────────── public function portfolio(): void { $this->requireAuth(); $this->requireCrossRole(); $orgIds = $this->getAccessibleOrgIds(); if (empty($orgIds)) { $this->jsonSuccess(['orgs' => [], 'aggregated' => null]); } $aggregated = $this->buildAggregatedData($orgIds); $this->jsonSuccess([ 'org_count' => count($orgIds), 'aggregated' => $aggregated, ]); } // ───────────────────────────────────────────────────────────────── // PRIVATE — autorizzazione // ───────────────────────────────────────────────────────────────── private function requireCrossRole(): void { $user = $this->getCurrentUser(); if (!$user) { $this->jsonError('Non autenticato', 401, 'UNAUTHENTICATED'); } $role = $user['role'] ?? ''; if (!in_array($role, self::ALLOWED_ROLES, true)) { $this->jsonError( 'Funzione riservata a Consulenti e Super Admin', 403, 'INSUFFICIENT_ROLE' ); } } // ───────────────────────────────────────────────────────────────── // PRIVATE — org IDs accessibili // ───────────────────────────────────────────────────────────────── private function getAccessibleOrgIds(): array { $user = $this->getCurrentUser(); $userId = (int)$user['id']; $role = $user['role'] ?? ''; if ($role === 'super_admin') { // Super admin vede tutte le org attive $rows = Database::fetchAll( 'SELECT id FROM organizations WHERE deleted_at IS NULL ORDER BY id', [] ); } else { // Consultant: solo org in cui è membro $rows = Database::fetchAll( 'SELECT uo.organization_id AS id FROM user_organizations uo JOIN organizations o ON o.id = uo.organization_id WHERE uo.user_id = ? AND o.deleted_at IS NULL', [$userId] ); } return array_column($rows, 'id'); } // ───────────────────────────────────────────────────────────────── // PRIVATE — aggregazione dati (NESSUN NOME ORG, NESSUN PII) // ───────────────────────────────────────────────────────────────── private function buildAggregatedData(array $orgIds): array { $in = implode(',', array_fill(0, count($orgIds), '?')); // 1. Distribuzione settoriale $sectorRows = Database::fetchAll( "SELECT sector, COUNT(*) as cnt FROM organizations WHERE id IN ({$in}) GROUP BY sector", $orgIds ); $bySector = []; foreach ($sectorRows as $r) { $bySector[$r['sector'] ?: 'Non specificato'] = (int)$r['cnt']; } // 2. Classificazione NIS2 $entityRows = Database::fetchAll( "SELECT entity_type, COUNT(*) as cnt FROM organizations WHERE id IN ({$in}) GROUP BY entity_type", $orgIds ); $byEntityType = []; foreach ($entityRows as $r) { $byEntityType[$r['entity_type'] ?: 'Non classificato'] = (int)$r['cnt']; } // 3. Compliance score medio (da assessments completati, uno per org) $scoreRows = Database::fetchAll( "SELECT a.organization_id, AVG(ar.compliance_level) as avg_score FROM assessments a JOIN assessment_responses ar ON ar.assessment_id = a.id WHERE a.organization_id IN ({$in}) AND a.status = 'completed' GROUP BY a.organization_id", $orgIds ); $scores = array_column($scoreRows, 'avg_score'); $avgScore = count($scores) > 0 ? round(array_sum($scores) / count($scores) * 100, 1) : null; // Distribuzione score $scoreDist = ['0-20%' => 0, '21-40%' => 0, '41-60%' => 0, '61-80%' => 0, '81-100%' => 0]; foreach ($scores as $s) { $pct = $s * 100; if ($pct <= 20) $scoreDist['0-20%']++; elseif ($pct <= 40) $scoreDist['21-40%']++; elseif ($pct <= 60) $scoreDist['41-60%']++; elseif ($pct <= 80) $scoreDist['61-80%']++; else $scoreDist['81-100%']++; } // 4. Assessment: org con assessment completato + score medio per categoria $completedOrgs = count($scoreRows); $catRows = Database::fetchAll( "SELECT ar.category, AVG(CASE ar.response_value WHEN 'implemented' THEN 100 WHEN 'partial' THEN 50 WHEN 'not_implemented' THEN 0 ELSE NULL END) AS avg_score FROM assessments a JOIN assessment_responses ar ON ar.assessment_id = a.id WHERE a.organization_id IN ({$in}) AND a.status = 'completed' AND ar.response_value != 'not_applicable' GROUP BY ar.category", $orgIds ); $avgByCategory = []; foreach ($catRows as $r) { if ($r['category']) { $avgByCategory[$r['category']] = round((float)$r['avg_score'], 1); } } asort($avgByCategory); // ordinati dal più basso // 5. Rischi $riskRows = Database::fetchAll( "SELECT severity, COUNT(*) as cnt FROM risks WHERE organization_id IN ({$in}) AND deleted_at IS NULL AND status != 'closed' GROUP BY severity", $orgIds ); $bySeverity = []; $totalRisks = 0; foreach ($riskRows as $r) { $bySeverity[$r['severity']] = (int)$r['cnt']; $totalRisks += (int)$r['cnt']; } $risksWithTreat = Database::fetchOne( "SELECT COUNT(DISTINCT r.id) as cnt FROM risks r JOIN risk_treatments rt ON rt.risk_id = r.id WHERE r.organization_id IN ({$in}) AND r.deleted_at IS NULL", $orgIds ); $pctWithTreatment = $totalRisks > 0 ? round((int)($risksWithTreat['cnt'] ?? 0) / $totalRisks * 100) : 0; // 6. Policy $polRows = Database::fetchAll( "SELECT organization_id, COUNT(*) as approved_count FROM policies WHERE organization_id IN ({$in}) AND status IN ('approved','published') AND deleted_at IS NULL GROUP BY organization_id", $orgIds ); $approvedCounts = array_column($polRows, 'approved_count'); $avgApproved = count($approvedCounts) > 0 ? round(array_sum($approvedCounts) / count($orgIds), 1) : 0; $withoutApproved = count($orgIds) - count($approvedCounts); // 7. Formazione $trainRows = Database::fetchAll( "SELECT organization_id, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as done, COUNT(*) as total FROM training_assignments WHERE organization_id IN ({$in}) GROUP BY organization_id", $orgIds ); $completionRates = []; $below50 = 0; foreach ($trainRows as $r) { $rate = $r['total'] > 0 ? round($r['done'] / $r['total'] * 100) : 0; $completionRates[] = $rate; if ($rate < 50) $below50++; } $avgTraining = count($completionRates) > 0 ? round(array_sum($completionRates) / count($completionRates)) : 0; // 8. Controlli $ctrlRows = Database::fetchAll( "SELECT organization_id, SUM(CASE WHEN implementation_status = 'implemented' THEN 1 ELSE 0 END) as impl, COUNT(*) as total FROM compliance_controls WHERE organization_id IN ({$in}) GROUP BY organization_id", $orgIds ); $ctrlRates = []; $weakestCats = []; foreach ($ctrlRows as $r) { $ctrlRates[] = $r['total'] > 0 ? round($r['impl'] / $r['total'] * 100) : 0; } $avgCtrl = count($ctrlRates) > 0 ? round(array_sum($ctrlRates) / count($ctrlRates)) : 0; // Categorie controlli più deboli (aggregate) $weakRows = Database::fetchAll( "SELECT category, AVG(CASE WHEN implementation_status = 'implemented' THEN 100 ELSE 0 END) as rate FROM compliance_controls WHERE organization_id IN ({$in}) GROUP BY category ORDER BY rate ASC LIMIT 3", $orgIds ); $weakestCats = array_column($weakRows, 'category'); // 9. Incidenti (ultimi 12 mesi) $incRows = Database::fetchAll( "SELECT severity, COUNT(*) as cnt FROM incidents WHERE organization_id IN ({$in}) AND created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH) GROUP BY severity", $orgIds ); $incBySeverity = []; $totalInc = 0; foreach ($incRows as $r) { $incBySeverity[$r['severity']] = (int)$r['cnt']; $totalInc += (int)$r['cnt']; } $orgsWithInc = Database::fetchOne( "SELECT COUNT(DISTINCT organization_id) as cnt FROM incidents WHERE organization_id IN ({$in}) AND created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)", $orgIds ); return [ 'by_sector' => $bySector, 'by_entity_type' => $byEntityType, 'avg_compliance_score'=> $avgScore, 'score_distribution' => $scoreDist, 'assessments' => [ 'with_completed' => $completedOrgs, 'avg_by_category' => $avgByCategory, ], 'risks' => [ 'total_open' => $totalRisks, 'avg_per_org' => $orgIds ? round($totalRisks / count($orgIds), 1) : 0, 'by_severity' => $bySeverity, 'pct_with_treatment' => $pctWithTreatment, ], 'policies' => [ 'avg_approved' => $avgApproved, 'without_approved'=> $withoutApproved, ], 'training' => [ 'avg_completion_rate' => $avgTraining, 'below_50pct' => $below50, ], 'controls' => [ 'avg_implementation' => $avgCtrl, 'weakest_categories' => $weakestCats, ], 'incidents' => [ 'total' => $totalInc, 'orgs_with_incidents'=> (int)($orgsWithInc['cnt'] ?? 0), 'by_severity' => $incBySeverity, ], ]; } // ───────────────────────────────────────────────────────────────── // PRIVATE — audit log // ───────────────────────────────────────────────────────────────── private function logCrossQuery(array $orgIds, string $question, array $result): void { // Log nella tabella ai_interactions // org_id = 0 (query cross-org, non legata a singola org) // prompt_summary contiene la domanda (non i dati aggregati per sicurezza) // response_summary contiene un estratto della risposta $responseSummary = isset($result['answer']) ? substr($result['answer'], 0, 500) : substr(json_encode($result), 0, 500); $orgIdsStr = implode(',', $orgIds); Database::insert('ai_interactions', [ 'organization_id' => 0, 'user_id' => $this->getCurrentUserId(), 'interaction_type' => 'cross_org_analysis', 'prompt_summary' => "CROSS_ORG [{$orgIdsStr}]: " . substr($question, 0, 400), 'response_summary' => $responseSummary, 'tokens_used' => 0, 'model_used' => ANTHROPIC_MODEL, ]); } }