$max) fail(($label ?: ucfirst(str_replace('_', ' ', $k))) . " is too long (max $max characters)."); return $v; } /** A rich text field from the editor, cleaned for storing. Too long is refused, never cut in the middle of a tag. */ function rich_in(string $k, int $max): string { // Read through post_field(), which verifies the nonce first. rich_clean() removes script, style and template elements // with their contents, then runs wp_kses() with a fixed allow list and drops frames from hosts that are not allowed. $v = (string)(post_field($k, 'rich') ?? ''); if (mb_strlen($v) > $max) fail(ucfirst(str_replace('_', ' ', $k)) . " is too long (max $max characters, including images and links)."); return $v; } function int_in(string $k, int $min, int $max, ?int $default = null): ?int { $v = post_text($k); if ($v === '') return $default; if (!preg_match('/^\d+$/', $v)) fail('Please enter a whole number for ' . str_replace('_', ' ', $k) . '.'); return max($min, min($max, (int)$v)); } function https_url_or_empty(string $v): string { if ($v === '') return ''; if (!preg_match('#^https://[^\s<>"]+$#i', $v) || !filter_var($v, FILTER_VALIDATE_URL)) fail('Links must start with https://'); return $v; } function event_tabs(array $ev, string $on): array { return ['ev' => $ev, 'tab' => $on]; } /* ---------------- Dashboard and notifications ---------------- */ function a_dashboard(): void { // Door staff are sent to Check-in before the page starts (see admin_init in wp.php); this is only a fallback. if (!can('reports')) abort(403, 'Your account does not have access to this page.'); $from = ldate('Y-m-d', lstrtotime('-29 days')); $kpi = q1("SELECT COALESCE(SUM(total),0) AS revenue, COUNT(*) AS orders FROM {orders} WHERE status = 'paid' AND paid_at >= ?", [$from . ' 00:00:00']); $tickets = (int)qv("SELECT COUNT(*) FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id JOIN {events} e ON e.id = o.event_id WHERE tk.status = 'valid' AND e.starts_at > ?", [now()]); $pending = (int)qv("SELECT COUNT(*) FROM {orders} WHERE status = 'pending_payment' AND expires_at > ?", [now()]); $upcoming = (int)qv("SELECT COUNT(*) FROM {events} WHERE status = 'published' AND starts_at > ?", [now()]); // Offline payments waiting for staff to confirm them. $queue = q("SELECT o.*, e.title AS event_title, (SELECT SUM(qty) FROM {order_items} WHERE order_id = o.id) AS qty FROM {orders} o JOIN {events} e ON e.id = o.event_id WHERE o.status = 'pending_payment' AND o.payment_method = 'offline' AND o.expires_at > ? ORDER BY o.created_at LIMIT 5", [now()])->fetchAll(); $events = q("SELECT e.*, v.name AS venue_name FROM {events} e LEFT JOIN {venues} v ON v.id = e.venue_id WHERE e.starts_at > ? AND e.status IN ('draft','published') ORDER BY e.starts_at LIMIT 8", [now()])->fetchAll(); foreach ($events as &$ev) { $held = held_counts((int)$ev['id']); $ev['sold'] = $held['total']; $ev['cap'] = event_cap($ev) ?? (int)qv('SELECT COALESCE(SUM(capacity),0) FROM {ticket_categories} WHERE event_id = ?', [$ev['id']]); $ev['revenue'] = (int)qv("SELECT COALESCE(SUM(total),0) FROM {orders} WHERE event_id = ? AND status = 'paid'", [$ev['id']]); } unset($ev); render('admin/dashboard', ['title' => 'Dashboard', 'kpi' => $kpi, 'tickets' => $tickets, 'upcoming' => $upcoming, 'pending' => $pending, 'queue' => $queue, 'events' => $events, 'daily' => report_daily(null, 14), 'nav' => 'dashboard']); } function a_notifications(): void { $u = current_user(); $rows = q('SELECT * FROM {notifications} WHERE user_id = ? ORDER BY id DESC LIMIT 100', [$u['id']])->fetchAll(); render('admin/notifications', ['title' => 'Notifications', 'rows' => $rows, 'nav' => 'notifications']); } function a_notifications_read(): void { $u = current_user(); q('UPDATE {notifications} SET read_at = ? WHERE user_id = ? AND read_at IS NULL', [now(), $u['id']]); back_to('/admin/notifications'); } /* ---------------- Events ---------------- */ function a_events(): void { $status = get_str('status'); $where = "e.status <> 'archived'"; $args = []; if (in_array($status, ['draft', 'published', 'cancelled', 'archived'], true)) { $where = 'e.status = ?'; $args[] = $status; } if ($venueId = get_int('venue')) { $where .= ' AND e.venue_id = ?'; $args[] = $venueId; } $when = get_str('when', 'upcoming'); if ($when === 'upcoming') { $where .= ' AND e.starts_at > ?'; $args[] = now(); } elseif ($when === 'past') { $where .= ' AND e.starts_at <= ?'; $args[] = now(); } $rows = q("SELECT e.*, v.name AS venue_name FROM {events} e LEFT JOIN {venues} v ON v.id = e.venue_id WHERE $where ORDER BY e.starts_at " . ($when === 'past' ? 'DESC' : 'ASC') . ' LIMIT 300', $args)->fetchAll(); foreach ($rows as &$r) { $r['sold'] = held_counts((int)$r['id'])['total']; $r['cap'] = event_cap($r) ?? (int)qv('SELECT COALESCE(SUM(capacity),0) FROM {ticket_categories} WHERE event_id = ?', [$r['id']]); } unset($r); $counts = q1("SELECT SUM(status <> 'archived' AND starts_at > ?) AS upcoming, SUM(status <> 'archived' AND starts_at <= ?) AS past, SUM(status = 'draft') AS draft FROM {events}", [now(), now()]); render('admin/events', ['title' => 'Events', 'rows' => $rows, 'status' => $status, 'when' => $when, 'nav' => 'events', 'counts' => array_map('intval', (array)$counts), 'venue' => $venueId ? q1('SELECT id, name FROM {venues} WHERE id = ?', [$venueId]) : null]); } function a_event_new(): void { $ev = ['id' => 0, 'title' => '', 'slug' => '', 'summary' => '', 'category' => '', 'description' => '', 'venue_id' => get_int('venue') ?: null, 'capacity' => null, 'starts_at' => null, 'ends_at' => null, 'doors_at' => null, 'cover_image' => null, 'status' => 'draft', 'visibility' => 'public', 'max_per_order' => null, 'hero_overlay' => 1, 'bot_check' => 0, 'terms' => '', 'email_note' => '']; render('admin/event_form', ['title' => 'New event', 'ev' => $ev, 'venues' => q('SELECT * FROM {venues} WHERE is_active = 1 ORDER BY name')->fetchAll(), 'nav' => 'events', 'tab' => 'details']); } function a_event_edit(int $id): void { render('admin/event_form', ['title' => 'Edit event', 'ev' => admin_event($id), 'venues' => q('SELECT * FROM {venues} ORDER BY is_active DESC, name')->fetchAll(), 'nav' => 'events', 'tab' => 'details']); } function a_event_save(): void { $id = post_int('id'); $old = $id ? q1('SELECT * FROM {events} WHERE id = ?', [$id]) : null; if ($id && !$old) abort(404); set_back($id ? "/admin/events/$id" : '/admin/events/new'); $title = str_in('title', 190, true, 'Title'); $slug = slugify(str_in('slug', 120) ?: $title); if (qv('SELECT 1 FROM {events} WHERE slug = ? AND id <> ?', [$slug, $id])) $slug = substr($slug, 0, 110) . '-' . strtolower(random_code(4)); $start = dt_in(post('starts_at')); if (!$start) fail('Start date and time are required.'); $end = dt_in(post('ends_at')); $doors = dt_in(post('doors_at')); if ($end && $end < $start) fail('The end time must be after the start time.'); if ($doors && $doors > $start) fail('Doors must open before the event starts.'); $venueId = post_int('venue_id') ?: null; $venueCap = null; if ($venueId) { $v = q1('SELECT capacity FROM {venues} WHERE id = ?', [$venueId]); if (!$v) fail('Choose a valid venue.'); $venueCap = (int)$v['capacity'] ?: null; } $cap = int_in('capacity', 0, 1000000); if ($cap === 0) $cap = null; if ($cap && $venueCap && $cap > $venueCap) fail("The capacity limit cannot be higher than the venue's $venueCap seats."); $status = in_array(post('status'), ['draft', 'published', 'cancelled', 'archived'], true) ? post('status') : 'draft'; if ($status === 'cancelled' && ($old['status'] ?? '') !== 'cancelled') fail('Use "Cancel event" at the bottom of the page so buyers are told.'); $vis = post('visibility') === 'unlisted' ? 'unlisted' : 'public'; $data = [ $title, $slug, str_in('summary', 300), str_in('category', 60), rich_in('description', 100000), $venueId, $cap, $start, $end, $doors, $status, $vis, (post_text('max_per_order') === '' ? null : int_in('max_per_order', 1, 100, 10)), str_in('terms', 5000), str_in('email_note', 1000), ]; // Payment methods allowed for this event. Ticking every method that is on stores "all" (NULL), so methods // you add later are offered too. $choices = array_merge(card_enabled() ? ['card'] : [], array_keys(offline_methods())); $picked = array_values(array_intersect($choices, array_map('strval', post_list('pay_methods')))); if ($choices && post_has('pay_methods_sent') && !$picked) fail('Choose at least one payment method for this event.'); $payMethods = (!post_has('pay_methods_sent') || count($picked) === count($choices)) ? null : wp_json_encode($picked); // Payment details for this event (another bank account or PayPay ID). Empty, or the same as the default, means the default. $payDetails = []; $in = post_list('pay_details'); foreach (offline_methods_all() as $mid => $m) { $v = isset($in[$mid]) && is_string($in[$mid]) ? trim(str_replace("\r", '', clean_text($in[$mid]))) : ''; if (mb_strlen($v) > 2000) fail('Payment details for ' . $m['name'] . ' are too long (max 2000 characters).'); if ($v !== '' && $v !== trim(str_replace("\r", '', $m['instructions']))) $payDetails[$mid] = $v; } $payDetailsJson = $payDetails ? wp_json_encode($payDetails, JSON_UNESCAPED_UNICODE) : null; $cover = handle_image_upload('cover', 1800); tx(function () use ($id, $data, $cover, $old, $payMethods, $payDetailsJson, &$newId) { if ($id) { q('UPDATE {events} SET title=?, slug=?, summary=?, category=?, description=?, venue_id=?, capacity=?, starts_at=?, ends_at=?, doors_at=?, status=?, visibility=?, max_per_order=?, terms=?, email_note=?, updated_at=? WHERE id=?', array_merge($data, [now(), $id])); $newId = $id; } else { q('INSERT INTO {events} (title, slug, summary, category, description, venue_id, capacity, starts_at, ends_at, doors_at, status, visibility, max_per_order, terms, email_note, created_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', array_merge($data, [current_user()['id'], now(), now()])); $newId = (int)db_insert_id(); } $designJson = $old['design'] ?? null; // the page designer (Gatecraft Pro) saves designs on its own screen q('UPDATE {events} SET payment_details = ? WHERE id = ?', [$payDetailsJson, $newId]); q('UPDATE {events} SET payment_methods = ?, hero_overlay = ?, bot_check = ?, design = ? WHERE id = ?', [$payMethods, post_bool('hero_overlay') ? 1 : 0, (post_has('bot_check_sent') ? post_bool('bot_check') : !empty($old['bot_check'])) ? 1 : 0, $designJson, $newId]); if (post_has('language')) q('UPDATE {events} SET language = ? WHERE id = ?', [isset(GATECRAFT_LANGUAGES[post('language')]) ? post('language') : null, $newId]); if ($cover) q('UPDATE {events} SET cover_image = ? WHERE id = ?', [$cover, $newId]); elseif (post_bool('remove_cover')) q('UPDATE {events} SET cover_image = NULL WHERE id = ?', [$newId]); }); if ($old && ($cover || post_bool('remove_cover'))) delete_upload($old['cover_image']); do_action('gatecraft_event_saved', $newId, $old); audit($id ? 'event.update' : 'event.create', 'event', $newId, ['title' => $title, 'status' => $status]); flash('success', $id ? 'Event saved.' : 'Event created. Now add ticket categories.'); back_to($id ? "/admin/events/$newId" : "/admin/events/$newId/tickets"); } function a_event_tab(int $id, string $tab): void { $ev = admin_event($id); $base = ['ev' => $ev, 'nav' => 'events', 'tab' => $tab, 'title' => $ev['title']]; switch ($tab) { case 'duplicate': render('admin/event_duplicate', $base); return; case 'tickets': $cats = categories_with_tiers($id); $held = held_counts($id); $sections = $ev['venue_id'] ? q('SELECT * FROM {venue_sections} WHERE venue_id = ? ORDER BY sort_order, id', [$ev['venue_id']])->fetchAll() : []; $selling = []; foreach (event_offer($ev, $held) as $c) if ($c['state'] === 'on_sale') $selling[] = (int)$c['tier']['id']; render('admin/event_tickets', $base + ['cats' => $cats, 'held' => $held, 'selling' => $selling, 'cap' => event_cap($ev), 'sections' => $sections, 'edit' => get_int('edit'), 'editTier' => get_int('tier')]); return; case 'discounts': render('admin/event_discounts', $base + discount_page_data($id)); return; case 'backup': render('admin/event_backup', $base + ['canBookings' => can('settings'), 'orders' => (int)qv('SELECT COUNT(*) FROM {orders} WHERE event_id = ?', [$id])]); return; case 'sponsors': $rows = q('SELECT * FROM {event_sponsors} WHERE event_id = ? ORDER BY sort_order, id', [$id])->fetchAll(); $edit = get_int('edit') ? q1('SELECT * FROM {event_sponsors} WHERE id = ? AND event_id = ?', [get_int('edit'), $id]) : null; render('admin/event_sponsors', $base + ['rows' => $rows, 'edit' => $edit]); return; case 'announcements': $rows = q('SELECT * FROM {event_announcements} WHERE event_id = ? ORDER BY id DESC', [$id])->fetchAll(); $edit = get_int('edit') ? q1('SELECT * FROM {event_announcements} WHERE id = ? AND event_id = ?', [get_int('edit'), $id]) : null; render('admin/event_announcements', $base + ['rows' => $rows, 'edit' => $edit]); return; case 'emails': render('admin/event_emails', $base + ['paid' => (int)qv("SELECT COUNT(*) FROM {orders} WHERE event_id = ? AND status = 'paid'", [$id])]); return; case 'attendees': $q = get_str('q'); $sql = "SELECT tk.*, c.name AS category_name, t.name AS tier_name, o.buyer_name, o.buyer_email, o.reference, o.id AS order_id FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id JOIN {ticket_categories} c ON c.id = tk.category_id JOIN {ticket_tiers} t ON t.id = tk.tier_id WHERE o.event_id = ?"; $args = [$id]; if ($q !== '') { $like = '%' . addcslashes($q, '%_\\') . '%'; $sql .= ' AND (o.buyer_name LIKE ? OR o.buyer_email LIKE ? OR tk.code = ? OR o.reference = ?)'; array_push($args, $like, $like, strtoupper(preg_replace('/\s/', '', $q)), strtoupper($q)); } $sum = q1("SELECT COUNT(*) AS total, SUM(tk.checked_in_at IS NOT NULL) AS done, SUM(o.payment_method = 'comp') AS comp, SUM(o.payment_method IN ('cash','door_card')) AS door FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id WHERE o.event_id = ? AND o.status = 'paid' AND tk.status = 'valid'", [$id]); render('admin/event_attendees', $base + ['rows' => q($sql . ' ORDER BY o.buyer_name, tk.id LIMIT 2000', $args)->fetchAll(), 'q' => $q, 'sum' => array_map('intval', (array)$sum)]); return; } if (has_action('gatecraft_event_tab_' . $tab)) { do_action('gatecraft_event_tab_' . $tab, $id); return; } abort(404); } function a_event_duplicate(int $id): void { $ev = q1('SELECT * FROM {events} WHERE id = ?', [$id]) ?? abort(404); set_back("/admin/events/$id/duplicate"); $title = str_in('title', 190, true, 'Title'); $start = dt_in(post_text('starts_at')); if (!$start) fail('Enter the start date and time of the copy.'); $r = tx(fn() => event_clone($id, ['starts_at' => $start, 'title' => $title, 'slug' => slugify($title) ?: $ev['slug'], 'status' => 'draft', 'series_id' => null, 'user_id' => current_user()['id'], 'copy' => event_copy_from_post()])); audit('event.duplicate', 'event', $r['id'], ['from' => $id]); flash('success', 'Copy created as a draft. Check the details, then publish it to start selling.' . ($r['notes'] ? ' ' . implode(' ', $r['notes']) : '')); redirect('/admin/events/' . $r['id']); } function a_event_delete(int $id): void { if (qv('SELECT 1 FROM {orders} WHERE event_id = ? LIMIT 1', [$id])) fail('This event has bookings, so it cannot be deleted. Archive or cancel it instead.'); $cover = qv('SELECT cover_image FROM {events} WHERE id = ?', [$id]); $files = q('SELECT logo, image FROM {event_sponsors} WHERE event_id = ?', [$id])->fetchAll(); q('DELETE FROM {events} WHERE id = ?', [$id]); delete_upload($cover ?: null); foreach ($files as $f) { delete_upload($f['logo'] ?: null); delete_upload($f['image'] ?: null); } audit('event.delete', 'event', $id); flash('success', 'Event deleted.'); back_to('/admin/events'); } /* ------------------------------------------------------------------ */ /* Sponsors: logos, pictures and links shown on the public event page */ /* ------------------------------------------------------------------ */ function a_sponsor_save(int $eventId): void { admin_event($eventId); $id = post_int('id'); $old = $id ? q1('SELECT * FROM {event_sponsors} WHERE id = ? AND event_id = ?', [$id, $eventId]) : null; if ($id && !$old) abort(404); set_back("/admin/events/$eventId/sponsors" . ($id ? "?edit=$id" : '')); $name = str_in('name', 160, true, 'Sponsor name'); $tier = str_in('tier', 60); $info = str_in('info', 1500); $site = https_url_or_empty(post_text('website')); if (mb_strlen($site) > 500) fail('The website link is too long.'); $order = post_int('sort_order'); $active = post_bool('is_active'); $logo = handle_image_upload('logo', 900); $image = handle_image_upload('image', 1600); $logoNow = $old['logo'] ?? null; $imageNow = $old['image'] ?? null; if ($logo) { $logoNow = $logo; } elseif (post_bool('remove_logo')) { $logoNow = null; } if ($image) { $imageNow = $image; } elseif (post_bool('remove_image')) { $imageNow = null; } if ($id) { q('UPDATE {event_sponsors} SET name=?, tier=?, info=?, website=?, logo=?, image=?, sort_order=?, is_active=?, updated_at=? WHERE id=?', [$name, $tier ?: null, $info ?: null, $site ?: null, $logoNow, $imageNow, $order, $active, now(), $id]); if ($old['logo'] && $old['logo'] !== $logoNow) delete_upload($old['logo']); if ($old['image'] && $old['image'] !== $imageNow) delete_upload($old['image']); } else { q('INSERT INTO {event_sponsors} (event_id, name, tier, info, website, logo, image, sort_order, is_active, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)', [$eventId, $name, $tier ?: null, $info ?: null, $site ?: null, $logoNow, $imageNow, $order ?: (int)qv('SELECT COALESCE(MAX(sort_order),0) + 10 FROM {event_sponsors} WHERE event_id = ?', [$eventId]), $active, now(), now()]); $id = (int)db_insert_id(); } audit($old ? 'sponsor.update' : 'sponsor.create', 'event', $eventId, ['sponsor' => $id, 'name' => $name]); flash('success', $active ? 'Sponsor saved and shown on the event page.' : 'Sponsor saved. It is hidden until you switch it on.'); back_to("/admin/events/$eventId/sponsors"); } function a_sponsor_toggle(int $id): void { $a = q1('SELECT * FROM {event_sponsors} WHERE id = ?', [$id]) ?? abort(404); q('UPDATE {event_sponsors} SET is_active = ?, updated_at = ? WHERE id = ?', [$a['is_active'] ? 0 : 1, now(), $id]); audit('sponsor.' . ($a['is_active'] ? 'hide' : 'show'), 'event', (int)$a['event_id'], ['sponsor' => $id]); flash('success', $a['is_active'] ? 'Sponsor hidden.' : 'Sponsor is now shown.'); back_to('/admin/events/' . (int)$a['event_id'] . '/sponsors'); } function a_sponsor_delete(int $id): void { $a = q1('SELECT * FROM {event_sponsors} WHERE id = ?', [$id]) ?? abort(404); q('DELETE FROM {event_sponsors} WHERE id = ?', [$id]); delete_upload($a['logo'] ?: null); delete_upload($a['image'] ?: null); audit('sponsor.delete', 'event', (int)$a['event_id'], ['sponsor' => $id, 'name' => $a['name']]); flash('success', 'Sponsor deleted.'); back_to('/admin/events/' . (int)$a['event_id'] . '/sponsors'); } /* ------------------------------------------------------------------ */ /* Announcements: updates shown at the top of an event's public page */ /* ------------------------------------------------------------------ */ function a_announcement_save(int $eventId): void { admin_event($eventId); $id = post_int('id'); $old = $id ? q1('SELECT * FROM {event_announcements} WHERE id = ? AND event_id = ?', [$id, $eventId]) : null; if ($id && !$old) abort(404); set_back("/admin/events/$eventId/announcements" . ($id ? "?edit=$id" : '')); $title = str_in('title', 190, true, 'Headline'); $body = rich_in('body', 5000); $level = in_array(post('level'), ['info', 'important', 'urgent'], true) ? post('level') : 'info'; $from = dt_in(post('show_from')); $until = dt_in(post('show_until')); if ($from && $until && $until <= $from) fail('The hide date must be after the show date.'); $active = post_bool('is_active'); if ($id) { q('UPDATE {event_announcements} SET title=?, body=?, level=?, is_active=?, show_from=?, show_until=?, updated_at=? WHERE id=?', [$title, $body, $level, $active, $from, $until, now(), $id]); } else { q('INSERT INTO {event_announcements} (event_id, title, body, level, is_active, show_from, show_until, created_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)', [$eventId, $title, $body, $level, $active, $from, $until, current_user()['id'], now(), now()]); $id = (int)db_insert_id(); } audit($old ? 'announcement.update' : 'announcement.create', 'event', $eventId, ['announcement' => $id, 'title' => $title]); flash('success', !$active ? 'Announcement saved. It is hidden until you switch it on.' : ($from && $from > now() ? 'Announcement saved. It will appear on ' . fmt_dt($from) . '.' : 'Announcement saved and live on the event page.')); back_to("/admin/events/$eventId/announcements"); } function a_announcement_toggle(int $id): void { $a = q1('SELECT * FROM {event_announcements} WHERE id = ?', [$id]) ?? abort(404); q('UPDATE {event_announcements} SET is_active = ?, updated_at = ? WHERE id = ?', [$a['is_active'] ? 0 : 1, now(), $id]); audit('announcement.' . ($a['is_active'] ? 'hide' : 'show'), 'event', (int)$a['event_id'], ['announcement' => $id]); flash('success', $a['is_active'] ? 'Announcement hidden.' : 'Announcement is live.'); back_to('/admin/events/' . (int)$a['event_id'] . '/announcements'); } function a_announcement_delete(int $id): void { $a = q1('SELECT * FROM {event_announcements} WHERE id = ?', [$id]) ?? abort(404); q('DELETE FROM {event_announcements} WHERE id = ?', [$id]); audit('announcement.delete', 'event', (int)$a['event_id'], ['announcement' => $id, 'title' => $a['title']]); flash('success', 'Announcement deleted.'); back_to('/admin/events/' . (int)$a['event_id'] . '/announcements'); } function a_event_cancel(int $id): void { $ev = admin_event($id); if ($ev['status'] === 'cancelled') fail('This event is already cancelled.'); if (post('confirm_title') !== $ev['title']) fail('Type the event title exactly to confirm.'); q("UPDATE {events} SET status = 'cancelled', updated_at = ? WHERE id = ?", [now(), $id]); q("UPDATE {orders} SET status = 'cancelled', closed_at = ?, decision_note = 'Event cancelled' WHERE event_id = ? AND status IN ('awaiting_approval','pending_payment')", [now(), $id]); $n = post_bool('notify') ? email_event_buyers($id, 'event_cancelled', ['staff_note' => str_in('note', 1000)]) : 0; audit('event.cancel', 'event', $id, ['emailed' => $n]); flash('success', 'Event cancelled.' . ($n ? " $n buyers will be emailed." : '') . ' Refund confirmed bookings from the Bookings page.'); back_to("/admin/events/$id"); } function a_event_note(int $id): void { admin_event($id); q('UPDATE {events} SET email_note = ?, updated_at = ? WHERE id = ?', [str_in('email_note', 1000) ?: null, now(), $id]); flash('success', 'Email note saved.'); back_to("/admin/events/$id/emails"); } /* ---------------- Ticket categories ---------------- */ function a_category_save(int $eventId): void { $ev = admin_event($eventId); $cid = post_int('category_id'); if ($cid && !qv('SELECT 1 FROM {ticket_categories} WHERE id = ? AND event_id = ?', [$cid, $eventId])) abort(404); set_back("/admin/events/$eventId/tickets" . ($cid ? "?edit=$cid" : '')); $name = str_in('name', 120, true, 'Category name'); $capacity = int_in('capacity', 0, 1000000, 0); $min = int_in('min_per_order', 1, 100, 1); $max = trim(post_text('max_per_order')) === '' ? null : int_in('max_per_order', 1, 1000, 10); if ($max !== null && $min > $max) fail('Minimum per booking cannot be more than the maximum.'); $section = post_int('venue_section_id') ?: null; if ($section && !qv('SELECT 1 FROM {venue_sections} WHERE id = ? AND venue_id = ?', [$section, (int)$ev['venue_id']])) $section = null; if ($cid) { $held = held_counts($eventId)['cat'][$cid] ?? 0; if ($capacity < $held) fail("Capacity cannot be lower than the $held tickets already sold or held."); } $color = strtolower(trim(post('color'))); if ($color !== '' && !preg_match('/^#[0-9a-f]{6}$/', $color)) fail('Enter the colour as a hex code like #c2410c, or leave it empty to use the event colour.'); $vals = [$section, $name, str_in('description', 500), $color !== '' ? $color : null, $capacity, $min, $max, post_has('is_active') ? post_bool('is_active') : 1, int_in('sort_order', 0, 1000, 0)]; $price = to_minor(post('price')); if ($cid) { q('UPDATE {ticket_categories} SET venue_section_id=?, name=?, description=?, color=?, capacity=?, min_per_order=?, max_per_order=?, is_active=?, sort_order=? WHERE id = ?', array_merge($vals, [$cid])); } else { q('INSERT INTO {ticket_categories} (venue_section_id, name, description, color, capacity, min_per_order, max_per_order, is_active, sort_order, event_id) VALUES (?,?,?,?,?,?,?,?,?,?)', array_merge($vals, [$eventId])); $cid = (int)db_insert_id(); } set_category_price($cid, $price); do_action('gatecraft_category_saved', $cid); audit('category.save', 'category', $cid, ['event' => $eventId, 'name' => $name, 'capacity' => $capacity]); $cap = event_cap($ev); $alloc = (int)qv('SELECT COALESCE(SUM(capacity),0) FROM {ticket_categories} WHERE event_id = ? AND is_active = 1', [$eventId]); flash('success', 'Ticket category saved.'); if ($cap !== null && $alloc > $cap) flash('warn', "Categories add up to $alloc seats, more than the $cap available. Sales stop at $cap in total."); back_to("/admin/events/$eventId/tickets"); } function a_categories_from_venue(int $eventId): void { $ev = admin_event($eventId); if (!$ev['venue_id']) fail('Choose a venue for this event first.'); $n = 0; foreach (q('SELECT * FROM {venue_sections} WHERE venue_id = ? ORDER BY sort_order, id', [$ev['venue_id']])->fetchAll() as $i => $s) { if (qv('SELECT 1 FROM {ticket_categories} WHERE event_id = ? AND venue_section_id = ?', [$eventId, $s['id']])) continue; q('INSERT INTO {ticket_categories} (event_id, venue_section_id, name, capacity, sort_order) VALUES (?,?,?,?,?)', [$eventId, $s['id'], $s['name'], $s['capacity'], $i]); q('INSERT INTO {ticket_tiers} (category_id, name, price, sort_order) VALUES (?,?,0,0)', [(int)db_insert_id(), 'Standard']); $n++; } flash($n ? 'success' : 'info', $n ? "Created $n categories from the venue sections. Set their prices." : 'Every venue section already has a category.'); back_to("/admin/events/$eventId/tickets"); } function a_category_delete(int $id): void { $c = q1('SELECT * FROM {ticket_categories} WHERE id = ?', [$id]) ?? abort(404); if (qv('SELECT 1 FROM {order_items} WHERE category_id = ? LIMIT 1', [$id])) fail('This category has bookings. Turn it off instead of deleting it.'); q('DELETE FROM {ticket_categories} WHERE id = ?', [$id]); audit('category.delete', 'category', $id, ['name' => $c['name']]); flash('success', 'Category deleted.'); back_to('/admin/events/' . $c['event_id'] . '/tickets'); } /* ---------------- Discounts ---------------- */ function discount_page_data(?int $eventId): array { $sql = 'SELECT d.*, e.title AS event_title, c.name AS cat_name FROM {discounts} d LEFT JOIN {events} e ON e.id = d.event_id LEFT JOIN {ticket_categories} c ON c.id = d.category_id'; $rows = $eventId ? q($sql . ' WHERE d.event_id = ? OR d.event_id IS NULL ORDER BY d.id DESC', [$eventId])->fetchAll() : q($sql . ' ORDER BY d.id DESC LIMIT 300')->fetchAll(); foreach ($rows as &$r) $r['uses'] = discount_uses((int)$r['id']); unset($r); $edit = get_int('edit') ? q1('SELECT * FROM {discounts} WHERE id = ?', [get_int('edit')]) : null; $cats = $eventId ? q('SELECT id, name FROM {ticket_categories} WHERE event_id = ? ORDER BY sort_order, id', [$eventId])->fetchAll() : []; $events = q("SELECT id, title, starts_at FROM {events} WHERE status <> 'archived' ORDER BY starts_at DESC LIMIT 200")->fetchAll(); return ['discounts' => $rows, 'edit' => $edit, 'cats' => $cats, 'events' => $events, 'eventId' => $eventId]; } function a_discounts(): void { render('admin/discounts', ['title' => 'Discounts', 'nav' => 'discounts'] + discount_page_data(null)); } function a_discount_save(): void { $id = post_int('id'); if ($id && !qv('SELECT 1 FROM {discounts} WHERE id = ?', [$id])) abort(404); $back = safe_next(post_path('_return'), '/admin/discounts'); set_back($back . (str_contains($back, '?') ? '&' : '?') . ($id ? "edit=$id" : '')); $auto = (int)(bool)apply_filters('gatecraft_discount_auto', false); $code = $auto ? null : normalize_code(post('code')); if (!$auto && ($code === '' || strlen($code) < 3)) fail('Enter a code of at least 3 letters or numbers.'); if ($code && qv('SELECT 1 FROM {discounts} WHERE code = ? AND id <> ?', [$code, $id])) fail('That code is already in use.'); $type = post('type') === 'fixed' ? 'fixed' : 'percent'; $value = $type === 'percent' ? int_in('value', 1, 100) : to_minor(post('value')); if (!$value) fail('Enter the discount value.'); $eventId = post_int('event_id') ?: null; if ($eventId && !qv('SELECT 1 FROM {events} WHERE id = ?', [$eventId])) fail('Choose a valid event.'); $cat = post_int('category_id') ?: null; if ($cat && (!$eventId || !qv('SELECT 1 FROM {ticket_categories} WHERE id = ? AND event_id = ?', [$cat, $eventId]))) $cat = null; $start = dt_in(post('starts_at')); $end = dt_in(post('ends_at')); if ($start && $end && $end <= $start) fail('The end must be after the start.'); $vals = [$eventId, $cat, str_in('name', 120, true, 'Name'), $code, $auto, $type, $value, post('applies_per') === 'ticket' ? 'ticket' : 'order', int_in('min_qty', 1, 100, 1), int_in('max_uses', 1, 1000000), int_in('max_per_email', 1, 1000), $start, $end, post_has('is_active') ? post_bool('is_active') : 1]; if ($id) q('UPDATE {discounts} SET event_id=?, category_id=?, name=?, code=?, auto_apply=?, type=?, value=?, applies_per=?, min_qty=?, max_uses=?, max_per_email=?, starts_at=?, ends_at=?, is_active=? WHERE id = ?', array_merge($vals, [$id])); else { q('INSERT INTO {discounts} (event_id, category_id, name, code, auto_apply, type, value, applies_per, min_qty, max_uses, max_per_email, starts_at, ends_at, is_active, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', array_merge($vals, [now()])); $id = (int)db_insert_id(); } do_action('gatecraft_discount_saved', $id); audit('discount.save', 'discount', $id, ['name' => $vals[2], 'code' => $code]); flash('success', 'Discount saved.'); back_to($back); } function a_discount_delete(int $id): void { $d = q1('SELECT * FROM {discounts} WHERE id = ?', [$id]) ?? abort(404); if (qv('SELECT 1 FROM {orders} WHERE discount_id = ? LIMIT 1', [$id])) { q('UPDATE {discounts} SET is_active = 0 WHERE id = ?', [$id]); flash('success', 'This discount has been used, so it was turned off instead of deleted.'); } else { q('DELETE FROM {discounts} WHERE id = ?', [$id]); flash('success', 'Discount deleted.'); } audit('discount.delete', 'discount', $id, ['name' => $d['name']]); back_to(safe_next(post_path('_return'), '/admin/discounts')); } /* ---------------- Venues ---------------- */ function a_venues(): void { $id = get_int('id'); $sel = $id ? (q1('SELECT * FROM {venues} WHERE id = ?', [$id]) ?? abort(404, 'Venue not found.')) : null; $counts = fn(string $where) => "(SELECT COUNT(*) FROM {events} e WHERE e.venue_id = v.id AND e.status IN ('draft','published') AND $where)"; if ($sel || get_str('new') === '1') { $sections = $sel ? q('SELECT * FROM {venue_sections} WHERE venue_id = ? ORDER BY sort_order, id', [$sel['id']])->fetchAll() : []; $stats = $sel ? q1('SELECT ' . $counts('e.starts_at > ?') . ' AS upcoming, (SELECT COUNT(*) FROM {events} e WHERE e.venue_id = v.id AND e.starts_at <= ?) AS past FROM {venues} v WHERE v.id = ?', [now(), now(), $sel['id']]) : null; $next = $sel ? q("SELECT id, title, starts_at, status FROM {events} WHERE venue_id = ? AND starts_at > ? AND status IN ('draft','published') ORDER BY starts_at LIMIT 4", [$sel['id'], now()])->fetchAll() : []; render('admin/venue_edit', ['title' => $sel ? $sel['name'] : 'New venue', 'sel' => $sel, 'sections' => $sections, 'stats' => $stats, 'next' => $next, 'nav' => 'venues']); return; } $rows = q('SELECT v.*, ' . $counts('e.starts_at > ?') . ' AS upcoming, (SELECT COUNT(*) FROM {events} e WHERE e.venue_id = v.id AND e.starts_at <= ?) AS past, (SELECT MIN(e.starts_at) FROM {events} e WHERE e.venue_id = v.id AND e.starts_at > ? AND e.status = \'published\') AS next_at FROM {venues} v ORDER BY v.is_active DESC, v.name', [now(), now(), now()])->fetchAll(); $secs = []; foreach (q('SELECT * FROM {venue_sections} ORDER BY sort_order, id')->fetchAll() as $s) $secs[(int)$s['venue_id']][] = $s; render('admin/venues', ['title' => 'Venues', 'rows' => $rows, 'secs' => $secs, 'nav' => 'venues', 'q' => mb_substr(get_str('q'), 0, 80)]); } function a_venue_save(): void { $id = post_int('id'); if ($id && !qv('SELECT 1 FROM {venues} WHERE id = ?', [$id])) abort(404); set_back($id ? "/admin/venues?id=$id" : '/admin/venues?new=1'); $cap = int_in('capacity', 0, 1000000, 0); $names = post_list('sec_name'); $caps = post_list('sec_cap'); $ids = post_list('sec_id'); $sections = []; foreach ($names as $i => $n) { $n = trim((string)$n); if ($n === '') continue; $sections[] = ['id' => (int)($ids[$i] ?? 0), 'name' => mb_substr($n, 0, 120), 'cap' => max(0, (int)($caps[$i] ?? 0))]; } $sum = array_sum(array_column($sections, 'cap')); if ($cap && $sum > $cap) fail("Sections add up to $sum seats, more than the venue's $cap."); if (!$cap && $sum) $cap = $sum; $vals = [str_in('name', 190, true, 'Venue name'), str_in('address', 255), str_in('city', 120), str_in('region', 120), str_in('postal_code', 20), str_in('country', 80), https_url_or_empty(str_in('map_url', 500)), $cap, str_in('description', 5000), str_in('accessibility', 3000), str_in('transport', 3000), str_in('contact_name', 120), str_in('contact_email', 190), str_in('contact_phone', 40), int_in('checkin_opens_minutes', 0, 1440, 60), post_has('is_active') ? post_bool('is_active') : 1]; if ($vals[12] !== '' && !filter_var($vals[12], FILTER_VALIDATE_EMAIL)) fail('Enter a valid contact email.'); $id = tx(function () use ($id, $vals, $sections) { if ($id) q('UPDATE {venues} SET name=?, address=?, city=?, region=?, postal_code=?, country=?, map_url=?, capacity=?, description=?, accessibility=?, transport=?, contact_name=?, contact_email=?, contact_phone=?, checkin_opens_minutes=?, is_active=?, updated_at=? WHERE id=?', array_merge($vals, [now(), $id])); else { q('INSERT INTO {venues} (name, address, city, region, postal_code, country, map_url, capacity, description, accessibility, transport, contact_name, contact_email, contact_phone, checkin_opens_minutes, is_active, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', array_merge($vals, [now(), now()])); $id = (int)db_insert_id(); } $keep = []; foreach ($sections as $i => $s) { if ($s['id'] && qv('SELECT 1 FROM {venue_sections} WHERE id = ? AND venue_id = ?', [$s['id'], $id])) { q('UPDATE {venue_sections} SET name = ?, capacity = ?, sort_order = ? WHERE id = ?', [$s['name'], $s['cap'], $i, $s['id']]); $keep[] = $s['id']; } else { q('INSERT INTO {venue_sections} (venue_id, name, capacity, sort_order) VALUES (?,?,?,?)', [$id, $s['name'], $s['cap'], $i]); $keep[] = (int)db_insert_id(); } } $keep = $keep ? array_map('intval', $keep) : [0]; q('DELETE FROM {venue_sections} WHERE venue_id = ? AND id NOT IN (' . placeholders($keep) . ')', array_merge([$id], $keep)); return $id; }); audit('venue.save', 'venue', $id, ['name' => $vals[0], 'capacity' => $cap]); flash('success', 'Venue saved.'); back_to("/admin/venues?id=$id"); } function a_venue_delete(int $id): void { if (qv('SELECT 1 FROM {events} WHERE venue_id = ? LIMIT 1', [$id])) fail('Events use this venue. Mark it inactive instead.'); q('DELETE FROM {venues} WHERE id = ?', [$id]); audit('venue.delete', 'venue', $id); flash('success', 'Venue deleted.'); back_to('/admin/venues'); } /* ---------------- Orders ---------------- */ function a_orders(): void { $f = ['event' => get_int('event'), 'status' => get_str('status'), 'type' => get_str('type'), 'q' => mb_substr(get_str('q'), 0, 100), 'from' => get_str('from'), 'to' => get_str('to')]; foreach (['from', 'to'] as $k) $f[$k] = date_in($f[$k]); [$w, $a] = orders_filter_sql($f); $total = (int)qv("SELECT COUNT(*) FROM {orders} o WHERE $w", $a); [$w2, $a2] = orders_filter_sql(['status' => ''] + $f); $byStatus = q("SELECT o.status, COUNT(*) FROM {orders} o WHERE $w2 GROUP BY o.status", $a2)->fetchAll(Result::KEY_PAIR); $page = max(1, get_int('page', 1)); $rows = q("SELECT o.*, e.title AS event_title, (SELECT SUM(qty) FROM {order_items} WHERE order_id = o.id) AS qty FROM {orders} o JOIN {events} e ON e.id = o.event_id WHERE $w ORDER BY o.id DESC LIMIT 50 OFFSET " . (($page - 1) * 50), $a)->fetchAll(); render('admin/orders', ['title' => 'Bookings', 'rows' => $rows, 'f' => $f, 'total' => $total, 'page' => $page, 'byStatus' => $byStatus, 'events' => q("SELECT id, title FROM {events} ORDER BY starts_at DESC LIMIT 200")->fetchAll(), 'nav' => 'orders']); } function a_order(int $id): void { $o = get_order($id) ?? abort(404, 'Booking not found.'); $log = q("SELECT a.*, u.user_email AS who FROM {audit_log} a LEFT JOIN {wpusers} u ON u.ID = a.user_id WHERE a.entity = 'order' AND a.entity_id = ? ORDER BY a.id DESC", [$id])->fetchAll(); $emails = q('SELECT id, subject, status, created_at, sent_at FROM {email_queue} WHERE to_email = ? ORDER BY id DESC LIMIT 20', [$o['buyer_email']])->fetchAll(); render('admin/order', ['title' => 'Booking ' . $o['reference'], 'o' => $o, 'items' => order_items($id), 'tickets' => order_tickets($id), 'discount' => $o['discount_id'] ? q1('SELECT * FROM {discounts} WHERE id = ?', [$o['discount_id']]) : null, 'log' => $log, 'emails' => $emails, 'nav' => 'orders']); } function a_order_paid(int $id): void { mark_paid($id, 'offline', (int)current_user()['id'], false); flash('success', 'Marked as paid. Tickets issued and emailed.'); back_to("/admin/orders/$id"); } function a_order_cancel(int $id): void { cancel_order($id, str_in('reason', 500), post_bool('notify') === 1); flash('success', 'Booking cancelled and its tickets voided.'); back_to("/admin/orders/$id"); } function a_order_refund(int $id): void { try { refund_order($id, str_in('reason', 500)); } catch (RuntimeException $ex) { if ($ex instanceof UserError) throw $ex; fail('Stripe did not accept the refund. Nothing was changed. Check the Stripe dashboard.'); } flash('success', 'Refunded. Tickets voided and the buyer emailed.'); back_to("/admin/orders/$id"); } function a_order_resend(int $id): void { $o = get_order($id) ?? abort(404); $kind = match ($o['status']) { 'paid' => 'tickets', 'pending_payment' => awaiting_email_confirm($o) ? 'confirm_email' : ($o['decided_at'] ? 'approved' : 'payment_pending'), 'awaiting_approval' => 'approval_pending', default => null }; if (!$kind) fail('There is nothing to resend for this booking.'); if (!rate_limit('order-resend', (string)current_user()['id'], 60, 3600)) fail('You have resent many emails in the last hour. Please wait a little.'); $to = strtolower(post('email')); if ($to !== '' && $to !== $o['buyer_email']) { if (!filter_var($to, FILTER_VALIDATE_EMAIL)) fail('Enter a valid email address.'); q('UPDATE {orders} SET buyer_email = ? WHERE id = ?', [$to, $id]); audit('order.email_changed', 'order', $id, ['from' => $o['buyer_email'], 'to' => $to]); } email_order($id, $kind); audit('order.resend', 'order', $id, ['kind' => $kind]); flash('success', 'Email queued.'); back_to("/admin/orders/$id"); } function a_ticket_undo(int $id): void { $t = q1('SELECT tk.*, o.id AS oid FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id WHERE tk.id = ?', [$id]) ?? abort(404); q('UPDATE {tickets} SET checked_in_at = NULL, checked_in_by = NULL WHERE id = ?', [$id]); audit('ticket.undo_checkin', 'order', (int)$t['oid'], ['ticket' => $id]); flash('success', 'Check-in undone for that ticket.'); back_to(safe_next(post_path('_return'), '/admin/orders/' . $t['oid'])); } /* ---------------- Check-in ---------------- */ function a_checkin_list(): void { $rows = q("SELECT e.*, v.name AS venue_name FROM {events} e LEFT JOIN {venues} v ON v.id = e.venue_id WHERE e.status = 'published' AND (e.ends_at IS NULL OR e.ends_at > ?) AND e.starts_at > ? ORDER BY e.starts_at LIMIT 50", [ldate('Y-m-d H:i:s', time() - 6 * 3600), ldate('Y-m-d H:i:s', time() - 24 * 3600)])->fetchAll(); render('admin/checkin_list', ['title' => 'Check-in', 'rows' => $rows, 'nav' => 'checkin']); } function checkin_counts(int $eventId): array { return q1("SELECT COUNT(*) AS total, SUM(tk.checked_in_at IS NOT NULL) AS done FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id WHERE o.event_id = ? AND o.status = 'paid' AND tk.status = 'valid'", [$eventId]); } function a_checkin(int $id): void { $ev = admin_event($id); $c = checkin_counts($id); send_security_headers(); nocache_headers(); header('Cache-Control: no-store'); stream_template('admin/checkin', ['title' => 'Check-in: ' . $ev['title'], 'ev' => $ev, 'total' => (int)$c['total'], 'done' => (int)$c['done'], 'admin' => true]); } function a_checkin_scan(int $eventId): void { $u = current_user(); if (!rate_limit('scan', (string)$u['id'], 240, 60)) json_out(['result' => 'invalid', 'title' => 'Slow down', 'detail' => 'Too many scans in a minute.'], 429); $code = strtoupper(preg_replace('/[^0-9A-Za-z]/', '', post('code'))); $t = strlen($code) === 16 ? q1('SELECT tk.*, o.event_id, o.status AS order_status, o.buyer_name, c.name AS category_name FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id JOIN {ticket_categories} c ON c.id = tk.category_id WHERE tk.code = ?', [$code]) : null; $result = 'invalid'; $title = 'Not a valid ticket'; $detail = 'Code not found.'; if ($t) { if ((int)$t['event_id'] !== $eventId) { $result = 'wrong_event'; $title = 'Wrong event'; $detail = 'This ticket is for: ' . qv('SELECT title FROM {events} WHERE id = ?', [$t['event_id']]); } elseif (($evStatus = (string)qv('SELECT status FROM {events} WHERE id = ?', [$eventId])) !== 'published') { $result = 'void'; $title = 'Ticket not valid'; $detail = $evStatus === 'cancelled' ? 'This event was cancelled.' : 'This event is not open for entry.'; } elseif ($t['status'] !== 'valid' || $t['order_status'] !== 'paid') { $result = 'void'; $title = 'Ticket not valid'; $detail = 'This ticket was cancelled or refunded. ' . $t['buyer_name']; } else { $upd = q('UPDATE {tickets} SET checked_in_at = ?, checked_in_by = ? WHERE id = ? AND checked_in_at IS NULL', [now(), $u['id'], $t['id']]); if ($upd->rowCount() === 1) { $result = 'ok'; $title = 'Valid. Let them in.'; $detail = $t['buyer_name'] . ' · ' . $t['category_name'] . ' · ' . format_code($code); } else { $prev = q1('SELECT tk.checked_in_at, u.display_name AS name, u.user_email AS email FROM {tickets} tk LEFT JOIN {wpusers} u ON u.ID = tk.checked_in_by WHERE tk.id = ?', [$t['id']]); $result = 'duplicate'; $title = 'Already used'; $detail = 'Scanned at ' . fmt_time($prev['checked_in_at']) . ($prev['name'] || $prev['email'] ? ' by ' . ($prev['name'] ?: $prev['email']) : '') . ' · ' . $t['buyer_name']; } } } q('INSERT INTO {checkin_scans} (event_id, ticket_id, user_id, result, created_at) VALUES (?,?,?,?,?)', [$eventId, $t['id'] ?? null, $u['id'], $result, now()]); json_out(['result' => $result, 'title' => $title, 'detail' => $detail, 'checked_in' => (int)checkin_counts($eventId)['done']]); } /* ---------------- Reports and exports ---------------- */ function report_filters(): array { $from = date_in(get_str('from')); $to = date_in(get_str('to')); if ($from !== '' && $to !== '' && $from > $to) [$from, $to] = [$to, $from]; return [get_int('event') ?: null, $from ?: null, $to ?: null]; } function a_reports(): void { [$eventId, $from, $to] = report_filters(); $ev = $eventId ? admin_event($eventId) : null; render('admin/reports', [ 'title' => 'Reports', 'nav' => 'reports', 'ev' => $ev, 'from' => $from, 'to' => $to, 'summary' => report_summary($eventId, $from, $to), 'types' => $eventId ? report_by_ticket_type($eventId) : [], 'daily' => report_daily($eventId, 30), 'checkin' => $eventId ? checkin_counts($eventId) : null, 'scans' => $eventId ? q('SELECT result, COUNT(*) AS n FROM {checkin_scans} WHERE event_id = ? GROUP BY result', [$eventId])->fetchAll(Result::KEY_PAIR) : [], 'events' => q("SELECT id, title, starts_at FROM {events} WHERE status <> 'draft' ORDER BY starts_at DESC LIMIT 200")->fetchAll(), ]); } function a_export(string $kind): void { // A download of personal data: the link carries a nonce, and the capability is checked again here. check_admin_referer('gatecraft_export'); if (!current_user_can_perm('reports')) abort(403, 'Your account does not have access to this page.'); [$eventId, $from, $to] = report_filters(); $format = get_str('format'); if (!in_array($format, (array)apply_filters('gatecraft_export_formats', []), true)) $format = 'csv'; $slug = $eventId ? (string)qv('SELECT slug FROM {events} WHERE id = ?', [$eventId]) : 'all-events'; audit('export.' . $kind, 'event', $eventId, ['format' => $format]); switch ($kind) { case 'attendees': if (!$eventId) fail('Choose an event to export its attendees.'); send_table("attendees-$slug", $format, ['Attendees' => export_attendees($eventId)]); case 'orders': send_table("bookings-$slug", $format, ['Bookings' => export_orders(['event' => $eventId, 'from' => $from, 'to' => $to, 'status' => get_str('status'), 'q' => get_str('q'), 'type' => get_str('type')])]); case 'sales': $sheets = export_sales_workbook($eventId, $from, $to); send_table("sales-$slug", $format, $format === 'csv' ? [$sheets['Summary']] : $sheets); } abort(404); } /* ---------------- Staff (WordPress users with ticketing roles) ---------------- */ function a_users(): void { $users = get_users(['capability__in' => ['gatecraft_checkin', 'gatecraft_manage', 'gatecraft_admin'], 'orderby' => 'email', 'number' => 500]); $rows = array_map(function (WP_User $u) { $v = user_view($u); $a = account_row($u->ID); return $v + ['wp_admin' => $u->has_cap('manage_options'), 'last_login_at' => $a['last_login_at'], 'login' => $u->user_login]; }, $users); usort($rows, fn($a, $b) => [ROLE_RANK[$b['role']], $a['email']] <=> [ROLE_RANK[$a['role']], $b['email']]); render('admin/users', ['title' => 'Staff and roles', 'rows' => $rows, 'nav' => 'users', 'me' => current_user()]); } /** Give a WordPress user one ticketing role (or none). Other WordPress roles they have are left alone. */ function gatecraft_set_staff_role(WP_User $u, string $role): void { $u->remove_role('gatecraft_manager'); $u->remove_role('gatecraft_checkin'); if ($role === 'manager') $u->add_role('gatecraft_manager'); if ($role === 'checkin') $u->add_role('gatecraft_checkin'); if (!$u->roles) $u->set_role('subscriber'); } function a_user_invite(): void { $email = strtolower(str_in('email', 190, true, 'Email')); if (!is_email($email)) fail('Enter a valid email address.'); $role = post('role') === 'manager' ? 'manager' : 'checkin'; $u = get_user_by('email', $email); if ($u && strtolower($u->user_email) !== $email) $u = null; if ($u && $u->has_cap('manage_options')) fail('That person is a WordPress administrator and already has full access.'); if (!$u) { $login = sanitize_user(strstr($email, '@', true), true) ?: 'staff'; $base = substr($login, 0, 50); for ($i = 0; username_exists($login); $i++) $login = $base . '-' . strtolower(random_code(4)); $id = wp_insert_user(['user_login' => $login, 'user_email' => $email, 'user_pass' => wp_generate_password(32, true, true), 'role' => 'subscriber']); if (is_wp_error($id)) fail($id->get_error_message()); $u = get_userdata($id); $created = true; } gatecraft_set_staff_role($u, $role); account_touch($u->ID); revoke_sessions($u->ID); if (!empty($created)) { // A new account has no password yet: the button opens WordPress's own "choose a password" page. $link = network_site_url('wp-login.php?action=rp&key=' . get_password_reset_key($u) . '&login=' . rawurlencode($u->user_login), 'login'); $life = (int)apply_filters('password_reset_expiration', DAY_IN_SECONDS); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter, read to show the same expiry WordPress uses queue_template_email($email, '', 'staff_invite', ['role' => $role === 'manager' ? 'Manager' : 'Check-in staff', 'expires' => fmt_dt(ldate('Y-m-d H:i:s', time() + $life))], $link); } else { queue_template_email($email, '', 'staff_alert', ['alert_title' => 'You have ticketing access at ' . setting('brand_name'), 'alert_body' => 'You have been given ' . ($role === 'manager' ? 'manager' : 'check-in') . ' access. Sign in with your WordPress account' . (google_signin_on() ? ' or with Google using this email address' : '') . '. You will set up an authenticator app at your first password sign-in.'], admin_url('admin.php?page=gatecraft')); } audit('user.invite', 'user', $u->ID, ['email' => $email, 'role' => $role]); flash('success', "Invite sent to $email."); back_to('/admin/users'); } function a_user_update(int $id): void { $me = current_user(); $u = get_userdata($id) ?: abort(404); if ($u->has_cap('manage_options')) fail('WordPress administrators are managed on the WordPress Users screen.'); if ($id === (int)$me['id']) fail('You cannot change your own access.'); $role = in_array(post('role'), ['customer', 'checkin', 'manager'], true) ? post('role') : 'customer'; $status = post('status') === 'disabled' ? 'disabled' : 'active'; $before = [gatecraft_role_of($u), account_row($id)['status']]; gatecraft_set_staff_role($u, $role); account_touch($id); q('UPDATE {accounts} SET status = ? WHERE user_id = ?', [$status, $id]); if ($before !== [$role, $status]) revoke_sessions($id); audit('user.update', 'user', $id, ['role' => $role, 'status' => $status, 'was' => $before]); flash('success', 'Access updated. Their open sessions were signed out.'); back_to('/admin/users'); } function a_user_reset_2fa(int $id): void { if ($id === (int)current_user()['id']) fail('Ask another admin to reset your own authenticator, or use: wp gatecraft reset-2fa.'); if (user_can($id, 'manage_options') && !current_user_can('manage_options')) fail('Only a WordPress administrator can reset the authenticator of another WordPress administrator.'); q('UPDATE {accounts} SET totp_secret_enc = NULL, totp_enabled = 0, totp_last_step = NULL WHERE user_id = ?', [$id]); revoke_sessions($id); audit('user.reset_2fa', 'user', $id); gatecraft_notify_security_change($id, 'An administrator reset the authenticator app on your account. You will set up a new one at your next sign-in'); flash('success', 'Authenticator reset. They will set it up again at their next password sign-in.'); back_to('/admin/users'); } /* ---------------- Settings ---------------- */ const SETTING_TABS = [ 'branding' => 'Site branding', 'content' => 'Site text and messages', 'emails' => 'Email templates', 'checkout' => 'Checkout and payments', 'notifications' => 'Notifications', 'security' => 'Sign-in and security', 'integrations' => 'Integrations', 'legal' => 'Legal and policies', 'regional' => 'Regional', 'about' => 'System information', ]; function a_settings(): void { $tab = get_str('tab', 'branding'); if (!isset(setting_tabs()[$tab])) $tab = 'branding'; $tpl = get_str('tpl', 'tickets'); if (!isset(email_templates()[$tpl])) $tpl = 'tickets'; render('admin/settings', ['title' => 'Settings', 'tab' => $tab, 'tpl' => $tpl, 'nav' => 'settings', 'queue' => q('SELECT status, COUNT(*) AS n FROM {email_queue} GROUP BY status')->fetchAll(Result::KEY_PAIR), 'lastWebhook' => qv("SELECT MAX(paid_at) FROM {orders} WHERE payment_method = 'stripe'")]); } /** Validation rules per field: [type, extra]. */ function setting_rules(string $tab): array { $t = ['text' => 200, 'long' => 2000, 'doc' => 30000]; return (array)apply_filters('gatecraft_setting_rules', match ($tab) { 'branding' => ['base_slug' => ['slug'], 'show_ticket_bar' => ['bool'], 'brand_name' => ['text', 80, true], 'brand_tagline' => ['text', 200], 'brand_color' => ['color'], 'button_shape' => ['enum', ['rounded', 'pill', 'square']], 'header_style' => ['enum', ['light', 'brand', 'dark']], 'hero_style' => ['enum', ['next', 'solid', 'image', 'none']], 'event_list_view' => ['enum', array_keys(event_list_views())], 'ui_style' => ['enum', ['theme', 'theme_brand', 'brand']], 'font' => ['enum', ['inter', 'system']]], 'content' => ['home_headline' => ['text', 160], 'home_intro' => ['text', 400], 'search_placeholder' => ['text', 80], 'empty_message' => ['text', 300], 'label_sold_out' => ['text', 40], 'label_low_stock' => ['text', 60], 'low_stock_threshold' => ['int', 0, 100000], 'label_upcoming' => ['text', 60], 'btn_checkout' => ['text', 40], 'msg_paid' => ['text', 500], 'msg_pending_payment' => ['text', 500], 'msg_closed' => ['text', 500], 'footer_text' => ['text', 200], 'support_email' => ['email'], 'support_phone' => ['text', 40], 'social_instagram' => ['url'], 'social_x' => ['url'], 'social_linkedin' => ['url'], 'social_facebook' => ['url'], 'social_website' => ['url'], 'footer_links' => ['links'], 'meta_description' => ['text', 300]], 'emails' => ['mail_from_name' => ['text', 80], 'mail_from_address' => ['email'], 'reply_to' => ['email'], 'email_footer' => ['text', 300]], 'checkout' => ['pay_card' => ['bool'], 'guest_checkout' => ['bool'], 'phone_mode' => ['enum', ['optional', 'required', 'hidden']], 'require_terms' => ['bool'], 'hold_minutes' => ['int', 10, 120], 'offline_hold_hours' => ['int', 1, 336], 'pay_konbini' => ['bool'], 'pay_paypay_stripe' => ['bool'], 'konbini_days' => ['int', 1, 14], 'tax_note' => ['text', 200]], 'notifications' => array_merge( array_merge(...array_map(fn($k) => ["alert_{$k}_app" => ['bool'], "alert_{$k}_email" => ['bool']], array_keys(alert_types()))), ['capacity_threshold' => ['int', 1, 100], 'alert_recipients' => ['enum', ['staff', 'list']], 'alert_emails' => ['emails'], 'expired_email' => ['bool'], 'daily_summary_enabled' => ['bool'], 'daily_summary_time' => ['time']]), 'security' => ['free_email_confirm' => ['bool'], 'staff_2fa' => ['bool'], 'session_idle_minutes' => ['int', 5, 1440], 'staff_session_hours' => ['int', 1, 24], 'login_max_attempts' => ['int', 3, 20], 'login_lock_minutes' => ['int', 1, 1440], 'code_tries' => ['int', 5, 200], 'admin_ip_allowlist' => ['ips']], 'legal' => ['terms_text' => ['text', $t['doc']], 'refund_text' => ['text', $t['doc']], 'privacy_text' => ['text', $t['doc']], 'checkout_agreement' => ['text', 300], 'business_details' => ['text', 5000]], 'regional' => ['date_format' => ['enum', ['en', 'ymd', 'us']], 'time_format' => ['enum', ['24', '12']], 'currency' => ['enum', ['JPY', 'USD', 'EUR', 'GBP', 'AUD', 'CAD', 'SGD', 'INR']], 'delete_data_on_uninstall' => ['bool']], default => [], }, $tab); } function a_settings_save(): void { $tab = post('tab'); if (!isset(setting_tabs()[$tab]) || $tab === 'integrations') abort(400); set_back("/admin/settings?tab=$tab"); $out = []; foreach (setting_rules($tab) as $k => $rule) { // A setting whose field is not on the form keeps its value (unticked checkboxes are never sent, so they still count as off). if ($rule[0] !== 'bool' && !post_has($k)) continue; $v = post_text($k); $label = ucfirst(str_replace('_', ' ', $k)); switch ($rule[0]) { case 'text': if (!empty($rule[2]) && $v === '') fail("$label is required."); if (mb_strlen($v) > $rule[1]) fail("$label is too long."); break; case 'bool': $v = (string)post_bool($k); break; case 'int': if (!preg_match('/^\d+$/', $v)) fail("$label must be a whole number."); $v = (string)max($rule[1], min($rule[2], (int)$v)); break; case 'enum': if (!in_array($v, $rule[1], true)) fail("Choose a valid option for $label."); break; case 'color': if (!preg_match('/^#[0-9a-fA-F]{6}$/', $v)) fail('Brand colour must be a hex value like #0b0b0c.'); $v = strtolower($v); break; case 'email': if ($v !== '' && !filter_var($v, FILTER_VALIDATE_EMAIL)) fail("$label must be a valid email address."); break; case 'emails': $list = array_filter(array_map('trim', preg_split('/[\s,;]+/', $v) ?: [])); foreach ($list as $e) if (!filter_var($e, FILTER_VALIDATE_EMAIL)) fail("\"$e\" is not a valid email address."); $v = implode(', ', $list); break; case 'url': $v = https_url_or_empty($v); break; case 'links': try { $v = implode("\n", array_map(fn($l) => $l[0] . ' | ' . $l[1], footer_links_parse($v))); } catch (\InvalidArgumentException $ex) { fail($ex->getMessage()); } break; case 'rate': $v = str_replace(',', '.', $v); if (!preg_match('/^\d{1,2}(\.\d{1,2})?$/', $v) && $v !== '100') fail("$label must be a percentage like 10 or 7.7."); break; case 'time': if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $v)) fail("$label must be a time like 08:30."); break; case 'slug': $v = strtolower($v); if (!preg_match('#^[a-z0-9-]{2,40}$#', $v)) fail('The page address may use lowercase letters, numbers and dashes.'); if (get_page_by_path($v) && $v !== setting('base_slug')) fail('A WordPress page already uses /' . $v . '/. Choose another address.'); break; case 'domains': $list = array_filter(array_map(fn($d) => strtolower(trim($d)), explode(',', $v))); foreach ($list as $d) if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $d)) fail("\"$d\" is not a valid domain."); $v = implode(', ', $list); break; case 'ips': $list = array_filter(array_map('trim', preg_split('/[\s,]+/', $v) ?: [])); foreach ($list as $ip) if (!valid_ip_or_cidr($ip)) fail("\"$ip\" is not a valid IP address or range."); if ($list && !ip_in_list(client_ip(), array_merge($list, (array)cfg('admin_ip_allowlist', [])))) fail('Your current address (' . client_ip() . ') is not in the list. Add it first so you do not lock yourself out.'); $v = implode("\n", $list); break; } $out[$k] = $v; } if ($tab === 'checkout') { $out['offline_methods'] = wp_json_encode(parse_offline_methods_post(), JSON_UNESCAPED_UNICODE); $anyOffline = (bool)array_filter(json_decode($out['offline_methods'], true), fn($m) => $m['on']); if (!$anyOffline && ($out['pay_card'] === '0' || cfg('stripe_secret', '') === '')) fail('Keep at least one payment method on.'); } if ($tab === 'checkout' && isset($out['tax_reg_number'])) $out['tax_reg_number'] = strtoupper((string)preg_replace('/\s+/', '', $out['tax_reg_number'])); if ($tab === 'checkout' && isset($out['tax_mode']) && $out['tax_mode'] !== 'none' && (float)($out['tax_rate'] ?? 0) <= 0) fail('Enter a tax rate above zero, or choose No tax line.'); if ($tab === 'regional' && $out['currency'] !== setting('currency') && qv('SELECT 1 FROM {orders} LIMIT 1')) fail('The currency cannot change once bookings exist.'); if ($tab === 'regional') $out['currency_decimals'] = in_array($out['currency'], ['JPY'], true) ? '0' : '2'; if ($tab === 'branding' && $out['base_slug'] !== setting('base_slug')) update_option('gatecraft_flush_rewrites', 1); if ($tab === 'branding') { $old = []; foreach (['brand_logo' => 800, 'brand_logo_dark' => 800, 'brand_favicon' => 256, 'default_cover' => 1800, 'hero_image' => 2400] as $f => $w) { $new = handle_image_upload($f, $w); if ($new) { $old[] = setting($f); $out[$f] = $new; } elseif (post_bool('remove_' . $f)) { $old[] = setting($f); $out[$f] = ''; } } } setting_save($out); foreach ($old ?? [] as $o) delete_upload($o ?: null); audit('settings.save', 'settings', null, ['tab' => $tab, 'keys' => array_keys($out)]); flash('success', 'Saved. Changes are live.'); back_to("/admin/settings?tab=$tab"); } function a_template_save(): void { $id = post('tpl'); if (!isset(email_templates()[$id])) abort(400); set_back("/admin/settings?tab=emails&tpl=$id"); $all = json_decode(setting('email_templates'), true) ?: []; if (post('reset') === '1') { unset($all[$id]); flash('success', 'Template reset to the default text.'); } else { $all[$id] = ['subject' => str_in('subject', 200, true, 'Subject'), 'heading' => str_in('heading', 200, true, 'Heading'), 'body' => str_in('body', 5000, true, 'Message'), 'button' => str_in('button', 60), 'enabled' => (bool)post_bool('enabled')]; if (in_array($id, ['tickets', 'staff_alert'], true) && !$all[$id]['enabled']) fail('This email cannot be turned off. Buyers and staff depend on it.'); flash('success', 'Template saved.'); } setting_save(['email_templates' => wp_json_encode($all, JSON_UNESCAPED_UNICODE)]); audit('settings.email_template', 'settings', null, ['template' => $id]); back_to("/admin/settings?tab=emails&tpl=$id"); } /** Offline payment methods from the Checkout settings form: rows of name, instructions and an on switch. */ function parse_offline_methods_post(): array { $rows = post_list('om'); $existing = offline_methods_all(); $out = []; foreach (array_slice($rows, 0, 12, true) as $r) { if (!is_array($r)) continue; $name = trim(mb_substr((string)($r['name'] ?? ''), 0, 60)); $instr = trim(mb_substr(str_replace("\r\n", "\n", (string)($r['instructions'] ?? '')), 0, 2000)); if ($name === '' && $instr === '') continue; if ($name === '') fail('Give every payment method a name.'); $id = (string)($r['id'] ?? ''); if (!isset($existing[$id])) { $base = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower(remove_accents($name))), '-') ?: 'method'; $id = substr($base, 0, 30); for ($i = 2; isset($out[$id]) || isset($existing[$id]); $i++) $id = substr($base, 0, 30) . '-' . $i; } if (isset($out[$id])) continue; $out[$id] = ['id' => $id, 'name' => $name, 'on' => !empty($r['on']), 'instructions' => $instr]; } return array_values($out); } function sample_email_vars(): array { return ['buyer_name' => 'Aiko', 'event_title' => 'Sample event', 'event_date' => fmt_dt(ldate('Y-m-d 19:00:00', lstrtotime('+14 days'))), 'venue' => 'Sample venue', 'doors_time' => '18:30', 'booking_reference' => 'SAMPLE01', 'amount' => money(9000 * (10 ** (int)cfg('currency_decimals', 0))), 'pay_deadline' => fmt_dt(ldate('Y-m-d 18:00:00', lstrtotime('+3 days'))), 'staff_note' => 'Note from the organiser: please bring your ID.', 'payment_method' => 'PayPay', 'payment_instructions' => "Send ¥9,000 with PayPay to: example-id\nWrite SAMPLE01 in the message.", 'bank_instructions' => "Send ¥9,000 with PayPay to: example-id", 'event_note' => '', 'brand_name' => setting('brand_name'), 'message_subject' => 'A message about your event', 'message_body' => 'Doors open 30 minutes earlier than planned.', 'alert_title' => 'Approval needed: SAMPLE01', 'alert_body' => 'A buyer requested tickets.']; } function a_template_test(): void { $id = post('tpl'); if (!isset(email_templates()[$id])) abort(400); rate_limit_or_abort('tpltest', 10, 600, (string)current_user()['id']); $u = current_user(); queue_template_email($u['email'], $u['name'], $id, sample_email_vars(), url('/')); flash('success', 'Test email queued to ' . $u['email'] . '. Save first if you changed the text.'); back_to("/admin/settings?tab=emails&tpl=$id"); } /** Clears settings made with Gatecraft Pro that have no effect without it (see the notice in wp.php). */ function a_pro_settings_clear(): void { $upcoming = "SELECT id FROM {events} WHERE status = 'published' AND starts_at > ?"; $n = 0; if (!feature('approvals')) $n += q("UPDATE {ticket_categories} SET requires_approval = 0 WHERE requires_approval = 1 AND event_id IN ($upcoming)", [now()])->rowCount(); if (!feature('auto_discounts')) $n += q("UPDATE {discounts} SET is_active = 0 WHERE auto_apply = 1 AND is_active = 1 AND (event_id IS NULL OR event_id IN ($upcoming))", [now()])->rowCount(); if (!feature('questions')) $n += q("DELETE FROM {event_questions} WHERE event_id IN ($upcoming)", [now()])->rowCount(); if (!feature('bot_check')) $n += q("UPDATE {events} SET bot_check = 0 WHERE bot_check = 1 AND id IN (SELECT id FROM ($upcoming) u)", [now()])->rowCount(); audit('settings.pro_cleared', null, null, ['changed' => $n]); flash('success', 'Done. ' . plural($n, 'setting') . ' cleared.'); back_to('/admin'); } /* ---------------- Logs ---------------- */ function a_emails(): void { $status = get_str('status'); $args = []; $w = '1=1'; if (in_array($status, ['queued', 'sending', 'sent', 'failed', 'cancelled'], true)) { $w = 'status = ?'; $args[] = $status; } $qs = mb_substr(get_str('q'), 0, 100); if ($qs !== '') { $w .= ' AND (to_email LIKE ? OR subject LIKE ?)'; $like = '%' . addcslashes($qs, '%_\\') . '%'; $args[] = $like; $args[] = $like; } $total = (int)qv("SELECT COUNT(*) FROM {email_queue} WHERE $w", $args); $page = max(1, get_int('page', 1)); $counts = q('SELECT status, COUNT(*) FROM {email_queue} GROUP BY status')->fetchAll(Result::KEY_PAIR); render('admin/emails', ['title' => 'Email log', 'nav' => 'emails', 'status' => $status, 'q' => $qs, 'total' => $total, 'page' => $page, 'counts' => $counts, 'rows' => q("SELECT id, to_email, subject, status, attempts, last_error, created_at, sent_at FROM {email_queue} WHERE $w ORDER BY id DESC LIMIT 50 OFFSET ?", array_merge($args, [($page - 1) * 50]))->fetchAll()]); } function a_email_retry(int $id): void { q("UPDATE {email_queue} SET status = 'queued', attempts = 0, send_after = ? WHERE id = ? AND status = 'failed'", [now(), $id]); flash('success', 'Email queued again.'); back_to('/admin/emails'); } function a_audit(): void { $qs = mb_substr(get_str('q'), 0, 100); $w = '1=1'; $args = []; if ($qs !== '') { $w = '(a.action LIKE ? OR a.details LIKE ? OR u.user_email LIKE ?)'; $like = '%' . addcslashes($qs, '%_\\') . '%'; array_push($args, $like, $like, $like); } $kind = get_str('kind'); $kinds = ['login' => 'login%', 'settings' => 'settings.%', 'orders' => 'order.%', 'events' => 'event.%', 'users' => 'user.%', 'audience' => 'audience.%']; if (isset($kinds[$kind])) { $w .= ' AND a.action LIKE ?'; $args[] = $kinds[$kind]; } $total = (int)qv("SELECT COUNT(*) FROM {audit_log} a LEFT JOIN {wpusers} u ON u.ID = a.user_id WHERE $w", $args); $page = max(1, get_int('page', 1)); render('admin/audit', ['title' => 'Audit log', 'nav' => 'audit', 'q' => $qs, 'kind' => $kind, 'total' => $total, 'page' => $page, 'rows' => q("SELECT a.*, u.user_email AS email FROM {audit_log} a LEFT JOIN {wpusers} u ON u.ID = a.user_id WHERE $w ORDER BY a.id DESC LIMIT 50 OFFSET ?", array_merge($args, [($page - 1) * 50]))->fetchAll()]); } function a_event_import(): void { render('admin/event_import', ['title' => 'Import an event', 'nav' => 'events', 'canBookings' => can('settings')]); } function a_event_import_do(): void { $f = uploaded_file('pack'); set_back('/admin/events/import'); if (!$f || $f['error'] !== UPLOAD_ERR_OK) fail('Choose an event file to import.'); if ($f['size'] > EVENTPACK_MAX_BYTES) fail('That file is too large.'); try { $pkg = eventpack_decode((string)gatecraft_fs()->get_contents($f['tmp_name']), post_password('password')); $withBookings = post_bool('with_bookings') && can('settings'); $r = eventpack_import($pkg, $withBookings, (int)(current_user()['id'] ?? 0) ?: null); } catch (UserError $e) { throw $e; } catch (\Throwable $e) { // Only our own checks carry a message for people; database and other errors could reveal internals. $own = $e instanceof \RuntimeException && !($e instanceof DbError) && !($e instanceof \PDOException) && get_class($e) === 'RuntimeException'; if (!$own) log_debug('event import: ' . $e->getMessage()); fail($own ? $e->getMessage() : 'The import failed and nothing was changed. The file may be damaged.'); } audit('event.import', 'event', $r['event_id'], ['orders' => $r['counts']['orders'], 'source' => $pkg['source']['app'] ?? '']); flash('success', 'Imported "' . $pkg['event']['title'] . '" as a draft. Check it, then publish.' . ($r['counts']['orders'] ? ' ' . $r['counts']['orders'] . ' bookings and ' . $r['counts']['tickets'] . ' tickets came with it.' : '')); foreach ($r['notes'] as $n) flash('warn', $n); back_to('/admin/events/' . $r['event_id']); } function a_event_export(int $id): void { $ev = admin_event($id); $with = (bool)post_bool('with_bookings'); if ($with && !can('settings')) abort(403, 'Only administrators can export bookings.'); $pw = post_password('password'); set_back('/admin/events/' . $id . '/backup'); if ($with && strlen($pw) < 10) fail('A file with bookings must be encrypted. Use a password of at least 10 characters.'); try { $payload = eventpack_payload(eventpack_build($id, $with), $with ? $pw : ''); } catch (\Throwable $e) { fail('The export failed: ' . $e->getMessage()); } audit('event.export', 'event', $id, ['bookings' => $with]); $name = preg_replace('/[^A-Za-z0-9_-]+/', '-', $ev['slug']) . '-' . gmdate('Ymd') . ($with ? '-with-bookings' : '') . '.gcevent.json'; // A JSON file download: wp_send_json() sends the Content-Type and encodes the data; the file name is built from a-z, 0-9, _ and -. header('Content-Disposition: attachment; filename="' . $name . '"'); header('Cache-Control: no-store'); header('X-Content-Type-Options: nosniff'); wp_send_json($payload, 200, EVENTPACK_JSON); }