From 261fc4cdd55214aba7dc6fb82c1a6db52ed3e3fc Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sun, 31 May 2026 17:27:50 +0200 Subject: [PATCH] [FEAT] Fase 2 cron: reminder/overdue/ricorrenza questionari fornitori MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/supplier-questionnaire-runner.php: 3 fasi idempotenti (claim atomico via UPDATE condizionato) — REMINDER (offset reminder_offsets), OVERDUE (due_at expired), RICORRENZA (clona campagna ricorrente con nuovo token sq_). scripts/supplier-questionnaire-cron.sh: wrapper TZ=Europe/Rome + flock + log. Dry-run su host OK (0 campagne, 0 errori). Crontab 06:00 da registrare via agile-services CRON_REGISTRY (azione utente/VIGILE). php -l + bash -n OK. Co-Authored-By: Claude Opus 4.8 --- scripts/supplier-questionnaire-cron.sh | 33 +++ scripts/supplier-questionnaire-runner.php | 274 ++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100755 scripts/supplier-questionnaire-cron.sh create mode 100644 scripts/supplier-questionnaire-runner.php diff --git a/scripts/supplier-questionnaire-cron.sh b/scripts/supplier-questionnaire-cron.sh new file mode 100755 index 0000000..1cc5900 --- /dev/null +++ b/scripts/supplier-questionnaire-cron.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# +# NIS2 Agile — Supplier Questionnaire Cron (Fase 2) +# Wrapper sottile per scripts/supplier-questionnaire-runner.php. +# - TZ=Europe/Rome (standard di progetto) +# - flock in /tmp (no run sovrapposti; il runner è comunque idempotente) +# - log su /var/log/nis2-supplier-cron.log +# +# Crontab root (06:00 Europe/Rome, fuori finestra DST 02:00-03:00): +# 0 6 * * * /var/www/nis2-agile/scripts/supplier-questionnaire-cron.sh +# +set -euo pipefail +export TZ=Europe/Rome + +PHP_BIN="${PHP_BIN:-/usr/bin/php}" +RUNNER="/var/www/nis2-agile/scripts/supplier-questionnaire-runner.php" +LOG_FILE="/var/log/nis2-supplier-cron.log" +LOCK_FILE="/tmp/nis2-supplier-questionnaire-cron.lock" + +mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true +touch "$LOG_FILE" 2>/dev/null || true + +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "[$(date '+%Y-%m-%d %H:%M:%S %Z')] Lock attivo, run precedente in corso. Uscita." >> "$LOG_FILE" + exit 0 +fi + +echo "[$(date '+%Y-%m-%d %H:%M:%S %Z')] --- avvio supplier-questionnaire-cron ---" >> "$LOG_FILE" +"$PHP_BIN" "$RUNNER" >> "$LOG_FILE" 2>&1 +rc=$? +echo "[$(date '+%Y-%m-%d %H:%M:%S %Z')] --- fine (exit=$rc) ---" >> "$LOG_FILE" +exit "$rc" diff --git a/scripts/supplier-questionnaire-runner.php b/scripts/supplier-questionnaire-runner.php new file mode 100644 index 0000000..721a346 --- /dev/null +++ b/scripts/supplier-questionnaire-runner.php @@ -0,0 +1,274 @@ +#!/usr/bin/env php + 0, 'reminders_failed' => 0, 'expired' => 0, 'recurrences' => 0, 'errors' => 0]; + +try { + $summary['reminders_sent'] = runReminders($LOG_FILE, $BATCH, $summary); + $summary['expired'] = runOverdue($LOG_FILE, $BATCH); + $summary['recurrences'] = runRecurrence($LOG_FILE, $BATCH); +} catch (\Throwable $e) { + $summary['errors']++; + qlog($LOG_FILE, 'ERRORE FATALE: ' . $e->getMessage()); +} + +qlog($LOG_FILE, sprintf( + 'SUMMARY reminders_sent=%d reminders_failed=%d expired=%d recurrences=%d errors=%d', + $summary['reminders_sent'], $summary['reminders_failed'], + $summary['expired'], $summary['recurrences'], $summary['errors'] +)); +qlog($LOG_FILE, '=== Runner completato ==='); +exit($summary['errors'] > 0 ? 1 : 0); + +// ── FASE 1 — REMINDER ─────────────────────────────────────────────────────── +function runReminders(string $log, int $batch, array &$summary): int +{ + $rows = Database::fetchAll( + "SELECT c.id, c.organization_id, c.supplier_id, c.sent_to_email, c.due_at, + c.reminder_offsets, c.last_reminder_at, c.language, s.name AS supplier_name + FROM questionnaire_campaigns c + LEFT JOIN suppliers s ON s.id = c.supplier_id + WHERE c.status IN ('sent','in_progress') + AND c.next_reminder_at IS NOT NULL AND c.next_reminder_at <= NOW() + ORDER BY c.next_reminder_at ASC LIMIT {$batch}", + [] + ); + if (!$rows) { qlog($log, 'REMINDER: nessuna campagna da sollecitare.'); return 0; } + qlog($log, 'REMINDER: ' . count($rows) . ' candidate.'); + + $sent = 0; + foreach ($rows as $c) { + $id = (int) $c['id']; + $claimed = Database::query( + "UPDATE questionnaire_campaigns SET next_reminder_at = NULL + WHERE id = ? AND status IN ('sent','in_progress') + AND next_reminder_at IS NOT NULL AND next_reminder_at <= NOW()", + [$id] + )->rowCount(); + if ($claimed === 0) { qlog($log, "REMINDER: #{$id} già preso, salto."); continue; } + + $email = trim((string) ($c['sent_to_email'] ?? '')); + $ok = $email !== '' ? sendReminderEmail($c, $log) : false; + + $offsets = json_decode((string) ($c['reminder_offsets'] ?? '[]'), true); + $offsets = is_array($offsets) ? $offsets : []; + $next = computeNextReminder($c['due_at'] ?? null, $offsets, date('Y-m-d H:i:s')); + + if ($ok) { + Database::query( + "UPDATE questionnaire_campaigns + SET reminder_count = reminder_count + 1, last_reminder_at = NOW(), next_reminder_at = ? + WHERE id = ?", + [$next, $id] + ); + $sent++; + qlog($log, "REMINDER: #{$id} inviato a " . maskEmail($email) . ($next ? " — prossimo {$next}" : ' — ultimo')); + } else { + $retry = $next ?? computeNextReminder($c['due_at'] ?? null, $offsets, null); + Database::query("UPDATE questionnaire_campaigns SET next_reminder_at = ? WHERE id = ?", [$retry, $id]); + $summary['reminders_failed']++; + qlog($log, "REMINDER: #{$id} invio FALLITO — riprogrammato a " . ($retry ?? 'mai')); + } + } + return $sent; +} + +// ── FASE 2 — OVERDUE ──────────────────────────────────────────────────────── +function runOverdue(string $log, int $batch): int +{ + $n = Database::query( + "UPDATE questionnaire_campaigns SET status = 'expired', next_reminder_at = NULL + WHERE status IN ('sent','in_progress') AND due_at IS NOT NULL AND due_at < NOW() + LIMIT {$batch}", + [] + )->rowCount(); + qlog($log, "OVERDUE: {$n} campagne marcate 'expired'."); + return $n; +} + +// ── FASE 3 — RICORRENZA ───────────────────────────────────────────────────── +function runRecurrence(string $log, int $batch): int +{ + $rows = Database::fetchAll( + "SELECT id, organization_id, supplier_id, template_id, template_version, + show_score_to_supplier, language, sent_to_email, reminder_offsets, + recurrence_months, created_by + FROM questionnaire_campaigns + WHERE status IN ('completed','expired') AND is_recurring = 1 + AND next_recurrence_at IS NOT NULL AND next_recurrence_at <= NOW() + ORDER BY next_recurrence_at ASC LIMIT {$batch}", + [] + ); + if (!$rows) { qlog($log, 'RICORRENZA: nessuna campagna da riemettere.'); return 0; } + qlog($log, 'RICORRENZA: ' . count($rows) . ' candidate.'); + + $created = 0; + foreach ($rows as $old) { + $oldId = (int) $old['id']; + $claimed = Database::query( + "UPDATE questionnaire_campaigns SET next_recurrence_at = NULL + WHERE id = ? AND is_recurring = 1 + AND next_recurrence_at IS NOT NULL AND next_recurrence_at <= NOW()", + [$oldId] + )->rowCount(); + if ($claimed === 0) { qlog($log, "RICORRENZA: #{$oldId} già riemessa, salto."); continue; } + + $months = (int) $old['recurrence_months']; + if ($months <= 0) { qlog($log, "RICORRENZA: #{$oldId} recurrence_months non valido, salto."); continue; } + + $now = date('Y-m-d H:i:s'); + $dueAt = date('Y-m-d H:i:s', strtotime("+{$months} months")); + $offsets = json_decode((string) ($old['reminder_offsets'] ?? '[]'), true); + $offsets = is_array($offsets) ? array_values(array_filter(array_map('intval', $offsets), fn($o) => $o < 0)) : []; + $nextReminderAt = computeNextReminder($dueAt, $offsets, $now); + $nextRecurrence = date('Y-m-d H:i:s', strtotime($dueAt . " +{$months} months")); + $rawToken = 'sq_' . bin2hex(random_bytes(20)); + + try { + Database::beginTransaction(); + $newId = Database::insert('questionnaire_campaigns', [ + 'organization_id' => (int) $old['organization_id'], + 'supplier_id' => (int) $old['supplier_id'], + 'template_id' => $old['template_id'] !== null ? (int) $old['template_id'] : null, + 'template_version' => $old['template_version'], + 'access_token_hash' => hash('sha256', $rawToken), + 'status' => 'sent', + 'show_score_to_supplier' => (int) $old['show_score_to_supplier'], + 'language' => $old['language'] ?: 'it', + 'sent_to_email' => $old['sent_to_email'], + 'sent_at' => $now, + 'due_at' => $dueAt, + 'expires_at' => $dueAt, + 'reminder_offsets' => json_encode($offsets), + 'next_reminder_at' => $nextReminderAt, + 'is_recurring' => 1, + 'recurrence_months' => $months, + 'next_recurrence_at' => $nextRecurrence, + 'created_by' => $old['created_by'] !== null ? (int) $old['created_by'] : null, + ]); + $email = trim((string) ($old['sent_to_email'] ?? '')); + if ($email !== '') { + $existing = Database::fetchOne('SELECT id FROM supplier_users WHERE supplier_id = ? AND email = ?', + [(int) $old['supplier_id'], $email]); + if (!$existing) { + Database::insert('supplier_users', [ + 'supplier_id' => (int) $old['supplier_id'], 'organization_id' => (int) $old['organization_id'], + 'email' => $email, 'role' => 'contact', 'is_active' => 1, + ]); + } + } + Database::commit(); + } catch (\Throwable $e) { + if (Database::getInstance()->inTransaction()) Database::rollback(); + qlog($log, "RICORRENZA: #{$oldId} clone FALLITO: " . $e->getMessage()); + continue; + } + $created++; + qlog($log, "RICORRENZA: #{$oldId} riemessa come #{$newId} (due {$dueAt})."); + + $email = trim((string) ($old['sent_to_email'] ?? '')); + if ($email !== '') { + sendInviteEmail(['supplier_name' => fetchSupplierName((int) $old['supplier_id']), + 'sent_to_email' => $email, 'due_at' => $dueAt], $rawToken, $log); + } + } + return $created; +} + +// ── HELPER ────────────────────────────────────────────────────────────────── +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; + if ($ts > $after) $candidates[] = $ts; + } + return empty($candidates) ? null : date('Y-m-d H:i:s', min($candidates)); +} + +function sendReminderEmail(array $c, string $log): bool +{ + if (!class_exists('EmailService')) return false; + $supplier = htmlspecialchars((string) ($c['supplier_name'] ?? 'fornitore'), ENT_QUOTES, 'UTF-8'); + $dueLabel = !empty($c['due_at']) ? (' entro il ' . date('d/m/Y', strtotime($c['due_at'])) . '') : ''; + $body = "

