# Code-Delivery Health Sweep + Property Health Score — Design

Status: **DRAFT for review** (no code yet)
Owner: platform
Related: per-property feature toggles (#383, merged), auto check-in failsafe (#382, merged)

## 1. Problem & goals

Sciener (TTLock) now rejects "too simple" passcodes with `errcode -2032`
("avoid consecutive or repeated digits"). Our passcodes are derived from
`substr(bookingId, -4)`, so a minority land on `1234`, `1111`, `2345`, etc. and
are silently refused — no working code on the lock, no error surfaced.

The hard constraint: **all guest messaging goes through the PMS, not us.** So we
can only change the code a guest is told for PMSes we can write back to. Any code
we change *must* match what the PMS messages, or we lock the guest out.

Goals:
1. Eliminate silent `-2032` failures for PMSes we control end-to-end.
2. For PMSes we don't control, **never fail silently** — detect and surface for a
   source-side (vendor) fix.
3. Make passcode delivery **observable and self-correcting** (codes present on the
   right lock, matching the guest's code, within the stay window).
4. Roll the signals into a per-property **health score**.

## 2. PMS capability matrix (the pivot)

| PMS (`Property.API`) | Guest code source | Can we override into the guest message? | Write-back method |
|---|---|---|---|
| `beds24` / `godo` | last-4 of bookingId (or DIRECTGROUPCODE) | **Yes** | `Beds24APIService::write_beds24_passcode` → `BRAFA_KEY` |
| `bookingfactory` (TBF) | last-4 of bookingId | **Yes** | `TBF_API_service::write_tbf_keycode` → `BRAFA_KEY` |
| `total` | Total `Keycode` field | **No** (fetch-only) | — |
| `frimann` | `Brafa_Key` pushed to us in their feed | **No** (inbound) | — |
| `fritimi` | Fritimi feed | **No** (fetch-only) | — |
| `golf_simulator` / `golfbokanir` | we generate `random_int` | already compliant | n/a |

Decision (agreed): for **read-only** PMSes, too-simple codes are resolved
**source-side** (push the vendor — Total/ein.is, Frímann, Fritimi — to generate
compliant keycodes). In-app we only **detect, report, and escalate**; we do not
heal or override them.

## 3. Components

### 3.1 `PasscodeHelper` (new, pure, unit-tested)
- `isTooSimple(string $code): bool` — true if all digits identical (`1111`) or a
  strictly consecutive run asc/desc (`1234`, `3210`). Mirrors Sciener's stated rule.
- `generateCompliant(int $len = 4): string` — random, rejects `isTooSimple`.
- No lock-collision awareness in v1; collisions (`-3007`) are handled by retry.

### 3.2 PMS capability helper
- `canOverrideGuestCode(string $api): bool` → true for `beds24`,`godo`,`bookingfactory`.
- `writeGuestCode(string $api, $bookingId, $propertyId, string $code)` → dispatches to
  the correct write-back (`write_beds24_passcode` / `write_tbf_keycode`).

### 3.3 Healing engine (writable PMS only)
For a booking whose code is too-simple / missing / mismatched:
1. If too-simple → `generateCompliant()`, persist to `bookings.passcode` **and**
   `BookingCode.Passcode` (single source of truth used downstream).
2. Send to the lock (existing `sendPasscode_for_booking`).
3. Write the code back to the PMS (`writeGuestCode`) so the guest message matches.
Idempotent / convergent: a healed booking won't be re-healed (its code is no
longer too-simple).

### 3.4 Detection-only (read-only PMS)
If too-simple → record a `simple_code_unfixable` issue (PMS, booking, code) and
**mark the booking blocked** so we do NOT re-send the known-bad code to the lock
every run (which would just re-trigger `-2032` noise). The issue feeds a
vendor-escalation list. No heal, no override.

### 3.5 The sweep — `app:codes:health-sweep`
Scheduled (proposed: every few hours). For each property with automation enabled:
- Load upcoming bookings (window: today − 1d … today + N days, not cancelled).
- Fetch the lock's passcode list **once per lock per run** (`listAllKeyboardPwd`,
  with token refresh), cache across bookings on the same lock.
