Allineato il commento del regime obblighi: Allegato 4 essenziali / Allegato 3 importanti
(coerente con AIService 0d748c6 e la fonte certa Allegato3/4.txt). La logica era gia'
corretta, solo il commento era fuorviante.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
547 lines
22 KiB
PHP
547 lines
22 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Incident Controller
|
|
*
|
|
* Gestione incidenti con workflow NIS2 Art. 23 (24h/72h/30d).
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
require_once APP_PATH . '/services/AIService.php';
|
|
require_once APP_PATH . '/services/EmailService.php';
|
|
require_once APP_PATH . '/services/WebhookService.php';
|
|
|
|
class IncidentController extends BaseController
|
|
{
|
|
/**
|
|
* GET /api/incidents/list
|
|
*/
|
|
public function list(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$pagination = $this->getPagination();
|
|
|
|
$where = 'organization_id = ?';
|
|
$params = [$this->getCurrentOrgId()];
|
|
|
|
if ($this->hasParam('status')) {
|
|
$where .= ' AND status = ?';
|
|
$params[] = $this->getParam('status');
|
|
}
|
|
if ($this->hasParam('severity')) {
|
|
$where .= ' AND severity = ?';
|
|
$params[] = $this->getParam('severity');
|
|
}
|
|
|
|
$total = Database::count('incidents', $where, $params);
|
|
|
|
$incidents = Database::fetchAll(
|
|
"SELECT i.*, u1.full_name as reported_by_name, u2.full_name as assigned_to_name
|
|
FROM incidents i
|
|
LEFT JOIN users u1 ON u1.id = i.reported_by
|
|
LEFT JOIN users u2 ON u2.id = i.assigned_to
|
|
WHERE i.{$where}
|
|
ORDER BY i.detected_at DESC
|
|
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}",
|
|
$params
|
|
);
|
|
|
|
$this->jsonPaginated($incidents, $total, $pagination['page'], $pagination['per_page']);
|
|
}
|
|
|
|
/**
|
|
* POST /api/incidents/create
|
|
*/
|
|
public function create(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'employee']);
|
|
$this->validateRequired(['title', 'classification', 'severity', 'detected_at']);
|
|
|
|
$detectedAt = $this->getParam('detected_at');
|
|
$isSignificant = (bool) $this->getParam('is_significant', false);
|
|
|
|
// Regime obblighi NIS2 (Determina ACN 164179/2025): Allegato 4 essenziali / Allegato 3 importanti.
|
|
$org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
|
|
$entityObligation = ($org && ($org['entity_type'] ?? '') === 'essential') ? 'essential' : 'important';
|
|
|
|
// IS-4 (incidenti ricorrenti) non si applica ai soggetti importanti.
|
|
$isType = $this->getParam('nis2_incident_type');
|
|
$validIs = $entityObligation === 'essential' ? ['IS-1','IS-2','IS-3','IS-4'] : ['IS-1','IS-2','IS-3'];
|
|
if ($isType !== null && !in_array($isType, $validIs, true)) {
|
|
$isType = null;
|
|
}
|
|
|
|
$data = [
|
|
'organization_id' => $this->getCurrentOrgId(),
|
|
'incident_code' => $this->generateCode('INC'),
|
|
'title' => trim($this->getParam('title')),
|
|
'description' => $this->getParam('description'),
|
|
'classification' => $this->getParam('classification'),
|
|
'severity' => $this->getParam('severity'),
|
|
'is_significant' => $isSignificant ? 1 : 0,
|
|
'nis2_incident_type' => $isType,
|
|
'entity_obligation' => $entityObligation,
|
|
'detected_at' => $detectedAt,
|
|
'affected_services' => $this->getParam('affected_services'),
|
|
'affected_users_count' => $this->getParam('affected_users_count'),
|
|
'cross_border_impact' => $this->getParam('cross_border_impact', 0),
|
|
'malicious_action' => $this->getParam('malicious_action', 0),
|
|
'reported_by' => $this->getCurrentUserId(),
|
|
'assigned_to' => $this->getParam('assigned_to'),
|
|
];
|
|
|
|
// Calcola scadenze NIS2 Art. 23 se significativo
|
|
if ($isSignificant) {
|
|
$detectedTime = strtotime($detectedAt);
|
|
$data['early_warning_due'] = date('Y-m-d H:i:s', $detectedTime + 24 * 3600); // +24h
|
|
$data['notification_due'] = date('Y-m-d H:i:s', $detectedTime + 72 * 3600); // +72h
|
|
$data['final_report_due'] = date('Y-m-d H:i:s', $detectedTime + 30 * 86400); // +30 giorni
|
|
}
|
|
|
|
$incidentId = Database::insert('incidents', $data);
|
|
|
|
// Aggiungi evento timeline
|
|
Database::insert('incident_timeline', [
|
|
'incident_id' => $incidentId,
|
|
'event_type' => 'detection',
|
|
'description' => "Incidente rilevato: {$data['title']}",
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
|
|
$this->logAudit('incident_created', 'incident', $incidentId, [
|
|
'severity' => $data['severity'], 'is_significant' => $isSignificant
|
|
]);
|
|
|
|
// Dispatch webhook events
|
|
try {
|
|
$incident = array_merge($data, ['id' => $incidentId]);
|
|
$webhookSvc = new WebhookService();
|
|
$webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.created', WebhookService::incidentPayload($incident, 'created'));
|
|
if ($isSignificant) {
|
|
$webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.significant', WebhookService::incidentPayload($incident, 'significant'));
|
|
}
|
|
} catch (Throwable $e) {
|
|
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
|
|
}
|
|
|
|
$this->jsonSuccess([
|
|
'id' => $incidentId,
|
|
'incident_code' => $data['incident_code'],
|
|
'is_significant' => $isSignificant,
|
|
'deadlines' => $isSignificant ? [
|
|
'early_warning' => $data['early_warning_due'],
|
|
'notification' => $data['notification_due'],
|
|
'final_report' => $data['final_report_due'],
|
|
] : null,
|
|
], 'Incidente registrato', 201);
|
|
}
|
|
|
|
/**
|
|
* GET /api/incidents/{id}
|
|
*/
|
|
public function get(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
|
|
$incident = Database::fetchOne(
|
|
'SELECT i.*, u1.full_name as reported_by_name, u2.full_name as assigned_to_name
|
|
FROM incidents i
|
|
LEFT JOIN users u1 ON u1.id = i.reported_by
|
|
LEFT JOIN users u2 ON u2.id = i.assigned_to
|
|
WHERE i.id = ? AND i.organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (!$incident) {
|
|
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
|
|
}
|
|
|
|
$incident['timeline'] = Database::fetchAll(
|
|
'SELECT it.*, u.full_name as created_by_name
|
|
FROM incident_timeline it
|
|
LEFT JOIN users u ON u.id = it.created_by
|
|
WHERE it.incident_id = ?
|
|
ORDER BY it.created_at',
|
|
[$id]
|
|
);
|
|
|
|
$this->jsonSuccess($incident);
|
|
}
|
|
|
|
/**
|
|
* PUT /api/incidents/{id}
|
|
*/
|
|
public function update(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
|
|
$incident = Database::fetchOne(
|
|
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (!$incident) {
|
|
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
|
|
}
|
|
|
|
$updates = [];
|
|
$allowedFields = [
|
|
'title', 'description', 'classification', 'severity', 'is_significant',
|
|
'nis2_incident_type', 'status', 'affected_services', 'affected_users_count',
|
|
'cross_border_impact', 'malicious_action', 'root_cause', 'remediation_actions',
|
|
'lessons_learned', 'assigned_to',
|
|
];
|
|
|
|
foreach ($allowedFields as $field) {
|
|
if ($this->hasParam($field)) {
|
|
$updates[$field] = $this->getParam($field);
|
|
}
|
|
}
|
|
|
|
// Timbra automaticamente i timestamp di fase al primo ingresso nello stato
|
|
// (per il calcolo metriche TTD/TTC/TTR). Non sovrascrive valori gia' presenti.
|
|
if (isset($updates['status'])) {
|
|
$now = date('Y-m-d H:i:s');
|
|
$stamp = [
|
|
'analyzing' => 'triaged_at',
|
|
'containing' => 'contained_at',
|
|
'eradicating'=> 'eradicated_at',
|
|
'recovering' => 'recovered_at',
|
|
];
|
|
$col = $stamp[$updates['status']] ?? null;
|
|
if ($col !== null && empty($incident[$col])) {
|
|
$updates[$col] = $now;
|
|
}
|
|
if ($updates['status'] === 'closed') {
|
|
$updates['closed_at'] = $now;
|
|
if (empty($incident['recovered_at'])) $updates['recovered_at'] = $now;
|
|
}
|
|
}
|
|
|
|
// Se diventa significativo, calcola scadenze
|
|
if (isset($updates['is_significant']) && $updates['is_significant'] && !$incident['is_significant']) {
|
|
$detectedTime = strtotime($incident['detected_at']);
|
|
$updates['early_warning_due'] = date('Y-m-d H:i:s', $detectedTime + 24 * 3600);
|
|
$updates['notification_due'] = date('Y-m-d H:i:s', $detectedTime + 72 * 3600);
|
|
$updates['final_report_due'] = date('Y-m-d H:i:s', $detectedTime + 30 * 86400);
|
|
}
|
|
|
|
if (!empty($updates)) {
|
|
Database::update('incidents', $updates, 'id = ?', [$id]);
|
|
$this->logAudit('incident_updated', 'incident', $id, $updates);
|
|
|
|
// Dispatch webhook: incident.updated e incident.significant se appena flaggato
|
|
try {
|
|
$updatedIncident = Database::fetchOne('SELECT * FROM incidents WHERE id = ?', [$id]);
|
|
$webhookSvc = new WebhookService();
|
|
$webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.updated', WebhookService::incidentPayload($updatedIncident, 'updated'));
|
|
if (isset($updates['is_significant']) && $updates['is_significant'] && !$incident['is_significant']) {
|
|
$webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.significant', WebhookService::incidentPayload($updatedIncident, 'significant'));
|
|
}
|
|
} catch (Throwable $e) {
|
|
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$this->jsonSuccess($updates, 'Incidente aggiornato');
|
|
}
|
|
|
|
/**
|
|
* POST /api/incidents/{id}/timeline
|
|
*/
|
|
public function addTimelineEvent(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'employee']);
|
|
$this->validateRequired(['event_type', 'description']);
|
|
|
|
$incident = Database::fetchOne(
|
|
'SELECT id FROM incidents WHERE id = ? AND organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (!$incident) {
|
|
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
|
|
}
|
|
|
|
$eventId = Database::insert('incident_timeline', [
|
|
'incident_id' => $id,
|
|
'event_type' => $this->getParam('event_type'),
|
|
'description' => $this->getParam('description'),
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
|
|
$this->jsonSuccess(['id' => $eventId], 'Evento aggiunto alla timeline', 201);
|
|
}
|
|
|
|
/**
|
|
* POST /api/incidents/{id}/early-warning
|
|
* Registra invio early warning (24h) al CSIRT
|
|
*/
|
|
public function sendEarlyWarning(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
|
|
Database::update('incidents', [
|
|
'early_warning_sent_at' => date('Y-m-d H:i:s'),
|
|
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
|
|
|
Database::insert('incident_timeline', [
|
|
'incident_id' => $id,
|
|
'event_type' => 'notification',
|
|
'description' => 'Early warning (24h) inviato al CSIRT nazionale (ACN)',
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
|
|
// Invia notifica email ai responsabili
|
|
$this->notifyIncidentStakeholders($id, 'early_warning');
|
|
|
|
$this->logAudit('early_warning_sent', 'incident', $id);
|
|
$this->jsonSuccess(null, 'Early warning registrato');
|
|
}
|
|
|
|
/**
|
|
* POST /api/incidents/{id}/notification
|
|
* Registra invio notifica (72h) al CSIRT
|
|
*/
|
|
public function sendNotification(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
|
|
Database::update('incidents', [
|
|
'notification_sent_at' => date('Y-m-d H:i:s'),
|
|
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
|
|
|
Database::insert('incident_timeline', [
|
|
'incident_id' => $id,
|
|
'event_type' => 'notification',
|
|
'description' => 'Notifica incidente (72h) inviata al CSIRT nazionale (ACN)',
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
|
|
// Invia notifica email ai responsabili
|
|
$this->notifyIncidentStakeholders($id, 'notification');
|
|
|
|
$this->logAudit('notification_sent', 'incident', $id);
|
|
$this->jsonSuccess(null, 'Notifica CSIRT registrata');
|
|
}
|
|
|
|
/**
|
|
* POST /api/incidents/{id}/final-report
|
|
* Registra invio report finale (30 giorni)
|
|
*/
|
|
public function sendFinalReport(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
|
|
Database::update('incidents', [
|
|
'final_report_sent_at' => date('Y-m-d H:i:s'),
|
|
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
|
|
|
|
Database::insert('incident_timeline', [
|
|
'incident_id' => $id,
|
|
'event_type' => 'notification',
|
|
'description' => 'Report finale (30 giorni) inviato al CSIRT nazionale (ACN)',
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
|
|
// Invia notifica email ai responsabili
|
|
$this->notifyIncidentStakeholders($id, 'final_report');
|
|
|
|
$this->logAudit('final_report_sent', 'incident', $id);
|
|
$this->jsonSuccess(null, 'Report finale registrato');
|
|
}
|
|
|
|
/**
|
|
* Notifica stakeholder via email per milestone incidente
|
|
*/
|
|
private function notifyIncidentStakeholders(int $incidentId, string $type): void
|
|
{
|
|
try {
|
|
$incident = Database::fetchOne('SELECT * FROM incidents WHERE id = ?', [$incidentId]);
|
|
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
|
|
|
|
if (!$incident || !$org) return;
|
|
|
|
// Trova org_admin e compliance_manager
|
|
$recipients = Database::fetchAll(
|
|
'SELECT u.email, u.full_name FROM users u
|
|
JOIN user_organizations uo ON uo.user_id = u.id
|
|
WHERE uo.organization_id = ? AND uo.role IN ("org_admin", "compliance_manager") AND u.is_active = 1',
|
|
[$this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (empty($recipients)) return;
|
|
|
|
$emailService = new EmailService();
|
|
$emails = array_column($recipients, 'email');
|
|
|
|
match ($type) {
|
|
'early_warning' => $emailService->sendIncidentEarlyWarning($incident, $org, $emails),
|
|
'notification' => $emailService->sendIncidentNotification($incident, $org, $emails),
|
|
'final_report' => $emailService->sendIncidentFinalReport($incident, $org, $emails),
|
|
};
|
|
} catch (Throwable $e) {
|
|
error_log('[EMAIL_ERROR] ' . $e->getMessage());
|
|
// Non bloccare il flusso principale per errori email
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/incidents/{id}/ai-classify
|
|
*/
|
|
public function aiClassify(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
|
|
$incident = Database::fetchOne(
|
|
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (!$incident) {
|
|
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
|
|
}
|
|
|
|
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
|
|
|
|
try {
|
|
$aiService = new AIService();
|
|
$classification = $aiService->classifyIncident($incident['title'], $incident['description'] ?? '', $org);
|
|
|
|
$aiService->logInteraction(
|
|
$this->getCurrentOrgId(),
|
|
$this->getCurrentUserId(),
|
|
'incident_classification',
|
|
"Classify incident #{$id}: {$incident['title']}",
|
|
substr(json_encode($classification), 0, 500)
|
|
);
|
|
|
|
$this->jsonSuccess($classification, 'Classificazione AI completata');
|
|
} catch (Throwable $e) {
|
|
$this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/incidents/{id}/metrics
|
|
* Calcola TTD/TTC/TTR e downtime dai timestamp di fase (in minuti).
|
|
*/
|
|
public function metrics(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$inc = Database::fetchOne(
|
|
'SELECT detected_at, triaged_at, contained_at, eradicated_at, recovered_at, closed_at, affected_users_count
|
|
FROM incidents WHERE id = ? AND organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
if (!$inc) {
|
|
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
|
|
}
|
|
$this->jsonSuccess($this->computeMetrics($inc));
|
|
}
|
|
|
|
/** Differenza in minuti tra due datetime, null se mancante. */
|
|
private function minutesBetween(?string $from, ?string $to): ?int
|
|
{
|
|
if (empty($from) || empty($to)) return null;
|
|
$a = strtotime($from); $b = strtotime($to);
|
|
if ($a === false || $b === false) return null;
|
|
return (int) round(($b - $a) / 60);
|
|
}
|
|
|
|
private function computeMetrics(array $inc): array
|
|
{
|
|
$det = $inc['detected_at'] ?? null;
|
|
return [
|
|
'ttd_minutes' => $this->minutesBetween($det, $inc['triaged_at'] ?? null),
|
|
'ttc_minutes' => $this->minutesBetween($det, $inc['contained_at'] ?? null),
|
|
'ttr_minutes' => $this->minutesBetween($det, $inc['recovered_at'] ?? null),
|
|
'downtime_minutes' => $this->minutesBetween($det, $inc['recovered_at'] ?? $inc['closed_at'] ?? null),
|
|
'affected_users' => isset($inc['affected_users_count']) ? (int) $inc['affected_users_count'] : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* GET /api/incidents/{id}/pir
|
|
* Ritorna la Post-Incident Review (RC.CO-03) con le metriche calcolate.
|
|
*/
|
|
public function getPir(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$inc = Database::fetchOne(
|
|
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
if (!$inc) {
|
|
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
|
|
}
|
|
$pir = Database::fetchOne('SELECT * FROM incident_pir WHERE incident_id = ?', [$id]);
|
|
if ($pir && !empty($pir['improvement_actions'])) {
|
|
$pir['improvement_actions'] = json_decode($pir['improvement_actions'], true);
|
|
}
|
|
$this->jsonSuccess([
|
|
'pir' => $pir,
|
|
'metrics' => $this->computeMetrics($inc),
|
|
'reference' => 'RC.CO-03 (NIST CSF) - PIR da completare entro 2 settimane dalla chiusura per incidenti critici',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/incidents/{id}/pir
|
|
* Crea o aggiorna la Post-Incident Review (upsert).
|
|
*/
|
|
public function savePir(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$inc = Database::fetchOne(
|
|
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
if (!$inc) {
|
|
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
|
|
}
|
|
|
|
$m = $this->computeMetrics($inc);
|
|
$actions = $this->getParam('improvement_actions');
|
|
|
|
$fields = [
|
|
'organization_id' => $this->getCurrentOrgId(),
|
|
'problem_statement' => $this->getParam('problem_statement'),
|
|
'why_1' => $this->getParam('why_1'), 'why_2' => $this->getParam('why_2'),
|
|
'why_3' => $this->getParam('why_3'), 'why_4' => $this->getParam('why_4'),
|
|
'why_5' => $this->getParam('why_5'),
|
|
'root_cause' => $this->getParam('root_cause'),
|
|
'ttd_minutes' => $m['ttd_minutes'],
|
|
'ttc_minutes' => $m['ttc_minutes'],
|
|
'ttr_minutes' => $m['ttr_minutes'],
|
|
'downtime_minutes' => $m['downtime_minutes'],
|
|
'affected_users' => $m['affected_users'],
|
|
'estimated_cost_eur' => $this->getParam('estimated_cost_eur'),
|
|
'notification_compliance' => $this->getParam('notification_compliance') !== null ? (int)(bool)$this->getParam('notification_compliance') : null,
|
|
'what_went_well' => $this->getParam('what_went_well'),
|
|
'what_to_improve' => $this->getParam('what_to_improve'),
|
|
'improvement_actions' => is_array($actions) ? json_encode($actions, JSON_UNESCAPED_UNICODE) : null,
|
|
'participants' => $this->getParam('participants'),
|
|
'reviewed_by' => $this->getCurrentUserId(),
|
|
'reviewed_at' => date('Y-m-d H:i:s'),
|
|
'status' => $this->getParam('status', 'draft'),
|
|
];
|
|
|
|
$existing = Database::fetchOne('SELECT id FROM incident_pir WHERE incident_id = ?', [$id]);
|
|
if ($existing) {
|
|
Database::update('incident_pir', $fields, 'incident_id = ?', [$id]);
|
|
$pirId = (int) $existing['id'];
|
|
} else {
|
|
$fields['incident_id'] = $id;
|
|
$pirId = Database::insert('incident_pir', $fields);
|
|
}
|
|
|
|
// Se la root cause e' definita, allineala anche all'incidente (campo legacy)
|
|
if (!empty($fields['root_cause'])) {
|
|
Database::update('incidents', ['root_cause' => $fields['root_cause']], 'id = ?', [$id]);
|
|
}
|
|
|
|
$this->logAudit('incident_pir_saved', 'incident', $id, ['pir_id' => $pirId, 'status' => $fields['status']]);
|
|
$this->jsonSuccess(['pir_id' => $pirId, 'metrics' => $m], 'Post-Incident Review salvata');
|
|
}
|
|
}
|