# Appointments API — v1

Base: `/api/v1/appointments` · Auth: Sanctum bearer token · Branch: session
if present, else `X-Branch` header (own branch implicit). Every endpoint is
policy-gated; a caller without the ability receives `403 forbidden`.

All instants are ISO-8601 Zulu (`2026-08-03T06:30:00Z`) plus the clinic
`timezone` they were booked in. The client localizes for display and **never
does calendar arithmetic** — it echoes back the exact `starts_at` the slot
endpoint served.

Rota/schedule management is deliberately not in v1: rotas change at the desk,
on the web screen, with the context a screen provides.

## Endpoints

| Method | Path | Ability | Purpose |
|---|---|---|---|
| GET | `/appointments` | `appointments.view` | Paginated list, filterable |
| POST | `/appointments` | `appointments.book` | Book into a served slot |
| GET | `/appointments/{ulid}` | `appointments.view` | One appointment |
| PATCH | `/appointments/{ulid}/status` | `appointments.update` (`appointments.cancel` to cancel) | Drive the status machine |
| GET | `/appointments/slots` | `appointments.view` | Free slots for a doctor and date |
| GET | `/appointments/queue` | `appointments.view` | Checked-in walk-in queue for a doctor |
| POST | `/appointments/walk-in` | `appointments.book` | Admit a walk-in (no grid position) |

### GET /appointments

Query: `doctor`, `patient` (ULIDs), `status`, `source`, `date`, `from`, `to`,
`reference`, `sort`, `direction`, `per_page` (1–100, default 25).

Rows embed `patient {ulid, name, mrn}` and `doctor {ulid, name, specialty}`
from one batch directory lookup per page — never a query per row. A
participant key is **omitted** (not `null`) when the directory cannot resolve
it, e.g. the patient record was archived after booking.

### GET /appointments/slots

Query: `doctor` (required, ULID), `date` (`Y-m-d`, defaults to today).

```json
{"success":true,"code":"ok","data":[
  {"starts_at":"2026-08-03T06:00:00Z","local_time":"09:00","duration_minutes":30,"remaining":1}
],"meta":{"date":"2026-08-03","timezone":"Asia/Baghdad"},"errors":null}
```

### POST /appointments

Body: `patient`, `doctor` (ULIDs), `starts_at` (an instant the slot endpoint
served), `source` (`front_desk|phone|online|follow_up`), optional `reason`.
Same form request as the web door.

`201 appointment_booked` with the full resource, participants embedded.

`409 appointment_slot_unavailable` when the slot was taken between the slot
list and the booking — the input was valid, the world changed; refresh the
slot list and pick again. Detail (`reason: taken|outside_schedule`, local
`time`) in `meta.context`.

### PATCH /appointments/{ulid}/status

Body: `status` (`confirmed|checked_in|in_progress|completed|cancelled|no_show`),
`reason` (required for `cancelled` and `no_show`).

Cancelling checks `appointments.cancel`; every other move checks
`appointments.update` — decided by the shared form request, identically on
both doors.

`422 appointment_invalid_transition` when the status machine refuses;
`from`/`to` in `meta.context`.

### GET /appointments/queue

Query: `doctor` (required, ULID). Returns checked-in walk-ins in queue order;
`meta.waiting` carries the count.

### POST /appointments/walk-in

Body: `patient`, `doctor` (ULIDs), optional `reason`. Books into the current
moment with the next queue number for that doctor's day —
`201 walk_in_admitted`, `data.queue_number` assigned.

## Error codes

Registered permanently in [errors.md](errors.md): `appointment_booked`,
`appointment_status_changed`, `walk_in_admitted`,
`appointment_slot_unavailable` (409), `appointment_invalid_transition` (422),
plus the platform set (`validation_failed`, `forbidden`, `not_found`, …).

Cross-branch reads are `404 not_found` with a body byte-identical to a
nonexistent ULID — the difference is an enumeration oracle.
