# LIS — Patient-Portal report-access tracking (viewed / downloaded)

**Status:** ✅ SHIPPED on `hazemdev` + FF'd to `main` (2026-06-26). FE deployed to `/app`, BE live. **Migration RUN on `moonui_dev_be`** (table + both indexes + recorded, verified). Auto-runs on client update via MoonStack.
**Commits:** BE `f8061545b` (feature) + `3fa81e9fa` (index-name fix) · FE `ddb24c5`.

> ⚠️ **Migration bug caught at run time (fixed in `3fa81e9fa`):** the auto-generated composite-index name (table + 3 cols + `_index`) exceeded MySQL's **64-char identifier limit** (error 1059) → the migration created the table but failed before the indexes and **never recorded itself** (would re-run + fail on every client). Fix = explicit short names `ppal_request_action_idx` / `ppal_company_patient_idx`. **Lesson: name composite indexes explicitly when the table name is long.**

## Why
Before a supervisor **edits / re-releases an already-released result**, they need to know the patient may already be holding the OLD report. So the Validation (and result-entry) worklist now shows, per request card, whether the patient **viewed** and/or **downloaded** their report from the public patient portal, with the exact date/time on hover.

## What was built

### Backend (`Modules/LIS`)
- **Table `lab_patient_portal_access_logs`** (migration `2026_06_26_120000_*`) — a *general* portal audit log, not view/download-only:
  - cols: `company_id, patient_id, lab_request_id, lab_result_id (reserved/nullable), action(30), portal_token_hash(64), ip_address(45), user_agent(text), metadata(json), accessed_at`.
  - `action` enum-by-convention: `viewed | downloaded | attachment | invoice | login` (only viewed/downloaded implemented now).
  - indexes: `(lab_request_id, action, accessed_at)` (covers the per-request `MAX(accessed_at)` lookup) + `(company_id, patient_id, accessed_at)`.
- **Model** `Modules\LIS\Models\LabPatientPortalAccessLog`.
- **`PatientPortalController::track(Request, $requestId)`** → `POST /lis/portal/requests/{request}/track` (inside the `PatientPortalAuthMiddleware` group):
  - validates `action in:viewed,downloaded`;
  - **re-verifies ownership** (`LabRequest where id + company_id=$patient->company_id + patient_id=$patient->id + whereNull(deleted_at)->exists()`) before logging;
  - returns **identical `{ok:true}` on both paths** (no request-ID enumeration vector);
  - on ownership FAIL → `Log::warning('Portal track: ownership check failed', …)` so IDOR probing is detectable (best-effort, never affects response);
  - `logPortalAccess()` is best-effort `try/catch` — tracking must NEVER break the portal.
- **`WorklistContextService::cards()`** — after building `$cards`, one **grouped query** (`whereIn lab_request_id` + `where company_id` + `MAX(accessed_at)` group by request,action) stamps `patient_viewed_at` / `patient_downloaded_at` (ISO) onto each card. Wrapped in `try/catch` so the worklist still loads **pre-migration**. No N+1.

### Frontend (`moon-erp`)
- `PatientPortalService.track(requestId, action)` → posts to the portal endpoint (standard `Authorization: Bearer`, portal session token).
- **`pp-request-detail.component`** — `markViewed()` fires `'viewed'` **once per visit** (guarded by `viewTracked`), called after `request.set(...)` in both the cached and the network-load paths.
- **`pp-print.service`** — `trackDownloaded()` fires `'downloaded'` for **both** the classic (jsPDF `doc.save`) **and** the HTML-template (`printReport` → window.print) paths. Clicking the portal's own print/download button is the trackable intent regardless of render engine.
- **Validation worklist** (`validation-worklist.component`): `WorklistCard` + `RequestCard` gained `patient_viewed_at`/`patient_downloaded_at`; `mapServerCard` maps them; card template renders `👁 Viewed` / `⬇ Downloaded` badges (`pi-eye`/`pi-download`) with date tooltips; scss `.req-card__portal` + `.wl-portal-badge--viewed/--downloaded`. Badges show **only** when a timestamp exists. Legacy (row-derived) worklist mode → always null.
- i18n: `LIS.PORTAL_ACCESS.{VIEWED,DOWNLOADED,VIEWED_AT,DOWNLOADED_AT}` (en/ar).

## ⚠️ Known limitation (by design)
Only the portal's **own** download/print button is tracked. A patient using the **browser's raw Ctrl+P / Save** bypasses the button → not recorded. The classic path saves a real PDF (definite download); the HTML-template path opens a print dialog (intent, may be cancelled) — both fire `downloaded`. This is the right trade-off (no way to detect raw browser print), and is stated in the CHANGELOG.

## Security model
- Portal auth = link-token session (`PatientPortalAuthMiddleware`, standard `Authorization: Bearer`, token stored as `sha256` in `lab_patients.portal_token`).
- `portal_token_hash` in the audit row stores `$patient->portal_token` **as-is** (it's already `sha256(plain)`). **Do NOT re-hash** — a code review caught a double-hash (`sha256(sha256(plain))`) that made the field uncorrelatable; fixed in `f8061545b`.
- Every tracked write re-checks ownership server-side (`company_id` + `patient_id`). The worklist read query is also `company_id`-scoped (defense-in-depth; matters given the `POST /api/lis/dev/reset-data` reset endpoint).

## Migration — DONE on moonui (2026-06-26)
Run with owner authorization via an isolated copy `database/ptrack_mig/` (so no other pending migration ran by accident), then the temp dir was deleted:
```
cd /home/moonui/moon-erp-be && sudo -u moonui php artisan migrate --path=database/ptrack_mig --force
rm -rf database/ptrack_mig
```
Laravel tracks migrations by **basename**, so this registered `2026_06_26_120000_create_lab_patient_portal_access_logs_table`; the real file under `Modules/LIS/database/migrations/` won't re-run. On client installs the real (now fixed) migration runs automatically via MoonStack update. Verified: 13 columns + `ppal_request_action_idx` + `ppal_company_patient_idx` + migration row (batch 217), and an insert/delete smoke test passed.

## Code review (native code-reviewer — Codex blocked on this server)
Verdict WARNING → all findings applied before commit: HIGH (double-hash, missing `company_id` scope, unnecessary `as any`), MEDIUM (track HTML-template path too + IDOR-probe audit log), LOW (i18n keys; `lab_result_id` kept + documented as forward-design for result-scoped events).
