diff --git a/db/migrations/20260918133221_add_publish_at_to_events.php b/db/migrations/20260918133221_add_publish_at_to_events.php new file mode 100644 index 0000000..7736bf1 --- /dev/null +++ b/db/migrations/20260918133221_add_publish_at_to_events.php @@ -0,0 +1,15 @@ +table('events') + ->addColumn('publish_at', 'datetime', ['null' => true, 'comment' => 'apertura prenotazioni programmata', 'after' => 'status']) + ->update(); + } +} diff --git a/public/apiwp/cron_reminders.php b/public/apiwp/cron_reminders.php new file mode 100644 index 0000000..4a5c7e1 --- /dev/null +++ b/public/apiwp/cron_reminders.php @@ -0,0 +1,127 @@ + false, 'error' => 'Forbidden'], 403); +} + +$report = ['published' => 0, 'reminders' => 0, 'offers_expired' => 0, 'promoted' => 0]; + +// Default scuola +$opt = []; +try { + $opt = $pdo->query("SELECT * FROM `option` LIMIT 1")->fetch(PDO::FETCH_ASSOC) ?: []; +} catch (Throwable $e) { + $opt = []; +} +$defRemEnabled = ($opt['event_reminder_enabled'] ?? 'N') === 'Y'; +$defRemHours = (int) ($opt['event_reminder_hours'] ?? 24); +$defOfferHours = (int) ($opt['event_offer_expiry_hours'] ?? 24); + +/* 1) Pubblica gli eventi programmati arrivati a scadenza */ +try { + $pub = $pdo->prepare("UPDATE events SET status = 'published', updated_at = NOW() + WHERE status = 'scheduled' AND publish_at IS NOT NULL AND publish_at <= NOW()"); + $pub->execute(); + $report['published'] = $pub->rowCount(); +} catch (Throwable $e) { + error_log('[cron publish] ' . $e->getMessage()); +} + +/* 2) Promemoria automatici (una sola volta per evento) */ +try { + $evs = $pdo->query("SELECT * FROM events + WHERE status = 'published' AND reminder_sent_at IS NULL AND starts_at > NOW()") + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($evs as $ev) { + $enabled = $ev['reminder_enabled'] !== null ? ($ev['reminder_enabled'] === 'Y') : $defRemEnabled; + if (!$enabled) { + continue; + } + $hours = $ev['reminder_hours'] !== null ? (int) $ev['reminder_hours'] : $defRemHours; + if (strtotime($ev['starts_at']) - $hours * 3600 > time()) { + continue; // non ancora l'ora del promemoria + } + $ps = $pdo->prepare("SELECT * FROM event_bookings WHERE event_id = ? AND status = 'confirmed'"); + $ps->execute([$ev['id']]); + foreach ($ps->fetchAll(PDO::FETCH_ASSOC) as $p) { + try { + yse_mail_reminder($p, $ev); + $report['reminders']++; + } catch (Throwable $me) { + error_log('[cron reminder] ' . $me->getMessage()); + } + } + $pdo->prepare("UPDATE events SET reminder_sent_at = NOW() WHERE id = ?")->execute([$ev['id']]); + } +} catch (Throwable $e) { + error_log('[cron reminders] ' . $e->getMessage()); +} + +/* 3) Scadenza offerte lista d'attesa */ +try { + $offers = $pdo->query("SELECT b.*, e.offer_expiry_hours AS ev_offer_hours + FROM event_bookings b JOIN events e ON e.id = b.event_id + WHERE b.status = 'offered' AND b.offered_at IS NOT NULL") + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($offers as $o) { + $hours = $o['ev_offer_hours'] !== null ? (int) $o['ev_offer_hours'] : $defOfferHours; + if (strtotime($o['offered_at']) + $hours * 3600 > time()) { + continue; // offerta ancora valida + } + $promoted = []; + try { + $pdo->beginTransaction(); + $pdo->prepare("SELECT id FROM events WHERE id = ? FOR UPDATE")->execute([(int) $o['event_id']]); + $st = $pdo->prepare("SELECT status FROM event_bookings WHERE id = ?"); + $st->execute([(int) $o['id']]); + if ($st->fetchColumn() === 'offered') { + $pdo->prepare("UPDATE event_bookings SET status = 'cancelled', updated_at = NOW() WHERE id = ?") + ->execute([(int) $o['id']]); + $promoted = promoteFromWaitlist($pdo, (int) $o['event_id']); + $report['offers_expired']++; + } + $pdo->commit(); + + if (!empty($promoted)) { + $ev = $pdo->prepare("SELECT * FROM events WHERE id = ?"); + $ev->execute([(int) $o['event_id']]); + $evRow = $ev->fetch(PDO::FETCH_ASSOC); + foreach ($promoted as $pr) { + try { + yse_mail_promotion($pr, $evRow); + $report['promoted']++; + } catch (Throwable $pe) { + error_log('[cron promo] ' . $pe->getMessage()); + } + } + } + } catch (Throwable $te) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('[cron offer] ' . $te->getMessage()); + } + } +} catch (Throwable $e) { + error_log('[cron offers] ' . $e->getMessage()); +} + +send_json(['ok' => true, 'report' => $report, 'at' => date('c')]); diff --git a/public/apiwp/events.php b/public/apiwp/events.php index cc9c5e2..85fd31c 100644 --- a/public/apiwp/events.php +++ b/public/apiwp/events.php @@ -22,7 +22,7 @@ try { $idsParam = trim($_GET['ids'] ?? ''); $limit = isset($_GET['limit']) ? max(1, min(50, (int) $_GET['limit'])) : 0; - $where = ["e.status = 'published'", "COALESCE(e.ends_at, e.starts_at) >= NOW()"]; + $where = ["e.status IN ('published','scheduled')", "COALESCE(e.ends_at, e.starts_at) >= NOW()"]; $params = []; if ($id > 0) { @@ -96,6 +96,10 @@ try { 'id' => (int) $r['id'], 'title' => $r['title'], 'slug' => $r['slug'], + 'status' => $r['status'], + 'scheduled' => $r['status'] === 'scheduled', + 'bookable' => $r['status'] === 'published', + 'publish_at' => $r['publish_at'], 'description' => $r['description'], // HTML (sanificare al rendering in WP) 'instructions' => $r['instructions'], 'what_to_bring' => $r['what_to_bring'], diff --git a/public/event_form.php b/public/event_form.php index e455dcf..96d79fb 100644 --- a/public/event_form.php +++ b/public/event_form.php @@ -74,6 +74,8 @@ $val = [ 'reminder_enabled' => $ev['reminder_enabled'] ?? null, 'reminder_hours' => $ev['reminder_hours'] ?? '', 'offer_expiry_hours' => $ev['offer_expiry_hours'] ?? '', + 'schedule_enabled' => (($ev['status'] ?? '') === 'scheduled') || !empty($ev['publish_at']), + 'publish_at' => !empty($ev['publish_at']) ? date('Y-m-d H:i', strtotime($ev['publish_at'])) : '', ]; $isEdit = $id > 0; @@ -555,6 +557,22 @@ $pageTitle = $isEdit ? 'Modifica evento' : 'Nuovo evento'; + +
+
+ +
+ > + +
+
+
+ + +
+
+

