- #7 (go-live ready): send() aggancia l'invio email del questionario/firma-lettura via EmailService, GATED dal kill-switch EMAIL_SENDING_ENABLED (oggi=false → ZERO invii; pronto al go-live). Magic-link assoluto via APP_URL; invio solo ai target con email valida; i link restano sempre disponibili per la condivisione manuale. Smoke prod: email_sent=0, email_enabled=false (nessun invio). - #2 (prevale Simon): nis2_sources.php — aggiunta NOTA DI PROVENIENZA sul 92 vs 87 (prodotto usa 92 = file referente; ACN ufficiale 87; divergenza nota/accettata). Numero NON modificato per decisione confermata. Note operative (host, fuori repo): - #8: EMAIL_MS_URL spostato sul path interno http://172.21.0.1:8081/api/emails (verificato 400 con X-Internal-Key = funzionante; pre edge-strip). Rollback: URL pubblico. - #9: pre-equip PHP-CA del DB CONFERMATO (CA presente, app connessa in TLS 1.3); enforce ALTER USER nis2_user REQUIRE SSL resta azione DB coordinata con VIGILE (utente @%). v1.21.3. Additivo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
800 lines
44 KiB
PHP
800 lines
44 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Attività stakeholder (Epic C / C5.2a)
|
|
* ----------------------------------------------------------------------------
|
|
* Gestione interna delle ATTIVITA' verso gli stakeholder (Simon C5 §4):
|
|
* - QUESTIONARI TIPO (template): nome, tipo (questionario da compilare /
|
|
* firma di avvenuta lettura), domande (JSON) e collegamento m2m a procedure
|
|
* (policies) E a misure/requisiti del framework (cfg_nis2_misure/requisiti).
|
|
* - ATTIVITA': istanza basata (opz.) su un template, pianificabile con data e
|
|
* scadenza (→ calendario NIS2 review_schedule), assegnabile a un CODICE
|
|
* stakeholder (tutti quelli di quel tipo) o a singoli stakeholder selezionati.
|
|
* - INVIO: materializza i destinatari (target) e genera un magic-link per
|
|
* destinatario (token SHA-256) per il portale esterno self-service (C5.2b).
|
|
* Le email sono disattivate (kill-switch) → i link vanno condivisi a mano.
|
|
*
|
|
* La sotto-dashboard feedback (risposte/firma, commenti, allegati) e il portale
|
|
* esterno arrivano in C5.2b. Le AZIONI (§4.2) si appoggiano a NCR/CAPA (non qui).
|
|
*
|
|
* Multi-tenancy: ogni query filtra organization_id. Anti-IDOR su template/policy/
|
|
* misura/requisito/stakeholder (verificati org o catalogo di sistema).
|
|
* NOTE: Database::query/fetchAll/fetchOne/insert/update/delete; jsonSuccess/Error exit.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
require_once APP_PATH . '/services/EmailService.php';
|
|
|
|
class StakeholderActivityController extends BaseController
|
|
{
|
|
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// QUESTIONARI TIPO (template)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/** GET /api/stakeholder-activities/templates */
|
|
public function templates(): void
|
|
{
|
|
$this->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'));
|
|
|
|
$emailOn = defined('EMAIL_SENDING_ENABLED') && EMAIL_SENDING_ENABLED;
|
|
$base = defined('APP_URL') ? rtrim(APP_URL, '/') : 'https://nis2.agile.software';
|
|
$emailer = $emailOn ? new EmailService() : null; // istanziato solo se l'invio è abilitato
|
|
$isReadAck = ($a['type'] === 'read_ack');
|
|
|
|
$links = [];
|
|
$emailSent = 0;
|
|
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']]);
|
|
$absLink = $base . '/stk-portal.html?t=' . $token;
|
|
// Invio email gated dal kill-switch: EmailService::send rispetta EMAIL_SENDING_ENABLED
|
|
// (con kill-switch OFF non parte nulla; pronto per il go-live). Solo se c'è un'email.
|
|
if ($emailer && !empty($g['contact_email']) && filter_var($g['contact_email'], FILTER_VALIDATE_EMAIL)) {
|
|
if ($emailer->send($g['contact_email'], $this->mailSubject($isReadAck, $a['title']), $this->mailBody($isReadAck, $a['title'], $g['stakeholder_name'], $absLink))) {
|
|
$emailSent++;
|
|
}
|
|
}
|
|
$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]);
|
|
|
|
$this->logAudit('stk_activity_sent', 'stk_activity', $id, ['targets' => count($links), 'email_enabled' => $emailOn, 'email_sent' => $emailSent]);
|
|
$this->jsonSuccess([
|
|
'sent' => count($links),
|
|
'email_sent' => $emailSent,
|
|
'email_enabled' => $emailOn,
|
|
'links' => $links,
|
|
'note' => $emailOn
|
|
? ($emailSent . ' email inviate ai contatti disponibili; condividi i magic-link con gli stakeholder senza email.')
|
|
: 'Email disattivate (kill-switch): copia e condividi manualmente i magic-link qui sotto.',
|
|
], 'Attività inviata');
|
|
}
|
|
|
|
/** Oggetto email invito (gated): questionario o presa visione. */
|
|
private function mailSubject(bool $isReadAck, string $title): string
|
|
{
|
|
return ($isReadAck ? 'Presa visione richiesta: ' : 'Questionario NIS2: ') . $title;
|
|
}
|
|
|
|
/** Corpo HTML email con il magic-link (EmailService applica il wrap/branding). */
|
|
private function mailBody(bool $isReadAck, string $title, string $name, string $link): string
|
|
{
|
|
$esc = static fn($s) => htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
|
|
$azione = $isReadAck ? 'prendere visione del documento e confermarne la lettura' : 'compilare il questionario';
|
|
return '<p>Gentile ' . $esc($name) . ',</p>'
|
|
. '<p>nell\'ambito degli adempimenti NIS2 della nostra organizzazione, ti chiediamo di ' . $azione . ': <strong>' . $esc($title) . '</strong>.</p>'
|
|
. '<p><a href="' . $esc($link) . '" style="display:inline-block;padding:10px 18px;background:#0066CC;color:#fff;border-radius:8px;text-decoration:none;">Apri</a></p>'
|
|
. '<p style="font-size:.85rem;color:#666;">Oppure copia questo link: ' . $esc($link) . '</p>'
|
|
. '<p style="font-size:.8rem;color:#999;">Il link è personale: non inoltrarlo.</p>';
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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);
|
|
}
|
|
}
|