Skip to content

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 calendars table, no specialist_weekly_hours, no specialist_schedule_overrides, no repository, no route in routes.go, no hold store, and zero calendars.* / 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 it appointment_type, carried over from leo's Strapi appointment_template. That name is retired — data-model.md Area 4 and the glossary both say calendars. Do not reintroduce appointment_type in 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:

  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 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:

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 API. One binary, one database, same RLS and audit. No sync, no mapping layer.

Core concepts

ConceptDescriptionLeo name
SpecialistA provider with a scheduling profile: IANA timezone, weekly hours, date overridesopening
CalendarThe bookable unit. Groups specialists by priority and carries the booking rules: slot duration, gap, cooldown, min lead time, horizon or explicit windowschedule
OfferingThe 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.mdappointment_template / serviciu
HoldA temporary slot reservation backed by Redis. 30s TTL, extended via client heartbeatsame
AppointmentThe 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:

  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 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 calendarId:slotStartDate) for consistency.

Redis key patterns:

PatternPurposeTTL
hold:{calendarId}:{slotStart}:{specialistId}Hold storage30s
client:{clientId}:holdsClient hold indexSame as hold
holds:events:{calendarId}Pub/sub channel for SSEN/A
timeslots:{calendarId}:{specialistId}Computed slot cache5 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 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-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 NULL and an RLS policy calling current_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_log row
  • Forms: calendar_forms decides which forms attach at appointment creation, merged with offering_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:

NeedsWhyState
F1 specialistsspecialist_weekly_hours.specialist_id, calendar_specialists.specialist_id, and specialists.scheduling_timezone (P23 fallback layer 2)not built
F2.1 offeringscalendars.offering_id NOT NULL — no FK target without itnot built; scoped and settled 2026-08-02
F3 form_templatescalendar_forms.form_template_idnot built
btree_gistthe availability exclusion constraints and the appointment double-booking guardnot enabled000001 enables uuid-ossp, pgcrypto, unaccent, pg_trgm, vector, pg_stat_statements only. CREATE EXTENSION must run on DATABASE_DIRECT_URL (P44)
1B.14 locationslocation_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.

#QuestionAnswer
§8.2Override scopingcalendar_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 relationshipcalendar_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 rulesForbidden (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.10Public booking pathNot 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.9Calendar gridHand-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.

FileState
availability.go, assignment.go, types.goshipped to internal/core/domain/scheduling/, differential-tested
go/holds.gostill a transcription. Rebuilt in F4 stage 4 on internal/core/locks + internal/core/ssenot copied, because every key it builds is org-less (C13)
go/ip.gosuperseded by internal/shared/clientip; delete with stage 4
go/ratelimit_ip.godoes 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.