[FEAT] Epic C / C5.2b — Portale esterno stakeholder + sotto-dashboard feedback (mig.053)

Completa C5 (Epic C). Gli stakeholder rispondono in self-service tramite magic-link
(token SHA-256 per destinatario, NESSUN account/JWT); il compliance manager vede gli
esiti, i commenti e gli allegati nel dettaglio dell'attività.

- StakeholderPortalController (non-JWT, token-only): access / respond (questionario) /
  acknowledge (firma di avvenuta lettura) / comment / attachment. Submit one-shot (409),
  validazione risposte obbligatorie, anti-IDOR (un token = un solo destinatario),
  auto-completamento attività quando tutti hanno risposto/firmato.
- StakeholderActivityController: feedback (risposte per destinatario), comments
  (GET/POST), attachments (upload interno + lista; riuso evidence_files entity_type=
  'stk_activity', file sotto public/uploads/stk_activity/{org}/).
- mig.053: stk_activity_responses (answers JSON / acknowledged_at), stk_activity_comments
  (interni/esterni). Estende il seeder idempotente.
- Frontend: stk-portal.html (pagina pubblica dependency-free: questionario per tipo di
  domanda o testo+firma, commento, upload); dettaglio attività in stakeholder-activities.html
  con esiti, thread commenti e allegati.

