[FEAT] Fase 2 backend: campagne questionario (questionnaire_campaigns) + scadenze/ricorrenze

- createCampaign(supplierId): invia un template a un fornitore via questionnaire_campaigns
  (token sq_ retrocompat, scadenza/ricorrenza/reminder_offsets configurabili, crea
  supplier_user per accesso OTP, email invito col link al portale). Migrazioni 034+035
  applicate su host.
- campaigns(): cruscotto con semaforo scadenze (success/warning<=7gg/danger scaduto)
  + answers_count per campagna.
- computeNextReminder(): helper per il prossimo reminder dagli offset.
- Route: GET:campaigns, POST:{id}/campaigns. api.js: getQuestionnaireCampaigns,
  createQuestionnaireCampaign.

Smoke: rotte 401 (router+auth ok). php -l + node --check OK. USR2 applicato.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-31 17:14:24 +02:00
parent 306960cbf0
commit 7baa596b37
3 changed files with 179 additions and 0 deletions

View File

@ -710,6 +710,176 @@ class SupplyChainController extends BaseController
return $v; return $v;
} }
// ══════════════════════════════════════════════════════════════════════
// CAMPAGNE QUESTIONARIO (Fase 2) — questionnaire_campaigns + scadenze/cruscotto
// ══════════════════════════════════════════════════════════════════════
/**
* POST /api/supply-chain/{supplierId}/campaigns
* Crea una campagna (invia un template a un fornitore) con scadenza/ricorrenza/reminder.
* Genera token sq_ (retrocompat portale), crea il supplier_user per l'accesso OTP,
* invia l'email di invito col link al portale.
*/
public function createCampaign(int $supplierId): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$orgId = $this->getCurrentOrgId();
$supplier = Database::fetchOne(
'SELECT id, name, contact_email, criticality FROM suppliers WHERE id = ? AND organization_id = ? AND deleted_at IS NULL',
[$supplierId, $orgId]
);
if (!$supplier) {
$this->jsonError('Fornitore non trovato', 404, 'NOT_FOUND');
}
$templateId = (int) $this->getParam('template_id');
$tpl = Database::fetchOne(
'SELECT id, current_version FROM questionnaire_templates WHERE id = ? AND organization_id = ?',
[$templateId, $orgId]
);
if (!$tpl) {
$this->jsonError('Template non valido', 422, 'INVALID_TEMPLATE');
}
$email = trim((string) ($this->getParam('email') ?: $supplier['contact_email']));
if ($email === '') {
$this->jsonError('Email del referente fornitore mancante', 422, 'EMAIL_REQUIRED');
}
// Scadenza configurabile (nessun default imposto). Reminder e ricorrenza opzionali.
$dueDays = $this->getParam('due_days');
$dueAt = ($dueDays !== null && $dueDays !== '') ? date('Y-m-d H:i:s', time() + ((int) $dueDays) * 86400) : null;
$recurrenceMonths = $this->getParam('recurrence_months');
$recurrenceMonths = ($recurrenceMonths !== null && $recurrenceMonths !== '') ? (int) $recurrenceMonths : null;
$isRecurring = $recurrenceMonths ? 1 : 0;
$nextRecurrenceAt = ($isRecurring && $dueAt) ? date('Y-m-d H:i:s', strtotime($dueAt . " +{$recurrenceMonths} months")) : null;
// reminder_offsets: giorni PRIMA della scadenza (default [-7,-1] se c'è due_at)
$offsets = $this->getParam('reminder_offsets');
if (!is_array($offsets)) $offsets = $dueAt ? [-7, -1] : [];
$offsets = array_values(array_filter(array_map('intval', $offsets), fn($o) => $o < 0));
$nextReminderAt = $this->computeNextReminder($dueAt, $offsets, null);
$rawToken = 'sq_' . bin2hex(random_bytes(20));
$expires = $dueAt ?: date('Y-m-d H:i:s', time() + 30 * 86400);
$showScore = $this->getParam('show_score_to_supplier') ? 1 : 0;
$language = in_array($this->getParam('language'), ['it', 'en', 'auto'], true) ? $this->getParam('language') : 'it';
$campaignId = Database::insert('questionnaire_campaigns', [
'organization_id' => $orgId,
'supplier_id' => $supplierId,
'template_id' => $templateId,
'template_version' => $tpl['current_version'],
'access_token_hash' => hash('sha256', $rawToken),
'status' => 'sent',
'show_score_to_supplier' => $showScore,
'language' => $language,
'sent_to_email' => $email,
'sent_at' => date('Y-m-d H:i:s'),
'due_at' => $dueAt,
'expires_at' => $expires,
'reminder_offsets' => json_encode($offsets),
'next_reminder_at' => $nextReminderAt,
'is_recurring' => $isRecurring,
'recurrence_months' => $recurrenceMonths,
'next_recurrence_at' => $nextRecurrenceAt,
'created_by' => $this->getCurrentUserId(),
]);
// Crea/aggiorna il supplier_user per l'accesso al portale (OTP) — idempotente.
$existingUser = Database::fetchOne(
'SELECT id FROM supplier_users WHERE supplier_id = ? AND email = ?',
[$supplierId, $email]
);
if (!$existingUser) {
Database::insert('supplier_users', [
'supplier_id' => $supplierId,
'organization_id' => $orgId,
'email' => $email,
'role' => 'contact',
'is_active' => 1,
]);
}
$portalLink = 'https://nis2.agile.software/supplier-portal.html';
$legacyLink = 'https://nis2.agile.software/supplier-assessment.html?token=' . $rawToken;
$dueLabel = $dueAt ? (' entro il ' . date('d/m/Y', strtotime($dueAt))) : '';
try {
if (class_exists('EmailService')) {
$svc = new EmailService();
$svc->send($email, 'Questionario di sicurezza fornitore - NIS2',
"<p>Gentile referente di <strong>" . htmlspecialchars($supplier['name']) . "</strong>,</p>"
. "<p>vi chiediamo di compilare un questionario di sicurezza richiesto dalla normativa NIS2{$dueLabel}.</p>"
. "<p>Accedete al <strong>portale fornitori</strong> con la vostra email: <a href=\"{$portalLink}\">{$portalLink}</a></p>"
. "<p style=\"font-size:13px;color:#666\">In alternativa potete usare il link diretto: <a href=\"{$legacyLink}\">compila qui</a></p>");
}
} catch (Throwable $e) {
error_log('[CAMPAIGN] email: ' . $e->getMessage());
}
$this->logAudit('questionnaire_campaign_created', 'questionnaire_campaign', $campaignId, ['supplier_id' => $supplierId, 'template_id' => $templateId]);
$this->jsonSuccess([
'campaign_id' => $campaignId,
'sent_to' => $email,
'due_at' => $dueAt,
'portal_link' => $portalLink,
'link' => $legacyLink,
], 'Campagna creata e invito inviato', 201);
}
/**
* GET /api/supply-chain/campaigns cruscotto campagne con semaforo scadenze.
*/
public function campaigns(): void
{
$this->requireOrgAccess();
$rows = Database::fetchAll(
'SELECT c.id, c.supplier_id, s.name AS supplier_name, c.template_id, t.name AS template_name,
c.status, c.score, c.risk_level, c.sent_to_email, c.sent_at, c.due_at, c.completed_at,
c.reminder_count, c.is_recurring, c.recurrence_months,
(SELECT COUNT(*) FROM questionnaire_answers a WHERE a.campaign_id = c.id) AS answers_count
FROM questionnaire_campaigns c
LEFT JOIN suppliers s ON s.id = c.supplier_id
LEFT JOIN questionnaire_templates t ON t.id = c.template_id
WHERE c.organization_id = ?
ORDER BY (c.status IN ("sent","in_progress")) DESC, c.due_at IS NULL, c.due_at ASC, c.sent_at DESC',
[$this->getCurrentOrgId()]
);
// Semaforo calcolato lato server: in_regola / in_scadenza (<=7gg) / scaduto
$now = time();
foreach ($rows as &$r) {
$light = 'neutral';
if ($r['status'] === 'completed') {
$light = 'success';
} elseif ($r['status'] === 'expired') {
$light = 'danger';
} elseif (!empty($r['due_at'])) {
$days = (strtotime($r['due_at']) - $now) / 86400;
$light = $days < 0 ? 'danger' : ($days <= 7 ? 'warning' : 'neutral');
}
$r['semaphore'] = $light;
}
unset($r);
$this->jsonSuccess(['campaigns' => $rows]);
}
/** Calcola il prossimo next_reminder_at dato due_at, offset (giorni negativi) e l'ultimo già inviato. */
private function computeNextReminder(?string $dueAt, array $offsets, ?string $afterTs): ?string
{
if (!$dueAt || empty($offsets)) return null;
$due = strtotime($dueAt);
$after = $afterTs ? strtotime($afterTs) : time();
$candidates = [];
foreach ($offsets as $o) {
$ts = $due + ((int) $o) * 86400; // o è negativo → prima della scadenza
if ($ts > $after) $candidates[] = $ts;
}
if (empty($candidates)) return null;
return date('Y-m-d H:i:s', min($candidates));
}
/** Risolve un token grezzo nel record questionario, validando scadenza. */ /** Risolve un token grezzo nel record questionario, validando scadenza. */
private function resolveQuestionnaire(string $rawToken): array private function resolveQuestionnaire(string $rawToken): array
{ {

View File

@ -293,6 +293,9 @@ $actionMap = [
'PUT:questions/{subId}' => 'updateTemplateQuestion', 'PUT:questions/{subId}' => 'updateTemplateQuestion',
'DELETE:questions/{subId}' => 'deleteTemplateQuestion', 'DELETE:questions/{subId}' => 'deleteTemplateQuestion',
'POST:import' => 'importSuppliers', 'POST:import' => 'importSuppliers',
// Campagne questionario (Fase 2): cruscotto + invio template a fornitore
'GET:campaigns' => 'campaigns',
'POST:{id}/campaigns' => 'createCampaign', // {id} = supplier id
'GET:{id}' => 'get', 'GET:{id}' => 'get',
'PUT:{id}' => 'update', 'PUT:{id}' => 'update',
'DELETE:{id}' => 'delete', 'DELETE:{id}' => 'delete',

View File

@ -286,6 +286,12 @@ class NIS2API {
updateTemplateQuestion(id, data) { return this.put(`/supply-chain/questions/${id}`, data); } updateTemplateQuestion(id, data) { return this.put(`/supply-chain/questions/${id}`, data); }
deleteTemplateQuestion(id) { return this.del(`/supply-chain/questions/${id}`); } deleteTemplateQuestion(id) { return this.del(`/supply-chain/questions/${id}`); }
importSuppliers(suppliers) { return this.post('/supply-chain/import', { suppliers }); } importSuppliers(suppliers) { return this.post('/supply-chain/import', { suppliers }); }
// Campagne questionario (Fase 2)
getQuestionnaireCampaigns() { return this.get('/supply-chain/campaigns'); }
createQuestionnaireCampaign(supplierId, data) { return this.post(`/supply-chain/${supplierId}/campaigns`, data); }
// Campagne questionario (Fase 2)
getQuestionnaireCampaigns() { return this.get('/supply-chain/campaigns'); }
createQuestionnaireCampaign(supplierId, data) { return this.post(`/supply-chain/${supplierId}/campaigns`, data); }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Audit // Audit