Enquiry Flow
The enquiry endpoint (POST /api/enquiry) is the entry point for every new athlete. It accepts form submissions from the public website, resolves the correct age group, routes the enquiry, and dispatches the first email — all within a single request.
End-to-end flow
P->>API: POST with name, email, dob, phone API->>API: Parse body (JSON or form-data) API->>API: Validate fields API->>DB: INSERT enquiry (processed=0) API->>DB: SELECT age_groups WHERE active=1 API->>API: resolveAgeGroup(dob, today, groups) API->>DB: UPDATE enquiry SET age_group_id=…
alt booking_type = taster API->>DB: INSERT invite (status=pending) API->>Email: sendBookingInvite() API->>DB: UPDATE invite SET status=sent API->>DB: Append event: booking_invite_sent API->>DB: UPDATE enquiry SET processed=1 else booking_type = waitlist API->>DB: INSERT academy_waitlist entry API->>Email: sendAcademyWaitlist() API->>DB: UPDATE waitlist entry sent_at API->>DB: Append event: academy_waitlist_added API->>DB: UPDATE enquiry SET processed=1 end
API—>>P: 201 { message: “Enquiry received” }
Request format
The endpoint accepts both JSON and HTML form submissions (application/x-www-form-urlencoded and multipart/form-data).
JSON body
{
"enquiry_for": "other",
"enquirer_name": "Jane Smith",
"enquirer_email": "jane@example.com",
"enquirer_phone": "07700 900000",
"athlete_name": "Tom Smith",
"athlete_dob": "2015-04-12",
"source": "website"
}
Legacy flat body (also accepted)
{
"name": "Jane Smith",
"email": "jane@example.com",
"dob": "2015-04-12"
}
Both formats are normalised to the same internal representation before validation.
Required fields
| Field | Requirement |
|---|---|
enquirer_email (or email) | Valid email address |
athlete_dob (or dob) | YYYY-MM-DD, plausible age 4–100 |
athlete_name | Required only when enquiry_for = "other" |
Validation rules (validateEnquiryData)
Implemented in src/lib/business/enquiry.ts. Throws descriptive errors on failure — never silently swallows bad data.
contact_name: must be a non-empty stringcontact_email: must match/^[^\s@]+@[^\s@]+\.[^\s@]+$/dob: must parse to a valid date; age must be between 4 and 100 years
All validation errors return 422 VALIDATION_ERROR with a human-readable message field.
Age group resolution
After the enquiry is saved, the platform resolves which age group the athlete belongs to:
const allGroups = await listAgeGroups(env);
const ageGroup = resolveAgeGroup(enquiry.dob, today, allGroups);
resolveAgeGroup computes the athlete’s UK Athletics age — their age on 31 August of the athletics season that contains today’s date — and finds the matching active age group by age_min_aug31/age_max_aug31 range.
Example:
- Session date: 14 October 2026 → season ends August 2027
- Reference date: 31 August 2027
- DOB: 12 April 2015 → age on ref date = 12 → U13 group
Routing decision
export function routeEnquiry(ageGroup: AgeGroupConfig | null): 'taster' | 'waitlist' {
if (!ageGroup) return 'taster';
return ageGroup.booking_type;
}
null(no DOB, or DOB out of all age group ranges) →tasterbooking_type = 'taster'→ booking invite pathbooking_type = 'waitlist'→ academy waitlist path
Taster path: booking invite
- A new row is created in
inviteswithstatus = 'pending'and a 48-char random hextoken sendBookingInvite()renders thebooking_inviteemail template with the booking URL and a list of upcoming Tuesday dates- On success: invite marked
sent, eventbooking_invite_sentappended, enquiryprocessed = 1 - On failure: invite stays
pendingfor the retry cron to pick up
Academy path: waitlist entry
- A new row is created in
academy_waitlistwith the next availablepositionin the current open season (if any) sendAcademyWaitlist()renders theacademy_waitlistemail template with a unique response URL- On success:
sent_atis recorded, eventacademy_waitlist_addedappended, enquiryprocessed = 1
Error responses
| Status | Code | Cause |
|---|---|---|
400 | INVALID_JSON | Body could not be parsed |
405 | — | Non-POST method |
422 | VALIDATION_ERROR | Missing or invalid field |
Email send failures do not affect the HTTP response. The parent always receives a 201. Failures are logged to the structured console for operator review.
Rate limiting
Public API routes including /api/enquiry are protected by KV-backed rate limiting applied in src/middleware/index.ts:
- 10 requests per minute per IP (minute-bucket key in KV)
- Exceeding the limit returns
429with aRetry-Afterheader - Rate limit failures are non-fatal and never block legitimate requests
CORS
The endpoint returns Access-Control-Allow-Origin: * headers to allow embedding in third-party websites. CORS preflight (OPTIONS) requests are handled before any validation or processing.