[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:
parent
306960cbf0
commit
7baa596b37
@ -710,6 +710,176 @@ class SupplyChainController extends BaseController
|
||||
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. */
|
||||
private function resolveQuestionnaire(string $rawToken): array
|
||||
{
|
||||
|
||||
@ -293,6 +293,9 @@ $actionMap = [
|
||||
'PUT:questions/{subId}' => 'updateTemplateQuestion',
|
||||
'DELETE:questions/{subId}' => 'deleteTemplateQuestion',
|
||||
'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',
|
||||
'PUT:{id}' => 'update',
|
||||
'DELETE:{id}' => 'delete',
|
||||
|
||||
@ -286,6 +286,12 @@ class NIS2API {
|
||||
updateTemplateQuestion(id, data) { return this.put(`/supply-chain/questions/${id}`, data); }
|
||||
deleteTemplateQuestion(id) { return this.del(`/supply-chain/questions/${id}`); }
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user