Skip to content

Scheduling & Booking

How patients find available times and book appointments — without double-booking, without race conditions.


What this enables

  • Specialists configure their own availability — working hours, days off, buffers between appointments
  • The platform calculates open slots in real time, in the specialist's timezone
  • When a patient selects a slot, it's held in reserve instantly so two people can't book the same slot simultaneously
  • Patients see live availability updates as others are browsing and holding slots
  • A public booking page works without any login — just pick a time and fill in contact details
  • Patient account creation happens separately, after the initial booking

How it works

Finding available times

Availability is calculated fresh for each request. For a given specialist:

  1. Their weekly schedule defines working hours (e.g., "Monday 9am–5pm, Romanian time")
  2. Any date-specific overrides are applied (days off, special hours)
  3. Already-booked appointments are subtracted from the available windows
  4. What remains is sliced into bookable slots

Slots are aligned to the clinic's timezone, so daylight saving time changes are handled correctly.

Holding a slot

Booking is a two-step process to prevent collisions:

Patient selects a slot

Platform creates a hold (reserved for ~30 seconds)
All other patients see that slot as taken immediately

Patient fills in their contact details

Patient confirms → appointment created, hold released

If the patient abandons the form or the 30-second window expires, the hold releases automatically and the slot becomes available again. The heartbeat (every 20 seconds) extends the hold while the form is being filled.

Real-time updates

The availability page uses Server-Sent Events (SSE) — a live connection that pushes updates to the patient's browser as slots are held and released by other users. No page refresh needed.


Technical Reference

Everything below is intended for developers.

History: why this is merged into the Core API

Scheduling was originally a separate microservice ("Intakes") — a Next.js API with its own database. The separation created real problems:

ProblemImpact
Mapping layerSchedule ↔ Appointment Template, Opening ↔ Specialist — 3 link columns, 3 resolution queries, bidirectional sync
Dual databasesTwo Postgres instances, no shared RLS, no unified audit log
Auth duplicationPer-org encrypted API keys for an internal service on a private network
No GDPRNo audit logging, no encryption, plaintext contact data in an unprotected database
Sync bugsStatus divergence between Intake and Appointment, race conditions on cancel/reinstate

The fix: Merged into the Core API. One binary, one database, same RLS and audit. No sync, no mapping layer.

Core concepts

ConceptDescription
SpecialistA provider with a scheduling profile: IANA timezone, weekly hours, date overrides
Appointment TypeA bookable service grouping specialists by priority. Defines slot duration, gap, cooldown, booking horizon
HoldA temporary slot reservation backed by Redis. 30s TTL, extended via client heartbeat
AppointmentThe booking record. Created at booked status with contact info only (no patient account yet)

Availability engine

Availability is computed in three passes:

  1. Weekly hours — Build UTC intervals from recurring wall-clock rules. Overnight rules (e.g., Fri 20:00 – Sat 02:00) split at local midnight. Each day processed individually to handle DST.
  2. Overrides — Apply date-specific changes. availability=true replaces weekly hours for that day; availability=false blocks the day entirely.
  3. Existing appointments — Subtract booked time. Resulting intervals are merged and de-duplicated.

Slot generation: Slots align to a local-timezone grid (step size = duration + gap). Each slot is individually converted to UTC via safeLocalToUTC() which probes forward during spring-forward DST gaps.

Booking window: Controlled by slots_open_at (defaults to today midnight UTC) and slots_close_at (defaults to slots_horizon_days from open). Current time is rounded up to the next minute to avoid showing past slots.

Hold system

Lifecycle:

  1. Create — Client requests a slot. System picks best specialist by priority, atomically claims with Redis SET NX (atomic, no race condition).
  2. Heartbeat — Client extends TTL every 20 seconds while viewing the form.
  3. Confirm — Booking creates appointment, releases hold, publishes confirm event.
  4. Release — Client abandons or TTL expires. Slot becomes available again.

Priority assignment: When multiple specialists are available for a slot, the system selects by priority first, then uses a deterministic hash tiebreaker (FNV-1a of appointmentTypeId:slotStartDate) for consistency.

Redis key patterns:

PatternPurposeTTL
hold:{appointmentTypeId}:{slotStart}:{specialistId}Hold storage30s
client:{clientId}:holdsClient hold indexSame as hold
holds:events:{appointmentTypeId}Pub/sub channel for SSEN/A
timeslots:{appointmentTypeId}:{specialistId}Computed slot cache5 min
client_limit:{clientId}:{appointmentTypeId}Rate limit (cooldown after booking)configurable

SSE streaming

Clients subscribe per appointment type and receive real-time events:

  • hold / release / confirm / heartbeat
  • Events include isOwnHold flag so the UI can differentiate the current user's hold
  • Connection deduplication: one stream per clientId
  • 15-minute lease with automatic reconnect

Rate limiting

Public endpoints are rate-limited per IP:

  • Different limits per endpoint group (timeslots, holds, booking)
  • Per-appointment-type cooldown after a successful booking (default 24h)
  • Cooldown cleared on cancellation
  • Fails open on Redis errors (never blocks legitimate users due to Redis downtime)

Integration points

  • Auth: Public endpoints (no auth), Admin endpoints (Clerk auth)
  • RLS: All DB queries are organization-scoped
  • Audit logging: All mutations logged
  • Forms: Appointment types define which forms to generate on onboarding
  • Videocalls: Room created automatically when patient is onboarded