Se attivo, l'evento resta "Programmato" (visibile ma non prenotabile) finché il cron non lo apre alla data indicata.

+
@@ -612,6 +630,10 @@ $pageTitle = $isEdit ? 'Modifica evento' : 'Nuovo evento'; }; var endPicker = flatpickr("#ends_at", fpOpts); + flatpickr("#publish_at", fpOpts); + document.getElementById('schedule_enabled').addEventListener('change', function() { + document.getElementById('publish-at-wrap').style.display = this.checked ? '' : 'none'; + }); var startPicker = flatpickr("#starts_at", Object.assign({}, fpOpts, { onChange: function(dates) { if (!dates[0]) return; diff --git a/public/event_save.php b/public/event_save.php index 8279617..7015048 100644 --- a/public/event_save.php +++ b/public/event_save.php @@ -105,6 +105,15 @@ if (!in_array($status, ['draft', 'published', 'cancelled'], true)) { $status = 'draft'; } +// Apertura programmata: se attiva con data futura -> stato 'scheduled' + publish_at +$scheduleEnabled = isset($_POST['schedule_enabled']) && $_POST['schedule_enabled'] === 'Y'; +$publishAt = toDateTime($_POST['publish_at'] ?? null); +if ($scheduleEnabled && $publishAt && strtotime($publishAt) > time()) { + $status = 'scheduled'; +} else { + $publishAt = null; // niente programmazione (o data già passata) +} + // Campi facoltativi (testo) $instructions = trim($_POST['instructions'] ?? ''); $whatToBring = trim($_POST['what_to_bring'] ?? ''); @@ -144,7 +153,7 @@ try { max_seats = :max_seats, max_per_booking = :max_per_booking, color = :color, waitlist_enabled = :waitlist_enabled, reminder_enabled = :reminder_enabled, reminder_hours = :reminder_hours, offer_expiry_hours = :offer_expiry_hours, - cover_image = :cover_image, gallery = :gallery, status = :status, updated_at = NOW() + cover_image = :cover_image, gallery = :gallery, status = :status, publish_at = :publish_at, updated_at = NOW() WHERE id = :id"; $params = [':id' => $id]; } else { @@ -154,14 +163,14 @@ try { address, lat, lng, cost, currency, max_seats, max_per_booking, color, waitlist_enabled, reminder_enabled, reminder_hours, offer_expiry_hours, - cover_image, gallery, status, created_at, updated_at) + cover_image, gallery, status, publish_at, created_at, updated_at) VALUES (:user_id, :title, :slug, :description, :instructions, :what_to_bring, :suitable_for, :organizer_name, :starts_at, :ends_at, :address, :lat, :lng, :cost, :currency, :max_seats, :max_per_booking, :color, :waitlist_enabled, :reminder_enabled, :reminder_hours, :offer_expiry_hours, - :cover_image, :gallery, :status, NOW(), NOW())"; + :cover_image, :gallery, :status, :publish_at, NOW(), NOW())"; $params = [':user_id' => $currentUserId]; } @@ -189,6 +198,7 @@ try { ':color' => $color, ':cover_image' => $coverImage !== '' ? $coverImage : null, ':gallery' => $gallery, + ':publish_at' => $publishAt, ':status' => $status, ]; diff --git a/public/events.php b/public/events.php index c17a791..c57ce63 100644 --- a/public/events.php +++ b/public/events.php @@ -22,6 +22,7 @@ $message = $_GET['message'] ?? ''; $sql = "SELECT e.*, COALESCE(b.booked, 0) AS booked, COALESCE(b.bookings_count, 0) AS bookings_count, + COALESCE(w.waiting, 0) AS waiting, u.first_name AS creator_first, u.last_name AS creator_last FROM events e @@ -33,6 +34,12 @@ $sql = "SELECT e.*, WHERE status IN ('confirmed','pending','offered') GROUP BY event_id ) b ON b.event_id = e.id + LEFT JOIN ( + SELECT event_id, SUM(seats) AS waiting + FROM event_bookings + WHERE status = 'waiting' + GROUP BY event_id + ) w ON w.event_id = e.id LEFT JOIN auth_users u ON u.id = e.user_id ORDER BY e.starts_at ASC"; $events = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); @@ -63,11 +70,13 @@ function statusBadge(string $status): array { switch ($status) { case 'published': - return ['Aperto', '#e7f7ee', '#1a7f52']; + return ['Aperto', '#e7f7ee', '#1a7f52']; + case 'scheduled': + return ['Programmato', '#eef2fb', '#3a5bb8']; case 'cancelled': - return ['Annullato', '#fdecea', '#c0392b']; + return ['Annullato', '#fdecea', '#c0392b']; default: - return ['Bozza', '#eef0f3', '#6b7280']; + return ['Bozza', '#eef0f3', '#6b7280']; } } ?> @@ -209,6 +218,29 @@ function statusBadge(string $status): array color: #c0392b; } + .wait-pill { + display: inline-block; + background: #eef2fb; + color: #3a5bb8; + font-weight: 700; + font-size: 12px; + padding: 2px 9px; + border-radius: 20px; + margin-left: 6px; + } + + .id-chip { + display: inline-block; + background: #eef0f3; + color: #4b5563; + font-weight: 700; + font-size: 11px; + padding: 1px 8px; + border-radius: 6px; + margin-left: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + } + .status-badge { display: inline-block; font-size: 12px; @@ -375,7 +407,7 @@ function statusBadge(string $status): array - (ID: ) + ID
@@ -383,11 +415,15 @@ function statusBadge(string $status): array / + 0) : ?> + + in lista + + Anteprima Partecipanti Modifica @@ -428,6 +464,27 @@ function statusBadge(string $status): array