Scheduling & Booking
How patients find available times and book appointments — without double-booking, without race conditions.
⚠️ MOSTLY NOT BUILT. Updated 2026-08-05. There is no
calendarstable, nospecialist_weekly_hours, nospecialist_schedule_overrides, no repository, no route inroutes.go, no hold store, and zerocalendars.*/appointments.*permission rows seeded in any migration — those codes exist only in rbac-permissions.md prose. Everything below describes F4 as it is to be built; present tense is spec voice throughout.The one exception is the availability/assignment engine, which shipped to
services/api/internal/core/domain/scheduling/and is differential-tested against the system running in production — see Reference implementation. It computes correctly against inputs nothing yet supplies.
Naming, settled 2026-08-02. The bookable configuration entity is
calendar. Earlier drafts of these files called itappointment_type, carried over from leo's Strapiappointment_template. That name is retired — data-model.md Area 4 and the glossary both saycalendars. Do not reintroduceappointment_typein schema, code, JSON, or prose.
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 API
Scheduling runs today, in production, as a separate microservice — restartix-intakes, a Next.js + Drizzle + Postgres + Redis app. It is the source this feature ports from, and its source tree is on disk (restartix-intakes @ 624a73e), which makes every rule below verifiable rather than remembered. 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 API. One binary, one database, same RLS and audit. No sync, no mapping layer.
Core concepts
| Concept | Description | Leo name |
|---|---|---|
| Specialist | A provider with a scheduling profile: IANA timezone, weekly hours, date overrides | opening |
| Calendar | The bookable unit. Groups specialists by priority and carries the booking rules: slot duration, gap, cooldown, min lead time, horizon or explicit window | schedule |
| Offering | The clinical service the calendar sells (calendars.offering_id). Owns catalog identity and the form roster — not part of F4; see the F2.1 stand-in in platform-completion.md | appointment_template / serviciu |
| Hold | A temporary slot reservation backed by Redis. 30s TTL, extended via client heartbeat | same |
| Appointment | The booking record. Created at booked status with contact info only (no patient account yet) | intake |
opening, schedule, intake, and franchise are forbidden terms in new code and new prose. So is appointment_type.
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 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 calendarId:slotStartDate) for consistency.
Redis key patterns:
| Pattern | Purpose | TTL |
|---|---|---|
hold:{calendarId}:{slotStart}:{specialistId} | Hold storage | 30s |
client:{clientId}:holds | Client hold index | Same as hold |
holds:events:{calendarId} | Pub/sub channel for SSE | N/A |
timeslots:{calendarId}:{specialistId} | Computed slot cache | 5 min |
client_limit:{clientId}:{calendarId} | Rate limit (cooldown after booking) | configurable |
⚠️ Those are the leo key shapes, shown for provenance. None of them carries an organization dimension, which on a multi-tenant platform is a cross-tenant collision waiting to happen. Every key ships through
cache.OrgResource(orgID, …)— see hold-system.md → Redis key patterns for the platform form.
SSE streaming
Clients subscribe per calendar 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-calendar 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), org-scoped endpoints (Clerk auth)
- RLS: every scheduling table carries
organization_id UUID NOT NULLand an RLS policy callingcurrent_app_has_permission(resource, action). No exceptions, junctions included - P47: every per-org route group mounts
middleware.RequireURLOrgMatchesScope("id")— without it a cached response crosses tenants - Audit logging: every state-changing mutation writes an
audit_logrow - Forms:
calendar_formsdecides which forms attach at appointment creation, merged withoffering_forms. Both depend on F3 — F3 ships before F4 - Videocall: Daily.co is a Cat A curated provider (
video.Provider) wired in F5, not F4
Dependencies
F4 is not independently shippable. It needs, in order:
| Needs | Why | State |
|---|---|---|
| F1 specialists | specialist_weekly_hours.specialist_id, calendar_specialists.specialist_id, and specialists.scheduling_timezone (P23 fallback layer 2) | not built |
| F2.1 offerings | calendars.offering_id NOT NULL — no FK target without it | not built; scoped and settled 2026-08-02 |
| F3 form_templates | calendar_forms.form_template_id | not built |
btree_gist | the availability exclusion constraints and the appointment double-booking guard | not enabled — 000001 enables uuid-ossp, pgcrypto, unaccent, pg_trgm, vector, pg_stat_statements only. CREATE EXTENSION must run on DATABASE_DIRECT_URL (P44) |
| 1B.14 locations | location_id UUID NULL on weekly hours, overrides, and calendars (P40) | shipped |
Decisions
All four F4 decisions are settled. Do not reopen one by writing code that assumes otherwise.
| # | Question | Answer |
|---|---|---|
| §8.2 | Override scoping | calendar_id UUID NULL — NULL = every calendar, which is what a vacation is; set = one channel ("block Physio next Tuesday, keep Nutrition open"). leo's NOT NULL would make a vacation N rows that drift apart. Settled 2026-08-05. |
| — | Roster relationship | calendar_specialists is a validated SUBSET of offering_specialists, enforced by a composite FK rather than a trigger — which also gets the right cascade for free. Settled 2026-08-05. |
| — | Overnight weekly rules | Forbidden (CHECK (end_time > start_time)), because an overnight row's occupied minutes are a union no gist range can express — and without that range the single-true-availability constraint cannot exist. A night shift is two rows. Settled 2026-08-05. |
| §8.10 | Public booking path | Not in F4. Org-scoped only; /v1/public/ availability and holds land with F5.4, where appointments exist to be created. Settled 2026-08-05. |
| §8.9 | Calendar grid | Hand-rolled, no drag in v1. The licensing objection was void (all five plugins are MIT); the availability editor rides the already-shipped react-day-picker; and FullCalendar cannot render a named IANA timezone without two more packages, which P23 makes mandatory rather than optional. Drag-to-move waits on §8.13's reschedule semantics. Settled 2026-08-05. |
Still open, lower-blocking: the timezone-change policy when weekly hours already exist (§8.13) — see availability-engine.md → Specialist timezone change — and reschedule semantics, which gate drag-to-move above.
Reference implementation
The engine has landed. The hold store has not.
availability.go, assignment.go and types.go moved to services/api/internal/core/domain/scheduling/ on 2026-08-05. They compile, vet, lint and test as part of make check, and are asserted case-by-case against the production TypeScript engine — see availability-engine.md → The differential test suite.
| File | State |
|---|---|
availability.go, assignment.go, types.go | ✅ shipped to internal/core/domain/scheduling/, differential-tested |
go/holds.go | still a transcription. Rebuilt in F4 stage 4 on internal/core/locks + internal/core/sse — not copied, because every key it builds is org-less (C13) |
go/ip.go | superseded by internal/shared/clientip; delete with stage 4 |
go/ratelimit_ip.go | does not compile — calls httputil.JSON with no import. Superseded by internal/core/ratelimit; delete with stage 4 |
What did not come across from types.go: the Redis key builders, hold payloads and SSE envelopes. Copying them would have committed leo's org-less key shapes into the platform, which is the collision C13 exists to prevent.
Three deliberate divergences from the oracle, each with its own test: now is an explicit parameter (the oracle reads an ambient clock, which is untestable); a zero step returns no slots rather than dividing by zero; an empty roster returns an empty response rather than indexing specialists[0].
What the differential test bought. Two real defects, neither of which any amount of reading would have caught: the DST spring-forward probe resolved 03:30 to 01:30Z where the oracle says 01:00Z, and an unconditional minute-rounding dropped the first slot of every request whose clock landed on :00. It also closed the fall-back question this spec had carried as UNRESOLVED.
Related docs
- API Reference →
- Availability Engine (deep dive) →
- Hold System & SSE (deep dive) →
- leo-port-map.md → — §3 (F4 port plan), §4.1 (scheduling business rules S1–S19), §5 (foundation conflicts), §7 (anti-pattern guardrails), §8 (open decisions)
- platform-completion.md → — the plan this feature sits in