[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>
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user