diff --git a/application/cli/seed_stakeholder_activities.php b/application/cli/seed_stakeholder_activities.php
new file mode 100644
index 0000000..784f83c
--- /dev/null
+++ b/application/cli/seed_stakeholder_activities.php
@@ -0,0 +1,114 @@
+exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
+
+$ddl = [
+"CREATE TABLE IF NOT EXISTS stk_questionnaire_templates (
+ id INT NOT NULL AUTO_INCREMENT, organization_id INT NOT NULL,
+ name VARCHAR(255) NOT NULL, kind ENUM('questionnaire','read_ack') NOT NULL DEFAULT 'questionnaire',
+ description TEXT NULL, content TEXT NULL, questions JSON NULL,
+ status ENUM('draft','active','archived') NOT NULL DEFAULT 'active',
+ created_by INT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id), KEY idx_stkqt_org (organization_id),
+ CONSTRAINT fk_stkqt_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stkqt_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+
+"CREATE TABLE IF NOT EXISTS stk_template_procedures (
+ template_id INT NOT NULL, policy_id INT NOT NULL,
+ PRIMARY KEY (template_id, policy_id), KEY idx_stktp_policy (policy_id),
+ CONSTRAINT fk_stktp_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stktp_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+
+"CREATE TABLE IF NOT EXISTS stk_template_misure (
+ template_id INT NOT NULL, misura_code VARCHAR(16) NOT NULL,
+ PRIMARY KEY (template_id, misura_code), KEY idx_stktm_misura (misura_code),
+ CONSTRAINT fk_stktm_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stktm_misura FOREIGN KEY (misura_code) REFERENCES cfg_nis2_misure (misura_code) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+
+"CREATE TABLE IF NOT EXISTS stk_template_requisiti (
+ template_id INT NOT NULL, requisito_id INT NOT NULL,
+ PRIMARY KEY (template_id, requisito_id), KEY idx_stktr_req (requisito_id),
+ CONSTRAINT fk_stktr_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stktr_req FOREIGN KEY (requisito_id) REFERENCES cfg_nis2_requisiti (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+
+"CREATE TABLE IF NOT EXISTS stk_activities (
+ id INT NOT NULL AUTO_INCREMENT, organization_id INT NOT NULL,
+ title VARCHAR(255) NOT NULL, type ENUM('questionnaire','read_ack','action') NOT NULL DEFAULT 'questionnaire',
+ template_id INT NULL, description TEXT NULL,
+ assign_mode ENUM('by_code','individual') NOT NULL DEFAULT 'individual', stak_code VARCHAR(16) NULL,
+ planned_date DATE NULL, due_date DATE NULL,
+ status ENUM('draft','scheduled','sent','in_progress','completed','cancelled') NOT NULL DEFAULT 'draft',
+ created_by INT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id), KEY idx_stka_org (organization_id), KEY idx_stka_tpl (template_id),
+ CONSTRAINT fk_stka_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stka_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE SET NULL,
+ CONSTRAINT fk_stka_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+
+"CREATE TABLE IF NOT EXISTS stk_activity_targets (
+ id INT NOT NULL AUTO_INCREMENT, activity_id INT NOT NULL, stakeholder_id INT NOT NULL,
+ access_token_hash CHAR(64) NULL,
+ state ENUM('pending','sent','responded','acknowledged','expired') NOT NULL DEFAULT 'pending',
+ sent_at DATETIME NULL, responded_at DATETIME NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id), UNIQUE KEY uq_stkat (activity_id, stakeholder_id),
+ KEY idx_stkat_stk (stakeholder_id), KEY idx_stkat_token (access_token_hash),
+ CONSTRAINT fk_stkat_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stkat_stk FOREIGN KEY (stakeholder_id) REFERENCES stakeholders (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+
+"CREATE TABLE IF NOT EXISTS stk_activity_procedures (
+ activity_id INT NOT NULL, policy_id INT NOT NULL,
+ PRIMARY KEY (activity_id, policy_id), KEY idx_stkap_policy (policy_id),
+ 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",
+];
+foreach ($ddl as $stmt) {
+ try { $pdo->exec($stmt); }
+ catch (PDOException $e) { if (!in_array($e->errorInfo[1] ?? 0, [1050, 1061], true)) { throw $e; } }
+}
+
+// Estende l'ENUM del calendario (review_schedule). Idempotente in effetto.
+try {
+ $pdo->exec("ALTER TABLE review_schedule
+ MODIFY COLUMN entity_type ENUM('role','skill','inventory','procedure','risk','supplier','measure','custom','stakeholder_activity') NOT NULL");
+} catch (PDOException $e) {
+ fwrite(STDERR, "WARN ALTER review_schedule: " . $e->getMessage() . "\n");
+}
+
+$counts = [];
+foreach (['stk_questionnaire_templates','stk_template_procedures','stk_template_misure','stk_template_requisiti',
+ 'stk_activities','stk_activity_targets','stk_activity_procedures'] as $t) {
+ $counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn();
+}
+$enum = $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'review_schedule' AND COLUMN_NAME = 'entity_type'")->fetchColumn();
+echo "OK seed-stakeholder-activities — " . json_encode($counts, JSON_UNESCAPED_UNICODE) . "\n";
+echo "review_schedule.entity_type has stakeholder_activity: " . (str_contains((string) $enum, 'stakeholder_activity') ? 'YES' : 'NO') . "\n";
diff --git a/application/controllers/ReviewScheduleController.php b/application/controllers/ReviewScheduleController.php
index 0a763e6..1e7e50a 100644
--- a/application/controllers/ReviewScheduleController.php
+++ b/application/controllers/ReviewScheduleController.php
@@ -42,7 +42,7 @@ class ReviewScheduleController extends BaseController
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
private const ENTITY_TYPES = [
- 'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom',
+ 'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom', 'stakeholder_activity',
];
// ═══════════════════════════════════════════════════════════════════════
diff --git a/application/controllers/StakeholderActivityController.php b/application/controllers/StakeholderActivityController.php
new file mode 100644
index 0000000..4ae7690
--- /dev/null
+++ b/application/controllers/StakeholderActivityController.php
@@ -0,0 +1,591 @@
+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 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')) {
+ [$assignMode, $stakCode] = $this->validateAssign($b, $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)));
+ $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'); }
+
+ $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'); }
+
+ $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 = ?',
+ [$id]
+ );
+ if (!$targets) { $this->jsonError('Nessun destinatario: assegna prima gli stakeholder', 422, 'NO_TARGETS'); }
+
+ $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'),
+ ], '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)');
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // HELPER
+ // ─────────────────────────────────────────────────────────────────────────
+
+ 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);
+ }
+}
diff --git a/docs/sql/052_stakeholder_activities.sql b/docs/sql/052_stakeholder_activities.sql
new file mode 100644
index 0000000..2c7aa97
--- /dev/null
+++ b/docs/sql/052_stakeholder_activities.sql
@@ -0,0 +1,140 @@
+-- =====================================================================
+-- 052 — Attività stakeholder: questionari tipo + attività + calendario (Epic C / C5.2a)
+-- =====================================================================
+-- Simon C5 §4: a ogni stakeholder si possono assegnare ATTIVITA' (questionari
+-- da compilare, richieste di firma di avvenuta lettura, o azioni), pianificabili
+-- su un calendario NIS2. Questa migrazione (C5.2a) crea la FONDAZIONE:
+-- - questionari tipo (template) con domande + m2m a procedure e misure/requisiti
+-- - attività + assegnazione (per codice stakeholder o per singoli)
+-- - destinatari (target) con token magic-link per il portale esterno (C5.2b)
+-- La sotto-dashboard feedback (risposte/firma, commenti, allegati) e il portale
+-- esterno arrivano in C5.2b (mig.053).
+--
+-- Calendario: si RIUSA review_schedule (mig.044) aggiungendo il valore
+-- 'stakeholder_activity' all'ENUM entity_type (ALTER MODIFY, idempotente in
+-- effetto: re-eseguibile senza errore).
+--
+-- Ancoraggio: GV.SC-02 (ruoli/responsabilità verso terze parti), GV.SC-05/07
+-- (requisiti negli accordi, monitoraggio nel tempo) -> art. 24 D.Lgs. 138/2024.
+--
+-- Additivo, reversibile. Runner-safe: CREATE TABLE IF NOT EXISTS con FK dentro
+-- il CREATE; nessun DELIMITER/stored-procedure; nessun ';' nei commenti.
+-- Dati: nessun seed di sistema (i template sono per-org). DDL eseguita dal CLI
+-- idempotente application/cli/seed_stakeholder_activities.php.
+-- =====================================================================
+
+-- 1) Questionari tipo (template)
+CREATE TABLE IF NOT EXISTS stk_questionnaire_templates (
+ id INT NOT NULL AUTO_INCREMENT,
+ organization_id INT NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ kind ENUM('questionnaire','read_ack') NOT NULL DEFAULT 'questionnaire',
+ description TEXT NULL,
+ content TEXT NULL, -- testo da leggere (per kind=read_ack)
+ questions JSON NULL, -- per kind=questionnaire: [{code,text,type,options,required}]
+ status ENUM('draft','active','archived') NOT NULL DEFAULT 'active',
+ created_by INT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_stkqt_org (organization_id),
+ CONSTRAINT fk_stkqt_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stkqt_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 2) m2m template -> procedure (policies)
+CREATE TABLE IF NOT EXISTS stk_template_procedures (
+ template_id INT NOT NULL,
+ policy_id INT NOT NULL,
+ PRIMARY KEY (template_id, policy_id),
+ KEY idx_stktp_policy (policy_id),
+ CONSTRAINT fk_stktp_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stktp_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 3) m2m template -> misure (cfg_nis2_misure.misura_code)
+CREATE TABLE IF NOT EXISTS stk_template_misure (
+ template_id INT NOT NULL,
+ misura_code VARCHAR(16) NOT NULL,
+ PRIMARY KEY (template_id, misura_code),
+ KEY idx_stktm_misura (misura_code),
+ CONSTRAINT fk_stktm_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stktm_misura FOREIGN KEY (misura_code) REFERENCES cfg_nis2_misure (misura_code) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 4) m2m template -> requisiti (cfg_nis2_requisiti.id)
+CREATE TABLE IF NOT EXISTS stk_template_requisiti (
+ template_id INT NOT NULL,
+ requisito_id INT NOT NULL,
+ PRIMARY KEY (template_id, requisito_id),
+ KEY idx_stktr_req (requisito_id),
+ CONSTRAINT fk_stktr_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stktr_req FOREIGN KEY (requisito_id) REFERENCES cfg_nis2_requisiti (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 5) Attività
+CREATE TABLE IF NOT EXISTS stk_activities (
+ id INT NOT NULL AUTO_INCREMENT,
+ organization_id INT NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ type ENUM('questionnaire','read_ack','action') NOT NULL DEFAULT 'questionnaire',
+ template_id INT NULL,
+ description TEXT NULL,
+ assign_mode ENUM('by_code','individual') NOT NULL DEFAULT 'individual',
+ stak_code VARCHAR(16) NULL, -- quando assign_mode=by_code
+ planned_date DATE NULL,
+ due_date DATE NULL,
+ status ENUM('draft','scheduled','sent','in_progress','completed','cancelled') NOT NULL DEFAULT 'draft',
+ created_by INT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_stka_org (organization_id),
+ KEY idx_stka_tpl (template_id),
+ CONSTRAINT fk_stka_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stka_tpl FOREIGN KEY (template_id) REFERENCES stk_questionnaire_templates (id) ON DELETE SET NULL,
+ CONSTRAINT fk_stka_user FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 6) Destinatari dell'attività (target) — un record per stakeholder coinvolto
+CREATE TABLE IF NOT EXISTS stk_activity_targets (
+ id INT NOT NULL AUTO_INCREMENT,
+ activity_id INT NOT NULL,
+ stakeholder_id INT NOT NULL,
+ access_token_hash CHAR(64) NULL, -- SHA-256 del magic-link (portale esterno C5.2b)
+ state ENUM('pending','sent','responded','acknowledged','expired') NOT NULL DEFAULT 'pending',
+ sent_at DATETIME NULL,
+ responded_at DATETIME NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_stkat (activity_id, stakeholder_id),
+ KEY idx_stkat_stk (stakeholder_id),
+ KEY idx_stkat_token (access_token_hash),
+ CONSTRAINT fk_stkat_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stkat_stk FOREIGN KEY (stakeholder_id) REFERENCES stakeholders (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- 7) m2m attività -> procedure (policies) — collegamento all'elenco procedure (§4.1)
+CREATE TABLE IF NOT EXISTS stk_activity_procedures (
+ activity_id INT NOT NULL,
+ policy_id INT NOT NULL,
+ PRIMARY KEY (activity_id, policy_id),
+ KEY idx_stkap_policy (policy_id),
+ 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;
+
+-- 8) Calendario NIS2: estende l'ENUM di review_schedule (mig.044).
+-- ALTER MODIFY e' idempotente in effetto (re-eseguibile senza errore).
+ALTER TABLE review_schedule
+ MODIFY COLUMN entity_type ENUM('role','skill','inventory','procedure','risk','supplier','measure','custom','stakeholder_activity') NOT NULL;
+
+-- ROLLBACK (manuale):
+-- DROP TABLE IF EXISTS stk_activity_procedures
+-- DROP TABLE IF EXISTS stk_activity_targets
+-- DROP TABLE IF EXISTS stk_activities
+-- DROP TABLE IF EXISTS stk_template_requisiti
+-- DROP TABLE IF EXISTS stk_template_misure
+-- DROP TABLE IF EXISTS stk_template_procedures
+-- DROP TABLE IF EXISTS stk_questionnaire_templates
+-- (review_schedule: rimettere l'ENUM senza 'stakeholder_activity' dopo aver eliminato le righe relative)
diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html
index 39c72be..71f16f3 100644
--- a/public/_app-bi-demo.html
+++ b/public/_app-bi-demo.html
@@ -70,9 +70,9 @@
-
-
-
+
+
+
-
-
-
+
+
+
+
-
+
+
@@ -165,9 +165,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
+
@@ -372,9 +372,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
}
-
-
-
+
+
+
-
+
+
-
-
-
+
+
+
-
+
+
-
-
-
+
+
+