[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:
co-authored by
Claude Sonnet 4.6
parent
3b51b5bd95
commit
1382530189
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;">✓ Segnalazione Risolta</strong><br>
|
||||
<span style="color: #166534;">Il problema segnalato è stato risolto</span>
|
||||
</div>
|
||||
|
||||
<p>Una segnalazione nella tua organizzazione è 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 = "✓ Risolto: {$reportTitle}";
|
||||
|
||||
return $this->send($to, $subject, $html);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TEMPLATE HTML
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user