# Scheduled consultations (bookings)

Lets a marketplace sell **appointments** with a consultant, not only on-demand calls. The server
stores each consultant's availability, prevents double booking, reminds both sides before the
slot, and at `start_at` creates the consultation itself through the normal
[consultation flow](../API.md#consultations-metered-11-sessions). From there on everything is
unchanged: the consultant accepts on the presence socket, both parties join with the room
tokens, the meter runs and the billing-grade end event fires.

Code: `internal/booking/` (model, scheduler, JSON snapshot), `internal/api/ext_booking.go` (routes).
All endpoints are application-scoped (`Authorization: Bearer ck_…`).

## Availability model

```
PUT /v1/consultants/{id}/availability
{
  "timezone": "Asia/Kolkata",                       // IANA name, default UTC
  "weekly": [ { "day": 1, "start": "09:00", "end": "17:00" },   // day: 0=Sunday … 6=Saturday
              { "day": 1, "start": "19:00", "end": "21:00" },   // several windows per day are fine
              { "day": 3, "start": "09:00", "end": "13:00" } ],
  "exceptions": [ { "date": "2026-11-01", "closed": true },                      // whole day off
                  { "date": "2026-11-08", "start": "10:00", "end": "12:00" } ],  // replaces the weekly windows that day
  "slot_minutes": 30                                // slot grid + default duration (5–1440)
}
→ 200 { "availability": {...} }        GET returns the same, 404 until it is configured
```

Rules:

- Times are wall-clock in `timezone`; `end` is exclusive; windows must not cross midnight (use two).
- An exception for a date wins over the weekly windows for that date.
- A booking must lie entirely inside one window **of the day on which it starts**. On a DST
  fall-back day the repeated hour is real time, so a 01:00–04:00 window yields four hourly slots.
- A consultant with **no availability configured** can be booked at any time (the overlap rule
  still applies). Set an empty `weekly` list to make a consultant unbookable without `force`.

### Free slots

```
GET /v1/consultants/{id}/slots?from=2026-11-02T00:00:00Z&to=2026-11-09T00:00:00Z&duration_minutes=30
→ 200 { "from", "to", "slots": [ { "start_at": "2026-11-02T03:30:00Z", "end_at": "2026-11-02T04:00:00Z" }, … ] }
```

`from` defaults to now, `to` to `from + 7 days` (capped at 62 days), `duration_minutes` to
`slot_minutes`. Slot starts fall on the `slot_minutes` grid inside each window; a slot is
returned only if the whole `[start, start+duration)` interval is inside the window, inside
`[from, to)`, not in the past, and does not overlap a `scheduled` or `started` booking of that
consultant. The consultant's live presence status is irrelevant for slots: a consultant who is
offline now can be booked for tomorrow.

## Bookings

```
POST /v1/bookings
{ "consultant_id": "astro-1", "user_id": "user-42", "user_name": "Asha",
  "start_at": "2026-11-02T03:30:00Z", "duration_minutes": 30, "mode": "audio",
  "max_seconds": 1800, "warn_at_seconds": 120,          // optional; max_seconds defaults to the duration
  "reminder_minutes": 15, "grace_seconds": 300,         // optional; override the server defaults (10 min / 5 min)
  "metadata": { "order_id": "o_123" },
  "force": false }                                      // true: ignore availability (never overlap)
→ 201 { "booking": { "id": "bk_…", "status": "scheduled", "start_at", "end_at", ... } }
```

| Code | Why |
|------|-----|
| 400 | bad ids, `start_at` missing or not in the future, `duration_minutes` outside 1–1440, negative numbers |
| 404 | consultant does not exist |
| 409 | slot overlaps another active booking of the consultant (even with `force`), slot outside availability (without `force`), consultant does not offer `mode` |

Other endpoints:

| Endpoint | Purpose |
|----------|---------|
| `GET /v1/bookings?consultant_id=&user_id=&status=&from=&to=` | List, ordered by `start_at`. `from`/`to` (RFC3339) select bookings whose interval overlaps `[from, to)`. |
| `GET /v1/bookings/{id}` | One booking. Reading a `started` booking also checks whether its consultation has ended. |
| `POST /v1/bookings/{id}/cancel` `{ "by": "user-42" }` | Only while `scheduled` (409 otherwise). A `started` booking is controlled through `/v1/consultations/{consultation_id}/cancel|end`. |
| `POST /v1/bookings/{id}/reschedule` `{ "start_at": "…", "force": false }` | Only while `scheduled`; same duration, same checks as create; reminder and start timers are reset. |

### Booking record

```
id, app_id, consultant_id, user_id, user_name, mode, start_at, end_at, duration_minutes,
max_seconds, warn_at_seconds, reminder_minutes, grace_seconds, metadata,
status, consultation_id, created_at, updated_at,
reminder_sent_at, starting_at (first start attempt), last_attempt_at, last_error, started_at,
cancelled_at, cancelled_by, completed_at,
consultation_status, end_reason, billed_seconds        // copied from the consultation when it ends
```

Statuses: `scheduled → started → completed`, or `scheduled → cancelled`, or `scheduled|started → missed`.

## Timeline of events

All events go out through the normal event pipeline (per-app webhook + analytics) with
`type`, `app_id`, `consultation_id` (once known) and `data` containing at least
`booking_id, consultant_id, user_id, start_at, end_at, duration_minutes, mode, status`.

| When | Event | Extra `data` | What the server does |
|------|-------|--------------|----------------------|
| `POST /v1/bookings` | `booking.created` | | |
| cancel / reschedule | `booking.cancelled` / `booking.rescheduled` | `by` / `previous_start_at` | |
| `start_at − reminder lead` | `booking.reminder` | `starts_in_seconds` | nothing else; see below |
| `start_at` | `booking.starting` | `attempt: 1`, `error` if the first try failed | calls `POST /v1/consultations` internally with the booking's `mode`, `max_seconds`, `warn_at_seconds`, `metadata + booking_id` |
| consultation created | `booking.started` | `consultation_status` | booking → `started`, `consultation_id` set. The usual `consultation.requested` goes to the presence socket and the webhook; accept, tokens and meter are unchanged. |
| consultant not reachable at `start_at` | (retry every 30 s) | | `Request` returns 409 while the consultant is offline, busy or has another pending request |
| `start_at + grace` still not started | `booking.missed` | `reason: consultant_unavailable`, `error` | booking → `missed`; no consultation exists |
| consultation ended | `booking.completed` | `reason, ended_by, billed_seconds, duration_ms, consultation_status` | booking → `completed`; `end_reason`/`billed_seconds` copied |
| consultation `declined` or `expired` (request timeout) | `booking.missed` | same as above | booking → `missed`: the consultant was online but never answered |

Default reminder lead is 10 minutes and grace 5 minutes (`booking.Defaults`); each booking can
override them with `reminder_minutes` / `grace_seconds`. A booking created inside its lead window
gets the reminder immediately. The consultation's own `request_timeout` (45 s by default) still
applies after `booking.started`; if the consultant does not accept in time the consultation
expires and the booking becomes `missed`.

Completion is detected by a sweep every 30 s and whenever the booking is read, so
`booking.completed` can trail `consultation.ended` by up to 30 s. Use the consultation event for
real-time billing; use the booking event to close the appointment.

## How the platform should use this

- **Reminders are push notifications.** On `booking.reminder`, notify both the user ("your
  session with Pandit Sharma starts in 10 minutes") and the consultant. The server does not
  message anybody itself. Make sure the consultant's app opens the presence socket (or your
  backend calls `POST /v1/consultants/{id}/status {"status":"online"}`) before `start_at`;
  otherwise the start is retried for the grace window and the booking is `missed`.
- **At `booking.started`** the user's app should show "connecting…"; when `consultation.accepted`
  arrives, push the `user_token` to the user exactly as for an on-demand consultation. The
  consultation's `metadata.booking_id` links the two records.
- **`booking.missed`** is your refund / rebook trigger. `data.reason` tells you whether the
  consultant was unreachable (`consultant_unavailable`), did not answer (`declined`, `request_timeout`),
  or the slot passed while the server was down (`start_window_passed`).
- **Wallets:** `max_seconds` defaults to the booked duration. If the user pays per minute, set it
  from the balance at booking time and `extend` the consultation after a top-up, as usual.
- Only consultants own a calendar; a user may hold overlapping bookings with different consultants.

## Restart behaviour

State lives in `<DATA_DIR>/bookings.json` (availability + all bookings, including scheduler
progress fields such as `reminder_sent_at`, `starting_at` and `last_attempt_at`). Nothing is
kept in timers alone. On startup the scheduler recomputes everything from the stored bookings:

- a reminder that became due during the downtime is sent now if `start_at` is still ahead; if
  the start already passed, the reminder is skipped (marked sent) rather than sent late;
- a booking whose `start_at` passed but whose grace window is still open is started now;
- a booking whose grace window closed during the downtime is marked `missed` with
  `reason: start_window_passed` without an attempt;
- `started` bookings are checked against their consultation (consultations survive in the store
  only until pruned; a vanished consultation completes the booking with `end_reason: unknown`);
- `completed`, `missed` and `cancelled` bookings are left alone — events are never replayed.

The in-memory store is single-node, like the rest of the server; for a multi-node deployment move
`internal/booking/store.go` to a shared database and make sure only one node runs the scheduler.

## Trying it locally

```bash
API=http://localhost:8080; KEY=ck_live_…            # make create-app NAME=Astro
H=(-H "Authorization: Bearer $KEY" -H "Content-Type: application/json")
curl -X PUT $API/v1/consultants/astro-1 "${H[@]}" -d '{"name":"Pandit Sharma","modes":["audio","chat"]}'
curl -X PUT $API/v1/consultants/astro-1/availability "${H[@]}" \
     -d '{"timezone":"Asia/Kolkata","slot_minutes":30,"weekly":[{"day":1,"start":"09:00","end":"17:00"}]}'
curl "$API/v1/consultants/astro-1/slots?duration_minutes=30" "${H[@]}"
START=$(date -u -v+90S +%Y-%m-%dT%H:%M:%SZ)          # GNU date: date -u -d '+90 seconds' +%Y-%m-%dT%H:%M:%SZ
curl -X POST $API/v1/bookings "${H[@]}" \
     -d "{\"consultant_id\":\"astro-1\",\"user_id\":\"user-42\",\"start_at\":\"$START\",\"duration_minutes\":10,\"reminder_minutes\":1,\"force\":true}"
curl -X POST $API/v1/consultants/astro-1/status "${H[@]}" -d '{"status":"online"}'   # or open the presence socket
# ~30 s later: booking.reminder; at START: booking.starting + booking.started and a consultation in "requested"
curl "$API/v1/bookings?consultant_id=astro-1" "${H[@]}"
curl "$API/v1/consultations?consultant_id=astro-1" "${H[@]}"
```

Point the app's `webhook_url` at a request bin (or `nc -l 9999`) to watch the events.
