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>
302 lines
9.7 KiB
PHP
302 lines
9.7 KiB
PHP
<?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.');
|
|
}
|
|
}
|