[FEAT] Sistema Segnalazioni & Risoluzione AI (feedback)

Adattato da alltax.it — il sistema più maturo testato con utenti reali.

Backend:
- FeedbackController: 6 endpoint (submit, mine, list, show, update, resolve)
- FeedbackService: createReport + classifyWithAI + broadcastResolution
- AIService::classifyFeedback() — 10s timeout, 500 token, JSON puro
- EmailService::sendFeedbackResolved() — broadcast email org
- DB migration 014: tabella feedback_reports

Frontend:
- feedback.js: FAB rosso #EF4444, modal 2 fasi (form → AI → password gate)
- Tab "Le mie segnalazioni" con badge status
- Auto-init su tutte le pagine autenticate (common.js::checkAuth)
- api.js: 6 metodi client; style.css: stili completi

Worker:
- scripts/feedback-worker.php: cron ogni 30 min
  → docker exec nis2-agile-devenv + Claude Code CLI
  → risoluzione autonoma con POST /api/feedback/{id}/resolve

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-03-10 08:51:52 +01:00
co-authored by Claude Sonnet 4.6
parent 3b51b5bd95
commit 1382530189
13 changed files with 1911 additions and 43 deletions
+71
View File
@@ -310,6 +310,77 @@ PROMPT;
return $data;
}
/**
* Classifica una segnalazione feedback/bug per il sistema di ticketing interno
*
* @param string $tipo Tipo segnalazione (bug|ux|funzionalita|domanda|altro)
* @param string $descrizione Testo della segnalazione (non contiene PII org)
* @return array ['categoria', 'priorita', 'suggerimento', 'risposta_utente']
*/
public function classifyFeedback(string $tipo, string $descrizione): array
{
$prompt = <<<PROMPT
Sei un assistente tecnico per una piattaforma SaaS di compliance NIS2.
Analizza la seguente segnalazione e rispondi SOLO con un oggetto JSON valido.
Tipo segnalazione: {$tipo}
Descrizione: {$descrizione}
Rispondi con questo JSON (senza markdown, solo JSON puro):
{
"categoria": "una tra: autenticazione, dashboard, rischi, incidenti, policy, assessment, formazione, fornitori, asset, report, impostazioni, performance, interfaccia, documentazione, altro",
"priorita": "una tra: alta, media, bassa",
"suggerimento": "suggerimento tecnico breve per il team di sviluppo (max 150 caratteri)",
"risposta_utente": "risposta leggibile e rassicurante per l'utente finale in italiano (max 200 caratteri)"
}
Criteri priorità:
- alta: blocca funzionalità critica (compliance, accesso, salvataggio dati)
- media: degrada l'esperienza ma esiste un workaround
- bassa: miglioria estetica o funzionalità secondaria
PROMPT;
$ch = curl_init($this->baseUrl);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'model' => $this->model,
'max_tokens' => 500,
'system' => 'Sei un assistente tecnico. Rispondi SEMPRE con JSON puro, senza markdown né testo aggiuntivo.',
'messages' => [['role' => 'user', 'content' => $prompt]],
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError || $httpCode !== 200) {
throw new RuntimeException("classifyFeedback AI error [{$httpCode}]: {$curlError}");
}
$data = json_decode($response, true);
$text = $data['content'][0]['text'] ?? '';
$result = $this->parseJsonResponse($text);
// Normalizza priorità
$validP = ['alta', 'media', 'bassa'];
if (!in_array($result['priorita'] ?? '', $validP)) {
$result['priorita'] = 'media';
}
return $result;
}
/**
* Analisi cross-organizzazione per consulenti (L4)
* Riceve dati già aggregati e anonimizzati dal controller.
+42
View File
@@ -524,6 +524,48 @@ class EmailService
$this->send($user['email'], $subject, $html);
}
// ═══════════════════════════════════════════════════════════════════════════
// FEEDBACK & SEGNALAZIONI
// ═══════════════════════════════════════════════════════════════════════════
/**
* Broadcast risoluzione segnalazione a un membro dell'org
*
* @param string $to Email destinatario
* @param string $reportTitle Titolo breve della segnalazione
* @param string $resolution Testo risoluzione (nota admin o risposta AI)
*/
public function sendFeedbackResolved(string $to, string $reportTitle, string $resolution): bool
{
$html = <<<HTML
<div style="background-color: #f0fdf4; border-left: 4px solid #16a34a; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
<strong style="color: #16a34a; font-size: 16px;">&#10003; Segnalazione Risolta</strong><br>
<span style="color: #166534;">Il problema segnalato &egrave; stato risolto</span>
</div>
<p>Una segnalazione nella tua organizzazione &egrave; stata risolta:</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="background-color: #f8fafc;">
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 160px;">Segnalazione</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($reportTitle)}</td>
</tr>
<tr>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Risoluzione</td>
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; line-height: 1.6;">{$this->esc($resolution)}</td>
</tr>
</table>
<p style="margin-top: 20px;">
<a href="{$this->esc($this->appUrl)}/dashboard" style="display: inline-block; background-color: #ef4444; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Vai alla Dashboard</a>
</p>
HTML;
$subject = "&#10003; Risolto: {$reportTitle}";
return $this->send($to, $subject, $html);
}
// ═══════════════════════════════════════════════════════════════════════════
// TEMPLATE HTML
// ═══════════════════════════════════════════════════════════════════════════
+149
View File
@@ -0,0 +1,149 @@
<?php
/**
* FeedbackService — Logica segnalazioni & classificazione AI
*
* Responsabilità:
* - Persistenza segnalazioni (INSERT/UPDATE feedback_reports)
* - Classificazione asincrona via AIService::classifyFeedback()
* - Broadcast email agli utenti dell'org quando una segnalazione è risolta
*/
require_once APP_PATH . '/config/database.php';
require_once APP_PATH . '/services/AIService.php';
require_once APP_PATH . '/services/EmailService.php';
class FeedbackService
{
private AIService $ai;
private EmailService $email;
public function __construct()
{
$this->ai = new AIService();
$this->email = new EmailService();
}
/**
* Crea una nuova segnalazione e tenta classificazione AI (max 10s)
*
* @param int $orgId
* @param int $userId
* @param string $userEmail
* @param string $userRole
* @param array $data [tipo, priorita, descrizione, page_url, attachment]
* @return array Il record inserito con eventuali campi AI già popolati
*/
public function createReport(
int $orgId,
int $userId,
string $userEmail,
string $userRole,
array $data
): array {
$tipo = $data['tipo'] ?? 'bug';
$priorita = $data['priorita'] ?? 'media';
$descrizione = $data['descrizione'] ?? '';
$pageUrl = $data['page_url'] ?? '';
$attachment = $data['attachment'] ?? null;
Database::query(
'INSERT INTO feedback_reports
(organization_id, user_id, user_email, user_role, page_url,
tipo, priorita, descrizione, attachment)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[$orgId, $userId, $userEmail, $userRole, $pageUrl,
$tipo, $priorita, $descrizione, $attachment]
);
$reportId = (int) Database::lastInsertId();
// Classificazione AI con timeout 10s
$this->classifyWithAI($reportId, $tipo, $descrizione);
return Database::fetchOne(
'SELECT * FROM feedback_reports WHERE id = ?',
[$reportId]
);
}
/**
* Chiama AI per classificare la segnalazione e aggiorna il DB
* Fallisce silenziosamente (non blocca la risposta all'utente)
*/
public function classifyWithAI(int $reportId, string $tipo, string $descrizione): void
{
try {
$result = $this->ai->classifyFeedback($tipo, $descrizione);
Database::query(
'UPDATE feedback_reports SET
ai_categoria = ?,
ai_priorita = ?,
ai_suggerimento = ?,
ai_risposta = ?,
ai_processed = 1
WHERE id = ?',
[
$result['categoria'] ?? null,
$result['priorita'] ?? null,
$result['suggerimento'] ?? null,
$result['risposta_utente'] ?? null,
$reportId,
]
);
} catch (\Throwable $e) {
error_log("FeedbackService::classifyWithAI failed for report #{$reportId}: " . $e->getMessage());
}
}
/**
* Broadcast email a tutti i membri attivi dell'org quando status → risolto
*/
public function broadcastResolution(int $reportId, int $orgId): void
{
$report = Database::fetchOne(
'SELECT tipo, descrizione, ai_risposta, nota_admin FROM feedback_reports WHERE id = ?',
[$reportId]
);
if (!$report) {
return;
}
// Tutti gli utenti attivi dell'org
$members = Database::fetchAll(
'SELECT u.email, u.name
FROM users u
JOIN user_organizations uo ON uo.user_id = u.id
WHERE uo.organization_id = ? AND u.is_active = 1',
[$orgId]
);
if (empty($members)) {
return;
}
$tipoLabel = $this->translateTipo($report['tipo']);
$excerpt = mb_strimwidth($report['descrizione'], 0, 120, '…');
$resolution = $report['nota_admin'] ?: $report['ai_risposta'] ?: 'Segnalazione risolta dal team tecnico.';
foreach ($members as $member) {
$this->email->sendFeedbackResolved(
$member['email'],
$tipoLabel . ': ' . $excerpt,
$resolution
);
}
}
private function translateTipo(string $tipo): string
{
return match ($tipo) {
'bug' => 'Bug',
'ux' => 'Miglioramento UX',
'funzionalita' => 'Richiesta funzionalità',
'domanda' => 'Domanda',
default => 'Altro',
};
}
}