requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $rows = Database::fetchAll( 'SELECT t.id, t.name, t.kind, t.description, t.status, t.questions, t.updated_at, (SELECT COUNT(*) FROM stk_template_procedures p WHERE p.template_id = t.id) AS n_proc, (SELECT COUNT(*) FROM stk_template_misure m WHERE m.template_id = t.id) AS n_mis, (SELECT COUNT(*) FROM stk_template_requisiti r WHERE r.template_id = t.id) AS n_req FROM stk_questionnaire_templates t WHERE t.organization_id = ? ORDER BY t.name ASC', [$orgId] ); $out = array_map(static function ($t) { $q = $t['questions'] ? json_decode($t['questions'], true) : []; return [ 'id' => (int) $t['id'], 'name' => $t['name'], 'kind' => $t['kind'], 'description' => $t['description'], 'status' => $t['status'], 'n_questions' => is_array($q) ? count($q) : 0, 'n_proc' => (int) $t['n_proc'], 'n_mis' => (int) $t['n_mis'], 'n_req' => (int) $t['n_req'], 'updated_at' => $t['updated_at'], ]; }, $rows); $this->jsonSuccess(['templates' => $out]); } /** GET /api/stakeholder-activities/templates/{id} */ public function getTemplate(int $id): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $t = Database::fetchOne( 'SELECT id, name, kind, description, content, questions, status FROM stk_questionnaire_templates WHERE id = ? AND organization_id = ?', [$id, $orgId] ); if (!$t) { $this->jsonError('Questionario tipo non trovato', 404, 'NOT_FOUND'); } $q = $t['questions'] ? json_decode($t['questions'], true) : []; $this->jsonSuccess([ 'id' => (int) $t['id'], 'name' => $t['name'], 'kind' => $t['kind'], 'description' => $t['description'], 'content' => $t['content'], 'questions' => is_array($q) ? $q : [], 'status' => $t['status'], 'policy_ids' => $this->idCol('SELECT policy_id FROM stk_template_procedures WHERE template_id = ?', $id), 'misura_codes' => $this->valCol('SELECT misura_code FROM stk_template_misure WHERE template_id = ?', $id), 'requisito_ids'=> $this->idCol('SELECT requisito_id FROM stk_template_requisiti WHERE template_id = ?', $id), ]); } /** POST /api/stakeholder-activities/templates */ public function createTemplate(): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $b = $this->getJsonBody(); $name = trim((string) ($b['name'] ?? '')); if ($name === '' || mb_strlen($name) > 255) { $this->jsonError('Nome obbligatorio (max 255)', 422, 'INVALID_NAME'); } $kind = ($b['kind'] ?? '') === 'read_ack' ? 'read_ack' : 'questionnaire'; $questions = $this->validateQuestions($b['questions'] ?? [], $kind); $polIds = $this->validatePolicyIds($b['policy_ids'] ?? null, $orgId); $misCodes = $this->validateMisuraCodes($b['misura_codes'] ?? null); $reqIds = $this->validateRequisitoIds($b['requisito_ids'] ?? null); $id = Database::insert('stk_questionnaire_templates', [ 'organization_id' => $orgId, 'name' => $name, 'kind' => $kind, 'description' => $this->nullableStr($b['description'] ?? null), 'content' => $kind === 'read_ack' ? $this->nullableStr($b['content'] ?? null) : null, 'questions' => $kind === 'questionnaire' ? json_encode($questions, JSON_UNESCAPED_UNICODE) : null, 'status' => in_array($b['status'] ?? '', ['draft','active','archived'], true) ? $b['status'] : 'active', 'created_by' => $this->getCurrentUserId(), ]); $this->syncTemplateLinks((int) $id, $polIds, $misCodes, $reqIds); $this->logAudit('stk_template_created', 'stk_questionnaire_template', (int) $id, ['name' => $name, 'kind' => $kind]); $this->jsonSuccess(['id' => (int) $id], 'Questionario tipo creato', 201); } /** PUT /api/stakeholder-activities/templates/{id} */ public function updateTemplate(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $b = $this->getJsonBody(); $t = Database::fetchOne('SELECT id, kind FROM stk_questionnaire_templates WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$t) { $this->jsonError('Questionario tipo non trovato', 404, 'NOT_FOUND'); } $kind = $t['kind']; if ($this->hasParam('kind')) { $kind = ($b['kind'] === 'read_ack') ? 'read_ack' : 'questionnaire'; } $updates = []; if ($this->hasParam('name')) { $name = trim((string) ($b['name'] ?? '')); if ($name === '' || mb_strlen($name) > 255) { $this->jsonError('Nome non valido (max 255)', 422, 'INVALID_NAME'); } $updates['name'] = $name; } if ($this->hasParam('kind')) { $updates['kind'] = $kind; } if ($this->hasParam('description')) { $updates['description'] = $this->nullableStr($b['description'] ?? null); } if ($this->hasParam('content')) { $updates['content'] = $this->nullableStr($b['content'] ?? null); } if ($this->hasParam('questions')) { $updates['questions'] = json_encode($this->validateQuestions($b['questions'] ?? [], $kind), JSON_UNESCAPED_UNICODE); } if ($this->hasParam('status') && in_array($b['status'], ['draft','active','archived'], true)) { $updates['status'] = $b['status']; } if (!empty($updates)) { Database::update('stk_questionnaire_templates', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); } if ($this->hasParam('policy_ids') || $this->hasParam('misura_codes') || $this->hasParam('requisito_ids')) { $polIds = $this->validatePolicyIds($b['policy_ids'] ?? [], $orgId); $misCodes = $this->validateMisuraCodes($b['misura_codes'] ?? []); $reqIds = $this->validateRequisitoIds($b['requisito_ids'] ?? []); $this->syncTemplateLinks($id, $polIds, $misCodes, $reqIds); } $this->logAudit('stk_template_updated', 'stk_questionnaire_template', $id, array_keys($updates)); $this->jsonSuccess(['id' => $id], 'Questionario tipo aggiornato'); } /** DELETE /api/stakeholder-activities/templates/{id} */ public function deleteTemplate(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); // le attività che lo usavano restano (template_id -> NULL via FK SET NULL) $del = Database::delete('stk_questionnaire_templates', 'id = ? AND organization_id = ?', [$id, $orgId]); if ($del === 0) { $this->jsonError('Questionario tipo non trovato', 404, 'NOT_FOUND'); } $this->logAudit('stk_template_deleted', 'stk_questionnaire_template', $id); $this->jsonSuccess(null, 'Questionario tipo eliminato'); } /** * GET /api/stakeholder-activities/pickers * Opzioni per i form dei template: procedure (org) + misure + requisiti (catalogo). */ public function pickers(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $policies = Database::fetchAll( 'SELECT id, title FROM policies WHERE organization_id = ? AND deleted_at IS NULL ORDER BY title ASC', [$orgId] ); $misure = Database::fetchAll('SELECT misura_code, misura_descr FROM cfg_nis2_misure ORDER BY ord ASC'); $requisiti = Database::fetchAll( 'SELECT id, requisito_code, misura_code FROM cfg_nis2_requisiti ORDER BY n ASC, id ASC' ); $this->jsonSuccess([ 'policies' => array_map(static fn($p) => ['id' => (int) $p['id'], 'title' => $p['title']], $policies), 'misure' => array_map(static fn($m) => ['code' => $m['misura_code'], 'descr' => $m['misura_descr']], $misure), 'requisiti' => array_map(static fn($r) => ['id' => (int) $r['id'], 'code' => $r['requisito_code'], 'misura_code' => $r['misura_code']], $requisiti), ]); } // ───────────────────────────────────────────────────────────────────────── // ATTIVITA' // ───────────────────────────────────────────────────────────────────────── /** GET /api/stakeholder-activities/list */ public function list(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $rows = Database::fetchAll( 'SELECT a.id, a.title, a.type, a.template_id, t.name AS template_name, a.description, a.assign_mode, a.stak_code, a.planned_date, a.due_date, a.status, a.updated_at, (SELECT COUNT(*) FROM stk_activity_targets g WHERE g.activity_id = a.id) AS n_targets, (SELECT COUNT(*) FROM stk_activity_targets g WHERE g.activity_id = a.id AND g.state IN (\'responded\',\'acknowledged\')) AS n_done FROM stk_activities a LEFT JOIN stk_questionnaire_templates t ON t.id = a.template_id WHERE a.organization_id = ? ORDER BY (a.due_date IS NULL), a.due_date ASC, a.id DESC', [$orgId] ); $out = array_map(static fn($a) => [ 'id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type'], 'template_id' => $a['template_id'] !== null ? (int) $a['template_id'] : null, 'template_name' => $a['template_name'], 'description' => $a['description'], 'assign_mode' => $a['assign_mode'], 'stak_code' => $a['stak_code'], 'planned_date' => $a['planned_date'], 'due_date' => $a['due_date'], 'status' => $a['status'], 'n_targets' => (int) $a['n_targets'], 'n_done' => (int) $a['n_done'], 'updated_at' => $a['updated_at'], ], $rows); $this->jsonSuccess(['activities' => $out]); } /** GET /api/stakeholder-activities/{id} */ public function get(int $id): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $a = Database::fetchOne('SELECT * FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } $targets = Database::fetchAll( 'SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.stak_code, g.state, g.sent_at, g.responded_at, (g.access_token_hash IS NOT NULL) AS has_token FROM stk_activity_targets g JOIN stakeholders s ON s.id = g.stakeholder_id WHERE g.activity_id = ? ORDER BY s.name ASC', [$id] ); $this->jsonSuccess([ 'id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type'], 'template_id' => $a['template_id'] !== null ? (int) $a['template_id'] : null, 'description' => $a['description'], 'assign_mode' => $a['assign_mode'], 'stak_code' => $a['stak_code'], 'planned_date' => $a['planned_date'], 'due_date' => $a['due_date'], 'status' => $a['status'], 'policy_ids' => $this->idCol('SELECT policy_id FROM stk_activity_procedures WHERE activity_id = ?', $id), 'targets' => array_map(static fn($g) => [ 'id' => (int) $g['id'], 'stakeholder_id' => (int) $g['stakeholder_id'], 'stakeholder_name' => $g['stakeholder_name'], 'stak_code' => $g['stak_code'], 'state' => $g['state'], 'sent_at' => $g['sent_at'], 'responded_at' => $g['responded_at'], 'has_token' => ((int) $g['has_token'] === 1), ], $targets), ]); } /** POST /api/stakeholder-activities/create */ public function create(): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $b = $this->getJsonBody(); $title = trim((string) ($b['title'] ?? '')); if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo obbligatorio (max 255)', 422, 'INVALID_TITLE'); } $type = in_array($b['type'] ?? '', ['questionnaire','read_ack','action'], true) ? $b['type'] : 'questionnaire'; $templateId = $this->validateTemplate($b['template_id'] ?? null, $orgId); [$assignMode, $stakCode] = $this->validateAssign($b, $orgId); $planned = $this->validateDate($b['planned_date'] ?? null, 'planned_date'); $due = $this->validateDate($b['due_date'] ?? null, 'due_date'); $polIds = $this->validatePolicyIds($b['policy_ids'] ?? null, $orgId); $id = Database::insert('stk_activities', [ 'organization_id' => $orgId, 'title' => $title, 'type' => $type, 'template_id' => $templateId, 'description' => $this->nullableStr($b['description'] ?? null), 'assign_mode' => $assignMode, 'stak_code' => $assignMode === 'by_code' ? $stakCode : null, 'planned_date' => $planned, 'due_date' => $due, 'status' => $due ? 'scheduled' : 'draft', 'created_by' => $this->getCurrentUserId(), ]); $this->syncActivityProcedures((int) $id, $polIds); $this->upsertCalendar((int) $id, $orgId, $title, $due); $this->logAudit('stk_activity_created', 'stk_activity', (int) $id, ['title' => $title, 'type' => $type]); $this->jsonSuccess(['id' => (int) $id], 'Attività creata', 201); } /** PUT /api/stakeholder-activities/{id} */ public function update(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $b = $this->getJsonBody(); $a = Database::fetchOne('SELECT id, title, due_date, assign_mode, stak_code FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } $updates = []; if ($this->hasParam('title')) { $title = trim((string) ($b['title'] ?? '')); if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo non valido (max 255)', 422, 'INVALID_TITLE'); } $updates['title'] = $title; } if ($this->hasParam('type') && in_array($b['type'], ['questionnaire','read_ack','action'], true)) { $updates['type'] = $b['type']; } if ($this->hasParam('template_id')) { $updates['template_id'] = $this->validateTemplate($b['template_id'] ?? null, $orgId); } if ($this->hasParam('description')) { $updates['description'] = $this->nullableStr($b['description'] ?? null); } if ($this->hasParam('assign_mode') || $this->hasParam('stak_code')) { $bb = $b; // se cambia solo lo stak_code, conserva la modalità esistente (non forzare 'individual') if (!$this->hasParam('assign_mode')) { $bb['assign_mode'] = $a['assign_mode']; } [$assignMode, $stakCode] = $this->validateAssign($bb, $orgId); $updates['assign_mode'] = $assignMode; $updates['stak_code'] = $assignMode === 'by_code' ? $stakCode : null; } if ($this->hasParam('planned_date')) { $updates['planned_date'] = $this->validateDate($b['planned_date'] ?? null, 'planned_date'); } if ($this->hasParam('due_date')) { $updates['due_date'] = $this->validateDate($b['due_date'] ?? null, 'due_date'); } if ($this->hasParam('status') && in_array($b['status'], ['draft','scheduled','sent','in_progress','completed','cancelled'], true)) { $updates['status'] = $b['status']; } if (!empty($updates)) { Database::update('stk_activities', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); } if ($this->hasParam('policy_ids')) { $this->syncActivityProcedures($id, $this->validatePolicyIds($b['policy_ids'] ?? [], $orgId)); } // riallinea il calendario $title = $updates['title'] ?? $a['title']; $due = array_key_exists('due_date', $updates) ? $updates['due_date'] : $a['due_date']; $this->upsertCalendar($id, $orgId, $title, $due); $this->logAudit('stk_activity_updated', 'stk_activity', $id, array_keys($updates)); $this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Attività aggiornata'); } /** DELETE /api/stakeholder-activities/{id} */ public function delete(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $a = Database::fetchOne('SELECT id FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'stakeholder_activity', $id]); Database::delete('stk_activities', 'id = ? AND organization_id = ?', [$id, $orgId]); // targets/procedure cascata $this->logAudit('stk_activity_deleted', 'stk_activity', $id); $this->jsonSuccess(null, 'Attività eliminata'); } /** * POST /api/stakeholder-activities/{id}/assign * Materializza i destinatari. by_code → tutti gli stakeholder con quel codice; * individual → body {stakeholder_ids:[...]} (validati org). Idempotente (UNIQUE). */ public function assign(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $a = Database::fetchOne('SELECT id, assign_mode, stak_code FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } $b = $this->getJsonBody(); $stakeholderIds = []; if ($a['assign_mode'] === 'by_code') { if (!$a['stak_code']) { $this->jsonError('Attività by_code senza codice stakeholder', 422, 'NO_CODE'); } $stakeholderIds = $this->valCol( 'SELECT id FROM stakeholders WHERE organization_id = ? AND stak_code = ?', $orgId, [$a['stak_code']] ); } else { $raw = $b['stakeholder_ids'] ?? null; if (!is_array($raw) || !$raw) { $this->jsonError('Seleziona almeno uno stakeholder', 422, 'NO_TARGETS'); } $ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0))); if (!$ids) { $this->jsonError('Seleziona almeno uno stakeholder valido', 422, 'NO_TARGETS'); } $place = implode(',', array_fill(0, count($ids), '?')); $stakeholderIds = $this->valCol( "SELECT id FROM stakeholders WHERE organization_id = ? AND id IN ($place)", $orgId, $ids ); if (count($stakeholderIds) !== count($ids)) { $this->jsonError('Uno o più stakeholder non sono validi', 422, 'INVALID_TARGETS'); } } if (!$stakeholderIds) { $this->jsonError('Nessuno stakeholder da assegnare per questo criterio', 422, 'EMPTY_TARGETS'); } $stakeholderIds = array_map('intval', $stakeholderIds); // Modalità individuale = sincronizzazione "replace": rimuovi i destinatari deselezionati // che NON hanno ancora risposto/firmato (gli esiti raccolti non si perdono). if ($a['assign_mode'] === 'individual') { $keep = implode(',', array_fill(0, count($stakeholderIds), '?')); Database::query( "DELETE FROM stk_activity_targets WHERE activity_id = ? AND state NOT IN ('responded','acknowledged') AND stakeholder_id NOT IN ($keep)", array_merge([$id], $stakeholderIds) ); } $added = 0; foreach ($stakeholderIds as $sid) { try { Database::insert('stk_activity_targets', ['activity_id' => $id, 'stakeholder_id' => (int) $sid, 'state' => 'pending']); $added++; } catch (PDOException $e) { if (($e->errorInfo[1] ?? 0) !== 1062) { throw $e; } // 1062 = già assegnato (idempotente) } } $total = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ?', [$id])['c']; $this->logAudit('stk_activity_assigned', 'stk_activity', $id, ['added' => $added, 'total' => $total]); $this->jsonSuccess(['added' => $added, 'total_targets' => $total], 'Destinatari assegnati'); } /** * POST /api/stakeholder-activities/{id}/send * Genera il magic-link per ogni destinatario (token SHA-256), stato='sent'. * Email DISATTIVATE (kill-switch) → i link vengono restituiti per la condivisione manuale. */ public function send(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $a = Database::fetchOne('SELECT id, due_date, title FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } // Solo i destinatari NON ancora conclusi: NON rigenerare token né azzerare lo stato // di chi ha già risposto/firmato (altrimenti se ne perderebbe l'esito). $targets = Database::fetchAll( "SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.contact_email, g.access_token_hash FROM stk_activity_targets g JOIN stakeholders s ON s.id = g.stakeholder_id WHERE g.activity_id = ? AND g.state NOT IN ('responded','acknowledged')", [$id] ); if (!$targets) { $this->jsonError('Nessun destinatario da inviare (assegna stakeholder o tutti hanno già risposto)', 422, 'NO_TARGETS'); } // Scadenza del magic-link: scadenza attività + 30gg, altrimenti 90gg da oggi. $expires = $a['due_date'] ? date('Y-m-d H:i:s', strtotime($a['due_date'] . ' +30 days')) : date('Y-m-d H:i:s', strtotime('+90 days')); $links = []; foreach ($targets as $g) { $token = bin2hex(random_bytes(24)); // 48 hex Database::update('stk_activity_targets', [ 'access_token_hash' => hash('sha256', $token), 'state' => 'sent', 'sent_at' => date('Y-m-d H:i:s'), 'token_expires_at' => $expires, ], 'id = ?', [(int) $g['id']]); $links[] = [ 'target_id' => (int) $g['id'], 'stakeholder_name' => $g['stakeholder_name'], 'contact_email' => $g['contact_email'], 'magic_link' => '/stk-portal.html?t=' . $token, ]; } Database::update('stk_activities', ['status' => 'sent'], 'id = ? AND organization_id = ?', [$id, $orgId]); $emailOn = defined('EMAIL_SENDING_ENABLED') && EMAIL_SENDING_ENABLED; $this->logAudit('stk_activity_sent', 'stk_activity', $id, ['targets' => count($links), 'email_enabled' => $emailOn]); $this->jsonSuccess([ 'sent' => count($links), 'email_sent' => false, 'links' => $links, 'note' => $emailOn ? 'Invio email non ancora collegato: condividi i magic-link con gli stakeholder.' : 'Email disattivate (kill-switch): copia e condividi manualmente i magic-link qui sotto.', ], '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))); // ALLOWLIST (niente html/svg/js eseguibili same-origin): allineata a StakeholderPortalController::ALLOWED_EXT $allowed = ['pdf','png','jpg','jpeg','gif','webp','txt','csv','xlsx','xls','docx','doc','pptx','ppt','odt','ods','zip']; if ($ext === '' || !in_array($ext, $allowed, 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 = 'sa_' . bin2hex(random_bytes(16)) . '.' . $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; if ($id === null) { return null; } $row = Database::fetchOne('SELECT id FROM stk_questionnaire_templates WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$row) { $this->jsonError('Questionario tipo non valido', 422, 'INVALID_TEMPLATE'); } return $id; } /** Ritorna [assign_mode, stak_code]. by_code richiede uno stak_code visibile all'org. */ private function validateAssign(array $b, int $orgId): array { $mode = ($b['assign_mode'] ?? '') === 'by_code' ? 'by_code' : 'individual'; $code = null; if ($mode === 'by_code') { $code = trim((string) ($b['stak_code'] ?? '')); if ($code === '') { $this->jsonError('Indica il codice stakeholder per l\'assegnazione per codice', 422, 'MISSING_CODE'); } $ok = Database::fetchOne( 'SELECT code FROM cfg_stakeholder_types WHERE code = ? AND (organization_id IS NULL OR organization_id = ?)', [$code, $orgId] ); if (!$ok) { $this->jsonError('Codice stakeholder non valido', 422, 'INVALID_CODE'); } } return [$mode, $code]; } private function validateQuestions($raw, string $kind): array { if ($kind === 'read_ack') { return []; } if ($raw === null || $raw === '') { return []; } if (!is_array($raw)) { $this->jsonError('Le domande devono essere un array', 422, 'INVALID_QUESTIONS'); } $allowed = ['text','yes_no','single_choice','multi_choice','scale_1_5','number']; $out = []; $i = 0; foreach ($raw as $q) { $i++; if (!is_array($q)) { continue; } $text = trim((string) ($q['text'] ?? '')); if ($text === '') { continue; } $type = in_array($q['type'] ?? '', $allowed, true) ? $q['type'] : 'text'; $item = [ 'code' => trim((string) ($q['code'] ?? ('Q' . $i))), 'text' => mb_substr($text, 0, 500), 'type' => $type, 'required' => !empty($q['required']), ]; if (in_array($type, ['single_choice','multi_choice'], true) && !empty($q['options']) && is_array($q['options'])) { $item['options'] = array_values(array_map(static fn($o) => mb_substr(trim((string) $o), 0, 200), $q['options'])); } $out[] = $item; } return $out; } private function validatePolicyIds($raw, int $orgId): array { if ($raw === null) { return []; } if (!is_array($raw)) { $this->jsonError('policy_ids deve essere un array', 422, 'INVALID_POLICIES'); } $ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0))); if (!$ids) { return []; } $place = implode(',', array_fill(0, count($ids), '?')); $rows = Database::fetchAll( "SELECT id FROM policies WHERE id IN ($place) AND organization_id = ? AND deleted_at IS NULL", array_merge($ids, [$orgId]) ); if (count($rows) !== count($ids)) { $this->jsonError('Una o più procedure non sono valide', 422, 'INVALID_POLICIES'); } return $ids; } private function validateMisuraCodes($raw): array { if ($raw === null) { return []; } if (!is_array($raw)) { $this->jsonError('misura_codes deve essere un array', 422, 'INVALID_MISURE'); } $codes = array_values(array_unique(array_filter(array_map(static fn($c) => trim((string) $c), $raw), static fn($c) => $c !== ''))); if (!$codes) { return []; } $place = implode(',', array_fill(0, count($codes), '?')); $rows = Database::fetchAll("SELECT misura_code FROM cfg_nis2_misure WHERE misura_code IN ($place)", $codes); if (count($rows) !== count($codes)) { $this->jsonError('Una o più misure non sono valide', 422, 'INVALID_MISURE'); } return $codes; } private function validateRequisitoIds($raw): array { if ($raw === null) { return []; } if (!is_array($raw)) { $this->jsonError('requisito_ids deve essere un array', 422, 'INVALID_REQUISITI'); } $ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0))); if (!$ids) { return []; } $place = implode(',', array_fill(0, count($ids), '?')); $rows = Database::fetchAll("SELECT id FROM cfg_nis2_requisiti WHERE id IN ($place)", $ids); if (count($rows) !== count($ids)) { $this->jsonError('Uno o più requisiti non sono validi', 422, 'INVALID_REQUISITI'); } return $ids; } private function syncTemplateLinks(int $tplId, array $polIds, array $misCodes, array $reqIds): void { Database::delete('stk_template_procedures', 'template_id = ?', [$tplId]); foreach ($polIds as $p) { Database::insert('stk_template_procedures', ['template_id' => $tplId, 'policy_id' => (int) $p]); } Database::delete('stk_template_misure', 'template_id = ?', [$tplId]); foreach ($misCodes as $c) { Database::insert('stk_template_misure', ['template_id' => $tplId, 'misura_code' => $c]); } Database::delete('stk_template_requisiti', 'template_id = ?', [$tplId]); foreach ($reqIds as $r) { Database::insert('stk_template_requisiti', ['template_id' => $tplId, 'requisito_id' => (int) $r]); } } private function syncActivityProcedures(int $activityId, array $polIds): void { Database::delete('stk_activity_procedures', 'activity_id = ?', [$activityId]); foreach ($polIds as $p) { Database::insert('stk_activity_procedures', ['activity_id' => $activityId, 'policy_id' => (int) $p]); } } /** Crea/aggiorna/elimina la riga di calendario (review_schedule) per l'attività. */ private function upsertCalendar(int $activityId, int $orgId, string $title, ?string $dueDate): void { if ($dueDate === null) { Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'stakeholder_activity', $activityId]); return; } Database::query( 'INSERT INTO review_schedule (organization_id, entity_type, entity_id, title, next_review_date, created_by) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE title = VALUES(title), next_review_date = VALUES(next_review_date)', [$orgId, 'stakeholder_activity', $activityId, mb_substr('Attività stakeholder: ' . $title, 0, 255), $dueDate, $this->getCurrentUserId()] ); } private function validateDate($v, string $field): ?string { if ($v === null || $v === '') { return null; } $d = trim((string) $v); $dt = DateTime::createFromFormat('Y-m-d', $d); if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError("Data $field non valida (atteso AAAA-MM-GG)", 422, 'INVALID_DATE'); } return $d; } private function nullableStr($v, ?int $max = null): ?string { if ($v === null) { return null; } $s = trim((string) $v); if ($s === '') { return null; } if ($max !== null && mb_strlen($s) > $max) { $s = mb_substr($s, 0, $max); } return $s; } /** Colonna di interi da una query con un solo parametro id. */ private function idCol(string $sql, int $param): array { return array_map(static fn($r) => (int) array_values($r)[0], Database::fetchAll($sql, [$param])); } /** Colonna di valori: SELECT col FROM ... WHERE org = ? [AND ... IN (...)]. */ private function valCol(string $sql, int $orgId, array $extra = []): array { $rows = Database::fetchAll($sql, array_merge([$orgId], $extra)); return array_map(static fn($r) => array_values($r)[0], $rows); } }