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 . ' '
. ''
. ' |
';
}
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
. '| '
. ' YogiBook · Riepilogo classe '
. '' . h($cls['servicename']) . ''
. '' . h(italianDate($d)) . ' · ore ' . $d->format('H:i') . ' '
. '' . h($location) . ' '
. $teacherLine
. ' | '
. '| ' . $stats . ' ' . $lostNote . ' | '
. $body
. '| 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);