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

sequenceDiagram participant P as Parent (browser) participant API as POST /api/enquiry participant DB as D1 Database participant Email as Resend

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

FieldRequirement
enquirer_email (or email)Valid email address
athlete_dob (or dob)YYYY-MM-DD, plausible age 4–100
athlete_nameRequired 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 string
  • contact_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.

flowchart LR A[DOB + Session Date] --> B[athleticsSeasonEndYear\nseason end = next Aug if month≥Sep] B --> C[ageOnDate\nage on 31 Aug of that year] C --> D{Match active age_group\nwhere age_min ≤ age ≤ age_max} D -->|Found| E[Return AgeGroupConfig] D -->|None match| F[Return null → taster default]

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) → taster
  • booking_type = 'taster' → booking invite path
  • booking_type = 'waitlist' → academy waitlist path

Taster path: booking invite

  1. A new row is created in invites with status = 'pending' and a 48-char random hex token
  2. sendBookingInvite() renders the booking_invite email template with the booking URL and a list of upcoming Tuesday dates
  3. On success: invite marked sent, event booking_invite_sent appended, enquiry processed = 1
  4. On failure: invite stays pending for the retry cron to pick up

Academy path: waitlist entry

  1. A new row is created in academy_waitlist with the next available position in the current open season (if any)
  2. sendAcademyWaitlist() renders the academy_waitlist email template with a unique response URL
  3. On success: sent_at is recorded, event academy_waitlist_added appended, enquiry processed = 1

Error responses

StatusCodeCause
400INVALID_JSONBody could not be parsed
405Non-POST method
422VALIDATION_ERRORMissing 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 429 with a Retry-After header
  • 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.