Academy Waitlist

The Junior Academy is a structured programme with limited seasonal places. Athletes whose age group has booking_type = 'waitlist' are placed on the Academy waitlist rather than sent a taster booking invite. Admission is managed by admins who invite athletes from the waitlist; parents respond via a unique link in their email.

Season model

Each Academy season has a start date, end date, and capacity. Seasons progress through four statuses:

stateDiagram-v2 [*] --> upcoming : Season created upcoming --> open : Admin opens season open --> closed : Season rollover cron\n(end_date passed) closed --> archived : Admin archival

Typically one season runs per year, approximately April to August. Multiple seasons can exist simultaneously for different age groups (each academy_season links to one age_group_id).

Waitlist entry lifecycle

stateDiagram-v2 [*] --> waiting : Enquiry routed to Academy\nwaitlist entry created waiting --> invited : Admin sends invitation invited --> accepted : Parent responds "yes"\nvia /academy/respond/:token invited --> declined : Parent responds "no" waiting --> waiting : Season rollover\n(entry moved to next season) waiting --> ineligible : Admin marks ineligible waiting --> withdrawn : Parent or admin withdraws accepted --> [*] declined --> [*] ineligible --> [*] withdrawn --> [*]

Joining the waitlist

When an enquiry is routed to the Academy path (booking_type = 'waitlist'):

  1. A row is inserted in academy_waitlist linked to the current open season (if one exists)
  2. position is set to the next sequential number within the season (FCFS ordering)
  3. The academy_waitlist email template is sent with a response URL: /academy/respond/:token
  4. The enquiry event academy_waitlist_added is appended
sequenceDiagram participant E as Enquiry handler participant DB as D1 participant Email as Resend

E->>DB: getOpenSeason(age_group_id) E->>DB: INSERT academy_waitlist\n(season_id, position=next, status=waiting) E->>Email: sendAcademyWaitlist(enquiry, entry) Email—>>E: { success: true } E->>DB: UPDATE waitlist SET sent_at=now E->>DB: Append event: academy_waitlist_added

Parent response

When an admin is ready to offer places, they mark selected waitlist entries as invited. Parents receive the response email containing a unique link to /academy/respond/:token.

The POST /api/academy/respond endpoint:

  1. Validates the token exists and the entry hasn’t already been responded to
  2. Records the response (yes or no) and sets responded_at
  3. Updates status to accepted or declined
  4. Appends academy_response_received event to the enquiry
  5. Returns a confirmation message

The endpoint is idempotent — if a parent clicks the link again after responding, it returns their previous response without error.

// Idempotency check
if (invitation.status === 'accepted' || invitation.status === 'declined') {
  return ok({
    message: `Your response (${invitation.response}) has already been recorded.`,
    already_responded: true,
  });
}

Season rollover cron

POST /api/cron/academy-rollover runs on the 1st of each month at 09:00 UTC.

It finds all seasons with status = 'open' that have passed their end_date, then for each:

  1. Collects all waiting and invited (unplaced) entries
  2. For each unplaced entry, checks whether the athlete was accepted in any prior season (is_returning)
  3. Creates a new waitlist entry in the next upcoming season (or with season_id = null if no upcoming season exists yet)
  4. Sets the old season status to closed
flowchart TD A[Cron trigger: 1st of month] --> B[List seasons\nstatus=open, end_date < today] B --> C{Any expired\nopen seasons?} C -->|No| END[Return 0 rolled, 0 closed] C -->|Yes| D[getUpcomingAcademySeason]

D —> E[For each expired season] E —> F[List unplaced entries\nstatus = waiting or invited] F —> G[For each unplaced entry] G —> H{hasAcceptedEntry\nfor this enquiry?} H —>|Yes| I[is_returning = 1] H —>|No| J[is_returning = 0] I —> K[rolloverWaitlistEntry\ninto next season] J —> K K —> L{More entries?} L —>|Yes| G L —>|No| M[updateSeasonStatus → closed] M —> N{More seasons?} N —>|Yes| E N —>|No| END2[Return totals]

The rollover is idempotent: it only processes seasons where status = 'open' and end_date < today. Running it multiple times on the same day has no effect after the first run because the season status is set to closed.

Returning athlete priority

The is_returning flag on a waitlist entry indicates the athlete attended the club in a prior season. This field is available to admin filtering and reporting, allowing the club to prioritise returning athletes when allocating limited places.

Admin management

The admin panel at /admin/academy shows:

  • Current season status and capacity
  • Waitlist with position, athlete name, DOB, status, and is_returning flag
  • Controls to invite, mark ineligible, or withdraw entries

The GET /api/admin/academy endpoint supports filtering by season_id, status, and search (name/email).