nis2-agile/public/index.php
Cristiano Benassati bcc5a2b003 [FIX] E2E testing - fix router, EmailService, frontend data mapping
Critical fixes discovered during end-to-end testing:

Router (index.php):
- Rewrote route resolution engine to properly handle /{id}/subAction patterns
- All routes like GET /assessments/{id}/questions, POST /incidents/{id}/early-warning,
  GET /organizations/{id}/members now resolve correctly
- Routes with kebab-case sub-actions (early-warning, ai-analyze) now convert to camelCase
- Controller methods receive correct arguments via spread operator

EmailService.php:
- Fix PHP parse error: ?? operator cannot be used inside string interpolation {}
- Extract incident_code to variable before interpolation (3 occurrences)

assessment.html:
- Fix data structure handling: API returns categories with nested questions array
- Fix field names: question_code (not question_id), response_value (not compliance_level)
- Fix answer enum values: not_implemented/partial/implemented (not Italian)
- Fix question text field: question_text (not text/question/title)
- Show NIS2 article and ISO 27001 control references
- Fix response restoration from existing answers

dashboard.html:
- Fix data mapping from overview API response structure
- risks.total instead of open_risks, policies array instead of approved_policies
- Calculate training completion percentage from training object
- Load deadlines/activity from dedicated endpoints (not included in overview)

onboarding.html:
- Fix field name mismatches: annual_turnover_eur, contact_email, contact_phone,
  full_name, phone (matching OnboardingController expected params)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 19:40:26 +01:00

419 lines
18 KiB
PHP

