versione riservata: nomi "Mario R.", senza email dei partecipanti * - al titolare, se diverso dall'insegnante (se notifiche attive e con email) * -> copia separata con i dati completi * - se l'insegnante È il titolare: una sola mail completa * * Uso: * php cron/class_report.php -> classi di oggi * php cron/class_report.php 2026-09-25 -> classi di una data specifica * php cron/class_report.php --dry-run -> NON invia, salva le anteprime HTML in cron/preview/ * php cron/class_report.php --only-at=17 -> esce senza fare nulla se in Italia non sono le 17 */ use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\Exception as MailException; if (PHP_SAPI !== 'cli') { http_response_code(403); exit('Accesso consentito solo da CLI'); } date_default_timezone_set('Europe/Rome'); // --only-at=17 : esce senza fare nulla se in Italia non sono le 17 (server in UTC) foreach ($argv as $a) { if (preg_match('/^--only-at=(\d{1,2})$/', $a, $m) && (int) date('G') !== (int) $m[1]) { exit(0); } } $root = dirname(__DIR__); chdir($root); require_once $root . '/public/class/db-functions.php'; // include anche vendor/autoload.php $pdo = DBHandlerSelect::getInstance()->getConnection(); // carica anche il .env /* ---------------------------------------------------------------- helper */ function cfg(string $key, $default = null) { $v = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); return ($v === false || $v === null || $v === '') ? $default : $v; } function h($v): string { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); } function logmsg(string $m): void { echo '[' . date('Y-m-d H:i:s') . '] ' . $m . PHP_EOL; } function chip(string $label, string $bg, string $fg): string { return '' . h($label) . ''; } /** Nome completo (versione titolare) */ function fullName(array $p): string { $n = trim(($p['first_name'] ?? '') . ' ' . ($p['last_name'] ?? '')); return $n !== '' ? $n : 'Utente #' . (int) ($p['iduser'] ?? 0); } /** Nome + iniziale del cognome (versione insegnante): "Mario R." */ function shortName(array $p): string { $first = trim((string) ($p['first_name'] ?? '')); $last = trim((string) ($p['last_name'] ?? '')); if ($first === '' && $last === '') { return 'Utente #' . (int) ($p['iduser'] ?? 0); } $ini = $last !== '' ? ' ' . mb_strtoupper(mb_substr($last, 0, 1)) . '.' : ''; return ($first !== '' ? $first : '') . $ini; } function personName(array $p, bool $full): string { return $full ? fullName($p) : shortName($p); } function teacherName(?array $t): string { return $t ? trim($t['nameemployee'] . ' ' . $t['surnameemployee']) : ''; } function italianDate(DateTime $d): string { $days = ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato']; $months = ['', 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre']; return $days[(int) $d->format('w')] . ' ' . (int) $d->format('j') . ' ' . $months[(int) $d->format('n')] . ' ' . $d->format('Y'); } /** Motivo per cui non può ricevere ('' = può ricevere) */ function whyNot(?array $t): string { if (!$t) return 'nessun insegnante assegnato'; if (($t['active'] ?? 'N') !== 'Y') return 'insegnante non attivo'; if (($t['notifications'] ?? 'N') !== 'Y') return 'notifiche disattivate'; if (empty($t['email'])) return 'email mancante'; if (!filter_var($t['email'], FILTER_VALIDATE_EMAIL)) return 'email non valida'; return ''; } /** Può ricevere mail? notifiche attive, attivo, email valida */ function canReceive(?array $t): bool { return $t && ($t['notifications'] ?? 'N') === 'Y' && ($t['active'] ?? 'N') === 'Y' && !empty($t['email']) && filter_var($t['email'], FILTER_VALIDATE_EMAIL); } /* ----------------------------------------------------------- argomenti */ $dryRun = in_array('--dry-run', $argv, true); $day = date('Y-m-d'); foreach (array_slice($argv, 1) as $a) { if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $a)) { $day = $a; } } $location = cfg('CLASS_LOCATION', 'via Valassina 62/B Seregno - Sala Contesto Yoga'); // Le mail agli insegnanti (non titolari) partono solo se l'opzione "Mostra insegnante" è attiva $optRow = $pdo->query("SELECT show_teacher FROM `option` LIMIT 1")->fetch(); $teacherMailsEnabled = ($optRow['show_teacher'] ?? 'N') === 'Y'; /* ---------------------------------------------------------- insegnanti */ $employees = []; $owner = null; foreach ($pdo->query("SELECT idemployee, nameemployee, surnameemployee, email, is_owner, notifications, active FROM employee")->fetchAll() as $t) { $employees[(int) $t['idemployee']] = $t; if ($t['is_owner'] === 'Y' && $owner === null) { $owner = $t; } } /* ------------------------------------------------------ classi del giorno */ $stmt = $pdo->prepare( "SELECT ss.idserviceschedule, ss.dateschedule, s.servicename, s.colorclass, s.maxcapacity, s.idteacher FROM serviceschedule ss LEFT JOIN service s ON ss.idservice = s.idservice WHERE ss.dateschedule >= :d1 AND ss.dateschedule < :d2 ORDER BY ss.dateschedule" ); $stmt->execute([ ':d1' => $day . ' 00:00:00', ':d2' => date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00', ]); $classes = $stmt->fetchAll(); if (!$classes) { logmsg("Nessuna classe il $day. Nessuna mail inviata."); exit(0); } $ids = array_map(fn($c) => (int) $c['idserviceschedule'], $classes); $ph = implode(',', array_fill(0, count($ids), '?')); $stmtB = $pdo->prepare( "SELECT bc.idbookingclass, bc.idserviceschedule, bc.lostlesson, bc.status, u.id AS iduser, u.first_name, u.last_name, u.email FROM bookingclass bc LEFT JOIN auth_users u ON bc.iduser = u.id WHERE bc.idserviceschedule IN ($ph) AND bc.status IN ('booked', 'pending', 'waiting') ORDER BY bc.created_at, u.last_name, u.first_name" ); $stmtB->execute($ids); $bookingsBySchedule = []; foreach ($stmtB->fetchAll() as $b) { $bookingsBySchedule[(int) $b['idserviceschedule']][] = $b; } /* ------------------------------------------------------------ template */ function personRows(array $list, string $type, bool $full): string { if (!$list) { return 'Nessun partecipante'; } $numColor = ['booked' => '#1ebf73', 'pending' => '#8a6d00', 'waiting' => '#d1698a'][$type]; $html = ''; $i = 1; foreach ($list as $p) { $isLost = ($type === 'booked' && ($p['lostlesson'] ?? '') === 'Y'); if ($type === 'booked') { $chipHtml = $isLost ? chip('Persa', '#1f2937', '#ffffff') : chip('Confermata', '#e3f2ec', '#1a7f52'); } elseif ($type === 'pending') { $chipHtml = chip('In attesa', '#fff4d6', '#8a6d00'); } else { $chipHtml = chip('In coda', '#fbeef3', '#d1698a'); } $nameColor = $isLost ? '#9ca3af' : '#1f2937'; $email = ($full && !empty($p['email'])) ? '
' . h($p['email']) . '' : ''; $html .= '' . '' . '' . '' . '' . '
' . $i . ($type === 'waiting' ? '°' : '.') . '' . h(personName($p, $full)) . $email . '' . $chipHtml . '
'; $i++; } return $html; } function section(string $title, string $color, string $rows): string { return '' . '
' . $title . '
' . '' . $rows . '
' . ''; } function statBox(string $value, string $label, string $color): string { return '' . '
' . $value . '
' . '
' . $label . '
' . ''; } /** * @param bool $full true = versione titolare (nomi completi + email) * @param string $teacherFull nome insegnante da mostrare in testata * @param string|null $copyNote nota "copia per il titolare" (solo se il titolare non è l'insegnante) */ function renderEmail(array $cls, array $booked, array $pending, array $waiting, string $location, bool $full, string $teacherFull, ?string $copyNote): string { $d = new DateTime($cls['dateschedule']); $accent = $cls['colorclass'] ?: '#1ebf73'; $max = (int) $cls['maxcapacity']; $nBooked = count($booked); $nLost = count(array_filter($booked, fn($b) => ($b['lostlesson'] ?? '') === 'Y')); $free = $max > 0 ? max(0, $max - $nBooked) : '–'; $isFull = $max > 0 && $nBooked >= $max; $stats = '' . statBox($nBooked . '/' . $max, 'Iscritti', $isFull ? '#c0392b' : '#1a7f52') . statBox((string) $free, 'Posti liberi', '#374151') . statBox((string) count($pending), 'In attesa', '#8a6d00') . statBox((string) count($waiting), 'In coda', '#d1698a') . '
'; $lostNote = $nLost > 0 ? '
di cui ' . $nLost . ' segnat' . ($nLost === 1 ? 'a' : 'e') . ' come pers' . ($nLost === 1 ? 'a' : 'e') . '
' : ''; $body = section('Partecipanti confermati (' . $nBooked . ')', '#374151', personRows($booked, 'booked', $full)); if ($pending) { $body .= section('In attesa di conferma (' . count($pending) . ')', '#8a6d00', personRows($pending, 'pending', $full)); } if ($waiting) { $body .= section('Lista d\'attesa (' . count($waiting) . ')', '#d1698a', personRows($waiting, 'waiting', $full)); } $copyBanner = $copyNote ? '
' . $copyNote . '
' : ''; $teacherLine = $teacherFull !== '' ? '
Insegnante: ' . h($teacherFull) . '
' : ''; return '' . h($cls['servicename']) . '' . '' . '
' . '' . '' . $copyBanner . '' . '' . $body . '' . '
 
' . '
YogiBook · Riepilogo classe
' . '

' . h($cls['servicename']) . '

' . '
' . h(italianDate($d)) . ' · ore ' . $d->format('H:i') . '
' . '
' . h($location) . '
' . $teacherLine . '
' . $stats . '
' . $lostNote . '
Generato automaticamente da YogiBook il ' . date('d/m/Y \a\l\l\e H:i') . '
'; } function renderText(array $cls, array $booked, array $pending, array $waiting, string $location, bool $full, string $teacherFull, ?string $copyNote): string { $d = new DateTime($cls['dateschedule']); $max = (int) $cls['maxcapacity']; $t = ''; if ($copyNote) { $t .= strip_tags($copyNote) . "\n\n"; } $t .= $cls['servicename'] . "\n" . italianDate($d) . ' ore ' . $d->format('H:i') . "\n" . $location . "\n"; if ($teacherFull !== '') { $t .= 'Insegnante: ' . $teacherFull . "\n"; } $t .= "\nIscritti: " . count($booked) . '/' . $max . ' | In attesa: ' . count($pending) . ' | In coda: ' . count($waiting) . "\n\n"; $blocks = ['CONFERMATI' => $booked, 'IN ATTESA DI CONFERMA' => $pending, "LISTA D'ATTESA" => $waiting]; foreach ($blocks as $title => $list) { if ($title !== 'CONFERMATI' && !$list) { continue; } $t .= $title . "\n"; if (!$list) { $t .= " Nessun partecipante\n"; } $i = 1; foreach ($list as $p) { $lost = ($p['status'] === 'booked' && ($p['lostlesson'] ?? '') === 'Y') ? ' (PERSA)' : ''; $t .= ' ' . $i++ . '. ' . personName($p, $full) . $lost . "\n"; } $t .= "\n"; } return $t; } /* -------------------------------------------------------------- mailer */ function makeMailer(): PHPMailer { $m = new PHPMailer(true); $m->isSMTP(); $m->Host = cfg('MAIL_HOST'); $m->Port = (int) cfg('MAIL_PORT', 465); $m->SMTPAuth = true; $m->Username = cfg('MAIL_USERNAME'); $m->Password = cfg('MAIL_PASSWORD'); $enc = strtolower((string) cfg('MAIL_ENCRYPTION', 'ssl')); $m->SMTPSecure = $enc === 'ssl' ? PHPMailer::ENCRYPTION_SMTPS : ($enc === 'tls' ? PHPMailer::ENCRYPTION_STARTTLS : ''); $m->SMTPKeepAlive = true; $m->CharSet = 'UTF-8'; $m->Encoding = 'base64'; $m->setFrom(cfg('MAIL_FROM_ADDRESS'), cfg('MAIL_FROM_NAME', 'YogiBook')); $m->isHTML(true); return $m; } /* --------------------------------------------------------------- invio */ $mailer = $dryRun ? null : makeMailer(); $sent = 0; $failed = 0; $skipped = 0; foreach ($classes as $cls) { $sid = (int) $cls['idserviceschedule']; $all = $bookingsBySchedule[$sid] ?? []; $booked = array_values(array_filter($all, fn($b) => $b['status'] === 'booked')); $pending = array_values(array_filter($all, fn($b) => $b['status'] === 'pending')); $waiting = array_values(array_filter($all, fn($b) => $b['status'] === 'waiting')); $teacher = $employees[(int) $cls['idteacher']] ?? null; $teacherIsOwner = $teacher && $owner && (int) $teacher['idemployee'] === (int) $owner['idemployee']; $tName = teacherName($teacher); $d = new DateTime($cls['dateschedule']); $baseSubject = $cls['servicename'] . ' · ' . $d->format('d/m') . ' ore ' . $d->format('H:i') . ' · ' . count($booked) . '/' . (int) $cls['maxcapacity'] . ' iscritti'; if ($pending) { $baseSubject .= ' · ' . count($pending) . ' in attesa'; } if ($waiting) { $baseSubject .= ' · ' . count($waiting) . ' in coda'; } // Chi riceve cosa $deliveries = []; if ($teacherIsOwner) { if (canReceive($owner)) { $deliveries[] = ['to' => $owner, 'full' => true, 'copy' => null, 'tag' => 'titolare']; } } else { if (!$teacherMailsEnabled) { logmsg("Classe #$sid ({$cls['servicename']}): mail all'insegnante non inviata (opzione insegnanti disattivata)"); } elseif (canReceive($teacher)) { $deliveries[] = ['to' => $teacher, 'full' => false, 'copy' => null, 'tag' => 'insegnante']; } else { logmsg("Classe #$sid ({$cls['servicename']}): insegnante " . ($tName ?: '-') . " escluso: " . whyNot($teacher)); } if (canReceive($owner)) { $note = $tName !== '' ? 'Copia per il titolare · classe di ' . h($tName) : 'Copia per il titolare · classe senza insegnante assegnato'; $deliveries[] = ['to' => $owner, 'full' => true, 'copy' => $note, 'tag' => 'titolare']; } } if (!$deliveries) { $skipped++; logmsg("SALTATA classe #$sid ({$cls['servicename']}): nessun destinatario (notifiche disattivate o email mancante)."); continue; } foreach ($deliveries as $dv) { $subject = ($dv['copy'] ? '[Copia] ' : '') . $baseSubject; $html = renderEmail($cls, $booked, $pending, $waiting, $location, $dv['full'], $tName, $dv['copy']); $text = renderText($cls, $booked, $pending, $waiting, $location, $dv['full'], $tName, $dv['copy']); $toName = teacherName($dv['to']); if ($dryRun) { $dir = __DIR__ . '/preview'; if (!is_dir($dir)) { mkdir($dir, 0775, true); } $file = $dir . '/classe_' . $sid . '_' . $dv['tag'] . '.html'; file_put_contents($file, $html); logmsg("[DRY-RUN] a {$toName} <{$dv['to']['email']}> ({$dv['tag']}): $subject -> $file"); continue; } try { $mailer->clearAddresses(); $mailer->addAddress($dv['to']['email'], $toName); $mailer->Subject = $subject; $mailer->Body = $html; $mailer->AltBody = $text; $mailer->send(); $sent++; logmsg("OK a {$toName} ({$dv['tag']}): $subject"); } catch (MailException $e) { $failed++; logmsg("ERRORE classe #$sid a {$dv['to']['email']}: " . $mailer->ErrorInfo); } sleep(1); // evita limiti di invio del server SMTP } } if ($mailer) { $mailer->smtpClose(); } logmsg("Fine: $sent inviate, $failed errori, $skipped classi senza destinatari (" . count($classes) . " classi il $day)."); exit($failed > 0 ? 1 : 0);