public
imalexnord
read
Slot
Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.
Languages
Repository composition by tracked source files.
TypeScript
79%
CSS
19%
SQL
2%
HTML
0%
Create file
Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git
Commit
feat: add attendee insights and booking search
862116d
README.md | 8 +++
src/App.tsx | 192 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
src/styles.css | 151 ++++++++++++++++++++++++++++++++++++++++++++
worker/index.ts | 151 ++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 491 insertions(+), 11 deletions(-)
Diff
diff --git a/README.md b/README.md
index 6fba5f3..042330c 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,8 @@ No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service,
- Cancelling a paid booking issues a full Stripe refund to the original payment method before Slot finalizes the cancellation
- Every confirmed booking gets a public `SLT-...` booking ID shown on the confirmation screen and Google Calendar invitation; visitors can use it to cancel a future booking from the public booking page
- Admin Activity dashboard with booking, completion, cancellation, reschedule, payment, revenue, refund, and average-duration metrics plus a chronological lifecycle feed
+- Admin Attendees dashboard groups booking history by attendee email, showing repeat bookings, completions, cancellations, reschedules, booked minutes, and payment/refund counts when payments are enabled
+- Admin booking search matches public `SLT-...` booking IDs, attendee names, and email addresses across upcoming and past bookings
## First setup
@@ -242,6 +244,12 @@ Open `/admin` -> **Activity** for an all-time operational snapshot and recent li
Activity events are stored separately from the booking rows so repeated reschedules and payment/refund changes are preserved as history. Migration `0005_activity.sql` backfills the lifecycle points that can be safely reconstructed from existing bookings. Activity logging is observational: a feed-write failure never blocks a booking, payment, refund, cancellation, or calendar update.
+## Attendees and booking search
+
+Open `/admin` -> **Attendees** to see repeat-booking history grouped by normalized attendee email. Each attendee shows total bookings, upcoming/completed meetings, cancellations, reschedules, booked minutes, average duration, and (when Stripe payments are enabled) paid/refunded booking counts. Opening an attendee shows their recent booking history and links back into the normal booking detail view.
+
+The **Bookings** tab also has server-backed search. Search by the public `SLT-...` booking ID, internal booking UUID, attendee name, or attendee email; results can still be switched between **Upcoming** and **Past**. Search is not limited to the initially loaded booking list.
+
## Google project in Testing mode
If the Google OAuth audience is **External / Testing**, add the admin account as a test user. Google may expire testing-mode authorizations, so move the OAuth app to the appropriate production state when you are satisfied with it.
diff --git a/src/App.tsx b/src/App.tsx
index 875ab5b..3ba0fc8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,8 +17,10 @@ import {
MapPin,
MonitorUp,
Phone,
+ Search,
Settings2,
Sparkles,
+ Users,
Video,
} from 'lucide-react';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
@@ -164,6 +166,26 @@ type ActivityData = {
events: ActivityEvent[];
};
+type AttendeeRow = {
+ email: string;
+ name: string;
+ total_bookings: number;
+ upcoming: number;
+ completed: number;
+ cancelled: number;
+ reschedules: number;
+ paid_bookings: number;
+ refunded_bookings: number;
+ total_minutes: number;
+ average_minutes: number;
+ first_booked_at?: string | null;
+ last_booked_at?: string | null;
+};
+
+type AttendeeData = {
+ paymentsEnabled: boolean;
+ attendees: AttendeeRow[];
+};
type RescheduleProposal = {
title: string;
@@ -1277,8 +1299,17 @@ function Admin() {
const [availabilityBlocks, setAvailabilityBlocks] = useState<AvailabilityBlock[]>([]);
const [activity, setActivity] = useState<ActivityData | null>(null);
const [activityLoading, setActivityLoading] = useState(false);
- const [tab, setTab] = useState<'settings' | 'bookings' | 'activity'>('bookings');
+ const [attendeeData, setAttendeeData] = useState<AttendeeData | null>(null);
+ const [attendeesLoading, setAttendeesLoading] = useState(false);
+ const [attendeeSearch, setAttendeeSearch] = useState('');
+ const [selectedAttendee, setSelectedAttendee] = useState<AttendeeRow | null>(null);
+ const [attendeeBookings, setAttendeeBookings] = useState<BookingRow[]>([]);
+ const [attendeeBookingsLoading, setAttendeeBookingsLoading] = useState(false);
+ const [tab, setTab] = useState<'settings' | 'bookings' | 'activity' | 'attendees'>('bookings');
const [bookingFilter, setBookingFilter] = useState<'upcoming' | 'past'>('upcoming');
+ const [bookingSearch, setBookingSearch] = useState('');
+ const [bookingSearchResults, setBookingSearchResults] = useState<BookingRow[] | null>(null);
+ const [bookingSearchLoading, setBookingSearchLoading] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<BookingRow | null>(null);
const [cancelTarget, setCancelTarget] = useState<BookingRow | null>(null);
const [cancelling, setCancelling] = useState(false);
@@ -1344,6 +1375,32 @@ function Admin() {
void loadActivity();
}, [session?.authenticated, tab]);
+ useEffect(() => {
+ if (!session?.authenticated || tab !== 'attendees') return;
+ void loadAttendees();
+ }, [session?.authenticated, tab]);
+
+ useEffect(() => {
+ if (!session?.authenticated || tab !== 'bookings') return;
+ const query = bookingSearch.trim();
+ if (!query) {
+ setBookingSearchResults(null);
+ setBookingSearchLoading(false);
+ return;
+ }
+ let cancelled = false;
+ setError('');
+ setBookingSearchResults(null);
+ const timer = window.setTimeout(() => {
+ setBookingSearchLoading(true);
+ api<{ bookings: BookingRow[] }>(`/api/admin/bookings?q=${encodeURIComponent(query)}`)
+ .then((data) => { if (!cancelled) setBookingSearchResults(data.bookings); })
+ .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Could not search bookings.'); })
+ .finally(() => { if (!cancelled) setBookingSearchLoading(false); });
+ }, 180);
+ return () => { cancelled = true; window.clearTimeout(timer); };
+ }, [session?.authenticated, tab, bookingSearch]);
+
useEffect(() => {
if (!scheduleTarget || !scheduleDate) {
setScheduleSlots([]);
@@ -1421,14 +1478,51 @@ function Admin() {
}
}
+ async function loadAttendees() {
+ setAttendeesLoading(true);
+ setError('');
+ try {
+ setAttendeeData(await api<AttendeeData>('/api/admin/attendees'));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not load attendees.');
+ } finally {
+ setAttendeesLoading(false);
+ }
+ }
+
+ async function openAttendee(attendee: AttendeeRow) {
+ setSelectedAttendee(attendee);
+ setAttendeeBookings([]);
+ setAttendeeBookingsLoading(true);
+ setError('');
+ try {
+ const data = await api<{ bookings: BookingRow[] }>(`/api/admin/attendee-bookings?email=${encodeURIComponent(attendee.email)}`);
+ setAttendeeBookings(data.bookings);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not load attendee history.');
+ } finally {
+ setAttendeeBookingsLoading(false);
+ }
+ }
+
+ function openBookingFromAttendee(booking: BookingRow) {
+ setTab('bookings');
+ setBookingSearch(booking.public_booking_id || booking.id);
+ setSelectedBooking(booking);
+ }
+
async function refreshAdminData() {
- const [bookingData, availabilityData] = await Promise.all([
+ const query = bookingSearch.trim();
+ const [bookingData, availabilityData, searchedData] = await Promise.all([
api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
+ query ? api<{ bookings: BookingRow[] }>(`/api/admin/bookings?q=${encodeURIComponent(query)}`) : Promise.resolve(null),
]);
setBookings(bookingData.bookings);
+ if (searchedData) setBookingSearchResults(searchedData.bookings);
setRecurringUnavailable(availabilityData.recurring);
setAvailabilityBlocks(availabilityData.blocks);
+ if (attendeeData) void loadAttendees();
}
async function save() {
@@ -1556,9 +1650,14 @@ function Admin() {
const activeSettings = settings;
const now = Date.now();
- const upcoming = bookings.filter((b) => b.status !== 'cancelled' && Date.parse(b.end_time) >= now);
- const past = bookings.filter((b) => b.status === 'cancelled' || Date.parse(b.end_time) < now);
+ const bookingSource = bookingSearch.trim() ? (bookingSearchResults ?? []) : bookings;
+ const upcoming = bookingSource.filter((b) => b.status !== 'cancelled' && Date.parse(b.end_time) >= now);
+ const past = bookingSource.filter((b) => b.status === 'cancelled' || Date.parse(b.end_time) < now);
const shown = bookingFilter === 'upcoming' ? upcoming : past;
+ const attendeeNeedle = attendeeSearch.trim().toLowerCase();
+ const shownAttendees = (attendeeData?.attendees ?? []).filter((attendee) => !attendeeNeedle
+ || attendee.name.toLowerCase().includes(attendeeNeedle)
+ || attendee.email.toLowerCase().includes(attendeeNeedle));
return (
<main className="admin-page">
@@ -1574,6 +1673,7 @@ function Admin() {
<button className={tab === 'bookings' ? 'active' : ''} onClick={() => setTab('bookings')}><CalendarDays size={17} /> Bookings</button>
<button className={tab === 'settings' ? 'active' : ''} onClick={() => setTab('settings')}><Settings2 size={17} /> Settings</button>
<button className={tab === 'activity' ? 'active' : ''} onClick={() => setTab('activity')}><Activity size={17} /> Activity</button>
+ <button className={tab === 'attendees' ? 'active' : ''} onClick={() => setTab('attendees')}><Users size={17} /> Attendees</button>
<a href="/" target="_blank"><ExternalLink size={17} /> Open booking page</a>
</aside>
@@ -1900,6 +2000,71 @@ function Admin() {
<section className="activity-loading">Activity could not be loaded.</section>
)}
</>
+ ) : tab === 'attendees' ? (
+ <>
+ <div className="admin-heading">
+ <div><p className="eyebrow">ATTENDEES</p><h1>Who keeps booking.</h1></div>
+ <button className="secondary-button activity-refresh" type="button" onClick={loadAttendees} disabled={attendeesLoading}>
+ {attendeesLoading ? <><Loader2 className="spin" size={16} /> Refreshing…</> : <><Users size={16} /> Refresh</>}
+ </button>
+ </div>
+ {error && <div className="error-banner" role="alert">{error}</div>}
+ <label className="admin-search attendee-search">
+ <Search size={16} aria-hidden="true" />
+ <input value={attendeeSearch} onChange={(event) => setAttendeeSearch(event.target.value)} placeholder="Search attendee name or email" aria-label="Search attendees" />
+ </label>
+ {attendeesLoading && !attendeeData ? (
+ <section className="activity-loading"><Loader2 className="spin" size={20} /> Loading attendees…</section>
+ ) : attendeeData ? (
+ <>
+ <section className="attendee-list-card">
+ <div className="attendee-list-head">
+ <span>Attendee</span><span>Bookings</span><span>Completed</span><span>Cancelled</span><span>Reschedules</span><span>Last booked</span><span />
+ </div>
+ {shownAttendees.length === 0 ? (
+ <div className="empty-list activity-empty"><Users size={25} /><strong>No attendees found</strong><span>{attendeeSearch ? 'Try another name or email.' : 'Confirmed attendees will appear here after their first booking.'}</span></div>
+ ) : shownAttendees.map((attendee) => (
+ <article className="attendee-list-row" key={attendee.email.toLowerCase()}>
+ <div className="attendee-person"><span className="attendee-avatar" aria-hidden="true">{attendee.name.slice(0, 1).toUpperCase()}</span><div><strong>{attendee.name}</strong><span>{attendee.email}</span></div></div>
+ <strong>{attendee.total_bookings}</strong>
+ <span>{attendee.completed}</span>
+ <span>{attendee.cancelled}</span>
+ <span>{attendee.reschedules}</span>
+ <time>{attendee.last_booked_at ? formatInstant(databaseInstant(attendee.last_booked_at), activeSettings.timezone) : '—'}</time>
+ <button type="button" className="icon-button" aria-label={`Open ${attendee.name} attendee history`} onClick={() => void openAttendee(attendee)}><Inbox size={15} /></button>
+ </article>
+ ))}
+ </section>
+ {selectedAttendee && (
+ <section className="booking-detail-card attendee-detail-card">
+ <div className="card-heading">
+ <div><h2>{selectedAttendee.name}</h2><p>{selectedAttendee.email}</p></div>
+ <button type="button" className="text-button" onClick={() => setSelectedAttendee(null)}>Close</button>
+ </div>
+ <div className="attendee-metrics">
+ <article><span>Bookings</span><strong>{selectedAttendee.total_bookings}</strong></article>
+ <article><span>Upcoming</span><strong>{selectedAttendee.upcoming}</strong></article>
+ <article><span>Completed</span><strong>{selectedAttendee.completed}</strong></article>
+ <article><span>Cancelled</span><strong>{selectedAttendee.cancelled}</strong></article>
+ <article><span>Reschedules</span><strong>{selectedAttendee.reschedules}</strong></article>
+ <article><span>Booked time</span><strong>{selectedAttendee.total_minutes} min</strong></article>
+ <article><span>Average length</span><strong>{selectedAttendee.average_minutes} min</strong></article>
+ {attendeeData.paymentsEnabled && <article><span>Paid bookings</span><strong>{selectedAttendee.paid_bookings}</strong></article>}
+ {attendeeData.paymentsEnabled && <article><span>Refunded</span><strong>{selectedAttendee.refunded_bookings}</strong></article>}
+ </div>
+ <div className="attendee-history-heading"><div><p className="eyebrow">BOOKING HISTORY</p><h3>Recent meetings</h3></div><span>{attendeeBookings.length} shown</span></div>
+ {attendeeBookingsLoading ? (
+ <div className="activity-loading attendee-history-loading"><Loader2 className="spin" size={18} /> Loading booking history…</div>
+ ) : attendeeBookings.length ? (
+ <div className="attendee-booking-history">{attendeeBookings.map((booking) => <BookingRowItem key={booking.id} booking={booking} timezone={activeSettings.timezone} onOpen={openBookingFromAttendee} />)}</div>
+ ) : (
+ <div className="empty-list attendee-history-empty"><Inbox size={22} /><strong>No booking history</strong></div>
+ )}
+ </section>
+ )}
+ </>
+ ) : <section className="activity-loading">Attendees could not be loaded.</section>}
+ </>
) : (
<>
<div className="admin-heading"><div><p className="eyebrow">BOOKINGS</p><h1>Your meetings.</h1></div></div>
@@ -1923,12 +2088,25 @@ function Admin() {
Past <span className="count">{past.length}</span>
</button>
</div>
+ <label className="admin-search booking-search">
+ <Search size={16} aria-hidden="true" />
+ <input
+ value={bookingSearch}
+ onChange={(event) => setBookingSearch(event.target.value)}
+ placeholder="Search booking ID, name or email"
+ aria-label="Search bookings by booking ID, name or email"
+ autoComplete="off"
+ />
+ {bookingSearchLoading && <Loader2 className="spin admin-search-spinner" size={15} aria-label="Searching" />}
+ </label>
<section className="booking-list-card">
- {shown.length === 0 ? (
+ {bookingSearchLoading && bookingSearch.trim() && bookingSearchResults === null ? (
+ <div className="empty-list"><Loader2 className="spin" size={23} /><strong>Searching bookings…</strong></div>
+ ) : shown.length === 0 ? (
<div className="empty-list">
<Inbox size={26} />
- <strong>{bookingFilter === 'upcoming' ? 'No upcoming bookings' : 'No past bookings'}</strong>
- <span>{bookingFilter === 'upcoming' ? 'No Slot bookings are upcoming. Google Calendar busy time and availability rules can still make public times unavailable.' : 'Completed meetings will be listed here.'}</span>
+ <strong>{bookingSearch.trim() ? 'No matching bookings' : bookingFilter === 'upcoming' ? 'No upcoming bookings' : 'No past bookings'}</strong>
+ <span>{bookingSearch.trim() ? `No ${bookingFilter} booking matches “${bookingSearch.trim()}”.` : bookingFilter === 'upcoming' ? 'No Slot bookings are upcoming. Google Calendar busy time and availability rules can still make public times unavailable.' : 'Completed meetings will be listed here.'}</span>
</div>
) : (
shown.map((booking) => (
diff --git a/src/styles.css b/src/styles.css
index b0d1505..c5b4734 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1768,3 +1768,154 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
font-size: 11px;
letter-spacing: .015em;
}
+
+/* Admin search + attendees */
+.admin-search {
+ width: min(100%, 520px);
+ min-height: 42px;
+ margin: 0 0 16px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 0 12px;
+ color: var(--faint);
+}
+.admin-search:focus-within { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
+.admin-search input {
+ min-width: 0;
+ flex: 1;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--text);
+ font-size: 12px;
+ padding: 10px 0;
+}
+.admin-search input::placeholder { color: var(--faint); }
+.admin-search-spinner { flex: 0 0 auto; }
+.attendee-search { width: min(100%, 560px); }
+
+.attendee-list-card {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--surface);
+ overflow: hidden;
+}
+.attendee-list-head,
+.attendee-list-row {
+ display: grid;
+ grid-template-columns: minmax(220px, 1.55fr) 78px 78px 78px 88px minmax(145px, .9fr) 34px;
+ gap: 12px;
+ align-items: center;
+}
+.attendee-list-head {
+ padding: 10px 17px;
+ border-bottom: 1px solid var(--border);
+ color: var(--faint);
+ background: var(--surface-soft);
+ font-size: 9px;
+ font-weight: 760;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+}
+.attendee-list-row {
+ min-height: 66px;
+ padding: 12px 17px;
+ border-top: 1px solid var(--border);
+ color: var(--muted);
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+}
+.attendee-list-row:first-of-type { border-top: 0; }
+.attendee-list-row:hover { background: var(--surface-soft); }
+.attendee-list-row > strong { color: var(--text); font-size: 13px; }
+.attendee-list-row > time { color: var(--faint); font-size: 9px; white-space: nowrap; }
+.attendee-person { min-width: 0; display: flex; align-items: center; gap: 11px; }
+.attendee-avatar {
+ width: 36px;
+ height: 36px;
+ flex: 0 0 36px;
+ display: grid;
+ place-items: center;
+ border-radius: var(--radius-xs);
+ background: var(--surface-strong);
+ color: var(--text);
+ font-size: 13px;
+ font-weight: 780;
+}
+.attendee-person > div { min-width: 0; display: grid; gap: 3px; }
+.attendee-person strong,
+.attendee-person span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.attendee-person strong { color: var(--text); font-size: 12px; }
+.attendee-person span { color: var(--faint); font-size: 10px; }
+.attendee-detail-card { margin-top: 16px; }
+.attendee-metrics {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 8px;
+ margin-bottom: 22px;
+}
+.attendee-metrics article {
+ min-width: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface-soft);
+ padding: 12px;
+ display: grid;
+ gap: 7px;
+}
+.attendee-metrics span { color: var(--faint); font-size: 9px; text-transform: uppercase; letter-spacing: .05em; font-weight: 730; }
+.attendee-metrics strong { color: var(--text); font-size: 18px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.attendee-history-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 15px 0 11px;
+ border-top: 1px solid var(--border);
+}
+.attendee-history-heading h3 { font-size: 16px; margin-top: 3px; }
+.attendee-history-heading > span { color: var(--faint); font-size: 9px; }
+.attendee-booking-history { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
+.attendee-history-loading { min-height: 110px; border-radius: var(--radius-md); }
+.attendee-history-empty { min-height: 110px; border: 1px solid var(--border); border-radius: var(--radius-md); }
+
+@media (max-width: 1180px) {
+ .attendee-list-head,
+ .attendee-list-row { grid-template-columns: minmax(210px, 1.5fr) 70px 70px 70px minmax(135px, .9fr) 34px; }
+ .attendee-list-head > :nth-child(5),
+ .attendee-list-row > :nth-child(5) { display: none; }
+ .attendee-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+@media (max-width: 760px) {
+ .admin-search { width: 100%; }
+ .attendee-list-head { display: none; }
+ .attendee-list-row {
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ grid-template-areas:
+ "person bookings action"
+ "person cancelled action"
+ "last last last";
+ gap: 5px 12px;
+ padding: 14px;
+ }
+ .attendee-list-row > :nth-child(2) { grid-area: bookings; }
+ .attendee-list-row > :nth-child(2)::before { content: 'Bookings '; color: var(--faint); font-size: 8px; font-weight: 600; }
+ .attendee-list-row > :nth-child(3),
+ .attendee-list-row > :nth-child(5) { display: none; }
+ .attendee-list-row > :nth-child(4) { grid-area: cancelled; }
+ .attendee-list-row > :nth-child(4)::before { content: 'Cancelled '; color: var(--faint); font-size: 8px; }
+ .attendee-list-row > time { grid-area: last; padding-left: 47px; }
+ .attendee-list-row > button { grid-area: action; }
+ .attendee-person { grid-area: person; }
+ .attendee-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .attendee-history-heading { align-items: flex-start; flex-direction: column; }
+}
+
+@media (max-width: 480px) {
+ .attendee-metrics { grid-template-columns: 1fr 1fr; }
+}
diff --git a/worker/index.ts b/worker/index.ts
index 8d46757..df1dbbb 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -593,6 +593,129 @@ async function adminActivity(env: Env): Promise<Response> {
});
}
+async function adminAttendees(env: Env): Promise<Response> {
+ const settings = await getSettings(env);
+ await cleanupStalePendingBookings(env, settings);
+
+ const result = await env.DB.prepare(`
+ WITH facts AS (
+ SELECT
+ lower(trim(email)) AS email_key,
+ status,
+ start_time,
+ end_time,
+ duration_minutes,
+ payment_status,
+ paid_at,
+ refund_status,
+ refunded_at,
+ reschedule_count,
+ created_at,
+ CASE
+ WHEN status = 'confirmed' THEN 1
+ WHEN status = 'cancelled' AND (payment_status <> 'cancelled' OR paid_at IS NOT NULL) THEN 1
+ ELSE 0
+ END AS is_booking
+ FROM bookings
+ WHERE trim(email) <> ''
+ )
+ SELECT
+ f.email_key,
+ (
+ SELECT b2.email FROM bookings b2
+ WHERE lower(trim(b2.email)) = f.email_key
+ AND (b2.status = 'confirmed' OR (b2.status = 'cancelled' AND (b2.payment_status <> 'cancelled' OR b2.paid_at IS NOT NULL)))
+ ORDER BY datetime(b2.created_at) DESC, b2.rowid DESC
+ LIMIT 1
+ ) AS email,
+ (
+ SELECT b2.name FROM bookings b2
+ WHERE lower(trim(b2.email)) = f.email_key
+ AND (b2.status = 'confirmed' OR (b2.status = 'cancelled' AND (b2.payment_status <> 'cancelled' OR b2.paid_at IS NOT NULL)))
+ ORDER BY datetime(b2.created_at) DESC, b2.rowid DESC
+ LIMIT 1
+ ) AS name,
+ COALESCE(SUM(f.is_booking), 0) AS total_bookings,
+ COALESCE(SUM(CASE WHEN f.status = 'confirmed' AND julianday(f.end_time) >= julianday('now') THEN 1 ELSE 0 END), 0) AS upcoming,
+ COALESCE(SUM(CASE WHEN f.status = 'confirmed' AND julianday(f.end_time) < julianday('now') THEN 1 ELSE 0 END), 0) AS completed,
+ COALESCE(SUM(CASE WHEN f.status = 'cancelled' AND f.is_booking = 1 THEN 1 ELSE 0 END), 0) AS cancelled,
+ COALESCE(SUM(CASE WHEN f.is_booking = 1 THEN f.reschedule_count ELSE 0 END), 0) AS reschedules,
+ COALESCE(SUM(CASE WHEN f.is_booking = 1 AND f.paid_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS paid_bookings,
+ COALESCE(SUM(CASE WHEN f.is_booking = 1 AND (f.refunded_at IS NOT NULL OR f.refund_status = 'succeeded') THEN 1 ELSE 0 END), 0) AS refunded_bookings,
+ COALESCE(SUM(CASE WHEN f.is_booking = 1 THEN f.duration_minutes ELSE 0 END), 0) AS total_minutes,
+ COALESCE(ROUND(AVG(CASE WHEN f.is_booking = 1 THEN f.duration_minutes END), 1), 0) AS average_minutes,
+ MIN(CASE WHEN f.is_booking = 1 THEN f.created_at END) AS first_booked_at,
+ MAX(CASE WHEN f.is_booking = 1 THEN f.created_at END) AS last_booked_at
+ FROM facts f
+ GROUP BY f.email_key
+ HAVING SUM(f.is_booking) > 0
+ ORDER BY MAX(CASE WHEN f.is_booking = 1 THEN datetime(f.created_at) END) DESC
+ LIMIT 500
+ `).all<{
+ email_key: string;
+ email: string;
+ name: string;
+ total_bookings: number;
+ upcoming: number;
+ completed: number;
+ cancelled: number;
+ reschedules: number;
+ paid_bookings: number;
+ refunded_bookings: number;
+ total_minutes: number;
+ average_minutes: number;
+ first_booked_at: string | null;
+ last_booked_at: string | null;
+ }>();
+
+ return json({
+ paymentsEnabled: settings.paymentEnabled,
+ attendees: (result.results ?? []).map((row) => ({
+ email: row.email,
+ name: row.name,
+ total_bookings: Number(row.total_bookings ?? 0),
+ upcoming: Number(row.upcoming ?? 0),
+ completed: Number(row.completed ?? 0),
+ cancelled: Number(row.cancelled ?? 0),
+ reschedules: Number(row.reschedules ?? 0),
+ paid_bookings: Number(row.paid_bookings ?? 0),
+ refunded_bookings: Number(row.refunded_bookings ?? 0),
+ total_minutes: Number(row.total_minutes ?? 0),
+ average_minutes: Number(row.average_minutes ?? 0),
+ first_booked_at: row.first_booked_at,
+ last_booked_at: row.last_booked_at,
+ })),
+ });
+}
+
+async function adminAttendeeBookings(env: Env, email: string): Promise<Response> {
+ const normalizedEmail = email.trim().toLowerCase();
+ if (!normalizedEmail || normalizedEmail.length > 320 || !normalizedEmail.includes('@')) {
+ return json({ error: 'Invalid attendee email.' }, 400);
+ }
+
+ const result = await env.DB.prepare(`
+ SELECT id, public_booking_id, name, email, start_time, end_time, duration_minutes,
+ meeting_mode, status, meet_url, created_at,
+ cancelled_at, calendar_provider, calendar_sync_status,
+ calendar_sync_error, calendar_sync_updated_at,
+ payment_status, payment_amount, payment_currency,
+ stripe_checkout_session_id, stripe_payment_intent_id, paid_at,
+ rescheduled_at, reschedule_count, proposed_start_time, proposed_end_time,
+ proposed_at, proposal_status, refund_status, stripe_refund_id,
+ refunded_amount, refunded_at
+ FROM bookings
+ WHERE lower(trim(email)) = ?1
+ AND (
+ status = 'confirmed'
+ OR (status = 'cancelled' AND (payment_status <> 'cancelled' OR paid_at IS NOT NULL))
+ )
+ ORDER BY datetime(created_at) DESC, start_time DESC
+ LIMIT 100
+ `).bind(normalizedEmail).all();
+ return json({ bookings: result.results ?? [] });
+}
+
async function paymentBookingById(env: Env, bookingId: string): Promise<PaymentBookingRow | null> {
return env.DB.prepare(`
SELECT id, public_booking_id, name, email, phone, message, start_time, end_time, duration_minutes,
@@ -1895,10 +2018,19 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
return adminActivity(env);
}
+ if (request.method === 'GET' && url.pathname === '/api/admin/attendees') {
+ return adminAttendees(env);
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/admin/attendee-bookings') {
+ return adminAttendeeBookings(env, url.searchParams.get('email') ?? '');
+ }
+
if (request.method === 'GET' && url.pathname === '/api/admin/bookings') {
const settings = await getSettings(env);
await cleanupStalePendingBookings(env, settings);
- const result = await env.DB.prepare(`
+ const query = (url.searchParams.get('q') ?? '').trim().toLowerCase();
+ const select = `
SELECT id, public_booking_id, name, email, start_time, end_time, duration_minutes,
meeting_mode, status, meet_url, created_at,
cancelled_at, calendar_provider, calendar_sync_status,
@@ -1909,9 +2041,20 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
proposed_at, proposal_status, refund_status, stripe_refund_id,
refunded_amount, refunded_at
FROM bookings
- ORDER BY start_time DESC
- LIMIT 50
- `).all();
+ `;
+ const result = query
+ ? await env.DB.prepare(`${select}
+ WHERE lower(COALESCE(public_booking_id, '')) LIKE ?1
+ OR lower(id) LIKE ?1
+ OR lower(name) LIKE ?1
+ OR lower(email) LIKE ?1
+ ORDER BY start_time DESC
+ LIMIT 100
+ `).bind(`%${query}%`).all()
+ : await env.DB.prepare(`${select}
+ ORDER BY start_time DESC
+ LIMIT 100
+ `).all();
return json({ bookings: result.results ?? [] });
}