#!/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; }