diff --git a/application/config/config.php b/application/config/config.php index 238bc87..7cbbcac 100644 --- a/application/config/config.php +++ b/application/config/config.php @@ -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 // ═══════════════════════════════════════════════════════════════════════════ diff --git a/application/controllers/FeedbackController.php b/application/controllers/FeedbackController.php new file mode 100644 index 0000000..3d9ef3f --- /dev/null +++ b/application/controllers/FeedbackController.php @@ -0,0 +1,301 @@ +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.'); + } +} diff --git a/application/services/AIService.php b/application/services/AIService.php index e97849c..7c09be5 100644 --- a/application/services/AIService.php +++ b/application/services/AIService.php @@ -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 = <<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. diff --git a/application/services/EmailService.php b/application/services/EmailService.php index 7dcc69a..c90aa39 100644 --- a/application/services/EmailService.php +++ b/application/services/EmailService.php @@ -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 = << + ✓ Segnalazione Risolta
+ Il problema segnalato è stato risolto + + +

Una segnalazione nella tua organizzazione è stata risolta:

+ + + + + + + + + + +
Segnalazione{$this->esc($reportTitle)}
Risoluzione{$this->esc($resolution)}
+ +

+ Vai alla Dashboard +

+ HTML; + + $subject = "✓ Risolto: {$reportTitle}"; + + return $this->send($to, $subject, $html); + } + // ═══════════════════════════════════════════════════════════════════════════ // TEMPLATE HTML // ═══════════════════════════════════════════════════════════════════════════ diff --git a/application/services/FeedbackService.php b/application/services/FeedbackService.php new file mode 100644 index 0000000..2f45a45 --- /dev/null +++ b/application/services/FeedbackService.php @@ -0,0 +1,149 @@ +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', + }; + } +} diff --git a/docs/CONTEXT_LAST_SESSION.md b/docs/CONTEXT_LAST_SESSION.md index 8eac108..0887548 100644 --- a/docs/CONTEXT_LAST_SESSION.md +++ b/docs/CONTEXT_LAST_SESSION.md @@ -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) diff --git a/docs/sql/014_feedback.sql b/docs/sql/014_feedback.sql new file mode 100644 index 0000000..963ef21 --- /dev/null +++ b/docs/sql/014_feedback.sql @@ -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; diff --git a/public/css/style.css b/public/css/style.css index ea034e0..5033347 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; } +} diff --git a/public/index.php b/public/index.php index 196e80f..4d1afbd 100644 --- a/public/index.php +++ b/public/index.php @@ -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', + ], ]; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/public/js/api.js b/public/js/api.js index 35f6eca..e2c7199 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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 diff --git a/public/js/common.js b/public/js/common.js index 4ebac14..da367da 100644 --- a/public/js/common.js +++ b/public/js/common.js @@ -458,6 +458,10 @@ function checkAuth() { _idleInitialized = true; initIdleTimeout(); } + // Inizializza FAB segnalazioni su tutte le pagine autenticate + if (typeof initFeedbackFab === 'function') { + initFeedbackFab(); + } return true; } diff --git a/public/js/feedback.js b/public/js/feedback.js new file mode 100644 index 0000000..31f8562 --- /dev/null +++ b/public/js/feedback.js @@ -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 = ''; + 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]) => ``) + .join(''); + + const prioritaOptions = Object.entries(PRIORITA_LABELS) + .map(([v, l]) => ``) + .join(''); + + return ` +`; + } + + // ── 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 = '
Caricamento…
'; + + try { + const res = await api.get('/feedback/mine'); + _myReports = res.data || []; + + if (!_myReports.length) { + container.innerHTML = '

Nessuna segnalazione ancora. Usala per segnalare problemi o suggerire miglioramenti!

'; + return; + } + + container.innerHTML = _myReports.map(_renderReportRow).join(''); + } catch (err) { + container.innerHTML = '

Errore nel caricamento. Riprova.

'; + } + } + + 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 + ? `
${_esc(report.ai_risposta)}
` + : ''; + + return ` +
+ + + ${aiBox} +
`; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + function _esc(str) { + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; + } + +})(); diff --git a/scripts/feedback-worker.php b/scripts/feedback-worker.php new file mode 100644 index 0000000..96bfef9 --- /dev/null +++ b/scripts/feedback-worker.php @@ -0,0 +1,268 @@ +#!/usr/bin/env php +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 << $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; +}