Scheduling API Contracts
⚠️ NOT IMPLEMENTED. Verified against code 2026-08-02. None of the routes below exist. There is no
internal/core/domain/scheduling/package, nocalendarstable, and zerocalendars.*/appointments.*/specialists.*permission rows in any migration — those codes live only in rbac-permissions.md prose. This is a contract to build against, not an API to call.
The scheduling system ports from the standalone restartix-intakes service, which is live in production today, into the API Go binary. All routes use the /v1/ prefix. Terminology changes:
| Old term | New term | Reason |
|---|---|---|
| Opening | Specialist | Represents a specialist with scheduling properties (timezone, weekly hours, overrides) |
| Schedule | Calendar | The bookable unit. Carries slot duration, gap, cooldown, min lead time, and the booking window |
| Intake | Appointment | Represents a booked appointment; status booked, nullable patient_id, contact fields |
| franchise | organization | Tenancy |
appointment_typeis retired. Earlier drafts of this file usedappointment_type//v1/appointment-typesfor what the glossary and data-model.md Area 4 callcalendars. Renamed 2026-08-02, before any migration could copy the losing name.appointment_type,opening,schedule,intakeandfranchiseare forbidden terms in new code.
Cross-cutting requirements
Every route below inherits these. They are not optional and they are not per-endpoint decisions:
| Requirement | Detail |
|---|---|
| RLS | Every scheduling table carries organization_id UUID NOT NULL and RLS policies calling current_app_has_permission(resource, action). Junctions included. Repos use ConnFromContext(ctx) |
| P47 | Every per-org route group mounts middleware.RequireURLOrgMatchesScope("id"). Apply preemptively whether or not the endpoint caches today — without it, RLS hides one mismatched response and the cache propagates it |
| Permissions | RequirePermission gates every non-public route. The permission rows must be seeded in the same migration that creates the tables |
| Audit | Every state-changing mutation writes an audit_log row (actor, action, field-level changes, IP, status) |
| Pagination | Every list endpoint is server-paginated via apiquery — default limit ≤ 50, hard cap 500. No unbounded list endpoints, no client-side filtering |
| Pickers | Specialist and calendar pickers are async typeahead (?q= + ?ids=), never pre-loaded. Queryable text columns get GIN trigram + immutable_unaccent |
| Classification | Public projections go through classification.Filter(record, target) (P39). Never hand-build the public field list — contact fields on a booking are pii_basic |
Authentication
Clerk-Authenticated Routes (Admin / Specialist)
All org-scoped routes require the API's Clerk auth middleware. The authenticated user's organization is resolved from the Clerk session — no API keys, no Bearer sk_* tokens.
| Header | Value |
|---|---|
Authorization | Clerk session token (managed by Clerk SDK / session cookie) |
the API's existing RBAC determines what the user can access within their org.
Public Routes (Booking Flow)
Public routes require no authentication. They are used by the patient-facing booking UI.
All public routes are IP rate-limited. The limiter is shipped — internal/core/ratelimit provides a Redis Store, a Policy type, Middleware, and the IPKey / PrincipalKey / URLParamKey extractors, with tests. Compose it per endpoint group; do not write a scheduling-specific limiter (the go/ratelimit_ip.go draft is superseded and does not compile). Public routes run on the AdminPool per P5, with the org resolved from the calendar, and every response projected through classification.Filter.
⚠️ Whether the public self-booking path ships at all is §8.10, OPEN. In leo the real creation surface is staff-side; patient bookings arrive from the external Intakes public UI. Shipping the public path exposes unauthenticated hold + book endpoints and their abuse surface. The routes are specified either way; the decision is whether they mount.
| Route prefix | Auth | IP Rate Limit |
|---|---|---|
GET /v1/calendars/{id}/details | None | 30/min |
GET /v1/calendars/{id}/timeslots | None | 30/min |
POST /v1/calendars/{id}/book | None | 10/hour |
POST /v1/holds | None | 20/min |
PATCH /v1/holds | None | 60/min |
GET /v1/holds | None | 30/min |
POST /v1/holds/check | None | 30/min |
POST /v1/holds/release-all | None | 10/min |
GET /v1/holds/stream | None | 10/min |
Calendars (Org-Scoped, Clerk Auth)
GET /v1/calendars
List calendars for the authenticated organization. Supports advanced query DSL for filtering, sorting, and pagination.
Query Parameters: Advanced query system (filters, sort, pagination).
Response:
{
"data": [Calendar],
"meta": { "pagination": {}, "filters": {}, "sort": [], "timestamp": "" }
}POST /v1/calendars
Create a calendar.
Body:
{
"offeringId": "uuid (required — FK to offerings)",
"displayName": "string (required)",
"slug": "string (required, unique per org)",
"locationId": "uuid (optional, null = remote/telerehab)",
"slotDurationMinutes": "int >= 1 (required)",
"slotGapMinutes": "int >= 0 (default 0)",
"slotsCooldownMinutes": "int >= 0 (default 1440)",
"minLeadTimeMinutes": "int >= 0 (default 1440)",
"horizonDays": "int >= 0 (default 0)",
"requiresTimeslot": "boolean (default true)",
"requiresPayment": "boolean (default false)",
"assignmentStrategy": "priority | round_robin | manual (default priority)",
"slotsOpenAt": "ISO 8601 (optional)",
"slotsCloseAt": "ISO 8601 (optional)",
"metadata": "object (optional)"
}Booking window is horizon XOR explicit range (S9). Either horizonDays > 0 with both timestamps null, or horizonDays = 0 with both timestamps set. Any other combination is a 400, and it is also rejected by chk_calendars_window_xor_horizon at the database — leo enforces this only in a client-side save handler, which is how it acquired calendars with a zero-width window and no slots.
offeringId is a NOT NULL FK. It has no target until the F2.1 offerings stand-in ships (settled in scope 2026-08-02, not yet built).
Response: 201 — Created calendar.
GET /v1/calendars/{id}
Get a single calendar by ID.
Response:
{ "calendar": Calendar }PATCH /v1/calendars/{id}
Update a calendar. Updates invalidate the timeslot cache.
Body: Any subset of the fields from POST /v1/calendars.
DELETE /v1/calendars/{id}
Delete a calendar. Invalidates the timeslot cache.
Specialist Assignment
GET /v1/calendars/{id}/specialists
List specialists assigned to this calendar, with priority info.
Response:
{
"data": [
{
"specialistId": "uuid",
"displayName": "string",
"priority": 1,
"active": true
}
]
}POST /v1/calendars/{id}/specialists
Assign a specialist to this calendar.
Body:
{ "specialistId": "uuid (required)", "priority": "int (required)" }DELETE /v1/calendars/{id}/specialists/{specialistId}
Remove a specialist from this calendar. Invalidates cache.
PATCH /v1/calendars/{id}/specialists/reorder
Bulk reorder specialist priorities on this calendar.
Body:
{
"specialistIds": ["uuid", "uuid"],
"evenDistribution": "boolean (optional, default false)"
}evenDistribution=true sets all priorities to 0 (pure round-robin). false uses descending priority based on array order.
Override Cleanup
DELETE /v1/calendars/{id}/overrides/cleanup
Delete expired overrides for all specialists on this calendar. ?strategy=aggressive|moderate|conservative
| Strategy | Retention |
|---|---|
| aggressive | 0 days (delete all expired) |
| moderate | 60 days |
| conservative | 365 days |
Specialists Scheduling (Org-Scoped, Clerk Auth)
Specialists represent providers with scheduling properties: timezone, weekly recurring hours, and date-specific overrides.
Specialist CRUD
GET /v1/specialists
List specialists for the authenticated organization.
POST /v1/specialists
Create a specialist.
Body:
{
"displayName": "string (required)",
"timezone": "IANA timezone (required, e.g. America/New_York)",
"active": "boolean (default true)",
"metadata": "object (optional)"
}Response: 201 — Created specialist.
GET /v1/specialists/{id}
Get a single specialist by ID.
PATCH /v1/specialists/{id}
Update a specialist. Timezone change is blocked if calendar-specific overrides exist for this specialist.
Body: Any subset of the fields from POST /v1/specialists.
DELETE /v1/specialists/{id}
Delete a specialist.
Weekly Hours
GET /v1/specialists/{id}/weekly-hours
List recurring weekly availability blocks for a specialist.
Response:
{
"data": [
{
"id": "uuid",
"dayOfWeek": "mon",
"startTime": "09:00:00",
"endTime": "12:00:00"
}
]
}POST /v1/specialists/{id}/weekly-hours
Create a weekly hours block.
Body:
{
"dayOfWeek": "mon|tue|wed|thu|fri|sat|sun (required)",
"startTime": "HH:MM:SS (required)",
"endTime": "HH:MM:SS (required)"
}Times are wall-clock in the specialist's timezone.
PATCH /v1/specialists/{id}/weekly-hours/{weeklyHourId}
Update a weekly hours block.
Body:
{
"dayOfWeek": "mon|tue|wed|thu|fri|sat|sun (optional)",
"startTime": "HH:MM:SS (optional)",
"endTime": "HH:MM:SS (optional)"
}DELETE /v1/specialists/{id}/weekly-hours/{weeklyHourId}
Delete a weekly hours block.
POST /v1/specialists/{id}/weekly-hours/bulk-replace
Replace all weekly hours for a specialist in a single transaction.
Semantics: per-day transactional replace. For each dayOfWeek present in the payload, delete that day's rows and insert the new ones, all in one transaction. Days absent from the payload are untouched. Port these semantics exactly as leo has them (openings.repository.ts:200-234 already loops per day inside a transaction) — a whole-table replace would silently wipe days the editor never opened.
Body:
{
"replacements": [
{
"dayOfWeek": "mon",
"slots": [
{ "startTime": "09:00:00", "endTime": "12:00:00" },
{ "startTime": "14:00:00", "endTime": "18:00:00" }
]
}
]
}Times are wall-clock in the specialist's scheduling_timezone. Overlapping slots within a day are rejected by the EXCLUDE USING gist constraint regardless of locationId — a specialist cannot be in two places at once (P40).
Overrides
Date-specific availability overrides.
⚠️ Override scoping is §8.2, OPEN. The
calendarIdparameter below reflects leo's per-schedule scoping (schedule_opening_overrides.schedule_id NOT NULL, with live production rows). The platform recommendation iscalendar_id UUID NULLwhere NULL means "all calendars", which would makecalendarIdoptional on these routes rather than required. Do not write the migration or freeze these signatures until the decision lands. See availability-engine.md → Override scoping.
Two semantics are settled regardless of scoping:
- S1 — an override REPLACES a day, it never merges. Any override touching a local date causes that date's weekly hours to be skipped entirely.
- S2 — a day blocked entirely is one
availability=falserow spanning the day. There is no separate "blocked" flag; "block Tuesday" is expressed as an override with no available intervals.
GET /v1/specialists/{id}/overrides?calendarId=
List overrides. calendarId required-or-optional per §8.2.
Response:
{
"data": [
{
"id": "uuid",
"calendarId": "uuid",
"startDate": "ISO 8601",
"endDate": "ISO 8601",
"availability": true
}
]
}POST /v1/specialists/{id}/overrides
Create an override.
Body:
{
"calendarId": "uuid (required)",
"startDate": "ISO 8601 (required)",
"endDate": "ISO 8601 (required)",
"availability": "boolean (required)"
}availability=true adds availability (replaces weekly hours for the affected days). availability=false blocks availability.
PATCH /v1/specialists/{id}/overrides/{overrideId}
Update an override.
Body:
{
"startDate": "ISO 8601 (optional)",
"endDate": "ISO 8601 (optional)",
"availability": "boolean (optional)"
}DELETE /v1/specialists/{id}/overrides/{overrideId}
Delete an override. calendarId required as query param.
POST /v1/specialists/{id}/overrides/bulk-create
Batch create overrides. Max 100 overrides per request.
Body:
{
"overrides": [
{
"calendarId": "uuid",
"startDate": "ISO 8601",
"endDate": "ISO 8601",
"availability": true
}
]
}POST /v1/specialists/{id}/overrides/bulk-upsert
Replace overrides for specified dates. Supports two input formats.
Body:
{
"calendarId": "uuid",
"dates": ["2025-03-15", "2025-03-16"],
"overrides": [
{ "date": "2025-03-15", "startTime": "09:00", "endTime": "17:00", "availability": true },
{ "startDate": "2025-03-16T09:00:00Z", "endDate": "2025-03-16T17:00:00Z", "availability": true }
]
}DELETE /v1/specialists/{id}/overrides/cleanup?strategy=
Cleanup expired overrides across all calendars for this specialist.
| Strategy | Retention |
|---|---|
| aggressive | 0 days (delete all expired) |
| moderate | 60 days |
| conservative | 365 days |
Availability
GET /v1/specialists/{id}/availability
Admin availability view. Max 90-day range.
Query Parameters:
| Param | Type | Required |
|---|---|---|
startDate | YYYY-MM-DD | Yes |
endDate | YYYY-MM-DD | Yes |
calendarId | UUID | No (includes calendar-specific overrides if provided) |
Response:
{
"specialistId": "uuid",
"timezone": "Europe/Bucharest",
"dateRange": {
"requestedStart": "2025-03-01",
"requestedEnd": "2025-09-01",
"effectiveStart": "2025-03-01",
"effectiveEnd": "2025-05-30"
},
"generatedAt": "2025-03-15T14:37:00Z",
"days": {
"2025-03-15": {
"weeklyHours": [{ "startTime": "09:00:00", "endTime": "17:00:00" }],
"overrides": [],
"effectiveSlots": ["2025-03-15T07:00:00Z", "2025-03-15T07:45:00Z"]
}
}
}The range is capped at 90 days. When the request exceeds it, the response is clamped and dateRange reports both the requested and effective bounds so the UI can grey out the remainder instead of silently showing an empty calendar. generatedAt is the S19 freshness stamp — a cached grid is detectable client-side.
GET /v1/specialists/{id}/calendars
List calendars using this specialist, with priority info.
Response:
{
"data": [
{
"calendarId": "uuid",
"displayName": "string",
"priority": 1
}
]
}Public Booking Routes (No Auth)
GET /v1/calendars/{id}/details
Public calendar info for the booking UI. Returns the calendar with full specialist details (weekly hours, overrides) so the frontend can render a calendar.
Response:
{
"calendar": {
"id": "uuid",
"displayName": "string",
"slotDurationMinutes": 30,
"requiresTimeslot": true,
"requiresPayment": false,
"metadata": {}
},
"specialists": [
{
"id": "uuid",
"displayName": "string",
"timezone": "America/New_York",
"weeklyHours": [
{ "dayOfWeek": "mon", "startTime": "09:00:00", "endTime": "17:00:00" }
],
"overrides": []
}
]
}GET /v1/calendars/{id}/timeslots
Pooled available timeslots across all specialists assigned to this calendar. Results cached in Redis (5 min default).
Query Parameters:
| Param | Type | Required |
|---|---|---|
specialistId | UUID | No (pooled mode if omitted) |
Response:
{
"startDate": "ISO UTC",
"endDate": "ISO UTC",
"slots": {
"2025-03-15T00:00:00Z": ["2025-03-15T09:00:00Z", "2025-03-15T09:45:00Z"]
},
"capacity": {
"2025-03-15T09:00:00Z": { "remaining": 2, "max": 3, "total": 3 }
},
"slotDurationMinutes": 30,
"streamUrl": "/v1/holds/stream?calendarId=..."
}POST /v1/calendars/{id}/book
Confirm a booking. Creates an appointment with status=booked.
Body:
{
"contactName": "string (1-200 chars, required)",
"contactEmail": "email (max 254 chars, required)",
"contactPhone": "string (1-50 chars, required)",
"patientId": "uuid (optional — links to existing patient)",
"holdId": "string (required for timeslot calendars, forbidden for non-timeslot)"
}
clientIdis NOT a body field. It is read from a server-signed HttpOnly cookie and never accepted from the request. Leo takes it frombody > query > cookie > generated, validates none of it, and the dashboard regenerates it after each booking — which makes the 24h cooldown decorative. See hold-system.md → Booking client identity.
Flow:
- Validate request body
- Resolve the booking client id from the signed cookie; mint one if absent
- Verify the calendar exists, is published, and belongs to an active org
- Check min lead time (
minLeadTimeMinutes) — on failure return the structured S11 error, not a boolean - Check booking cooldown (
slotsCooldownMinutes), keyed per calendar (S12) and failing open on Redis error (S13) - If timeslot calendar: verify the hold still exists and belongs to this client, then create the appointment from it
- If non-timeslot: auto-assign specialist by priority + deterministic tiebreak (S16), create appointment with both dates NULL (S7 permits it)
- Insert. The
EXCLUDE USING gistconstraint is the authority — a23P01here means the hold was lost and the slot was taken; return409, do not retry blindly - Set the cooldown after the row commits
- Persist
booking_client_idon the appointment - Return appointment
Error responses:
| Status | Condition | Body |
|---|---|---|
409 | Hold expired or slot taken between hold and insert | slot_unavailable |
422 | Slot inside the window but sooner than minLeadTimeMinutes | S11 shape: { message, minLeadTimeMinutes, slotStart, earliestBookableAt } |
429 | Booking cooldown active for this (org, calendar, client) | remaining cooldown + earliest retry time |
400 | Slot not aligned to the grid, or outside the booking window | slot_not_aligned / outside_registration_window |
Response: 201
{
"appointment": {
"id": "uuid",
"calendarId": "uuid",
"specialistId": "uuid",
"status": "booked",
"startDate": "ISO 8601",
"endDate": "ISO 8601",
"contactName": "string",
"contactEmail": "string",
"contactPhone": "string",
"patientId": "uuid | null",
"clientId": "string",
"createdAt": "ISO 8601"
}
}Hold System (Public, No Auth)
All hold endpoints are public. They manage temporary slot reservations during the booking flow.
clientId is never a request input on any of these routes. It is resolved server-side from a signed HttpOnly cookie and echoed in responses. Accepting it from a body, query string, or header would let any caller impersonate another client's holds and bypass the booking cooldown — which is what leo does today. ttlMs is likewise server-capped, not client-dictated.
Holds are advisory. The durable double-booking guarantee is the EXCLUDE USING gist constraint documented in hold-system.md.
POST /v1/holds
Create/claim a hold on a timeslot.
Body:
{
"calendarId": "uuid (required)",
"slotStartDate": "ISO UTC (required)",
"specialistId": "uuid (optional — bypasses priority selection)",
"ttlMs": "number (optional, default 30000, server-capped)"
}Flow:
- Resolve clientId
- Check client rate limit
- If
specialistIdprovided: direct hold on that specialist's slot - Else: priority-based assignment with retry loop
- Propose specialist by priority
- Attempt
SET NX(atomic claim) - If slot already held: exclude specialist, retry with next candidate
- Until success or no candidates left
- Publish "hold" event to pub/sub
- Return hold payload
Response (success): 201
{
"holdId": "uuid",
"clientId": "string",
"specialistId": "uuid",
"calendarId": "uuid",
"slotStartDate": "ISO UTC",
"slotEndDate": "ISO UTC",
"holdExpiresAt": "ISO UTC"
}PATCH /v1/holds
Heartbeat — extend hold TTL.
Body:
{
"calendarId": "uuid (required)",
"specialistId": "uuid (required)",
"slotStartDate": "ISO UTC (required)",
"ttlMs": "number (optional, server-capped)"
}Response: 200 — Updated hold with new holdExpiresAt.
GET /v1/holds?calendarId=
List all active holds for a calendar.
Query Parameters:
| Param | Type | Required |
|---|---|---|
calendarId | UUID | Yes |
Response:
{
"data": [
{
"holdId": "uuid",
"clientId": "string",
"specialistId": "uuid",
"calendarId": "uuid",
"slotStartDate": "ISO UTC",
"slotEndDate": "ISO UTC",
"holdExpiresAt": "ISO UTC"
}
]
}POST /v1/holds/check
Check if a client has a hold for a specific slot.
Body:
{
"calendarId": "uuid (required)",
"slotStartDate": "ISO UTC (required)"
}Response:
{
"hasHold": true,
"hold": { "holdId": "uuid", "specialistId": "uuid", "holdExpiresAt": "ISO UTC" }
}POST /v1/holds/release-all
Release all holds for a client.
Body:
{
"calendarId": "uuid (optional — scope release to one calendar)"
}GET /v1/holds/stream
SSE stream for real-time hold updates. See hold-system.md for the full protocol; it rides the shipped internal/core/sse hub.
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
calendarId | UUID | required | Subscribe to this calendar's events |
clientId | string | auto-resolved | Client identifier |
leaseMs | number | 900000 (15 min) | Connection lease, server-capped at 1 hour |
Event Types:
| Type | When | Data |
|---|---|---|
init | Connection established | calendarId, clientId |
connected | Subscription active | calendarId, clientId, timestamp |
hold | Slot claimed | Full HoldPayload + isOwnHold |
heartbeat | Hold extended | Full HoldPayload + isOwnHold |
release | Slot freed | Full HoldPayload + isOwnHold |
confirm | Booking confirmed | Full HoldPayload + isOwnHold |
ping | Health check | timestamp, connectionId |
end | Connection closing | reason, retryAfterMs |
Response Headers:
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: noAppointments (Org-Scoped, Clerk Auth)
⚠️ There are no "existing appointment endpoints." The
appointmentstable does not exist. Every reference to it in current migrations is a forward-looking comment. Appointments are F5 and are specified in features/appointments/ plusappointments-substrate.md— not here. The routes below are retained only because they describe the scheduling↔appointments contract; F5 owns their final shape, including the status enum, the state machine, and the two-phase patient identity.Two known divergences to reconcile when F5 is written, rather than copying from this page:
- Status. The two-value
booked | cancelledshown below is the leo-era shape. F5's substrate specifies a 9-status enum withcancelledsplit three ways (cancelled_by_patient/cancelled_by_clinic/cancelled_late), because the adherence denominator must exclude clinic-attributable cancellations.- Patient identity. F5 uses two-phase identity —
patient_profile_idset at booking withpatient_idNULL, andpatient_idlinked at onboarding — not a single nullablepatientId.
Fields the scheduling domain contributes
| Field | Type | Description |
|---|---|---|
calendarId | UUID NOT NULL | The calendar this appointment was booked through |
specialistId | UUID | The specialist assigned by the assignment engine |
contactName | string (1-200 chars) | Contact name from booking (pii_basic) |
contactEmail | string (max 254 chars) | Contact email from booking (pii_basic) |
contactPhone | string (1-50 chars) | Contact phone from booking (pii_basic) |
bookingClientId | string | Server-signed booking client identifier; the cooldown key survives a cookie clear because of this column |
startedAt / endedAt | TIMESTAMPTZ NULL | Both NULL for non-timeslot bookings, or both set with end > start (S7). Participates in the double-booking exclusion constraint (S6) |
Every column above needs a data-classification.md entry in the migration's own PR — make check fails otherwise.
GET /v1/appointments
List appointments for the authenticated organization. Supports advanced query DSL for filtering, sorting, and pagination.
Query Parameters: Advanced query system (filters, sort, pagination).
Response:
{
"data": [Appointment],
"meta": { "pagination": {}, "filters": {}, "sort": [], "timestamp": "" }
}GET /v1/appointments/{id}
Get an appointment with related calendar and specialist.
Response:
{
"appointment": Appointment,
"calendar": Calendar,
"specialist": Specialist
}PATCH /v1/appointments/{id}
Update an appointment. Cancellation clears rate limits and invalidates the timeslot cache.
Body:
{
"status": "booked | cancelled (optional)",
"startDate": "ISO 8601 (optional)",
"endDate": "ISO 8601 (optional)",
"contactName": "string (optional)",
"contactEmail": "email (optional)",
"contactPhone": "string (optional)",
"patientId": "uuid (optional)"
}DELETE /v1/appointments/{id}
Soft-delete (marks as cancelled). Clears rate limits and invalidates cache.
POST /v1/appointments/{id}/cancel
Cancel an appointment. Fails if already cancelled. Clears rate limits and invalidates cache.
POST /v1/appointments/{id}/reschedule
Reschedule to a new time. Requires requiresTimeslot=true on the calendar.
Body:
{ "startDate": "ISO 8601 (required)" }New endDate calculated from the calendar's slotDurationMinutes.
POST /v1/appointments/{id}/onboard
Link an appointment to a patient record. Used after the patient completes onboarding forms.
Body:
{
"patientId": "uuid (required)"
}Response: 200 — Updated appointment with patientId set.
Flow:
- Validate that the appointment exists and belongs to the authenticated org
- Validate that the patient exists and belongs to the same org
- Set
patientIdon the appointment - Return updated appointment
GET /v1/appointments/calendar
Calendar view of appointments grouped by date.
Query Parameters:
| Param | Type | Description |
|---|---|---|
view | month|week|day | Month returns counts, week/day returns details |
startDate | ISO 8601 | Range start |
endDate | ISO 8601 | Range end |
specialistId | UUID (optional) | Filter to a specific specialist |
calendarId | UUID (optional) | Filter to a specific calendar |
Response (month view):
{
"view": "month",
"totalCount": 42,
"data": { "2025-03-15": 3, "2025-03-16": 1 },
"meta": { "timestamp": "", "timezone": "UTC" }
}Response (week/day view):
{
"view": "week",
"totalCount": 7,
"data": {
"2025-03-15": [
{
"id": "uuid",
"calendarId": "uuid",
"specialistId": "uuid",
"status": "booked",
"startDate": "ISO 8601",
"endDate": "ISO 8601",
"contactName": "string",
"patientId": "uuid | null"
}
]
},
"meta": { "timestamp": "", "timezone": "UTC" }
}Org-Scoped Timeslots (Clerk Auth)
GET /v1/timeslots
Available timeslots. Results cached in Redis (5 min default). This endpoint is org-scoped and allows querying by specialist — useful for admin scheduling views.
Query Parameters:
| Param | Type | Required |
|---|---|---|
calendarId | UUID | Yes |
specialistId | UUID | No (pooled mode if omitted) |
Response:
{
"startDate": "ISO UTC",
"endDate": "ISO UTC",
"slots": {
"2025-03-15T00:00:00Z": ["2025-03-15T09:00:00Z", "2025-03-15T09:45:00Z"]
},
"capacity": {
"2025-03-15T09:00:00Z": { "remaining": 2, "max": 3, "total": 3 }
},
"slotDurationMinutes": 30,
"streamUrl": "/v1/holds/stream?calendarId=..."
}What Was Dropped from the Old Intakes Service
The following routes and systems from the standalone Intakes Node.js service are not carried into the merged API:
| Dropped | Reason |
|---|---|
GET/POST/PATCH/DELETE /admin/organizations | Org management is handled by the API's existing org system |
GET/POST/DELETE /api/api-keys, GET/POST/DELETE /admin/organizations/{id}/api-keys | API key auth replaced by the API's Clerk auth middleware |
GET /admin/health, GET /admin/health/metrics, GET /admin/health/metrics/stream | Health monitoring handled by the API's infrastructure |
GET /admin/health/subscribers, GET/POST /admin/health/cors | Redis and CORS diagnostics handled at the platform level |
POST /admin/email/test | Email testing integrated into the API's notification system |
| CORS middleware | Handled by the API's middleware stack or API gateway |
| API key authentication middleware | Replaced by Clerk session auth; org resolved from Clerk context |
/api/ route prefix | Replaced by /v1/ to match the API conventions |
Caller-supplied clientId | Server-signed HttpOnly cookie; persisted to appointments.booking_client_id |
Wildcard SCAN-based cache and cooldown invalidation | Explicit per-key invalidation (P45); a SCAN per cancellation does not survive 20k patients |
| Org-less Redis keys | cache.OrgResource(orgID, …) on every hold, stream, cooldown and timeslot key (C13) |
All core scheduling functionality — calendars, specialists, weekly hours, overrides, timeslots, holds, booking — is carried across. The data model is not: leo's schema, tenancy model, and naming are explicitly rejected. See leo-port-map.md §5 for the sixteen foundation conflicts and §7 for the anti-patterns the port must not carry.
Related
- Scheduling overview — concepts, dependencies, open decisions
- Availability engine — the slot algorithm, ported invariants, data model
- Hold system & SSE — holds, cooldown, the durable double-booking guard
- leo-port-map.md — §3 (F4 port plan), §4.1 (rules S1–S19), §8 (open decisions)