- For each booking, per assigned lock:
  - **writable + too-simple** → heal (3.3).
  - **read-only + too-simple** → detect + block (3.4).
  - **lock-state check** (all): does a passcode equal to the intended code exist
    on the lock with an active window covering the stay? If missing / mismatched /
    expired → resend (writable) or record `missing_on_lock` / `window_wrong`
    (read-only).
- Record every outcome; resolve issues that are now healthy.

This generalizes today's reactive `-2032`/`-3007` handling into a **preventive**
sweep, and is the safety net behind the proactive (ingestion-time) generation.

### 3.6 Data model
`code_health_issues`:
- `id`, `PropertyID`, `bookingId`, `lockId` (nullable), `api`
- `issue_type` enum: `simple_code_healed`, `simple_code_unfixable`,
  `missing_on_lock`, `mismatch`, `window_wrong`, `send_failed`
- `severity` (info / warn / critical), `detail` (text/json)
- `first_seen_at`, `resolved_at`, timestamps
- one OPEN row per (`PropertyID`,`bookingId`,`issue_type`) to avoid duplicates.

`property_health_snapshots` (optional, for trend):
- `PropertyID`, `score`, `components` (json), `computed_at`.
Live score computed on demand; daily snapshot stored for history.

### 3.7 Property health score (0–100, banded green/amber/red)
Proposed components & weights:
- **Code delivery (40%)** — 1 − openCodeIssues / upcomingBookings.
- **Battery (20%)** — from `lock_records.electricQuantity` (min/avg, # below threshold).
- **Automation (20%)** — enabled + last successful run within expected interval.
- **Unfixable codes (10%)** — read-only escalations (informational penalty).
- **No-shows / anomalies (10%)** — optional, later phase.

### 3.8 Surfacing
- **Vendor-escalation list** — `simple_code_unfixable` grouped by PMS, exportable,
  so you can batch-report to Total/ein.is, Frímann, Fritimi.
- **Digest alert** — open issues by property/severity (reuse the low-battery report
  channel now; move to Resend per the roadmap).
- **Dashboard** — per-property score + drill-down to issues (superadmin
  lock-statistics page, or the property page).

## 4. How this answers `-2032`
- **Writable PMS** → auto-healed end-to-end; guest message (`BRAFA_KEY`) and lock
  always agree. Healing happens proactively at ingestion *and* reactively when
  Sciener returns `-2032`, with the sweep as a backstop.
- **Read-only PMS** → cannot be healed (their messaging); the sweep detects, blocks
  re-send loops, and produces the vendor-escalation list to fix at the source.

## 5. Phasing (when we build)
- **P1** — `PasscodeHelper` + writable auto-heal (proactive at ingestion + reactive
  on `-2032`). Kills the bulk of failures.
- **P2** — sweep + `code_health_issues` + read-only detection/blocking + escalation list.
- **P3** — property health score + dashboard + digest.

## 6. Edge cases & risks
- **Collision (`-3007`)** on a generated code → regenerate + retry, bounded.
- **Multiple locks per booking** (front door + room) → check each assigned lock.
- **DIRECTGROUPCODE** (beds24, guest-chosen group code): if it's simple, do we
  override it? It's guest-intended — proposal: leave it, but flag. **Open.**
- **Re-send loop** for read-only bad codes → the `blocked` flag prevents it.
- **PMS template assumption** — beds24/TBF guest messages must render `BRAFA_KEY`
  (true today). Document as a prerequisite.
- **Production, no staging** — ship the sweep behind a flag with a **dry-run mode**
  first (report only, no writes); enable healing per-property after validation.
- **Timezone** — stay-window comparisons use the property/app timezone consistently.

## 7. Open questions for review
1. Score weights/bands above — acceptable?
2. Sweep cadence (every few hours? aligned to automation runs?).
3. Dashboard home: superadmin lock-statistics vs per-property page vs both.
4. Alert channel now: reuse low-battery report path, or wait for Resend?
5. DIRECTGROUPCODE simple-code handling (override vs leave+flag).
6. Keep the reactive `-2032` handler as belt-and-suspenders once proactive+sweep exist?
