[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:
co-authored by
Claude Opus 4.8
parent
a5ff29e0da
commit
4f386faae5
@@ -89,6 +89,25 @@ $ddl = [
|
||||
CONSTRAINT fk_stkap_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_stkap_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
// C5.2b (mig.053) — feedback: risposte + commenti
|
||||
"CREATE TABLE IF NOT EXISTS stk_activity_responses (
|
||||
id INT NOT NULL AUTO_INCREMENT, target_id INT NOT NULL,
|
||||
answers JSON NULL, acknowledged_at DATETIME NULL,
|
||||
respondent_name VARCHAR(255) NULL, respondent_email VARCHAR(255) NULL, ip_address VARCHAR(45) NULL,
|
||||
submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id), UNIQUE KEY uq_stkar_target (target_id),
|
||||
CONSTRAINT fk_stkar_target FOREIGN KEY (target_id) REFERENCES stk_activity_targets (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS stk_activity_comments (
|
||||
id INT NOT NULL AUTO_INCREMENT, activity_id INT NOT NULL, body TEXT NOT NULL,
|
||||
author_kind ENUM('internal','external') NOT NULL DEFAULT 'internal',
|
||||
author_user_id INT NULL, author_label VARCHAR(255) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id), KEY idx_stkac_act (activity_id),
|
||||
CONSTRAINT fk_stkac_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_stkac_user FOREIGN KEY (author_user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
foreach ($ddl as $stmt) {
|
||||
try { $pdo->exec($stmt); }
|
||||
@@ -105,7 +124,8 @@ try {
|
||||
|
||||
$counts = [];
|
||||
foreach (['stk_questionnaire_templates','stk_template_procedures','stk_template_misure','stk_template_requisiti',
|
||||
'stk_activities','stk_activity_targets','stk_activity_procedures'] as $t) {
|
||||
'stk_activities','stk_activity_targets','stk_activity_procedures',
|
||||
'stk_activity_responses','stk_activity_comments'] as $t) {
|
||||
$counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn();
|
||||
}
|
||||
$enum = $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Portale esterno Stakeholder (Epic C / C5.2b)
|
||||
* ----------------------------------------------------------------------------
|
||||
* Accesso self-service degli stakeholder a un'attività (questionario da compilare
|
||||
* o firma di avvenuta lettura) tramite MAGIC-LINK per-destinatario: il token (in
|
||||
* chiaro nell'URL) viene confrontato con l'hash SHA-256 salvato su
|
||||
* stk_activity_targets.access_token_hash (mig.052). NESSUN account, NESSUN JWT.
|
||||
* Stesso spirito dei token sq_ legacy del supplier-portal.
|
||||
*
|
||||
* Poiché le email sono disattivate (kill-switch), il magic-link viene generato e
|
||||
* condiviso manualmente dal compliance manager (StakeholderActivityController::send).
|
||||
*
|
||||
* Sicurezza: il token risolve UN solo destinatario; non si espone nulla di altre
|
||||
* organizzazioni o di altri target. Submit consumabile una sola volta (409 dopo).
|
||||
*
|
||||
* NOTE: niente requireAuth. Database::query/fetchAll/fetchOne/insert/update.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class StakeholderPortalController extends BaseController
|
||||
{
|
||||
/** GET /api/stakeholder-portal/access?t=<token> */
|
||||
public function access(): void
|
||||
{
|
||||
$tg = $this->resolveTarget();
|
||||
$template = $this->loadTemplate($tg);
|
||||
$resp = Database::fetchOne(
|
||||
'SELECT answers, acknowledged_at, submitted_at FROM stk_activity_responses WHERE target_id = ?',
|
||||
[(int) $tg['target_id']]
|
||||
);
|
||||
$existing = null;
|
||||
if ($resp) {
|
||||
$ans = $resp['answers'] ? json_decode($resp['answers'], true) : null;
|
||||
$existing = [
|
||||
'answers' => is_array($ans) ? $ans : null,
|
||||
'acknowledged_at' => $resp['acknowledged_at'],
|
||||
'submitted_at' => $resp['submitted_at'],
|
||||
];
|
||||
}
|
||||
$this->jsonSuccess([
|
||||
'activity' => ['title' => $tg['title'], 'type' => $tg['type'], 'description' => $tg['description']],
|
||||
'stakeholder_name' => $tg['stakeholder_name'],
|
||||
'state' => $tg['state'],
|
||||
'template' => $template,
|
||||
'submitted' => in_array($tg['state'], ['responded', 'acknowledged'], true),
|
||||
'existing' => $existing,
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-portal/respond Body: {t*, answers*, respondent_name?} */
|
||||
public function respond(): void
|
||||
{
|
||||
$tg = $this->resolveTarget();
|
||||
if ($tg['type'] === 'read_ack') { $this->jsonError('Questa attività richiede una firma di avvenuta lettura, non un questionario.', 422, 'WRONG_TYPE'); }
|
||||
if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Risposta già inviata: non è più modificabile.', 409, 'ALREADY_SUBMITTED'); }
|
||||
|
||||
$b = $this->getJsonBody();
|
||||
$answers = $b['answers'] ?? null;
|
||||
if (!is_array($answers) || !$answers) { $this->jsonError('Nessuna risposta fornita', 422, 'NO_ANSWERS'); }
|
||||
|
||||
// verifica risposte obbligatorie del template (se presente)
|
||||
$template = $this->loadTemplate($tg);
|
||||
if ($template && !empty($template['questions'])) {
|
||||
$missing = [];
|
||||
foreach ($template['questions'] as $q) {
|
||||
if (!empty($q['required'])) {
|
||||
$code = $q['code'] ?? '';
|
||||
$v = $answers[$code] ?? null;
|
||||
if ($v === null || $v === '' || (is_array($v) && !$v)) { $missing[] = $code; }
|
||||
}
|
||||
}
|
||||
if ($missing) { $this->jsonError('Mancano risposte obbligatorie.', 422, 'REQUIRED_MISSING', ['missing' => $missing]); }
|
||||
}
|
||||
|
||||
$this->saveResponse((int) $tg['target_id'], json_encode($answers, JSON_UNESCAPED_UNICODE), null, $b['respondent_name'] ?? null);
|
||||
Database::update('stk_activity_targets', ['state' => 'responded', 'responded_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $tg['target_id']]);
|
||||
$this->maybeComplete((int) $tg['activity_id']);
|
||||
$this->jsonSuccess(['state' => 'responded'], 'Grazie, risposta inviata.');
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-portal/acknowledge Body: {t*, respondent_name?} */
|
||||
public function acknowledge(): void
|
||||
{
|
||||
$tg = $this->resolveTarget();
|
||||
if ($tg['type'] !== 'read_ack') { $this->jsonError('Questa attività è un questionario da compilare.', 422, 'WRONG_TYPE'); }
|
||||
if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Firma già registrata.', 409, 'ALREADY_SUBMITTED'); }
|
||||
|
||||
$b = $this->getJsonBody();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->saveResponse((int) $tg['target_id'], null, $now, $b['respondent_name'] ?? null);
|
||||
Database::update('stk_activity_targets', ['state' => 'acknowledged', 'responded_at' => $now], 'id = ?', [(int) $tg['target_id']]);
|
||||
$this->maybeComplete((int) $tg['activity_id']);
|
||||
$this->jsonSuccess(['state' => 'acknowledged'], 'Firma di avvenuta lettura registrata. Grazie.');
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-portal/comment Body: {t*, body*} */
|
||||
public function comment(): void
|
||||
{
|
||||
$tg = $this->resolveTarget();
|
||||
$body = trim((string) ($this->getJsonBody()['body'] ?? ''));
|
||||
if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); }
|
||||
Database::insert('stk_activity_comments', [
|
||||
'activity_id' => (int) $tg['activity_id'],
|
||||
'body' => mb_substr($body, 0, 5000),
|
||||
'author_kind' => 'external',
|
||||
'author_label'=> mb_substr((string) $tg['stakeholder_name'], 0, 255),
|
||||
]);
|
||||
$this->jsonSuccess(null, 'Commento inviato.');
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-portal/attachment?t=<token> (multipart: file) */
|
||||
public function attachment(): void
|
||||
{
|
||||
$tg = $this->resolveTarget();
|
||||
if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
$this->jsonError('File non fornito', 400, 'NO_FILE');
|
||||
}
|
||||
$file = $_FILES['file'];
|
||||
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'); }
|
||||
|
||||
$orgId = (int) $tg['organization_id'];
|
||||
$dir = UPLOAD_PATH . "/stk_activity/{$orgId}";
|
||||
if (!is_dir($dir)) { mkdir($dir, 0755, true); }
|
||||
$filename = uniqid('sa_') . '.' . $ext;
|
||||
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { $this->jsonError('Errore caricamento', 500, 'UPLOAD_ERROR'); }
|
||||
Database::insert('evidence_files', [
|
||||
'organization_id' => $orgId,
|
||||
'entity_type' => 'stk_activity',
|
||||
'entity_id' => (int) $tg['activity_id'],
|
||||
'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' => null,
|
||||
]);
|
||||
$this->jsonSuccess(null, 'Allegato caricato.', 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// HELPER
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Risolve il destinatario dal token (?t= o body.t). 401 se assente, 404 se non valido. */
|
||||
private function resolveTarget(): array
|
||||
{
|
||||
$token = trim((string) ($_GET['t'] ?? $this->getJsonBody()['t'] ?? ''));
|
||||
if ($token === '' || !preg_match('/^[a-f0-9]{48}$/', $token)) {
|
||||
$this->jsonError('Link di accesso mancante o non valido.', 401, 'MISSING_TOKEN');
|
||||
}
|
||||
$row = Database::fetchOne(
|
||||
'SELECT g.id AS target_id, g.activity_id, g.state, s.name AS stakeholder_name,
|
||||
a.organization_id, a.title, a.type, a.description, a.template_id
|
||||
FROM stk_activity_targets g
|
||||
JOIN stk_activities a ON a.id = g.activity_id
|
||||
JOIN stakeholders s ON s.id = g.stakeholder_id
|
||||
WHERE g.access_token_hash = ?',
|
||||
[hash('sha256', $token)]
|
||||
);
|
||||
if (!$row) { $this->jsonError('Link di accesso non valido o scaduto.', 404, 'INVALID_TOKEN'); }
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function loadTemplate(array $tg): ?array
|
||||
{
|
||||
if (empty($tg['template_id'])) { return null; }
|
||||
$t = Database::fetchOne('SELECT kind, content, questions FROM stk_questionnaire_templates WHERE id = ?', [(int) $tg['template_id']]);
|
||||
if (!$t) { return null; }
|
||||
$q = $t['questions'] ? json_decode($t['questions'], true) : [];
|
||||
return ['kind' => $t['kind'], 'content' => $t['content'], 'questions' => is_array($q) ? $q : []];
|
||||
}
|
||||
|
||||
/** Upsert della risposta (una per target). */
|
||||
private function saveResponse(int $targetId, ?string $answersJson, ?string $ackAt, $respondentName): void
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$name = $respondentName !== null ? mb_substr(trim((string) $respondentName), 0, 255) : null;
|
||||
Database::query(
|
||||
'INSERT INTO stk_activity_responses (target_id, answers, acknowledged_at, respondent_name, ip_address, submitted_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE answers = VALUES(answers), acknowledged_at = VALUES(acknowledged_at),
|
||||
respondent_name = VALUES(respondent_name), ip_address = VALUES(ip_address), submitted_at = NOW()',
|
||||
[$targetId, $answersJson, $ackAt, $name, $ip]
|
||||
);
|
||||
}
|
||||
|
||||
/** Se tutti i destinatari hanno risposto/firmato, segna l'attività 'completed'. */
|
||||
private function maybeComplete(int $activityId): void
|
||||
{
|
||||
$tot = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ?', [$activityId])['c'];
|
||||
$done = (int) Database::fetchOne(
|
||||
"SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ? AND state IN ('responded','acknowledged')",
|
||||
[$activityId]
|
||||
)['c'];
|
||||
if ($tot > 0 && $done >= $tot) {
|
||||
Database::update('stk_activities', ['status' => 'completed'], 'id = ?', [$activityId]);
|
||||
} else {
|
||||
Database::query("UPDATE stk_activities SET status = 'in_progress' WHERE id = ? AND status = 'sent'", [$activityId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user