diff --git a/application/controllers/SupplyChainController.php b/application/controllers/SupplyChainController.php index a6979df..7b5e697 100644 --- a/application/controllers/SupplyChainController.php +++ b/application/controllers/SupplyChainController.php @@ -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', + "

Gentile referente di " . htmlspecialchars($supplier['name']) . ",

" + . "

vi chiediamo di compilare un questionario di sicurezza richiesto dalla normativa NIS2{$dueLabel}.

" + . "

Accedete al portale fornitori con la vostra email: {$portalLink}

" + . "

In alternativa potete usare il link diretto: compila qui

"); + } + } 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 { diff --git a/public/index.php b/public/index.php index 519b141..ca7a352 100644 --- a/public/index.php +++ b/public/index.php @@ -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', diff --git a/public/js/api.js b/public/js/api.js index 6a777e5..b2ab232 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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