Taster Booking Flow
Once a parent has been sent a booking invite email, they follow the link to /book/:token where they select a session date and confirm their booking. The booking API (POST /api/booking) validates the request against several business rules before creating the booking.
End-to-end flow
P->>Page: Open invite link Page->>DB: GET invite by token Page—>>P: Show available session dates
P->>API: POST { token, date } API->>DB: getInviteByToken(token) API->>API: Check invite status & TTL (14 days) API->>DB: getEnquiryById(invite.enquiry_id) API->>DB: listAgeGroups() API->>API: resolveAgeGroup(dob, date, tasterGroups) API->>API: validateBookingRequest(date, dob, ageGroup) API->>DB: getBookingByInviteAndDate() — duplicate check API->>DB: countBookingsForDateAndGroup() — capacity check API->>DB: INSERT booking (status=confirmed) API->>DB: markInviteAccepted() API->>DB: Append event: booking_created API->>Email: sendBookingConfirmation() API—>>P: 200 { booking_id, session_date, … }
Validation chain
The booking handler runs seven sequential checks before creating a booking. Each returns an appropriate error code on failure.
Session date validation
validateSessionDate() in src/lib/business/age.ts checks three things:
- The date string parses to a valid date
- The date falls on a valid session day for the age group (e.g. Tuesday for taster groups)
- The date is strictly in the future (not today)
Session days are stored as a JSON array in age_groups.session_days (e.g. ["Tuesday"]). This means session days are fully configurable without code changes.
Age eligibility check
If the enquiry has a DOB, the system verifies the athlete is actually in the correct age group for the chosen date:
function isAgeGroupEligible(dob, sessionDate, ageGroup): boolean {
const athleticsAge = ageForAthletics(dob, sessionDate);
return athleticsAge >= ageGroup.age_min_aug31
&& athleticsAge <= ageGroup.age_max_aug31;
}
The athletics age uses the UK standard: age on 31 August of the season the session falls in.
Capacity management
Capacity is checked against the age_groups.capacity_per_session value:
const capacity = ageGroup.capacity_per_session ?? parseInt(config.capacity_per_slot, 10);
const currentCount = await countBookingsForDateAndGroup(date, ageGroup.id, env);
if (currentCount >= capacity) {
return err('This session is full', 409, 'SLOT_FULL');
}
The count query only includes bookings with status IN ('confirmed', 'attended') — cancelled and no-show bookings free up their slots.
Admin override
Admins can pass an optional age_group_id in the booking request to override the DOB-based resolution. This is used when a parent needs to be placed in a different group or when DOB is missing.
Confirmation email
After a successful booking, a confirmation email is sent using the booking_confirmation template. It includes:
- Formatted session date (e.g. “Tuesday, 14 October 2026”)
- Session time
- Age group label
- Venue name and address
- Google Calendar deep-link — pre-filled with title, date/time, location, and description
The calendar URL is built by buildGoogleCalendarUrl() in src/lib/business/booking.ts:
const params = new URLSearchParams({
action: 'TEMPLATE',
text: `EGAC Taster Session — ${ageGroup.label}`,
dates: `${startUtc}/${endUtc}`, // ISO format without separators
location: venueName,
details: `EGAC taster session for ${ageGroup.label}`,
});
return `https://calendar.google.com/calendar/render?${params}`;
Booking page (/book/:token)
The Astro page at src/pages/book/[token].astro handles the client-side UX:
- Loads the invite by token — shows an error if not found or expired
- Displays a list of upcoming valid session dates (up to
weeks_ahead_bookingweeks ahead) - On date selection, POSTs to
/api/bookingwith the token and date - Shows the confirmation details on success
7-day reminder
A cron job (POST /api/cron/send-reminders, runs Tuesdays at 18:00 UTC) sends a reminder email to all parents with confirmed bookings exactly 7 days from the run date. It checks enquiry.events for a prior reminder_sent event tied to the same booking_id before sending, making it fully idempotent.
Attendance recording
After a session, admins can record attendance via PATCH /api/admin/bookings/:id/attendance:
- Updates
booking.statustoattendedorno_show - Appends
attendance_recordedevent to the enquiry - If
attended: triggers a membership invite (createsmembership_otp, sendsmembership_inviteemail)