Files
nis2-agile/application/controllers/ConsultantController.php
T
DevEnv nis2-agileandClaude Opus 4.8 faadb62da6 [FEAT] Ruolo consulente: visibilità firm-scoped (org-switcher + Cruscotto Studio)
Un utente con role='consultant' e consulting_firm_id vede solo i CLIENTI del
proprio studio (organizations.consulting_firm_id = suo firm) UNION le proprie
membership dirette — non tutte le org (quello resta super_admin), non solo le
membership. Applicato in OrganizationController::list (selettore azienda) e
ConsultantController::portfolio (cruscotto). Additivo: super_admin e altri ruoli
invariati. Verificato: utente-test consultant/firm1 vede 2 aziende (cliente studio
996003 + membership 996001), non tutte. version 1.25.11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 15:53:54 +02:00

162 lines
7.9 KiB
PHP

<?php
require_once __DIR__ . '/BaseController.php';
/**
* ConsultantController — Cruscotto Studio (consulente / società di consulenza).
* Vista d'insieme su TUTTE le aziende gestite dall'utente: compliance, rischi,
* incidenti e SCADENZE GENERALI aggregate. Read-only, org-scoping = visibilità
* dell'utente (super_admin = tutte le org attive; altri = le proprie membership).
*/
class ConsultantController extends BaseController
{
/**
* GET /api/consultant/portfolio
*/
public function portfolio(): void
{
$this->requireAuth();
$user = $this->getCurrentUser() ?? [];
$uid = (int) $this->getCurrentUserId();
// Aziende visibili all'utente (stessa regola di OrganizationController::list):
// super_admin = tutte; consulente = clienti del proprio studio (consulting_firm_id)
// UNION le proprie membership; altri = solo le proprie membership.
$firmId = (int) ($user['consulting_firm_id'] ?? 0);
if (($user['role'] ?? '') === 'super_admin') {
$orgs = Database::fetchAll(
"SELECT id, name, entity_type, voluntary_compliance, sector
FROM organizations WHERE is_active = 1 ORDER BY name LIMIT 200"
);
} elseif (($user['role'] ?? '') === 'consultant' && $firmId > 0) {
$orgs = Database::fetchAll(
"SELECT DISTINCT o.id, o.name, o.entity_type, o.voluntary_compliance, o.sector
FROM organizations o
LEFT JOIN user_organizations uo ON uo.organization_id = o.id AND uo.user_id = ?
WHERE o.is_active = 1 AND (o.consulting_firm_id = ? OR uo.user_id IS NOT NULL)
ORDER BY o.name LIMIT 200",
[$uid, $firmId]
);
} else {
$orgs = Database::fetchAll(
"SELECT o.id, o.name, o.entity_type, o.voluntary_compliance, o.sector
FROM organizations o
JOIN user_organizations uo ON uo.organization_id = o.id
WHERE uo.user_id = ? AND o.is_active = 1 ORDER BY o.name LIMIT 200",
[$uid]
);
}
$now = date('Y-m-d H:i:s');
$companies = [];
$allDeadlines = [];
$sumScore = 0; $nScore = 0; $overdueTotal = 0;
foreach ($orgs as $o) {
$oid = (int) $o['id'];
$asmt = Database::fetchOne(
"SELECT overall_score FROM assessments
WHERE organization_id = ? AND status = 'completed'
ORDER BY completed_at DESC LIMIT 1", [$oid]);
$score = ($asmt && $asmt['overall_score'] !== null) ? (int) round((float) $asmt['overall_score']) : null;
$risks = (int) (Database::fetchOne(
"SELECT COUNT(*) c FROM risks WHERE organization_id = ? AND status <> 'closed'", [$oid])['c'] ?? 0);
$incs = (int) (Database::fetchOne(
"SELECT COUNT(*) c FROM incidents WHERE organization_id = ? AND status NOT IN ('closed','post_mortem')", [$oid])['c'] ?? 0);
$dls = $this->collectDeadlines($oid);
$overdue = 0;
foreach ($dls as $d) {
$d['org_id'] = $oid;
$d['org_name'] = $o['name'];
$d['overdue'] = ($d['due_date'] < $now);
if ($d['overdue']) { $overdue++; }
$allDeadlines[] = $d;
}
if ($score !== null) { $sumScore += $score; $nScore++; }
$overdueTotal += $overdue;
$companies[] = [
'id' => $oid,
'name' => $o['name'],
'entity_type' => $o['entity_type'],
'voluntary_compliance' => (int) $o['voluntary_compliance'],
'sector' => $o['sector'],
'compliance_score' => $score,
'open_risks' => $risks,
'open_incidents' => $incs,
'deadlines_overdue' => $overdue,
'deadlines_upcoming' => count($dls) - $overdue,
];
}
usort($allDeadlines, fn($a, $b) => strcmp((string) $a['due_date'], (string) $b['due_date']));
$this->jsonSuccess([
'companies' => $companies,
'deadlines' => $allDeadlines,
'kpis' => [
'total_companies' => count($companies),
'avg_compliance' => $nScore ? (int) round($sumScore / $nScore) : null,
'overdue_total' => $overdueTotal,
'upcoming_total' => count($allDeadlines) - $overdueTotal,
],
]);
}
/**
* Scadenze "aperte" di una org (incidenti CSIRT, policy, trattamenti rischio,
* formazione) con orizzonte a +30 giorni ma includendo anche gli scaduti (past-due),
* così il cruscotto distingue "in ritardo" da "in arrivo". Fonti coerenti con
* DashboardController::deadlines().
*/
private function collectDeadlines(int $orgId): array
{
$out = [];
$horizon = "DATE_ADD(NOW(), INTERVAL 30 DAY)";
$inc = Database::fetchAll(
"SELECT id, title, early_warning_due, early_warning_sent_at, notification_due,
notification_sent_at, final_report_due, final_report_sent_at
FROM incidents
WHERE organization_id = ? AND is_significant = 1 AND status NOT IN ('closed','post_mortem')",
[$orgId]);
foreach ($inc as $i) {
if ($i['early_warning_due'] && !$i['early_warning_sent_at'])
$out[] = ['type'=>'incident_early_warning','title'=>"Early Warning: {$i['title']}",'due_date'=>$i['early_warning_due'],'severity'=>'critical','entity_type'=>'incident','entity_id'=>(int)$i['id']];
if ($i['notification_due'] && !$i['notification_sent_at'])
$out[] = ['type'=>'incident_notification','title'=>"Notifica CSIRT: {$i['title']}",'due_date'=>$i['notification_due'],'severity'=>'high','entity_type'=>'incident','entity_id'=>(int)$i['id']];
if ($i['final_report_due'] && !$i['final_report_sent_at'])
$out[] = ['type'=>'incident_final_report','title'=>"Report finale: {$i['title']}",'due_date'=>$i['final_report_due'],'severity'=>'medium','entity_type'=>'incident','entity_id'=>(int)$i['id']];
}
foreach (Database::fetchAll(
"SELECT id, title, next_review_date FROM policies
WHERE organization_id = ? AND next_review_date IS NOT NULL
AND next_review_date <= $horizon AND status NOT IN ('archived')", [$orgId]) as $p) {
$out[] = ['type'=>'policy_review','title'=>"Revisione policy: {$p['title']}",'due_date'=>$p['next_review_date'],'severity'=>'medium','entity_type'=>'policy','entity_id'=>(int)$p['id']];
}
foreach (Database::fetchAll(
"SELECT rt.id, rt.due_date, r.title AS risk_title FROM risk_treatments rt
JOIN risks r ON r.id = rt.risk_id
WHERE r.organization_id = ? AND rt.status IN ('planned','in_progress')
AND rt.due_date IS NOT NULL AND rt.due_date <= $horizon", [$orgId]) as $t) {
$out[] = ['type'=>'risk_treatment','title'=>"Trattamento rischio: {$t['risk_title']}",'due_date'=>$t['due_date'],'severity'=>'medium','entity_type'=>'risk_treatment','entity_id'=>(int)$t['id']];
}
foreach (Database::fetchAll(
"SELECT ta.id, tc.title, ta.due_date FROM training_assignments ta
JOIN training_courses tc ON tc.id = ta.course_id
WHERE ta.organization_id = ? AND ta.status IN ('assigned','in_progress')
AND ta.due_date IS NOT NULL AND ta.due_date <= $horizon", [$orgId]) as $tr) {
$out[] = ['type'=>'training_due','title'=>"Formazione: {$tr['title']}",'due_date'=>$tr['due_date'],'severity'=>'low','entity_type'=>'training','entity_id'=>(int)$tr['id']];
}
return $out;
}
}