<?php
/**
* NIS2 Agile - Front Controller / Router
*
* Tutte le richieste API passano da qui.
* URL Pattern: /nis2/api/{controller}/{action}/{id?}
*/
// ═══════════════════════════════════════════════════════════════════════════
// BOOTSTRAP
// ═══════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/../application/config/config.php';
require_once __DIR__ . '/../application/config/database.php';
// ═══════════════════════════════════════════════════════════════════════════
// CORS HEADERS
// ═══════════════════════════════════════════════════════════════════════════
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, CORS_ALLOWED_ORIGINS)) {
header("Access-Control-Allow-Origin: {$origin}");
} elseif (APP_DEBUG) {
header("Access-Control-Allow-Origin: *");
}
header("Access-Control-Allow-Methods: " . CORS_ALLOWED_METHODS);
header("Access-Control-Allow-Headers: " . CORS_ALLOWED_HEADERS);
header("Access-Control-Max-Age: " . CORS_MAX_AGE);
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// ═══════════════════════════════════════════════════════════════════════════
// ROUTING
// ═══════════════════════════════════════════════════════════════════════════
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$basePath = '/nis2';
$path = parse_url($requestUri, PHP_URL_PATH);
$path = preg_replace("#^{$basePath}#", '', $path);
$path = trim($path, '/');
// Se non è una richiesta API, servi file statici o index.html
if (!preg_match('#^api/#', $path)) {
$staticFile = __DIR__ . '/' . $path;
if ($path && file_exists($staticFile) && is_file($staticFile)) {
return false;
}
if (empty($path) || $path === 'index.html') {
include __DIR__ . '/index.html';
exit;
}
// Prova a servire come HTML page
$htmlFile = __DIR__ . '/' . $path;
if (!str_ends_with($path, '.html')) {
$htmlFile = __DIR__ . '/' . $path . '.html';
}
if (file_exists($htmlFile) && is_file($htmlFile)) {
include $htmlFile;
exit;
}
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Not Found']);
exit;
}
// ═══════════════════════════════════════════════════════════════════════════
// API ROUTING
// ═══════════════════════════════════════════════════════════════════════════
$parts = explode('/', $path);
array_shift($parts); // Rimuovi "api"
$controllerName = $parts[0] ?? 'index';
$actionName = $parts[1] ?? 'index';
$resourceId = isset($parts[2]) ? $parts[2] : null;
$subAction = $parts[3] ?? null;
$subResourceId = isset($parts[4]) ? $parts[4] : null;
// Mappa controller
$controllerMap = [
'auth' => 'AuthController',
'organizations' => 'OrganizationController',
'assessments' => 'AssessmentController',
'dashboard' => 'DashboardController',
'risks' => 'RiskController',
'incidents' => 'IncidentController',
'policies' => 'PolicyController',
'supply-chain' => 'SupplyChainController',
'training' => 'TrainingController',
'assets' => 'AssetController',
'audit' => 'AuditController',
'admin' => 'AdminController',
'onboarding' => 'OnboardingController',
];
if (!isset($controllerMap[$controllerName])) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Endpoint non trovato',
'error_code' => 'NOT_FOUND',
]);
exit;
}
$controllerClass = $controllerMap[$controllerName];
$controllerFile = APP_PATH . "/controllers/{$controllerClass}.php";
if (!file_exists($controllerFile)) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Controller non disponibile',
]);
exit;
}
require_once $controllerFile;
// ═══════════════════════════════════════════════════════════════════════════
// MAPPA AZIONI PER METODO HTTP
// ═══════════════════════════════════════════════════════════════════════════
$method = $_SERVER['REQUEST_METHOD'];
// Converti action name con trattini in camelCase
$actionName = str_replace('-', '', lcfirst(ucwords($actionName, '-')));
$actionMap = [
// ── AuthController ──────────────────────────────
'auth' => [
'POST:register' => 'register',
'POST:login' => 'login',
'POST:logout' => 'logout',
'POST:refresh' => 'refresh',
'GET:me' => 'me',
'PUT:profile' => 'updateProfile',
'POST:changePassword' => 'changePassword',
],
// ── OrganizationController ──────────────────────
'organizations' => [
'POST:create' => 'create',
'GET:current' => 'getCurrent',
'GET:list' => 'list',
'PUT:{id}' => 'update',
'GET:{id}/members' => 'listMembers',
'POST:{id}/invite' => 'inviteMember',
'DELETE:{id}/members/{subId}' => 'removeMember',
'POST:classify' => 'classifyEntity',
],
// ── AssessmentController ────────────────────────
'assessments' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'GET:{id}/questions' => 'getQuestions',
'POST:{id}/respond' => 'saveResponse',
'POST:{id}/complete' => 'complete',
'GET:{id}/report' => 'getReport',
'POST:{id}/aiAnalyze' => 'aiAnalyze',
],
// ── DashboardController ─────────────────────────
'dashboard' => [
'GET:overview' => 'overview',
'GET:complianceScore' => 'complianceScore',
'GET:upcomingDeadlines' => 'deadlines',
'GET:recentActivity' => 'recentActivity',
'GET:riskHeatmap' => 'riskHeatmap',
],
// ── RiskController ──────────────────────────────
'risks' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/treatments' => 'addTreatment',
'PUT:treatments/{subId}' => 'updateTreatment',
'GET:matrix' => 'getRiskMatrix',
'POST:aiSuggest' => 'aiSuggestRisks',
],
// ── IncidentController ──────────────────────────
'incidents' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'POST:{id}/timeline' => 'addTimelineEvent',
'POST:{id}/earlyWarning' => 'sendEarlyWarning',
'POST:{id}/notification' => 'sendNotification',
'POST:{id}/finalReport' => 'sendFinalReport',
'POST:{id}/aiClassify' => 'aiClassify',
],
// ── PolicyController ────────────────────────────
'policies' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/approve' => 'approve',
'POST:aiGenerate' => 'aiGeneratePolicy',
'GET:templates' => 'getTemplates',
],
// ── SupplyChainController ───────────────────────
'supply-chain' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'POST:{id}/assess' => 'assessSupplier',
'GET:riskOverview' => 'riskOverview',
],
// ── TrainingController ──────────────────────────
'training' => [
'GET:courses' => 'listCourses',
'POST:courses' => 'createCourse',
'GET:assignments' => 'myAssignments',
'POST:assign' => 'assignCourse',
'PUT:assignments/{subId}' => 'updateAssignment',
'GET:complianceStatus' => 'complianceStatus',
],
// ── AssetController ─────────────────────────────
'assets' => [
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete',
'GET:dependencyMap' => 'dependencyMap',
],
// ── AuditController ─────────────────────────────
'audit' => [
'GET:controls' => 'listControls',
'PUT:controls/{subId}' => 'updateControl',
'POST:evidence/upload' => 'uploadEvidence',
'GET:evidence/list' => 'listEvidence',
'GET:report' => 'generateReport',
'GET:logs' => 'getAuditLogs',
'GET:iso27001Mapping' => 'getIsoMapping',
'GET:executiveReport' => 'executiveReport',
'GET:export' => 'export',
],
// ── AdminController ─────────────────────────────
'admin' => [
'GET:organizations' => 'listOrganizations',
'GET:users' => 'listUsers',
'GET:stats' => 'platformStats',
],
// ── OnboardingController ──────────────────────
'onboarding' => [
'POST:uploadVisura' => 'uploadVisura',
'POST:fetchCompany' => 'fetchCompany',
'POST:complete' => 'complete',
],
];
// ═══════════════════════════════════════════════════════════════════════════
// RISOLUZIONE AZIONE
// ═══════════════════════════════════════════════════════════════════════════
$actions = $actionMap[$controllerName] ?? [];
$resolvedAction = null;
$callArgs = [];
// Helper: convert kebab-case to camelCase (same logic as $actionName conversion)
$toCamel = function (string $s): string {
return str_replace('-', '', lcfirst(ucwords($s, '-')));
};
// Costruisci candidati pattern → argomenti (ordine: più specifico prima)
$candidates = [];
if (is_numeric($actionName)) {
// Il primo segmento è un ID numerico:
// /controller/123 → METHOD:{id}
// /controller/123/sub → METHOD:{id}/sub
// /controller/123/sub/456 → METHOD:{id}/sub/{subId}
$numericId = (int) $actionName;
$sub = $resourceId !== null ? $toCamel($resourceId) : null;
$subId = $subAction !== null && is_numeric($subAction) ? (int) $subAction : null;
if ($sub !== null && $subId !== null) {
$candidates[] = ['p' => "{$method}:{id}/{$sub}/{subId}", 'a' => [$numericId, $subId]];
}
if ($sub !== null) {
$candidates[] = ['p' => "{$method}:{id}/{$sub}", 'a' => [$numericId]];
}
$candidates[] = ['p' => "{$method}:{id}", 'a' => [$numericId]];
} else {
// Il primo segmento è un'azione nominale:
// /controller/action → METHOD:action
// /controller/action/123 → METHOD:action/{subId} oppure METHOD:{id}
// /controller/action/sub → METHOD:action/sub
// /controller/action/123/sub → METHOD:{id}/sub
// /controller/action/123/sub/456 → METHOD:{id}/sub/{subId}
if ($subAction !== null && $resourceId !== null && is_numeric($resourceId)) {
$rid = (int) $resourceId;
$camelSub = $toCamel($subAction);
if ($subResourceId !== null && is_numeric($subResourceId)) {
$sid = (int) $subResourceId;
$candidates[] = ['p' => "{$method}:{id}/{$camelSub}/{subId}", 'a' => [$rid, $sid]];
}
$candidates[] = ['p' => "{$method}:{id}/{$camelSub}", 'a' => [$rid]];
$candidates[] = ['p' => "{$method}:{$actionName}/{subId}", 'a' => [$rid]];
}
if ($resourceId !== null && $subAction === null) {
if (is_numeric($resourceId)) {
// /controller/action/123 → potrebbe essere action/{subId} o {id}
$rid = (int) $resourceId;
$candidates[] = ['p' => "{$method}:{$actionName}/{subId}", 'a' => [$rid]];
$candidates[] = ['p' => "{$method}:{id}", 'a' => [$rid]];
} else {
// /controller/action/subAction → nome composto (es: evidence/upload)
$camelResource = $toCamel($resourceId);
$candidates[] = ['p' => "{$method}:{$actionName}/{$camelResource}", 'a' => []];
}
}
// /controller/action
$candidates[] = ['p' => "{$method}:{$actionName}", 'a' => []];
}
// Cerca primo match
foreach ($candidates as $candidate) {
if (isset($actions[$candidate['p']])) {
$resolvedAction = $actions[$candidate['p']];
$callArgs = $candidate['a'];
break;
}
}
if (!$resolvedAction) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => "Azione non trovata: {$method} /{$controllerName}/{$actionName}",
'error_code' => 'ACTION_NOT_FOUND',
]);
exit;
}
// ═══════════════════════════════════════════════════════════════════════════
// ESECUZIONE
// ═══════════════════════════════════════════════════════════════════════════
try {
$controller = new $controllerClass();
if (!method_exists($controller, $resolvedAction)) {
http_response_code(501);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => "Metodo '{$resolvedAction}' non implementato",
]);
exit;
}
// Chiama con gli argomenti risolti
$controller->$resolvedAction(...$callArgs);
} catch (RuntimeException $e) {
// Rate limit exceeded (429)
if ($e->getCode() === 429) {
http_response_code(429);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'error_code' => 'RATE_LIMITED',
]);
exit;
}
throw $e;
} catch (PDOException $e) {
error_log('[DB_ERROR] ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => APP_DEBUG ? 'Errore database: ' . $e->getMessage() : 'Errore interno del server',
]);
} catch (Throwable $e) {
error_log('[ERROR] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => APP_DEBUG ? $e->getMessage() : 'Errore interno del server',
]);
}