nis2-agile/application/controllers/AssessmentController.php
Cristiano Benassati ae78a2f7f4 [CORE] Initial project scaffold - NIS2 Agile Compliance Platform
Complete MVP implementation including:
- PHP 8.4 backend with Front Controller pattern (80+ API endpoints)
- Multi-tenant architecture with organization_id isolation
- JWT authentication (HS256, 2h access + 7d refresh tokens)
- 14 controllers: Auth, Organization, Assessment, Dashboard, Risk,
  Incident, Policy, SupplyChain, Training, Asset, Audit, Admin
- AI Service integration (Anthropic Claude API) for gap analysis,
  risk suggestions, policy generation, incident classification
- NIS2 gap analysis questionnaire (~80 questions, 10 categories)
- MySQL schema (20 tables) with NIS2 Art. 21 compliance controls
- NIS2 Art. 23 incident reporting workflow (24h/72h/30d)
- Frontend: login, register, dashboard, assessment wizard, org setup
- Docker configuration (PHP-FPM + Nginx + MySQL)

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

449 lines
15 KiB
PHP

<?php
/**
* NIS2 Agile - Assessment Controller
*
* Gap Analysis wizard: questionario NIS2, scoring, AI analysis.
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/AIService.php';
class AssessmentController extends BaseController
{
/**
* GET /api/assessments/list
*/
public function list(): void
{
$this->requireOrgAccess();
$assessments = Database::fetchAll(
'SELECT a.*, u.full_name as completed_by_name
FROM assessments a
LEFT JOIN users u ON u.id = a.completed_by
WHERE a.organization_id = ?
ORDER BY a.created_at DESC',
[$this->getCurrentOrgId()]
);
$this->jsonSuccess($assessments);
}
/**
* POST /api/assessments/create
*/
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$title = $this->getParam('title', 'Assessment NIS2 - ' . date('d/m/Y'));
$type = $this->getParam('assessment_type', 'initial');
$assessmentId = Database::insert('assessments', [
'organization_id' => $this->getCurrentOrgId(),
'title' => $title,
'assessment_type' => $type,
'status' => 'draft',
]);
// Pre-popola le risposte con le domande dal questionario
$questionnaire = $this->loadQuestionnaire();
foreach ($questionnaire['categories'] as $category) {
foreach ($category['questions'] as $question) {
Database::insert('assessment_responses', [
'assessment_id' => $assessmentId,
'question_code' => $question['code'],
'nis2_article' => $question['nis2_article'],
'iso27001_control' => $question['iso27001_control'],
'category' => $category['id'],
'question_text' => $question['text_it'],
]);
}
}
$this->logAudit('assessment_created', 'assessment', $assessmentId);
$this->jsonSuccess([
'id' => $assessmentId,
'title' => $title,
'status' => 'draft',
], 'Assessment creato', 201);
}
/**
* GET /api/assessments/{id}
*/
public function get(int $id): void
{
$this->requireOrgAccess();
$assessment = $this->getAssessment($id);
// Conta risposte per stato
$stats = Database::fetchAll(
'SELECT response_value, COUNT(*) as count
FROM assessment_responses
WHERE assessment_id = ? AND response_value IS NOT NULL
GROUP BY response_value',
[$id]
);
$totalQuestions = Database::count('assessment_responses', 'assessment_id = ?', [$id]);
$answeredQuestions = Database::count(
'assessment_responses',
'assessment_id = ? AND response_value IS NOT NULL',
[$id]
);
$assessment['stats'] = $stats;
$assessment['total_questions'] = $totalQuestions;
$assessment['answered_questions'] = $answeredQuestions;
$assessment['progress_percentage'] = $totalQuestions > 0
? round($answeredQuestions / $totalQuestions * 100)
: 0;
$this->jsonSuccess($assessment);
}
/**
* GET /api/assessments/{id}/questions
* Restituisce domande con risposte correnti, organizzate per categoria
*/
public function getQuestions(int $id): void
{
$this->requireOrgAccess();
$this->getAssessment($id);
$responses = Database::fetchAll(
'SELECT * FROM assessment_responses WHERE assessment_id = ? ORDER BY question_code',
[$id]
);
// Carica questionario per i metadati
$questionnaire = $this->loadQuestionnaire();
$questionMeta = [];
foreach ($questionnaire['categories'] as $cat) {
foreach ($cat['questions'] as $q) {
$questionMeta[$q['code']] = [
'text_en' => $q['text_en'],
'guidance_it' => $q['guidance_it'],
'evidence_examples' => $q['evidence_examples'],
'weight' => $q['weight'],
];
}
}
// Organizza per categoria
$byCategory = [];
foreach ($responses as $r) {
$cat = $r['category'];
if (!isset($byCategory[$cat])) {
// Trova titolo categoria
$catTitle = $cat;
foreach ($questionnaire['categories'] as $c) {
if ($c['id'] === $cat) {
$catTitle = $c['title_it'];
break;
}
}
$byCategory[$cat] = [
'category_id' => $cat,
'category_title' => $catTitle,
'questions' => [],
];
}
$meta = $questionMeta[$r['question_code']] ?? [];
$r['text_en'] = $meta['text_en'] ?? null;
$r['guidance_it'] = $meta['guidance_it'] ?? null;
$r['evidence_examples'] = $meta['evidence_examples'] ?? [];
$r['weight'] = $meta['weight'] ?? 1;
$byCategory[$cat]['questions'][] = $r;
}
$this->jsonSuccess(array_values($byCategory));
}
/**
* POST /api/assessments/{id}/respond
* Salva una o più risposte
*/
public function saveResponse(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$assessment = $this->getAssessment($id);
if ($assessment['status'] === 'completed') {
$this->jsonError('Assessment già completato', 400, 'ALREADY_COMPLETED');
}
// Accetta singola risposta o array di risposte
$responses = $this->getParam('responses');
if (!$responses) {
// Singola risposta
$this->validateRequired(['question_code', 'response_value']);
$responses = [[
'question_code' => $this->getParam('question_code'),
'response_value' => $this->getParam('response_value'),
'maturity_level' => $this->getParam('maturity_level'),
'evidence_description' => $this->getParam('evidence_description'),
'notes' => $this->getParam('notes'),
]];
}
$savedCount = 0;
foreach ($responses as $resp) {
$code = $resp['question_code'] ?? null;
$value = $resp['response_value'] ?? null;
if (!$code || !$value) continue;
Database::update('assessment_responses', [
'response_value' => $value,
'maturity_level' => $resp['maturity_level'] ?? null,
'evidence_description' => $resp['evidence_description'] ?? null,
'notes' => $resp['notes'] ?? null,
'answered_by' => $this->getCurrentUserId(),
'answered_at' => date('Y-m-d H:i:s'),
], 'assessment_id = ? AND question_code = ?', [$id, $code]);
$savedCount++;
}
// Aggiorna status a in_progress se era draft
if ($assessment['status'] === 'draft') {
Database::update('assessments', ['status' => 'in_progress'], 'id = ?', [$id]);
}
$this->jsonSuccess(['saved' => $savedCount], "{$savedCount} risposte salvate");
}
/**
* POST /api/assessments/{id}/complete
*/
public function complete(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$assessment = $this->getAssessment($id);
if ($assessment['status'] === 'completed') {
$this->jsonError('Assessment già completato', 400, 'ALREADY_COMPLETED');
}
// Calcola score
$responses = Database::fetchAll(
'SELECT * FROM assessment_responses WHERE assessment_id = ?',
[$id]
);
$scores = $this->calculateScores($responses);
Database::update('assessments', [
'status' => 'completed',
'overall_score' => $scores['overall'],
'category_scores' => json_encode($scores['by_category']),
'completed_by' => $this->getCurrentUserId(),
'completed_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$id]);
$this->logAudit('assessment_completed', 'assessment', $id, [
'overall_score' => $scores['overall']
]);
$this->jsonSuccess([
'overall_score' => $scores['overall'],
'category_scores' => $scores['by_category'],
], 'Assessment completato');
}
/**
* GET /api/assessments/{id}/report
*/
public function getReport(int $id): void
{
$this->requireOrgAccess();
$assessment = $this->getAssessment($id);
if ($assessment['status'] !== 'completed') {
$this->jsonError('L\'assessment deve essere completato prima di generare il report', 400, 'NOT_COMPLETED');
}
$responses = Database::fetchAll(
'SELECT ar.*, u.full_name as answered_by_name
FROM assessment_responses ar
LEFT JOIN users u ON u.id = ar.answered_by
WHERE ar.assessment_id = ?
ORDER BY ar.category, ar.question_code',
[$id]
);
$categoryScores = json_decode($assessment['category_scores'], true) ?? [];
$this->jsonSuccess([
'assessment' => $assessment,
'category_scores' => $categoryScores,
'responses' => $responses,
'ai_summary' => $assessment['ai_summary'],
'ai_recommendations' => json_decode($assessment['ai_recommendations'], true),
]);
}
/**
* POST /api/assessments/{id}/ai-analyze
* Richiede analisi AI dell'assessment
*/
public function aiAnalyze(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$assessment = $this->getAssessment($id);
if ($assessment['status'] !== 'completed') {
$this->jsonError('Completare l\'assessment prima dell\'analisi AI', 400, 'NOT_COMPLETED');
}
// Carica organizzazione
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
// Carica risposte
$responses = Database::fetchAll(
'SELECT * FROM assessment_responses WHERE assessment_id = ?',
[$id]
);
try {
$aiService = new AIService();
$analysis = $aiService->analyzeGapAssessment($org, $responses, (float) $assessment['overall_score']);
// Salva risultati AI
Database::update('assessments', [
'ai_summary' => $analysis['executive_summary'] ?? json_encode($analysis),
'ai_recommendations' => json_encode($analysis),
], 'id = ?', [$id]);
// Log interazione AI
$aiService->logInteraction(
$this->getCurrentOrgId(),
$this->getCurrentUserId(),
'gap_analysis',
"Gap analysis assessment #{$id}",
substr(json_encode($analysis), 0, 500)
);
$this->logAudit('ai_analysis_requested', 'assessment', $id);
$this->jsonSuccess($analysis, 'Analisi AI completata');
} catch (Throwable $e) {
error_log('[AI_ERROR] ' . $e->getMessage());
$this->jsonError('Errore durante l\'analisi AI: ' . $e->getMessage(), 500, 'AI_ERROR');
}
}
// ═══════════════════════════════════════════════════════════════════════
// METODI PRIVATI
// ═══════════════════════════════════════════════════════════════════════
/**
* Carica assessment verificando ownership
*/
private function getAssessment(int $id): array
{
$assessment = Database::fetchOne(
'SELECT * FROM assessments WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$assessment) {
$this->jsonError('Assessment non trovato', 404, 'ASSESSMENT_NOT_FOUND');
}
return $assessment;
}
/**
* Carica questionario da file JSON
*/
private function loadQuestionnaire(): array
{
$file = DATA_PATH . '/nis2_questionnaire.json';
if (!file_exists($file)) {
$this->jsonError('Questionario NIS2 non disponibile', 500, 'QUESTIONNAIRE_MISSING');
}
$data = json_decode(file_get_contents($file), true);
if (!$data) {
$this->jsonError('Errore caricamento questionario', 500, 'QUESTIONNAIRE_ERROR');
}
return $data;
}
/**
* Calcola score dell'assessment
*/
private function calculateScores(array $responses): array
{
$byCategory = [];
$totalWeightedScore = 0;
$totalWeight = 0;
// Carica pesi dalle domande
$questionnaire = $this->loadQuestionnaire();
$weights = [];
foreach ($questionnaire['categories'] as $cat) {
foreach ($cat['questions'] as $q) {
$weights[$q['code']] = $q['weight'] ?? 1;
}
}
foreach ($responses as $r) {
$cat = $r['category'] ?? 'other';
$value = $r['response_value'];
$weight = $weights[$r['question_code']] ?? 1;
if (!isset($byCategory[$cat])) {
$byCategory[$cat] = ['score' => 0, 'max' => 0, 'count' => 0];
}
if ($value === 'not_applicable') continue;
$score = match ($value) {
'implemented' => 100,
'partial' => 50,
default => 0,
};
$byCategory[$cat]['score'] += $score * $weight;
$byCategory[$cat]['max'] += 100 * $weight;
$byCategory[$cat]['count']++;
$totalWeightedScore += $score * $weight;
$totalWeight += 100 * $weight;
}
// Calcola percentuali per categoria
$categoryScores = [];
foreach ($byCategory as $cat => $data) {
$categoryScores[$cat] = [
'score' => $data['max'] > 0 ? round($data['score'] / $data['max'] * 100, 1) : 0,
'count' => $data['count'],
];
}
$overallScore = $totalWeight > 0 ? round($totalWeightedScore / $totalWeight * 100, 1) : 0;
return [
'overall' => $overallScore,
'by_category' => $categoryScores,
];
}
}