[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
parent 3b51b5bd95
commit 1382530189
13 changed files with 1911 additions and 43 deletions

View File

@ -82,6 +82,12 @@ define('ANTHROPIC_API_KEY', Env::get('ANTHROPIC_API_KEY', ''));
define('ANTHROPIC_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929'));
define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096));
// ═══════════════════════════════════════════════════════════════════════════
// FEEDBACK & SEGNALAZIONI
// ═══════════════════════════════════════════════════════════════════════════
define('FEEDBACK_RESOLVE_PASSWORD', Env::get('FEEDBACK_RESOLVE_PASSWORD', ''));
define('FEEDBACK_WORKER_LOG', Env::get('FEEDBACK_WORKER_LOG', '/var/log/nis2/feedback-worker.log'));
// ═══════════════════════════════════════════════════════════════════════════
// TIMEZONE
// ═══════════════════════════════════════════════════════════════════════════

View File

@ -0,0 +1,301 @@
<?php
/**
* FeedbackController Sistema Segnalazioni & Risoluzione AI
*
* Adattato da alltax.it/docs/sistema-segnalazioni-standard.html
*
* Endpoint (tutti richiedono auth JWT + X-Organization-Id):
* POST /api/feedback/submit crea segnalazione + classifica con AI
* GET /api/feedback/mine ultime 20 segnalazioni dell'utente
* GET /api/feedback/list lista admin (org_admin, compliance_manager, auditor)
* GET /api/feedback/{id} dettaglio (admin o autore)
* PUT /api/feedback/{id} aggiorna status/nota_admin (admin)
* POST /api/feedback/{id}/resolve chiusura manuale con password gate
*/
require_once APP_PATH . '/controllers/BaseController.php';
require_once APP_PATH . '/services/FeedbackService.php';
class FeedbackController extends BaseController
{
private FeedbackService $feedbackService;
public function __construct()
{
$this->feedbackService = new FeedbackService();
}
/**
* POST /api/feedback/submit
* Crea segnalazione + classifica con AI (10s timeout)
*/
public function submit(): void
{
$this->requireOrgAccess();
$body = $this->getJsonBody();
$tipo = trim($body['tipo'] ?? 'bug');
$priorita = trim($body['priorita'] ?? 'media');
$descrizione = trim($body['descrizione'] ?? '');
$pageUrl = trim($body['page_url'] ?? '');
$attachment = $body['attachment'] ?? null; // base64 string
if (strlen($descrizione) < 10) {
$this->jsonError('Descrizione troppo breve (minimo 10 caratteri).', 422);
return;
}
$validTipi = ['bug', 'ux', 'funzionalita', 'domanda', 'altro'];
if (!in_array($tipo, $validTipi)) {
$tipo = 'bug';
}
$validPriorita = ['alta', 'media', 'bassa'];
if (!in_array($priorita, $validPriorita)) {
$priorita = 'media';
}
$report = $this->feedbackService->createReport(
$this->currentOrgId,
$this->getCurrentUserId(),
$this->currentUser['email'],
$this->currentOrgRole ?? 'employee',
[
'tipo' => $tipo,
'priorita' => $priorita,
'descrizione' => $descrizione,
'page_url' => $pageUrl,
'attachment' => $attachment,
]
);
$this->jsonSuccess($report, 'Segnalazione inviata. L\'AI ha analizzato il problema.');
}
/**
* GET /api/feedback/mine
* Ultime 20 segnalazioni dell'utente corrente per questa org
*/
public function mine(): void
{
$this->requireOrgAccess();
$reports = Database::fetchAll(
'SELECT id, tipo, priorita, descrizione, status,
ai_categoria, ai_priorita, ai_risposta, ai_processed,
created_at, updated_at
FROM feedback_reports
WHERE organization_id = ? AND user_id = ?
ORDER BY created_at DESC
LIMIT 20',
[$this->currentOrgId, $this->getCurrentUserId()]
);
$this->jsonSuccess($reports);
}
/**
* GET /api/feedback/list
* Lista admin con filtri opzionali: status, tipo, priorita
*/
public function list(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$pagination = $this->getPagination(20);
$status = $this->getParam('status', '');
$tipo = $this->getParam('tipo', '');
$priorita = $this->getParam('priorita', '');
$where = ['r.organization_id = ?'];
$params = [$this->currentOrgId];
if ($status) {
$where[] = 'r.status = ?';
$params[] = $status;
}
if ($tipo) {
$where[] = 'r.tipo = ?';
$params[] = $tipo;
}
if ($priorita) {
$where[] = 'r.priorita = ?';
$params[] = $priorita;
}
$whereSql = implode(' AND ', $where);
$total = (int) Database::fetchOne(
"SELECT COUNT(*) AS cnt FROM feedback_reports r WHERE {$whereSql}",
$params
)['cnt'];
$countParams = $params;
$params[] = $pagination['per_page'];
$params[] = $pagination['offset'];
$items = Database::fetchAll(
"SELECT r.id, r.tipo, r.priorita, r.descrizione, r.status,
r.page_url, r.nota_admin,
r.ai_categoria, r.ai_priorita, r.ai_risposta, r.ai_processed,
r.user_email, r.user_role, r.created_at, r.updated_at,
u.name AS user_name
FROM feedback_reports r
LEFT JOIN users u ON u.id = r.user_id
WHERE {$whereSql}
ORDER BY
FIELD(r.priorita,'alta','media','bassa'),
FIELD(r.status,'aperto','in_lavorazione','risolto','chiuso'),
r.created_at DESC
LIMIT ? OFFSET ?",
$params
);
$this->jsonPaginated($items, $total, $pagination['page'], $pagination['per_page']);
}
/**
* GET /api/feedback/{id}
* Dettaglio segnalazione (admin o autore)
*/
public function show(int $id): void
{
$this->requireOrgAccess();
$report = Database::fetchOne(
'SELECT r.*, u.name AS user_name
FROM feedback_reports r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.id = ? AND r.organization_id = ?',
[$id, $this->currentOrgId]
);
if (!$report) {
$this->jsonError('Segnalazione non trovata.', 404, 'NOT_FOUND');
return;
}
// Solo admin o autore possono vedere il dettaglio
$isAdmin = in_array($this->currentOrgRole, ['org_admin', 'compliance_manager', 'auditor', 'super_admin']);
if (!$isAdmin && $report['user_id'] != $this->getCurrentUserId()) {
$this->jsonError('Accesso non autorizzato.', 403, 'ACCESS_DENIED');
return;
}
$this->jsonSuccess($report);
}
/**
* PUT /api/feedback/{id}
* Aggiorna status e/o nota_admin (solo admin)
*/
public function update(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$report = Database::fetchOne(
'SELECT id, status FROM feedback_reports WHERE id = ? AND organization_id = ?',
[$id, $this->currentOrgId]
);
if (!$report) {
$this->jsonError('Segnalazione non trovata.', 404, 'NOT_FOUND');
return;
}
$body = $this->getJsonBody();
$fields = [];
$params = [];
$validStatus = ['aperto', 'in_lavorazione', 'risolto', 'chiuso'];
if (isset($body['status']) && in_array($body['status'], $validStatus)) {
$fields[] = 'status = ?';
$params[] = $body['status'];
}
if (isset($body['nota_admin'])) {
$fields[] = 'nota_admin = ?';
$params[] = htmlspecialchars(trim($body['nota_admin']), ENT_QUOTES, 'UTF-8');
}
if (empty($fields)) {
$this->jsonError('Nessun campo da aggiornare.', 422);
return;
}
$params[] = $id;
$params[] = $this->currentOrgId;
Database::query(
'UPDATE feedback_reports SET ' . implode(', ', $fields) . ' WHERE id = ? AND organization_id = ?',
$params
);
// Broadcast se risolto
if (isset($body['status']) && $body['status'] === 'risolto') {
$this->feedbackService->broadcastResolution($id, $this->currentOrgId);
}
$updated = Database::fetchOne(
'SELECT * FROM feedback_reports WHERE id = ?',
[$id]
);
$this->jsonSuccess($updated, 'Segnalazione aggiornata.');
}
/**
* POST /api/feedback/{id}/resolve
* Chiusura manuale con password gate (utente dice "sì, risolto")
*/
public function resolve(int $id): void
{
$this->requireOrgAccess();
$body = $this->getJsonBody();
$password = trim($body['password'] ?? '');
if (empty($password)) {
$this->jsonError('Password di conferma richiesta.', 422);
return;
}
$resolvePassword = defined('FEEDBACK_RESOLVE_PASSWORD') ? FEEDBACK_RESOLVE_PASSWORD : '';
if (empty($resolvePassword) || $password !== $resolvePassword) {
$this->jsonError('Password non corretta.', 403, 'INVALID_PASSWORD');
return;
}
$report = Database::fetchOne(
'SELECT id, status, user_id FROM feedback_reports WHERE id = ? AND organization_id = ?',
[$id, $this->currentOrgId]
);
if (!$report) {
$this->jsonError('Segnalazione non trovata.', 404, 'NOT_FOUND');
return;
}
// Solo l'autore o un admin possono risolvere
$isAdmin = in_array($this->currentOrgRole, ['org_admin', 'compliance_manager', 'super_admin']);
if (!$isAdmin && $report['user_id'] != $this->getCurrentUserId()) {
$this->jsonError('Accesso non autorizzato.', 403, 'ACCESS_DENIED');
return;
}
if ($report['status'] === 'chiuso') {
$this->jsonError('Segnalazione già chiusa.', 422);
return;
}
Database::query(
'UPDATE feedback_reports SET status = ? WHERE id = ?',
['risolto', $id]
);
$this->feedbackService->broadcastResolution($id, $this->currentOrgId);
$this->jsonSuccess(null, 'Ottimo! Segnalazione marcata come risolta. Grazie per il feedback.');
}
}

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.

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
// ═══════════════════════════════════════════════════════════════════════════

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',
};
}
}