Gentile referente di {$supplier},

" + . "

vi ricordiamo di compilare il questionario di sicurezza richiesto dalla normativa NIS2{$dueLabel}.

" + . "

Accedete al portale fornitori con la vostra email: " . PORTAL_LINK . "

"; + try { + return (new EmailService())->send((string) $c['sent_to_email'], 'Promemoria: questionario di sicurezza fornitore - NIS2', $body); + } catch (\Throwable $e) { qlog($log, '[REMINDER email] ' . $e->getMessage()); return false; } +} + +function sendInviteEmail(array $c, string $rawToken, string $log): bool +{ + if (!class_exists('EmailService')) return false; + $supplier = htmlspecialchars((string) ($c['supplier_name'] ?? 'fornitore'), ENT_QUOTES, 'UTF-8'); + $legacy = LEGACY_BASE . $rawToken; + $dueLabel = !empty($c['due_at']) ? (' entro il ' . date('d/m/Y', strtotime($c['due_at']))) : ''; + $body = "

Gentile referente di {$supplier},

" + . "

e' disponibile una nuova edizione periodica del questionario di sicurezza NIS2{$dueLabel}.

" + . "

Accedete al portale fornitori: " . PORTAL_LINK . "

" + . "

In alternativa: compila qui

"; + try { + return (new EmailService())->send((string) $c['sent_to_email'], 'Questionario di sicurezza fornitore - NIS2 (rinnovo periodico)', $body); + } catch (\Throwable $e) { qlog($log, '[RICORRENZA email] ' . $e->getMessage()); return false; } +} + +function fetchSupplierName(int $supplierId): string +{ + $r = Database::fetchOne('SELECT name FROM suppliers WHERE id = ?', [$supplierId]); + return (string) ($r['name'] ?? 'fornitore'); +} + +function maskEmail(string $email): string +{ + $at = strpos($email, '@'); + if ($at === false || $at === 0) return '***'; + return $email[0] . '***' . substr($email, $at); +} + +function qlog(string $logFile, string $message): void +{ + $line = '[' . date('Y-m-d H:i:s T') . '] ' . $message . PHP_EOL; + $dir = dirname($logFile); + if (!is_dir($dir)) @mkdir($dir, 0755, true); + @file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX); + echo $line; +}