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 FK TEXT events TEXT invite_id FK TEXT note TEXT raw_payload TEXT environment TEXT created_at }

invites { TEXT id PK TEXT token UK TEXT enquiry_id FK 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 FK TEXT invite_id FK TEXT age_group_id FK 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 FK }

academy_waitlist { TEXT id PK TEXT enquiry_id FK TEXT season_id FK 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 FK TEXT token UK TEXT used_at TEXT environment }

membership_form_submissions { TEXT id PK TEXT enquiry_id FK TEXT membership_otp_id FK 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 : “used for” 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.