Email disattivate (kill-switch) → i magic-link si condividono manualmente. Smoke prod OK
(access no-auth, respond+required+409, acknowledge+WRONG_TYPE, comment esterno/interno,
bad-token 404, feedback interno, auto-complete; cleanup org 151 pulita). Additivo. v1.21.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-16 22:20:14 +02:00
co-authored by Claude Opus 4.8
parent a5ff29e0da
commit 4f386faae5
44 changed files with 825 additions and 151 deletions
@@ -430,10 +430,160 @@ class StakeholderActivityController extends BaseController
], 'Attività inviata (magic-link generati)');
}
// ─────────────────────────────────────────────────────────────────────────
// FEEDBACK (C5.2b) — risposte, commenti, allegati (lato interno)
// ─────────────────────────────────────────────────────────────────────────
/** GET /api/stakeholder-activities/{id}/feedback */
public function feedback(int $id): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$a = Database::fetchOne('SELECT id, title, type, template_id FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
$template = null;
if ($a['template_id']) {
$t = Database::fetchOne('SELECT kind, content, questions FROM stk_questionnaire_templates WHERE id = ? AND organization_id = ?', [(int) $a['template_id'], $orgId]);
if ($t) {
$q = $t['questions'] ? json_decode($t['questions'], true) : [];
$template = ['kind' => $t['kind'], 'content' => $t['content'], 'questions' => is_array($q) ? $q : []];
}
}
$rows = Database::fetchAll(
'SELECT g.id AS target_id, g.state, g.sent_at, g.responded_at,
s.name AS stakeholder_name, s.stak_code,
r.answers, r.acknowledged_at, r.respondent_name, r.submitted_at
FROM stk_activity_targets g
JOIN stakeholders s ON s.id = g.stakeholder_id
LEFT JOIN stk_activity_responses r ON r.target_id = g.id
WHERE g.activity_id = ? ORDER BY s.name ASC',
[$id]
);
$targets = array_map(static function ($r) {
$ans = $r['answers'] ? json_decode($r['answers'], true) : null;
return [
'target_id' => (int) $r['target_id'], 'stakeholder_name' => $r['stakeholder_name'], 'stak_code' => $r['stak_code'],
'state' => $r['state'], 'sent_at' => $r['sent_at'], 'responded_at' => $r['responded_at'],
'answers' => is_array($ans) ? $ans : null, 'acknowledged_at' => $r['acknowledged_at'],
'respondent_name' => $r['respondent_name'], 'submitted_at' => $r['submitted_at'],
];
}, $rows);
$this->jsonSuccess([
'activity' => ['id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type']],
'template' => $template,
'targets' => $targets,
]);
}
/** GET /api/stakeholder-activities/{id}/comments */
public function comments(int $id): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$this->assertActivity($id, $orgId);
$rows = Database::fetchAll(
'SELECT c.id, c.body, c.author_kind, c.author_label, c.created_at, u.full_name AS author_name
FROM stk_activity_comments c LEFT JOIN users u ON u.id = c.author_user_id
WHERE c.activity_id = ? ORDER BY c.created_at ASC',
[$id]
);
$this->jsonSuccess(['comments' => array_map(static fn($c) => [
'id' => (int) $c['id'], 'body' => $c['body'], 'author_kind' => $c['author_kind'],
'author' => $c['author_kind'] === 'external' ? ($c['author_label'] ?: 'Stakeholder') : ($c['author_name'] ?: 'Interno'),
'created_at' => $c['created_at'],
], $rows)]);
}
/** POST /api/stakeholder-activities/{id}/comments Body: {body*} */
public function addComment(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$this->assertActivity($id, $orgId);
$body = trim((string) ($this->getJsonBody()['body'] ?? ''));
if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); }
$cid = Database::insert('stk_activity_comments', [
'activity_id' => $id, 'body' => mb_substr($body, 0, 5000),
'author_kind' => 'internal', 'author_user_id' => $this->getCurrentUserId(),
]);
$this->jsonSuccess(['id' => (int) $cid], 'Commento aggiunto', 201);
}
/** GET /api/stakeholder-activities/{id}/attachments */
public function attachments(int $id): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$this->assertActivity($id, $orgId);
$rows = Database::fetchAll(
"SELECT id, file_name, file_path, file_size, mime_type, created_at
FROM evidence_files WHERE organization_id = ? AND entity_type = 'stk_activity' AND entity_id = ?
ORDER BY created_at DESC",
[$orgId, $id]
);
$this->jsonSuccess(['attachments' => array_map(static fn($f) => [
'id' => (int) $f['id'], 'file_name' => $f['file_name'], 'url' => '/uploads/' . $f['file_path'],
'file_size' => (int) $f['file_size'], 'created_at' => $f['created_at'],
], $rows)]);
}
/** POST /api/stakeholder-activities/{id}/attachments (multipart: file) */
public function uploadAttachment(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$this->assertActivity($id, $orgId);
$fid = $this->storeUpload($orgId, $id, $this->getCurrentUserId());
$this->jsonSuccess(['id' => $fid], 'Allegato caricato', 201);
}
// ─────────────────────────────────────────────────────────────────────────
// HELPER
// ─────────────────────────────────────────────────────────────────────────
private function assertActivity(int $id, int $orgId): void
{
if (!Database::fetchOne('SELECT id FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId])) {
$this->jsonError('Attività non trovata', 404, 'NOT_FOUND');
}
}
/**
* Salva un file caricato (campo 'file') sotto public/uploads/stk_activity/{org}/
* e registra in evidence_files (entity_type='stk_activity'). Riusa il pattern di
* AuditController::uploadEvidence. uploadedBy null per upload esterni dal portale.
* Ritorna l'id evidence_files.
*/
public function storeUpload(int $orgId, int $activityId, ?int $uploadedBy): int
{
if (!isset($_FILES['file'])) { $this->jsonError('File non fornito', 400, 'NO_FILE'); }
$file = $_FILES['file'];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $this->jsonError('Caricamento non riuscito', 400, 'UPLOAD_ERROR'); }
if ($file['size'] > 10 * 1024 * 1024) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); }
$ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($file['name'], PATHINFO_EXTENSION)));
$blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess'];
if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); }
$uploadDir = UPLOAD_PATH . "/stk_activity/{$orgId}";
if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); }
$filename = uniqid('sa_') . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $filename)) {
$this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR');
}
return (int) Database::insert('evidence_files', [
'organization_id' => $orgId,
'entity_type' => 'stk_activity',
'entity_id' => $activityId,
'file_name' => mb_substr((string) $file['name'], 0, 255),
'file_path' => "stk_activity/{$orgId}/{$filename}",
'file_size' => (int) $file['size'],
'mime_type' => mb_substr((string) ($file['type'] ?? ''), 0, 100),
'uploaded_by' => $uploadedBy,
]);
}
private function validateTemplate($id, int $orgId): ?int
{
$id = ($id === null || $id === '') ? null : (int) $id;