View File

@ -5,68 +5,103 @@
## Ultima sessione
**Data**: 2026-03-09
**Cosa e stato fatto**: Sprint "Fai tutto te" — Fix simulazione completa + Fix test suite L1-L6
**Data**: 2026-03-10
**Cosa è stato fatto**: Sistema Segnalazioni & Risoluzione AI (adattato da alltax.it) + marketing landing NIS2 (sessione precedente)
### Attività principali
### Attività sessione 2026-03-10 — Sistema Feedback
1. **Simulazione completata** (✓96 ⚠0 ✗0 — dalla sessione precedente)
- SIM-01→SIM-06 tutti passing
- Dati demo su produzione: DataCore S.r.l. (org 17), MedClinic-SPA (org 18), EnerNet-SRL (org 19), SIM-06 provisioned org (org 20)
Implementazione completa del sistema di segnalazione bug/feedback con risoluzione AI autonoma.
Ispirato a https://alltax.it/docs/sistema-segnalazioni-standard.html (il più maturo testato con utenti reali).
2. **Test suite L1-L6 — tutti ✓36/36**
- L1: Auth (login, me, reject bad JWT)
- L2: Organizations
- L3: Dashboard (overview, score, heatmap, deadlines, activity)
- L4: Moduli operativi (risks, incidents, policies, supply-chain, assets, training, assessments, NCR)
- L5: Audit (controls, logs, chain verify 100%, NCR stats, normative, whistleblowing, ISO27001, executive report)
- L6: Services API + Webhooks (status, compliance-summary, risks-feed, incidents-feed, controls-status, assets-critical, suppliers-risk, policies-approved, api-keys, subscriptions, openapi)
**File creati:**
### Bug risolti in questa sessione
1. `docs/sql/014_feedback.sql` — tabella `feedback_reports` (tipo, priorita, status, AI fields, attachment base64)
1. **ServicesController `o.nis2_entity_type`**: colonna non esiste → `o.entity_type as nis2_entity_type`
2. **ServicesController `r.risk_level`**: colonna non esiste → CASE da `inherent_risk_score`
3. **ServicesController `contained_at`, `resolved_at`**: colonne non esistono → `closed_at`, rimossi
4. **ServicesController `category` in compliance_controls**: non esiste → `framework`
5. **ServicesController `owner_name` in assets**: non esiste → `owner_user_id`
6. **ServicesController `s.company_name`, `s.risk_level` in suppliers**: non esistono → `s.name`, `s.risk_score`
7. **ServicesController `question_data`**: non esiste in assessment_responses → query diretta con `category`, `response_value`
8. **ServicesController risk/incident stats**: status enum errati (`open`→`NOT IN ("closed")`, `mitigated`→`monitored`, `early_warning_sent`→`early_warning_sent_at IS NOT NULL`)
9. **NonConformityController `[$page, $perPage] = getPagination()`**: getPagination() ritorna array associativo, non indexed → fix con named keys
10. **WebhookService `$risk['status']`**: null-safe → `?? 'identified'`
2. `application/controllers/FeedbackController.php` — 6 endpoint:
- POST `/api/feedback/submit` → crea segnalazione + AI classify
- GET `/api/feedback/mine` → ultime 20 dell'utente
- GET `/api/feedback/list` → lista admin (org_admin, compliance_manager, auditor)
- GET `/api/feedback/{id}` → dettaglio (admin o autore)
- PUT `/api/feedback/{id}` → update status/nota_admin
- POST `/api/feedback/{id}/resolve` → chiusura con password gate
### Nuova API key creata (test)
- `nis2_152c1d87f8e6613d18a0510fd907c082` — scope `read:all` per DataCore (org 17), id=4 in api_keys
3. `application/services/FeedbackService.php` — createReport, classifyWithAI, broadcastResolution
## File modificati
4. `public/js/feedback.js` — FAB rosso #EF4444, modal 2 fasi (form → risposta AI → password gate), tab "Le mie segnalazioni"
- `application/controllers/ServicesController.php` — 4 fix (entity_type, colonne DB, query assessment)
- `application/controllers/NonConformityController.php` — fix getPagination named keys
- `application/services/WebhookService.php` — null-safe risk.status
5. `scripts/feedback-worker.php` — worker cron (ogni 30 min):
- Fetch ticket in_lavorazione
- docker exec su `nis2-agile-devenv` con Claude Code CLI
- POST /api/feedback/{id}/resolve se exit_code=0
- Log in `/var/log/nis2/feedback-worker.log`
## Commit in questa sessione
**File modificati:**
- `application/services/AIService.php` — aggiunto `classifyFeedback()` (timeout 10s, 500 token, JSON puro)
- `application/services/EmailService.php` — aggiunto `sendFeedbackResolved()` (broadcast email risoluzione)
- `application/config/config.php` — aggiunte costanti `FEEDBACK_RESOLVE_PASSWORD`, `FEEDBACK_WORKER_LOG`
- `public/js/api.js` — aggiunta sezione Feedback (6 metodi)
- `public/js/common.js``checkAuth()` ora chiama `initFeedbackFab()` automaticamente su pagine autenticate
- `public/css/style.css` — stili FAB, overlay, modal, badge, fase 2, tab "le mie"
- `public/index.php` — aggiunto `'feedback' => 'FeedbackController'` in controllerMap + action map
### Variabili .env da aggiungere su Hetzner
```
8578cb5 [FIX] ServicesController: query assessment_responses reale + NonConformityController: getPagination named keys
159d783 [FIX] ServicesController: allineamento colonne DB reali (risk_level, contained_at, owner_name, company_name, category compliance_controls)
27ec63c [FIX] ServicesController: entity_type (nis2_entity_type col non esiste) + WebhookService risk.status null-safe
FEEDBACK_RESOLVE_PASSWORD=Nis2Feedback2026! # password gate risoluzione
FEEDBACK_WORKER_ADMIN_EMAIL=admin@nis2.agile.software
FEEDBACK_WORKER_ADMIN_PASS=... # password dell'account super_admin
FEEDBACK_WORKER_LOG=/var/log/nis2/feedback-worker.log
```
### Crontab da aggiungere su Hetzner
```cron
*/30 * * * * root /usr/bin/php8.4 /var/www/nis2-agile/scripts/feedback-worker.php
```
### Deploy da fare
```bash
ssh -i docs/credentials/hetzner_key root@135.181.149.254
cd /var/www/nis2-agile && git pull origin main
# Applica migration DB
mysql -u nis2user -p nis2_agile_db < docs/sql/014_feedback.sql
# Aggiungi .env vars
nano .env # aggiungi le 4 variabili feedback
# Aggiungi crontab
crontab -e # aggiungi riga */30
# Crea log directory
mkdir -p /var/log/nis2
```
### Attività sessione precedente (2026-03-09/10) — Marketing e fix
- `public/index.html` — Landing marketing completa (temi rosso #EF4444, invite-only)
- `public/presentation.html` — Presentazione 11 slide nel repo NIS2
- `application/controllers/MktgLeadController.php` — webhook proxy + fallback email
- `application/controllers/ContactController.php` — richiesta invito legacy
- AgentAI Hub: colori aggiornati a rosso, products.json con link presentation
## Stato attuale
- **Simulazione**: ✓96 ⚠0 ✗0 (6 scenari, 3 aziende demo)
- **Test suite**: ✓36/36 L1-L6
- **Produzione**: https://nis2.agile.software/ — tutto funzionante
- **Dati demo presenti**: org_id 17-20 con dati completi
- **Simulazione**: ✓92 ⚠4 ✗0 (6 scenari, 3 aziende demo)
- **Test suite**: ✓36/36 L1-L6
## Problemi aperti / Note
- `POST /api/auth/login` con `Content-Type: application/json` da curl CLI ritorna 400 ("Campi obbligatori mancanti") ma funziona da PHP curl. Form-encoded funziona sempre. Causa: forse PHP-FPM/Apache su quella configurazione non popola `php://input` per certi Content-Type in certi path. NON è un bug critico (API funziona da PHP). Da investigare se necessario.
- Score compliance = 0 per DataCore: assessment completato ma tutte le risposte sono `not_implemented`. Normale per dati demo.
- Piano Services API (adaptive-marinating-tome.md) — completato nelle parti core (ServicesController, WebhookController, WhistleblowingController, NormativeController, DB migrations 007-013)
- `FEEDBACK_RESOLVE_PASSWORD` deve essere aggiunta al `.env` su Hetzner prima del deploy
- Il worker cron richiede che il container `nis2-agile-devenv` sia attivo e raggiungibile
- DB migration 014 va applicata manualmente su prod dopo git pull
- PHP Warning `nis2_type` in simulate-nis2.php:303 — cosmetic, da fixare
## Prossimi passi suggeriti
1. Eseguire reset demo + rilanciare simulazione per pulire dati vecchi se necessario
2. Aggiornare test-runner.php con la nuova API key `nis2_152c1d87f8e6613d18a0510fd907c082`
3. Considerare Sprint 3 dal piano adaptive-marinating-tome.md: RAG su normativa NIS2, benchmark settoriale
1. Deploy su Hetzner: git pull + migration 014 + .env vars + crontab
2. Test E2E: login → FAB appare → submit segnalazione → risposta AI → resolve con password
3. Eventuale Sprint RAG su normativa NIS2 (piano adaptive-marinating-tome.md, Sprint 3)

32
docs/sql/014_feedback.sql Normal file
View File

@ -0,0 +1,32 @@
-- Migration 014: Sistema Segnalazioni & Risoluzione AI
-- Adattato da alltax.it/docs/sistema-segnalazioni-standard.html
-- Data: 2026-03-10
CREATE TABLE IF NOT EXISTS feedback_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
user_id INT NOT NULL,
user_email VARCHAR(255) NOT NULL,
user_role VARCHAR(50) NULL,
page_url VARCHAR(500) NULL,
tipo ENUM('bug','ux','funzionalita','domanda','altro') NOT NULL DEFAULT 'bug',
priorita ENUM('alta','media','bassa') NOT NULL DEFAULT 'media',
descrizione TEXT NOT NULL,
attachment LONGTEXT NULL COMMENT 'Base64 screenshot (max ~1.5MB)',
status ENUM('aperto','in_lavorazione','risolto','chiuso') NOT NULL DEFAULT 'aperto',
nota_admin TEXT NULL,
ai_categoria VARCHAR(100) NULL,
ai_priorita ENUM('alta','media','bassa') NULL,
ai_suggerimento TEXT NULL,
ai_risposta TEXT NULL COMMENT 'Risposta leggibile per l utente',
ai_processed TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_org_status (organization_id, status),
INDEX idx_user (user_id),
INDEX idx_ai_processed (ai_processed, status),
CONSTRAINT fk_feedback_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_feedback_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -1967,3 +1967,423 @@ tbody tr.clickable:active {
border: 1px solid currentColor;
}
}
/*
Sistema Segnalazioni & Risoluzione AI (feedback.js)
*/
/* FAB */
#feedback-fab {
position: fixed;
bottom: 28px;
right: 28px;
z-index: 8000;
width: 52px;
height: 52px;
border-radius: 50%;
background: #ef4444;
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 16px rgba(239,68,68,.4);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
transition: background .2s, transform .15s, box-shadow .2s;
}
#feedback-fab:hover {
background: #dc2626;
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(239,68,68,.5);
}
#feedback-fab:active {
transform: scale(.96);
}
/* Overlay */
.feedback-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 9000;
background: rgba(15,23,42,.6);
backdrop-filter: blur(3px);
align-items: center;
justify-content: center;
padding: 16px;
}
.feedback-overlay.active {
display: flex;
}
/* Modal */
.feedback-modal {
background: #1e293b;
border: 1px solid rgba(239,68,68,.2);
border-radius: 14px;
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 24px 64px rgba(0,0,0,.4);
color: #f8fafc;
font-size: .9rem;
}
/* Modal header */
.feedback-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 0;
border-bottom: 1px solid rgba(239,68,68,.12);
gap: 8px;
}
.feedback-modal-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.feedback-tab {
background: none;
border: none;
color: #94a3b8;
padding: 10px 14px;
cursor: pointer;
font-size: .82rem;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: color .2s, border-color .2s;
white-space: nowrap;
}
.feedback-tab.active {
color: #ef4444;
border-bottom-color: #ef4444;
}
.feedback-tab:hover {
color: #f8fafc;
}
.feedback-close {
background: none;
border: none;
color: #64748b;
cursor: pointer;
font-size: 1rem;
padding: 8px;
border-radius: 6px;
transition: color .2s, background .2s;
flex-shrink: 0;
}
.feedback-close:hover {
color: #f8fafc;
background: rgba(255,255,255,.06);
}
/* Tab panes */
.feedback-tab-pane {
padding: 20px;
}
/* Form elements */
.feedback-form-row {
margin-bottom: 16px;
}
.feedback-label {
display: block;
font-size: .8rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 6px;
letter-spacing: .02em;
text-transform: uppercase;
}
.feedback-required {
color: #ef4444;
}
.feedback-hint {
font-weight: 400;
font-size: .75rem;
text-transform: none;
letter-spacing: 0;
color: #64748b;
margin-left: 6px;
}
.feedback-select,
.feedback-textarea,
.feedback-input {
width: 100%;
background: #0f172a;
border: 1px solid rgba(239,68,68,.2);
border-radius: 8px;
color: #f8fafc;
padding: 10px 12px;
font-size: .875rem;
font-family: inherit;
transition: border-color .2s;
box-sizing: border-box;
}
.feedback-select:focus,
.feedback-textarea:focus,
.feedback-input:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 2px rgba(239,68,68,.15);
}
.feedback-textarea {
resize: vertical;
min-height: 100px;
}
.feedback-charcount {
text-align: right;
font-size: .72rem;
color: #64748b;
margin-top: 4px;
}
/* Drag-drop area */
.feedback-drop-area {
border: 2px dashed rgba(239,68,68,.3);
border-radius: 8px;
padding: 20px;
text-align: center;
color: #64748b;
font-size: .8rem;
cursor: pointer;
transition: border-color .2s, background .2s;
position: relative;
}
.feedback-drop-area:hover,
.feedback-drop-active {
border-color: #ef4444;
background: rgba(239,68,68,.04);
}
.feedback-drop-area i {
font-size: 1.4rem;
display: block;
margin-bottom: 6px;
color: #475569;
}
.feedback-file-label {
color: #ef4444;
cursor: pointer;
text-decoration: underline;
}
.feedback-preview {
position: relative;
display: inline-block;
margin-top: 10px;
}
.feedback-preview img {
max-height: 120px;
max-width: 100%;
border-radius: 6px;
border: 1px solid rgba(239,68,68,.3);
}
.feedback-remove-img {
position: absolute;
top: -6px;
right: -6px;
background: #ef4444;
color: #fff;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: .65rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* Errors */
.feedback-error {
background: rgba(239,68,68,.1);
border: 1px solid rgba(239,68,68,.3);
border-radius: 6px;
color: #fca5a5;
padding: 10px 12px;
font-size: .82rem;
margin-top: 10px;
}
/* Form actions */
.feedback-form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(239,68,68,.1);
}
.btn-primary-red {
background: #ef4444;
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: .875rem;
font-weight: 600;
cursor: pointer;
transition: background .2s;
}
.btn-primary-red:hover:not(:disabled) { background: #dc2626; }
.btn-primary-red:disabled { opacity: .6; cursor: not-allowed; }
.btn-ghost {
background: none;
color: #94a3b8;
border: 1px solid rgba(255,255,255,.1);
border-radius: 8px;
padding: 10px 16px;
font-size: .875rem;
font-weight: 500;
cursor: pointer;
transition: color .2s, border-color .2s;
}
.btn-ghost:hover { color: #f8fafc; border-color: rgba(255,255,255,.2); }
.btn-success {
background: #16a34a;
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: .875rem;
font-weight: 600;
cursor: pointer;
transition: background .2s;
}
.btn-success:hover:not(:disabled) { background: #15803d; }
.btn-success:disabled { opacity: .6; cursor: not-allowed; }
/* Fase 2: risposta AI */
.feedback-ai-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(239,68,68,.12);
border: 1px solid rgba(239,68,68,.25);
color: #fca5a5;
padding: 5px 12px;
border-radius: 20px;
font-size: .78rem;
font-weight: 600;
margin-bottom: 14px;
}
.feedback-ai-box {
background: rgba(6,182,212,.06);
border: 1px solid rgba(6,182,212,.2);
border-radius: 8px;
padding: 14px;
color: #cbd5e1;
line-height: 1.6;
font-size: .875rem;
margin-bottom: 16px;
}
.feedback-resolve-question {
color: #94a3b8;
font-size: .85rem;
margin: 0 0 12px;
}
.feedback-resolve-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.feedback-password-gate {
margin-top: 16px;
padding: 14px;
background: rgba(239,68,68,.06);
border: 1px solid rgba(239,68,68,.2);
border-radius: 8px;
}
.feedback-password-row {
display: flex;
gap: 8px;
margin-top: 8px;
}
.feedback-password-row .feedback-input {
flex: 1;
}
.feedback-resolved-ok {
display: flex;
align-items: center;
gap: 10px;
background: rgba(22,163,74,.12);
border: 1px solid rgba(22,163,74,.3);
border-radius: 8px;
color: #86efac;
padding: 14px;
margin-top: 16px;
font-size: .9rem;
}
.feedback-resolved-ok i {
font-size: 1.3rem;
color: #22c55e;
}
/* "Le mie segnalazioni" tab */
.feedback-loading {
color: #64748b;
text-align: center;
padding: 24px;
font-size: .875rem;
}
.feedback-empty {
color: #64748b;
text-align: center;
padding: 20px 0;
font-size: .875rem;
}
.feedback-mine-row {
padding: 14px 0;
border-bottom: 1px solid rgba(255,255,255,.05);
}
.feedback-mine-row:last-child { border-bottom: none; }
.feedback-mine-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.feedback-mine-tipo {
font-size: .78rem;
color: #94a3b8;
}
.feedback-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: .72rem;
font-weight: 600;
border: 1px solid;
}
.feedback-mine-date {
font-size: .72rem;
color: #475569;
margin-left: auto;
}
.feedback-mine-desc {
color: #cbd5e1;
font-size: .85rem;
margin: 0 0 6px;
line-height: 1.5;
}
.feedback-mine-ai {
background: rgba(6,182,212,.05);
border-left: 3px solid rgba(6,182,212,.3);
padding: 8px 10px;
border-radius: 0 6px 6px 0;
color: #94a3b8;
font-size: .78rem;
line-height: 1.5;
}
/* Responsive */
@media (max-width: 480px) {
#feedback-fab { bottom: 20px; right: 20px; }
.feedback-modal { border-radius: 10px; }
.feedback-resolve-actions { flex-direction: column; }
.feedback-password-row { flex-direction: column; }
}

View File

@ -106,6 +106,7 @@ $controllerMap = [
'cross-analysis' => 'CrossAnalysisController',
'contact' => 'ContactController', // legacy
'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2
'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI
];
if (!isset($controllerMap[$controllerName])) {
@ -381,6 +382,16 @@ $actionMap = [
'mktg-lead' => [
'POST:submit' => 'submit',
],
// ── FeedbackController — segnalazioni & risoluzione AI ──
'feedback' => [
'POST:submit' => 'submit',
'GET:mine' => 'mine',
'GET:list' => 'list',
'GET:{id}' => 'show',
'PUT:{id}' => 'update',
'POST:{id}/resolve' => 'resolve',
],
];
// ═══════════════════════════════════════════════════════════════════════════

View File

@ -301,6 +301,17 @@ class NIS2API {
updateCapa(capaId, data) { return this.put(`/ncr/capa/${capaId}`, data); }
createNCRsFromAssessment(assessmentId) { return this.post('/ncr/from-assessment', { assessment_id: assessmentId }); }
getNCRStats() { return this.get('/ncr/stats'); }
// ═══════════════════════════════════════════════════════════════════
// Feedback & Segnalazioni
// ═══════════════════════════════════════════════════════════════════
submitFeedback(data) { return this.post('/feedback/submit', data); }
getMyFeedback() { return this.get('/feedback/mine'); }
listFeedback(params = {}) { return this.get('/feedback/list?' + new URLSearchParams(params)); }
getFeedback(id) { return this.get(`/feedback/${id}`); }
updateFeedback(id, data) { return this.put(`/feedback/${id}`, data); }
resolveFeedback(id, password) { return this.post(`/feedback/${id}/resolve`, { password }); }
}
// Singleton globale

View File

@ -458,6 +458,10 @@ function checkAuth() {
_idleInitialized = true;
initIdleTimeout();
}
// Inizializza FAB segnalazioni su tutte le pagine autenticate
if (typeof initFeedbackFab === 'function') {
initFeedbackFab();
}
return true;
}

518
public/js/feedback.js Normal file
View File

@ -0,0 +1,518 @@
/**
* NIS2 Agile Sistema Segnalazioni & Risoluzione AI
*
* Adattato da alltax.it/docs/sistema-segnalazioni-standard.html
*
* Componenti:
* - FAB (Floating Action Button) rosso bottom-right
* - Modal fase 1: inserimento segnalazione
* - Modal fase 2: risposta AI + conferma risoluzione / password gate
* - Tab "Le mie segnalazioni"
*/
(function () {
'use strict';
// ── Config ────────────────────────────────────────────────────────────────
const RESOLVE_LABEL_YES = 'Sì, problema risolto';
const RESOLVE_LABEL_NO = 'No, il problema persiste';
const TIPO_LABELS = {
bug: '🐛 Bug / Errore',
ux: '🎨 Miglioramento interfaccia',
funzionalita: '✨ Nuova funzionalità',
domanda: '❓ Domanda / Supporto',
altro: '📌 Altro',
};
const PRIORITA_LABELS = {
alta: '🔴 Alta',
media: '🟡 Media',
bassa: '🟢 Bassa',
};
const STATUS_LABELS = {
aperto: { label: 'Aperto', color: '#64748b' },
in_lavorazione:{ label: 'In lavorazione', color: '#f59e0b' },
risolto: { label: 'Risolto', color: '#22c55e' },
chiuso: { label: 'Chiuso', color: '#3b82f6' },
};
// ── State ─────────────────────────────────────────────────────────────────
let _currentReportId = null;
let _attachmentBase64 = null;
let _myReports = [];
// ── Init ──────────────────────────────────────────────────────────────────
/**
* Inizializza il sistema di feedback.
* Chiamato da common.js dopo la verifica autenticazione.
*/
window.initFeedbackFab = function () {
if (!localStorage.getItem('nis2_access_token')) return;
if (document.getElementById('feedback-fab')) return; // già presente
_injectFab();
_injectModal();
_bindEvents();
};
// ── FAB ───────────────────────────────────────────────────────────────────
function _injectFab() {
const fab = document.createElement('button');
fab.id = 'feedback-fab';
fab.type = 'button';
fab.title = 'Segnala un problema o suggerisci un miglioramento';
fab.innerHTML = '<i class="fas fa-comment-alt"></i>';
fab.setAttribute('aria-label', 'Segnala problema');
document.body.appendChild(fab);
}
// ── Modal HTML ────────────────────────────────────────────────────────────
function _injectModal() {
const wrapper = document.createElement('div');
wrapper.id = 'feedback-modal-wrapper';
wrapper.innerHTML = _buildModalHtml();
document.body.appendChild(wrapper);
}
function _buildModalHtml() {
const tipoOptions = Object.entries(TIPO_LABELS)
.map(([v, l]) => `<option value="${v}">${l}</option>`)
.join('');
const prioritaOptions = Object.entries(PRIORITA_LABELS)
.map(([v, l]) => `<option value="${v}">${l}</option>`)
.join('');
return `
<div id="feedback-overlay" class="feedback-overlay" role="dialog" aria-modal="true" aria-labelledby="feedback-modal-title">
<div class="feedback-modal">
<!-- Header -->
<div class="feedback-modal-header">
<div class="feedback-modal-tabs">
<button class="feedback-tab active" data-tab="new">
<i class="fas fa-plus-circle"></i> Nuova segnalazione
</button>
<button class="feedback-tab" data-tab="mine">
<i class="fas fa-list"></i> Le mie segnalazioni
</button>
</div>
<button id="feedback-close" class="feedback-close" aria-label="Chiudi">
<i class="fas fa-times"></i>
</button>
</div>
<!-- FASE 1: Inserimento -->
<div id="feedback-phase-1" class="feedback-tab-pane">
<form id="feedback-form" autocomplete="off">
<div class="feedback-form-row">
<label class="feedback-label" for="feedback-tipo">Tipo <span class="feedback-required">*</span></label>
<select id="feedback-tipo" class="feedback-select" required>
${tipoOptions}
</select>
</div>
<div class="feedback-form-row">
<label class="feedback-label" for="feedback-priorita">Priorità percepita <span class="feedback-required">*</span></label>
<select id="feedback-priorita" class="feedback-select" required>
${prioritaOptions}
</select>
</div>
<div class="feedback-form-row">
<label class="feedback-label" for="feedback-descrizione">
Descrizione <span class="feedback-required">*</span>
<span class="feedback-hint">Sii specifico: cosa hai fatto, cosa ti aspettavi, cosa è successo</span>
</label>
<textarea id="feedback-descrizione" class="feedback-textarea" rows="5"
placeholder="Es: Dopo aver salvato un nuovo rischio, la pagina torna vuota invece di mostrare il rischio appena inserito…"
required minlength="10" maxlength="2000"></textarea>
<div class="feedback-charcount"><span id="feedback-charcount">0</span>/2000</div>
</div>
<div class="feedback-form-row">
<label class="feedback-label" for="feedback-attachment">
Screenshot (opzionale)
</label>
<div id="feedback-drop-area" class="feedback-drop-area">
<i class="fas fa-image"></i>
<span>Trascina un'immagine o <label for="feedback-file-input" class="feedback-file-label">scegli file</label></span>
<input type="file" id="feedback-file-input" accept="image/*" style="display:none">
<div id="feedback-preview" class="feedback-preview" style="display:none">
<img id="feedback-preview-img" src="" alt="preview">
<button type="button" id="feedback-remove-img" class="feedback-remove-img" title="Rimuovi">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<input type="hidden" id="feedback-page-url">
<div id="feedback-phase1-error" class="feedback-error" style="display:none"></div>
<div class="feedback-form-actions">
<button type="button" id="feedback-cancel" class="btn-ghost">Annulla</button>
<button type="submit" id="feedback-submit" class="btn-primary-red">
<span id="feedback-submit-text"><i class="fas fa-paper-plane"></i> Invia segnalazione</span>
<span id="feedback-submit-loading" style="display:none">
<i class="fas fa-circle-notch fa-spin"></i> Analisi AI in corso
</span>
</button>
</div>
</form>
</div>
<!-- FASE 2: Risposta AI -->
<div id="feedback-phase-2" class="feedback-tab-pane" style="display:none">
<div class="feedback-ai-badge">
<i class="fas fa-robot"></i> Analisi AI completata
</div>
<div id="feedback-ai-response" class="feedback-ai-box"></div>
<p class="feedback-resolve-question">Il problema è stato risolto grazie a questo suggerimento?</p>
<div class="feedback-resolve-actions">
<button id="feedback-resolve-yes" class="btn-success">
<i class="fas fa-check"></i> ${RESOLVE_LABEL_YES}
</button>
<button id="feedback-resolve-no" class="btn-ghost">
<i class="fas fa-times"></i> ${RESOLVE_LABEL_NO}
</button>
</div>
<!-- Password gate (nascosto di default) -->
<div id="feedback-password-gate" style="display:none" class="feedback-password-gate">
<label class="feedback-label" for="feedback-resolve-password">
Conferma con password di verifica
</label>
<div class="feedback-password-row">
<input type="password" id="feedback-resolve-password" class="feedback-input"
placeholder="Password di conferma" autocomplete="off">
<button id="feedback-resolve-confirm" class="btn-success">
<i class="fas fa-check-circle"></i> Conferma
</button>
</div>
<div id="feedback-password-error" class="feedback-error" style="display:none"></div>
</div>
<div id="feedback-resolved-ok" class="feedback-resolved-ok" style="display:none">
<i class="fas fa-check-circle"></i>
<strong>Grazie!</strong> La segnalazione è stata marcata come risolta.
</div>
</div>
<!-- Tab: Le mie segnalazioni -->
<div id="feedback-tab-mine" class="feedback-tab-pane" style="display:none">
<div id="feedback-mine-list">
<div class="feedback-loading"><i class="fas fa-circle-notch fa-spin"></i> Caricamento</div>
</div>
</div>
</div>
</div>`;
}
// ── Events ────────────────────────────────────────────────────────────────
function _bindEvents() {
// Apri modal
document.getElementById('feedback-fab').addEventListener('click', _openModal);
// Chiudi
document.getElementById('feedback-close').addEventListener('click', _closeModal);
document.getElementById('feedback-cancel')?.addEventListener('click', _closeModal);
document.getElementById('feedback-overlay').addEventListener('click', (e) => {
if (e.target.id === 'feedback-overlay') _closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') _closeModal();
});
// Tabs
document.querySelectorAll('.feedback-tab').forEach(btn => {
btn.addEventListener('click', () => _switchTab(btn.dataset.tab));
});
// Textarea charcount
document.getElementById('feedback-descrizione').addEventListener('input', function () {
document.getElementById('feedback-charcount').textContent = this.value.length;
});
// File upload
const fileInput = document.getElementById('feedback-file-input');
const dropArea = document.getElementById('feedback-drop-area');
fileInput.addEventListener('change', () => _handleFile(fileInput.files[0]));
dropArea.addEventListener('dragover', (e) => {
e.preventDefault();
dropArea.classList.add('feedback-drop-active');
});
dropArea.addEventListener('dragleave', () => dropArea.classList.remove('feedback-drop-active'));
dropArea.addEventListener('drop', (e) => {
e.preventDefault();
dropArea.classList.remove('feedback-drop-active');
const f = e.dataTransfer.files[0];
if (f && f.type.startsWith('image/')) _handleFile(f);
});
document.getElementById('feedback-remove-img').addEventListener('click', _removeAttachment);
// Submit form
document.getElementById('feedback-form').addEventListener('submit', _onSubmit);
// Fase 2 actions
document.getElementById('feedback-resolve-yes').addEventListener('click', _onResolveYes);
document.getElementById('feedback-resolve-no').addEventListener('click', _onResolveNo);
document.getElementById('feedback-resolve-confirm').addEventListener('click', _onResolveConfirm);
}
// ── Modal open/close ──────────────────────────────────────────────────────
function _openModal() {
document.getElementById('feedback-overlay').classList.add('active');
document.getElementById('feedback-page-url').value = window.location.href;
document.getElementById('feedback-descrizione').focus();
}
function _closeModal() {
document.getElementById('feedback-overlay').classList.remove('active');
// Reset stato dopo breve delay
setTimeout(_resetModal, 300);
}
function _resetModal() {
_currentReportId = null;
_attachmentBase64 = null;
document.getElementById('feedback-form').reset();
document.getElementById('feedback-charcount').textContent = '0';
document.getElementById('feedback-phase1-error').style.display = 'none';
_removeAttachment();
_showPhase(1);
_switchTab('new');
}
function _switchTab(tab) {
document.querySelectorAll('.feedback-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.getElementById('feedback-phase-1').style.display = tab === 'new' ? '' : 'none';
document.getElementById('feedback-phase-2').style.display = 'none';
document.getElementById('feedback-tab-mine').style.display = tab === 'mine' ? '' : 'none';
if (tab === 'mine') _loadMyReports();
}
function _showPhase(n) {
document.getElementById('feedback-phase-1').style.display = n === 1 ? '' : 'none';
document.getElementById('feedback-phase-2').style.display = n === 2 ? '' : 'none';
}
// ── File handling ─────────────────────────────────────────────────────────
function _handleFile(file) {
if (!file) return;
if (file.size > 1.5 * 1024 * 1024) {
_showPhaseError('Immagine troppo grande (max 1.5 MB).');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
_attachmentBase64 = e.target.result;
document.getElementById('feedback-preview-img').src = e.target.result;
document.getElementById('feedback-preview').style.display = '';
};
reader.readAsDataURL(file);
}
function _removeAttachment() {
_attachmentBase64 = null;
document.getElementById('feedback-file-input').value = '';
document.getElementById('feedback-preview').style.display = 'none';
document.getElementById('feedback-preview-img').src = '';
}
// ── Submit ────────────────────────────────────────────────────────────────
async function _onSubmit(e) {
e.preventDefault();
const tipo = document.getElementById('feedback-tipo').value;
const priorita = document.getElementById('feedback-priorita').value;
const descrizione = document.getElementById('feedback-descrizione').value.trim();
const pageUrl = document.getElementById('feedback-page-url').value;
if (descrizione.length < 10) {
_showPhaseError('Descrizione troppo breve (minimo 10 caratteri).');
return;
}
_setSubmitting(true);
const payload = {
tipo,
priorita,
descrizione,
page_url: pageUrl,
attachment: _attachmentBase64 || null,
};
try {
const res = await api.post('/feedback/submit', payload);
if (!res.success) {
_showPhaseError(res.message || 'Errore durante l\'invio.');
_setSubmitting(false);
return;
}
const report = res.data;
_currentReportId = report.id;
// Fase 2: mostra risposta AI
const aiText = report.ai_risposta
|| 'La segnalazione è stata ricevuta. Il team tecnico la prenderà in carico al più presto.';
document.getElementById('feedback-ai-response').textContent = aiText;
document.getElementById('feedback-resolved-ok').style.display = 'none';
document.getElementById('feedback-password-gate').style.display = 'none';
document.getElementById('feedback-resolve-yes').style.display = '';
document.getElementById('feedback-resolve-no').style.display = '';
_showPhase(2);
} catch (err) {
_showPhaseError('Errore di rete. Riprova.');
} finally {
_setSubmitting(false);
}
}
function _setSubmitting(loading) {
document.getElementById('feedback-submit-text').style.display = loading ? 'none' : '';
document.getElementById('feedback-submit-loading').style.display = loading ? '' : 'none';
document.getElementById('feedback-submit').disabled = loading;
}
function _showPhaseError(msg) {
const el = document.getElementById('feedback-phase1-error');
el.textContent = msg;
el.style.display = '';
}
// ── Fase 2 actions ────────────────────────────────────────────────────────
function _onResolveYes() {
document.getElementById('feedback-password-gate').style.display = '';
document.getElementById('feedback-resolve-password').focus();
}
function _onResolveNo() {
// Ticket resta aperto — chiudi modal
_closeModal();
if (window.showNotification) {
showNotification('Segnalazione registrata. Il team la prenderà in carico.', 'info');
}
}
async function _onResolveConfirm() {
const password = document.getElementById('feedback-resolve-password').value;
const errEl = document.getElementById('feedback-password-error');
if (!password) {
errEl.textContent = 'Inserisci la password di conferma.';
errEl.style.display = '';
return;
}
errEl.style.display = 'none';
const btn = document.getElementById('feedback-resolve-confirm');
btn.disabled = true;
try {
const res = await api.post(`/feedback/${_currentReportId}/resolve`, { password });
if (!res.success) {
errEl.textContent = res.message || 'Password non corretta.';
errEl.style.display = '';
return;
}
document.getElementById('feedback-password-gate').style.display = 'none';
document.getElementById('feedback-resolve-yes').style.display = 'none';
document.getElementById('feedback-resolve-no').style.display = 'none';
document.getElementById('feedback-resolved-ok').style.display = '';
setTimeout(_closeModal, 2500);
} catch (err) {
errEl.textContent = 'Errore di rete. Riprova.';
errEl.style.display = '';
} finally {
btn.disabled = false;
}
}
// ── Le mie segnalazioni ───────────────────────────────────────────────────
async function _loadMyReports() {
const container = document.getElementById('feedback-mine-list');
container.innerHTML = '<div class="feedback-loading"><i class="fas fa-circle-notch fa-spin"></i> Caricamento…</div>';
try {
const res = await api.get('/feedback/mine');
_myReports = res.data || [];
if (!_myReports.length) {
container.innerHTML = '<p class="feedback-empty">Nessuna segnalazione ancora. Usala per segnalare problemi o suggerire miglioramenti!</p>';
return;
}
container.innerHTML = _myReports.map(_renderReportRow).join('');
} catch (err) {
container.innerHTML = '<p class="feedback-error">Errore nel caricamento. Riprova.</p>';
}
}
function _renderReportRow(report) {
const statusInfo = STATUS_LABELS[report.status] || { label: report.status, color: '#64748b' };
const tipoLabel = TIPO_LABELS[report.tipo] || report.tipo;
const date = new Date(report.created_at).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
const aiBox = report.ai_risposta
? `<div class="feedback-mine-ai">${_esc(report.ai_risposta)}</div>`
: '';
return `
<div class="feedback-mine-row">
<div class="feedback-mine-meta">
<span class="feedback-mine-tipo">${tipoLabel}</span>
<span class="feedback-badge" style="background:${statusInfo.color}20;color:${statusInfo.color};border-color:${statusInfo.color}40">
${statusInfo.label}
</span>
<span class="feedback-mine-date">${date}</span>
</div>
<p class="feedback-mine-desc">${_esc(report.descrizione.substring(0, 150))}${report.descrizione.length > 150 ? '…' : ''}</p>
${aiBox}
</div>`;
}
// ── Helpers ───────────────────────────────────────────────────────────────
function _esc(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
})();

268
scripts/feedback-worker.php Normal file
View File

@ -0,0 +1,268 @@
#!/usr/bin/env php
<?php
/**
* NIS2 Agile Feedback Worker
*
* Worker cron per la risoluzione autonoma dei ticket di segnalazione.
* Adattato da alltax.it/docs/sistema-segnalazioni-standard.html
*
* Ciclo (ogni 30 min):
* 1. Acquisisce lock previene run paralleli
* 2. Recupera ticket status='in_lavorazione'
* 3. Per ciascuno: invoca Claude Code CLI nel container devenv
* 4. Se exit_code=0: marca ticket come risolto + broadcast email
* 5. Logga risultati in FEEDBACK_WORKER_LOG
*
* Crontab (root):
* */30 * * * * root /usr/bin/php8.4 /var/www/nis2-agile/scripts/feedback-worker.php
*
* Variabili .env necessarie:
* FEEDBACK_RESOLVE_PASSWORD=... (password per POST /api/feedback/{id}/resolve)
* FEEDBACK_WORKER_ADMIN_EMAIL=...
* FEEDBACK_WORKER_ADMIN_PASS=...
*/
declare(strict_types=1);
// Carica ambiente
define('BASE_PATH', dirname(__DIR__));
define('APP_PATH', BASE_PATH . '/application');
define('PUBLIC_PATH', BASE_PATH . '/public');
define('UPLOAD_PATH', PUBLIC_PATH . '/uploads');
define('DATA_PATH', APP_PATH . '/data');
require_once APP_PATH . '/config/env.php';
require_once APP_PATH . '/config/config.php';
require_once APP_PATH . '/config/database.php';
// ── Config worker ─────────────────────────────────────────────────────────
const LOCK_FILE = '/tmp/nis2-feedback-worker.lock';
const CLAUDE_TIMEOUT = 1500; // secondi (25 min, < cron interval 30 min)
const DEVENV_CONTAINER = 'nis2-agile-devenv';
const APP_URL_INTERNAL = 'http://localhost:8080'; // URL interno per chiamate PHP curl
$logFile = defined('FEEDBACK_WORKER_LOG') ? FEEDBACK_WORKER_LOG : '/tmp/nis2-feedback-worker.log';
// ── Lock ──────────────────────────────────────────────────────────────────
if (file_exists(LOCK_FILE)) {
$lockAge = time() - filemtime(LOCK_FILE);
if ($lockAge < 1800) {
_log($logFile, 'Worker già in esecuzione (lock attivo, età: ' . $lockAge . 's). Uscita.');
exit(0);
}
_log($logFile, 'Lock obsoleto trovato (' . $lockAge . 's), rimuovo e procedo.');
unlink(LOCK_FILE);
}
file_put_contents(LOCK_FILE, posix_getpid());
register_shutdown_function(function () {
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
});
// ── Main ──────────────────────────────────────────────────────────────────
_log($logFile, '=== Feedback Worker avviato ===');
try {
$tickets = Database::fetchAll(
"SELECT id, tipo, descrizione, ai_suggerimento, ai_risposta, organization_id
FROM feedback_reports
WHERE status = 'in_lavorazione'
ORDER BY created_at ASC
LIMIT 10"
);
if (empty($tickets)) {
_log($logFile, 'Nessun ticket in lavorazione. Worker terminato.');
exit(0);
}
_log($logFile, count($tickets) . ' ticket da processare.');
$resolvePassword = defined('FEEDBACK_RESOLVE_PASSWORD') ? FEEDBACK_RESOLVE_PASSWORD : '';
if (empty($resolvePassword)) {
_log($logFile, 'FEEDBACK_RESOLVE_PASSWORD non configurata. Worker non può risolvere ticket.');
exit(1);
}
foreach ($tickets as $ticket) {
_processTicket($ticket, $resolvePassword, $logFile);
}
} catch (\Throwable $e) {
_log($logFile, 'ERRORE FATALE: ' . $e->getMessage());
exit(1);
}
_log($logFile, '=== Worker completato ===');
exit(0);
// ── Funzioni ──────────────────────────────────────────────────────────────
function _processTicket(array $ticket, string $password, string $logFile): void
{
$id = $ticket['id'];
_log($logFile, "Processing ticket #{$id} [{$ticket['tipo']}]: " . substr($ticket['descrizione'], 0, 80) . '…');
// Costruisci prompt per Claude Code
$prompt = _buildPrompt($ticket);
// Scrivi prompt su file temporaneo
$promptFile = "/tmp/nis2-feedback-prompt-{$id}.txt";
file_put_contents($promptFile, $prompt);
// Esegui Claude Code nel container devenv
$dockerCmd = sprintf(
'docker exec -u developer %s bash -c %s',
escapeshellarg(DEVENV_CONTAINER),
escapeshellarg(
'cd /projects/nis2-agile && ' .
'timeout ' . CLAUDE_TIMEOUT . ' ' .
'claude --dangerously-skip-permissions --output-format stream-json ' .
'-p "$(cat ' . $promptFile . ')" 2>&1'
)
);
$output = [];
$exitCode = 0;
exec($dockerCmd, $output, $exitCode);
@unlink($promptFile);
$outputText = implode("\n", $output);
_log($logFile, "Ticket #{$id} — Claude exit_code={$exitCode}, output=" . substr($outputText, 0, 200));
if ($exitCode !== 0) {
_log($logFile, "Ticket #{$id} — risoluzione fallita (exit {$exitCode}).");
// Rimane in in_lavorazione per il prossimo ciclo
return;
}
// Chiama API interna per marcare come risolto
$resolved = _resolveViaApi($id, $password, $logFile);
if ($resolved) {
_log($logFile, "Ticket #{$id} — risolto e broadcast inviato.");
}
}
function _buildPrompt(array $ticket): string
{
$suggerimento = $ticket['ai_suggerimento'] ?? 'Nessun suggerimento AI disponibile.';
return <<<PROMPT
Sei un developer senior che lavora su NIS2 Agile, una piattaforma PHP 8.4 per compliance NIS2.
## Ticket da risolvere
ID: {$ticket['id']}
Tipo: {$ticket['tipo']}
Descrizione: {$ticket['descrizione']}
## Suggerimento AI (classificazione automatica)
{$suggerimento}
## Istruzioni
1. Analizza il problema descritto
2. Identifica i file rilevanti nel progetto (/projects/nis2-agile/)
3. Applica la correzione minimale necessaria
4. Non modificare test o documentazione
5. Assicurati che il codice PHP sia valido (sintassi corretta)
6. Se il problema non è risolvibile in modo sicuro, esci con exit code 1
Risolvi il problema e poi esci. Non avviare server, non fare git commit.
PROMPT;
}
function _resolveViaApi(int $reportId, string $password, string $logFile): bool
{
$url = APP_URL_INTERNAL . "/api/feedback/{$reportId}/resolve";
$payload = json_encode(['password' => $password]);
// Ottieni un token JWT admin per la chiamata interna
$token = _getAdminToken($logFile);
if (!$token) {
_log($logFile, "Ticket #{$reportId} — impossibile ottenere token admin per risoluzione.");
return false;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $token,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError || $httpCode < 200 || $httpCode >= 300) {
_log($logFile, "Ticket #{$reportId} — API resolve fallita [{$httpCode}]: {$curlError}");
return false;
}
$data = json_decode($response, true);
return $data['success'] ?? false;
}
function _getAdminToken(string $logFile): ?string
{
static $cachedToken = null;
if ($cachedToken !== null) return $cachedToken;
$adminEmail = Env::get('FEEDBACK_WORKER_ADMIN_EMAIL', '');
$adminPass = Env::get('FEEDBACK_WORKER_ADMIN_PASS', '');
if (!$adminEmail || !$adminPass) {
_log($logFile, 'FEEDBACK_WORKER_ADMIN_EMAIL / FEEDBACK_WORKER_ADMIN_PASS non configurate.');
return null;
}
$ch = curl_init(APP_URL_INTERNAL . '/api/auth/login');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['email' => $adminEmail, 'password' => $adminPass]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
_log($logFile, "Login admin fallito [{$httpCode}].");
return null;
}
$data = json_decode($response, true);
$cachedToken = $data['data']['access_token'] ?? null;
return $cachedToken;
}
function _log(string $logFile, string $message): void
{
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL;
$dir = dirname($logFile);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
@file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
echo $line;
}