Data Model

All persistent data lives in Cloudflare D1 (SQLite). The schema is managed through numbered SQL migrations in db/migrations/. System configuration is stored separately in Cloudflare KV to minimise D1 request consumption.

Entity relationship diagram

erDiagram enquiries { TEXT id PK TEXT email TEXT name TEXT dob TEXT phone TEXT source INTEGER processed TEXT age_group_id TEXT events TEXT invite_id TEXT note TEXT raw_payload TEXT environment TEXT created_at } invites { TEXT id PK TEXT token UK TEXT enquiry_id TEXT status INTEGER send_attempts TEXT sent_at TEXT accepted_at TEXT environment TEXT created_at } age_groups { TEXT id PK TEXT code UK TEXT label TEXT booking_type INTEGER age_min_aug31 INTEGER age_max_aug31 TEXT session_days TEXT session_time INTEGER capacity_per_session INTEGER season_capacity INTEGER season_start_month INTEGER season_end_month INTEGER active INTEGER sort_order } bookings { TEXT id PK TEXT enquiry_id TEXT invite_id TEXT age_group_id TEXT session_date TEXT session_time TEXT status TEXT attendance_note TEXT environment TEXT created_at } academy_seasons { TEXT id PK TEXT code UK TEXT label TEXT start_date TEXT end_date INTEGER capacity TEXT status TEXT age_group_id } academy_waitlist { TEXT id PK TEXT enquiry_id TEXT season_id TEXT token UK INTEGER position TEXT status INTEGER is_returning TEXT rolled_over_from_season_id TEXT response INTEGER send_attempts } email_templates { TEXT id PK TEXT key UK TEXT name TEXT subject TEXT html TEXT text_content TEXT variables INTEGER active } membership_otps { TEXT id PK TEXT enquiry_id TEXT token UK TEXT used_at TEXT environment } membership_form_submissions { TEXT id PK TEXT enquiry_id TEXT membership_otp_id TEXT payload_json TEXT submitted_at } enquiries ||--o{ invites : has enquiries ||--o{ bookings : has enquiries ||--o{ academy_waitlist : has enquiries ||--o{ membership_otps : has enquiries ||--o{ membership_form_submissions : has invites ||--o{ bookings : uses age_groups ||--o{ bookings : categorises age_groups ||--o{ enquiries : assigned-to age_groups ||--o{ academy_seasons : linked-to academy_seasons ||--o{ academy_waitlist : contains membership_otps ||--o{ membership_form_submissions : validates

Table reference

enquiries

The central table. Every inbound request creates exactly one row. All downstream activity (invites, bookings, waitlist entries, memberships) links back here.

ColumnTypeNotes
idTEXT PKPrefixed ID: enq_...
emailTEXT NOT NULLEnquirer (parent) email
nameTEXTParent name
dobTEXTAthlete date of birth (YYYY-MM-DD)
phoneTEXTOptional contact phone
sourceTEXTOrigin: website, form, etc.
processedINTEGER0 = pending, 1 = processed
age_group_idTEXT FKResolved from DOB at enquiry time
invite_idTEXT FKThe active taster invite (denormalised)
eventsTEXTJSON array of EnquiryEvent objects
noteTEXTAdmin notes or “enquiring about: {name}“
raw_payloadTEXTOriginal POST body as JSON
environmentTEXTdevelopment / staging / production

The events column is an append-only log stored as JSON. Every meaningful state change appends a typed event:

type EnquiryEventType =
  | 'academy_waitlist_added'
  | 'academy_response_received'
  | 'booking_invite_sent'
  | 'booking_created'
  | 'invite_expired'
  | 'invite_resent'
  | 'attendance_recorded'
  | 'membership_invite_sent'
  | 'membership_form_submitted'
  | 'reminder_sent';

invites

Taster-path only — not used for the Academy. One invite per enquiry (though an enquiry can have its invite resent, tracked via send_attempts).

ColumnTypeNotes
tokenTEXT UNIQUE48-char hex string, used in the booking URL
statusTEXTpendingsentaccepted / expired
send_attemptsINTEGERIncremented by the retry cron
last_send_errorTEXTLast Resend API error message

age_groups

The single source of truth for all age-group configuration. No age group data is hardcoded in application logic.

ColumnTypeNotes
booking_typeTEXTtaster or waitlist — drives enquiry routing
age_min_aug31INTEGERMin age on 31 Aug of the athletics season
age_max_aug31INTEGERMax age on 31 Aug of the athletics season
session_daysTEXTJSON array e.g. ["Tuesday"] or ["Saturday","Tuesday"]
capacity_per_sessionINTEGERMax bookings per session date
season_capacityINTEGERAcademy: total season places
season_start_monthINTEGERnull = year-round
activeINTEGER1 = included in routing; 0 = ignored

bookings

Taster sessions only. Created when a parent selects a date via the /book/:token page.

ColumnTypeNotes
session_dateTEXTYYYY-MM-DD
statusTEXTconfirmedattended / no_show / cancelled
attendance_noteTEXTFree-text note from admin when recording attendance
age_group_idTEXT FKDenormalised at creation time for reporting stability

Unique index on (invite_id, session_date) prevents duplicate bookings per invite per date.

academy_seasons

One row per Academy season, typically running April – August.

ColumnTypeNotes
statusTEXTupcomingopenclosedarchived
capacityINTEGERTotal places in the season
start_date / end_dateTEXTYYYY-MM-DD; used by the rollover cron

academy_waitlist

One row per enquiry per season they are considered for. An athlete can appear across multiple seasons via the season rollover cron.

ColumnTypeNotes
tokenTEXT UNIQUEUnique per entry; used in /academy/respond/:token
positionINTEGERFCFS order within a season (lower = earlier)
statusTEXTwaitinginvitedaccepted / declined
is_returningINTEGER1 if athlete was accepted in any prior season
rolled_over_from_season_idTEXTSet when created via the rollover cron
responseTEXTyes / no — recorded when parent responds

email_templates

All email content is admin-editable in D1. Templates use {{variable}} syntax rendered by renderTemplate().

ColumnTypeNotes
keyTEXT UNIQUEOne of 6 fixed keys (see Email Templates doc)
htmlTEXTFull HTML body with {{variable}} placeholders
text_contentTEXTPlain-text fallback
variablesTEXTJSON array of variable names — used by admin preview UI
activeINTEGER0 = template disabled (send will fail safely)

membership_otps

One-time tokens sent to parents who attended a taster and have been invited to join the club.

ColumnTypeNotes
tokenTEXT UNIQUE48-char hex; used as query param in the membership URL
used_atTEXTSet when the membership form is submitted

Cloudflare KV — system configuration

Config is stored as a single JSON blob under the key config:system with a 5-minute TTL. On a miss, hard-coded defaults are used.

KeyDefaultDescription
site_nameEast Grinstead Athletics ClubUsed in email templates
site_urlhttps://egac.pages.devBase URL for email links
email_fromno-reply@updates…Resend sender address
venue_nameEast Grinstead Leisure CentreUsed in booking confirmations
venue_addressKing Street, East Grinstead…Used in booking emails
weeks_ahead_booking8How many weeks of dates to show in booking invite
capacity_per_slot2Default per-session capacity (overridden by age group)
academy_max_age10Upper age limit for Academy routing
admin_email(empty)BCC address for admin notifications

Environments

Every row in enquiry-related tables carries an environment column (development / staging / production). Admin list queries filter by the current APP_ENV, so test data never surfaces in production views.