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

sequenceDiagram participant P as Parent (browser) participant Page as /book/:token (Astro) participant API as POST /api/booking participant DB as D1 Database participant Email as Resend

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.

flowchart TD A[POST /api/booking] --> B{Token present?} B -->|No| Z1[422 MISSING_TOKEN] B -->|Yes| C{Invite found?} C -->|No| Z2[404 INVITE_NOT_FOUND] C -->|Yes| D{Invite status} D -->|accepted| Z3[409 INVITE_USED] D -->|expired| Z4[410 INVITE_EXPIRED] D -->|sent or pending| E{TTL expired?\n14 days} E -->|Yes| Z4 E -->|No| F{Age group\nresolved?} F -->|No| Z5[422 NO_AGE_GROUP] F -->|Yes| G{Date valid for\nage group session day?} G -->|No| Z6[422 SLOT_INVALID] G -->|Yes| H{Already booked\nthis date?} H -->|Yes| Z7[409 DUPLICATE_BOOKING] H -->|No| I{Session full?\ncapacity check} I -->|Yes| Z8[409 SLOT_FULL] I -->|No| J[INSERT booking] J --> K[markInviteAccepted] K --> L[Send confirmation email] L --> M[200 OK]

Session date validation

validateSessionDate() in src/lib/business/age.ts checks three things:

  1. The date string parses to a valid date
  2. The date falls on a valid session day for the age group (e.g. Tuesday for taster groups)
  3. 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:

  1. Loads the invite by token — shows an error if not found or expired
  2. Displays a list of upcoming valid session dates (up to weeks_ahead_booking weeks ahead)
  3. On date selection, POSTs to /api/booking with the token and date
  4. 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.status to attended or no_show
  • Appends attendance_recorded event to the enquiry
  • If attended: triggers a membership invite (creates membership_otp, sends membership_invite email)