# Milestone 2 — Clinic Core (Implementation Notes)

## Modules delivered
Dentists (CRUD + per-dentist color/slot/buffer/online flag) · Weekly schedules
(grid editor, up to 2 ranges/day) · Schedule exceptions (whole-day or partial
off, extra hours, per-dentist or clinic-wide) · Patients CRM (search, profile,
visit history, encrypted medical history) · Booking engine · Public booking
page with WhatsApp contact FAB (v2.1-A) · Availability engine · Calendar
(day/week/month, drag-&-drop reschedule) · Status state machine · Dashboard
KPIs + 14-day chart · 18 feature tests.

## Double-booking prevention — two layers
1. **BookingService** (the only write path): transaction +
   `lockForUpdate` on the slot key + a fresh `SlotEngine::isAvailable()`
   check inside the lock.
2. **`appointments.active_slot_key` UNIQUE index** — maintained in the model's
   `saving` event as `"{dentist_id}|{starts_at YmdHi}"` while the status holds
   the slot (pending/confirmed/checked_in/completed), `NULL` otherwise.
   Even code that bypasses the service cannot commit a duplicate
   (covered by `test_unique_slot_key_blocks_writes_that_bypass_the_service`).
   Cancelling/no-show nulls the key, freeing the slot automatically.

## Availability semantics (SlotEngine)
availability = weekly windows − whole/partial offs + extra windows
             − occupied appointments − past times, all computed in the
**clinic timezone** (`tenants.timezone`), returned as UTC + local label.
Slot stride = `slot_duration + buffer`.

## Timezone & API conventions
- Storage UTC everywhere; UI and API I/O are clinic-local
  (`App\Support\ClinicTime`). Forms post `Y-m-d H:i` local.
- Calendar feed serves clinic-local ISO strings so FullCalendar needs no
  timezone plugin; drag-&-drop PATCHes local time back.
- Conflicts return HTTP 409 (JSON) or a friendly flash (web).
- (v2.2) The standalone API v1 originally shipped here was removed with the
  web-only scope change; internal JSON endpoints remain under web auth.

## Public booking identity
Patients are keyed by normalized phone (digits-only, Iraqi `07xx → 9647xx`):
returning numbers reuse the record, new numbers create one with
`source=online`. Online bookings start as **Pending** (reception confirms →
M5 sends the WhatsApp confirmation); reception bookings start **Confirmed**.

## Deliberate boundaries
- Patient picker in the calendar modal is a simple select (≤500 patients);
  swaps to async search in M3 polish.
- `reminder_24h/1h/review_request` timestamp columns already exist on
  appointments so M5 needs no schema change.
- No new composer packages: FullCalendar 6 + Chart.js 4 via CDN.

## Run the tests
```bash
php artisan test
# Booking engine: slots, offs/extras, double-booking, slot-key guard,
# cancellation freeing, stats; Public booking: WA button, phone identity,
# conflict handling, uuid cancel; CRM: search, cross-tenant isolation, RBAC.
```
