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:
- Their weekly schedule defines working hours (e.g., "Monday 9am–5pm, Romanian time")
- Any date-specific overrides are applied (days off, special hours)
- Already-booked appointments are subtracted from the available windows
- 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 releasedIf 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:
| Problem | Impact |
|---|---|
| Mapping layer | Schedule ↔ Appointment Template, Opening ↔ Specialist — 3 link columns, 3 resolution queries, bidirectional sync |
| Dual databases | Two Postgres instances, no shared RLS, no unified audit log |
| Auth duplication | Per-org encrypted API keys for an internal service on a private network |
| No GDPR | No audit logging, no encryption, plaintext contact data in an unprotected database |
| Sync bugs | Status 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
| Concept | Description |
|---|---|
| Specialist | A provider with a scheduling profile: IANA timezone, weekly hours, date overrides |
| Appointment Type | A bookable service grouping specialists by priority. Defines slot duration, gap, cooldown, booking horizon |
| Hold | A temporary slot reservation backed by Redis. 30s TTL, extended via client heartbeat |
| Appointment | The booking record. Created at booked status with contact info only (no patient account yet) |
Availability engine
Availability is computed in three passes:
- 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.
- Overrides — Apply date-specific changes.
availability=truereplaces weekly hours for that day;availability=falseblocks the day entirely. - 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:
- Create — Client requests a slot. System picks best specialist by priority, atomically claims with Redis
SET NX(atomic, no race condition). - Heartbeat — Client extends TTL every 20 seconds while viewing the form.
- Confirm — Booking creates appointment, releases hold, publishes
confirmevent. - 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:
| Pattern | Purpose | TTL |
|---|---|---|
hold:{appointmentTypeId}:{slotStart}:{specialistId} | Hold storage | 30s |
client:{clientId}:holds | Client hold index | Same as hold |
holds:events:{appointmentTypeId} | Pub/sub channel for SSE | N/A |
timeslots:{appointmentTypeId}:{specialistId} | Computed slot cache | 5 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
isOwnHoldflag 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