validateRequired(['category', 'title', 'description']); $orgId = (int)($this->getParam('organization_id') ?: $this->getCurrentOrgId()); if (!$orgId) { $this->jsonError('organization_id obbligatorio per segnalazioni anonime', 400, 'ORG_REQUIRED'); } $category = $this->getParam('category'); $title = trim($this->getParam('title')); $description = trim($this->getParam('description')); $priority = $this->getParam('priority', 'medium'); $contactEmail = $this->getParam('contact_email'); $nisArticle = $this->getParam('nis2_article'); // Valida categoria $validCategories = ['security_incident','policy_violation','unauthorized_access','data_breach', 'supply_chain_risk','corruption','fraud','nis2_non_compliance','other']; if (!in_array($category, $validCategories)) { $this->jsonError("Categoria non valida: {$category}", 400, 'INVALID_CATEGORY'); } // Determina se anonima $userId = null; $isAnonymous = 1; try { $userId = $this->getCurrentUserId(); $isAnonymous = $this->getParam('is_anonymous', 0) ? 1 : 0; } catch (Throwable) { // Nessuna auth → forza anonima $isAnonymous = 1; } // Token anonimo per tracking $anonymousToken = $isAnonymous ? bin2hex(random_bytes(24)) : null; $code = $this->generateCode('WB'); $reportId = Database::insert('whistleblowing_reports', [ 'organization_id' => $orgId, 'report_code' => $code, 'is_anonymous' => $isAnonymous, 'submitted_by' => $isAnonymous ? null : $userId, 'anonymous_token' => $anonymousToken, 'contact_email' => $contactEmail ?: null, 'category' => $category, 'title' => $title, 'description' => $description, 'nis2_article' => $nisArticle ?: null, 'priority' => in_array($priority, ['critical','high','medium','low']) ? $priority : 'medium', 'status' => 'received', ]); // Prima voce timeline Database::insert('whistleblowing_timeline', [ 'report_id' => $reportId, 'event_type' => 'received', 'description' => 'Segnalazione ricevuta tramite canale interno.', 'is_visible_to_reporter' => 1, ]); // Dispatch webhook try { (new WebhookService())->dispatch($orgId, 'whistleblowing.received', [ 'id' => $reportId, 'code' => $code, 'category' => $category, 'priority' => $priority, 'anonymous' => (bool)$isAnonymous, ]); } catch (Throwable $e) { error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); } $this->jsonSuccess([ 'id' => $reportId, 'report_code' => $code, 'anonymous_token' => $anonymousToken, // Usabile per tracking se anonima 'note' => $anonymousToken ? 'Conserva questo token per verificare lo stato della segnalazione: /api/whistleblowing/track-anonymous?token=' . $anonymousToken : null, ], 'Segnalazione ricevuta', 201); } // ══════════════════════════════════════════════════════════════════════ // LIST (solo CISO/admin) // ══════════════════════════════════════════════════════════════════════ /** * GET /api/whistleblowing/list */ public function list(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $conditions = ['wr.organization_id = ?']; $params = [$this->getCurrentOrgId()]; if ($this->hasParam('status')) { $conditions[] = 'wr.status = ?'; $params[] = $this->getParam('status'); } if ($this->hasParam('priority')) { $conditions[] = 'wr.priority = ?'; $params[] = $this->getParam('priority'); } if ($this->hasParam('category')) { $conditions[] = 'wr.category = ?'; $params[] = $this->getParam('category'); } $where = implode(' AND ', $conditions); $reports = Database::fetchAll( "SELECT wr.id, wr.report_code, wr.category, wr.title, wr.priority, wr.status, wr.is_anonymous, wr.created_at, wr.closed_at, u.full_name as assigned_to_name FROM whistleblowing_reports wr LEFT JOIN users u ON u.id = wr.assigned_to WHERE {$where} ORDER BY CASE wr.priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, wr.created_at DESC", $params ); $this->jsonSuccess(['reports' => $reports, 'total' => count($reports)]); } /** * GET /api/whistleblowing/{id} */ public function get(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $report = Database::fetchOne( 'SELECT wr.*, u1.full_name as assigned_to_name, u2.full_name as submitted_by_name FROM whistleblowing_reports wr LEFT JOIN users u1 ON u1.id = wr.assigned_to LEFT JOIN users u2 ON u2.id = wr.submitted_by WHERE wr.id = ? AND wr.organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); } // Non esporre token e contact_email (privacy) unset($report['anonymous_token']); if ($report['is_anonymous']) unset($report['contact_email']); $report['timeline'] = Database::fetchAll( 'SELECT wt.*, u.full_name as created_by_name FROM whistleblowing_timeline wt LEFT JOIN users u ON u.id = wt.created_by WHERE wt.report_id = ? ORDER BY wt.created_at ASC', [$id] ); $this->jsonSuccess($report); } /** * PUT /api/whistleblowing/{id} * Aggiorna status, priorità, note di risoluzione. */ public function update(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $report = Database::fetchOne( 'SELECT * FROM whistleblowing_reports WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); } $updates = []; $oldStatus = $report['status']; foreach (['priority', 'resolution_notes', 'nis2_article'] as $field) { if ($this->hasParam($field)) $updates[$field] = $this->getParam($field); } if ($this->hasParam('status')) { $newStatus = $this->getParam('status'); $validStatuses = ['received','under_review','investigating','resolved','closed','rejected']; if (!in_array($newStatus, $validStatuses)) { $this->jsonError("Status non valido: {$newStatus}", 400, 'INVALID_STATUS'); } $updates['status'] = $newStatus; // Aggiungi voce timeline su cambio status if ($newStatus !== $oldStatus) { Database::insert('whistleblowing_timeline', [ 'report_id' => $id, 'event_type' => 'status_change', 'description' => "Status cambiato da '{$oldStatus}' a '{$newStatus}'.", 'new_status' => $newStatus, 'created_by' => $this->getCurrentUserId(), 'is_visible_to_reporter' => 1, ]); } } if (!empty($updates)) { Database::query( 'UPDATE whistleblowing_reports SET ' . implode(', ', array_map(fn($k) => "{$k} = ?", array_keys($updates))) . ', updated_at = NOW() WHERE id = ?', array_merge(array_values($updates), [$id]) ); $this->logAudit('whistleblowing_updated', 'whistleblowing', $id, $updates); } $this->jsonSuccess(null, 'Segnalazione aggiornata'); } /** * POST /api/whistleblowing/{id}/assign */ public function assign(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['user_id']); $report = Database::fetchOne( 'SELECT * FROM whistleblowing_reports WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); } $userId = (int)$this->getParam('user_id'); Database::query( 'UPDATE whistleblowing_reports SET assigned_to = ?, updated_at = NOW() WHERE id = ?', [$userId, $id] ); $user = Database::fetchOne('SELECT full_name FROM users WHERE id = ?', [$userId]); Database::insert('whistleblowing_timeline', [ 'report_id' => $id, 'event_type' => 'assigned', 'description' => "Segnalazione assegnata a " . ($user['full_name'] ?? "utente #{$userId}"), 'created_by' => $this->getCurrentUserId(), ]); $this->jsonSuccess(null, 'Segnalazione assegnata'); } /** * POST /api/whistleblowing/{id}/close */ public function close(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $report = Database::fetchOne( 'SELECT * FROM whistleblowing_reports WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); } $resolution = trim($this->getParam('resolution_notes', '')); Database::query( 'UPDATE whistleblowing_reports SET status = "closed", closed_at = NOW(), closed_by = ?, resolution_notes = ?, updated_at = NOW() WHERE id = ?', [$this->getCurrentUserId(), $resolution, $id] ); Database::insert('whistleblowing_timeline', [ 'report_id' => $id, 'event_type' => 'closed', 'description' => 'Segnalazione chiusa.' . ($resolution ? " Note: {$resolution}" : ''), 'created_by' => $this->getCurrentUserId(), 'is_visible_to_reporter' => 1, ]); $this->logAudit('whistleblowing_closed', 'whistleblowing', $id, ['resolution' => $resolution]); $this->jsonSuccess(null, 'Segnalazione chiusa'); } /** * GET /api/whistleblowing/stats */ public function stats(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $orgId = $this->getCurrentOrgId(); $stats = Database::fetchOne( 'SELECT COUNT(*) as total, SUM(CASE WHEN status = "received" THEN 1 ELSE 0 END) as received, SUM(CASE WHEN status = "under_review" THEN 1 ELSE 0 END) as under_review, SUM(CASE WHEN status = "investigating" THEN 1 ELSE 0 END) as investigating, SUM(CASE WHEN status IN ("resolved","closed") THEN 1 ELSE 0 END) as closed, SUM(CASE WHEN priority = "critical" THEN 1 ELSE 0 END) as critical, SUM(CASE WHEN priority = "high" THEN 1 ELSE 0 END) as high, SUM(CASE WHEN is_anonymous = 1 THEN 1 ELSE 0 END) as anonymous_count FROM whistleblowing_reports WHERE organization_id = ?', [$orgId] ); $byCategory = Database::fetchAll( 'SELECT category, COUNT(*) as count FROM whistleblowing_reports WHERE organization_id = ? GROUP BY category ORDER BY count DESC', [$orgId] ); $this->jsonSuccess(['stats' => $stats, 'by_category' => $byCategory]); } /** * GET /api/whistleblowing/track-anonymous * Permette a segnalante anonimo di verificare stato via token. */ public function trackAnonymous(): void { $token = $this->getParam('token'); if (!$token) { $this->jsonError('Token obbligatorio', 400, 'TOKEN_REQUIRED'); } $report = Database::fetchOne( 'SELECT id, report_code, category, status, created_at FROM whistleblowing_reports WHERE anonymous_token = ?', [$token] ); if (!$report) { $this->jsonError('Token non valido', 404, 'INVALID_TOKEN'); } // Solo eventi visibili al reporter $timeline = Database::fetchAll( 'SELECT event_type, description, created_at FROM whistleblowing_timeline WHERE report_id = ? AND is_visible_to_reporter = 1 ORDER BY created_at ASC', [$report['id']] ); $this->jsonSuccess([ 'report_code' => $report['report_code'], 'category' => $report['category'], 'status' => $report['status'], 'created_at' => $report['created_at'], 'timeline' => $timeline, ]); } }