> ## Documentation Index
> Fetch the complete documentation index at: https://q.odontox.io/llms.txt
> Use this file to discover all available pages before exploring further.

# v1.5 — Multi-Clinic, Mobile v2 Foundations, and Platform-Wide Performance

> True multi-clinic support across login, sessions, and real-time events; a much faster dashboard cold load; mobile app overhaul with shared design tokens, mPIN + device-trust foundations, 90-day sessions, and a sweeping set of correctness, privacy, and reliability fixes.

## Overview

v1.5 is a scale and reliability release. Where v1.4 was about new surfaces (radiology, WhatsApp, sessions, permissions), v1.5 is about making the platform behave correctly at the *clinic boundary* — when a single user belongs to two or more clinics, when the network is flaky, when a tab refreshes, when an invitation lands on an existing account, when a payment is double-clicked. Every layer of the app — sessions, real-time events, branding, modules, settings, billing — has been audited and aligned around the **active clinic** instead of the user's primary clinic stored in their profile.

The headline addition is **true multi-clinic**: a post-login picker, an in-app top-bar switcher, scoped welcome emails, and a new superadmin **Multi-Clinic** panel that surfaces every cross-clinic membership and any role mismatches. A user can now hold doctor at Clinic A and admin at Clinic B without the platform ever confusing the two.

Cold dashboard performance has been **rebuilt from the ground up**: the initial download dropped from \~3.5 MB to \~320 KB by lazy-loading every authenticated module and splitting heavy libraries into on-demand chunks. The browser no longer eagerly downloads code for modules you haven't opened yet.

Sessions are now stable across reconnects and tabs. Access tokens were extended to 90 days end-to-end, refresh rotation now recognises benign races instead of revoking the whole family, and the browser coordinates across tabs so only one tab refreshes the session at a time. Database queries that previously could hang now fail fast with a clean error so the page can recover and you can retry.

The **mobile app** got its v2 foundations: a shared design-tokens module that drives both web and mobile, theme-aware logo art, the Poppins font, an animated splash with a dark variant, a slim 5-tab bottom bar, a bento home screen, an iOS-style booking screen, and clearer API error messages. Server-side, the **mPIN + device-trust** foundations landed — the building blocks for mPIN sign-in and biometric unlock that the mobile v2 redesign is built around.

Patient-facing documents are cleaner and more honest: invoices generated from a quote now carry a "From Quote: #EST-1023 (12 Mar)" lineage on both screen and PDF, bank details and an optional **Scan-to-Pay QR** render on every export, patient phone is stripped from shared documents entirely, and previously encrypted-looking phone strings are decrypted before they reach the UI.

Account integrity has been hardened across the board: accepting an invite to a second clinic no longer overwrites your existing identity or password, duplicate checks are scoped to the clinic instead of the platform, the plan-expiry gate stops misreading new clinics as expired, and a non-dismissable banner reminds any user without 2FA to set it up before they can silently end up unprotected.

Rounding out the release: a real treatment-plan success rate (no more hardcoded 92%), a unified activity timeline that auto-refreshes from the real-time event bus, per-clinic receipt numbering, full-page patient and inventory forms with double-submit guards everywhere, a lab module that finally shows files uploaded by the lab via the shared link, AI widgets hidden when the AI Insights module is off, clearer server logs for permission issues, dental chart auto-save, faster patient search, and a WhatsApp confirmation that actually fires when the appointment is created.

***

## Multi-Clinic — End-to-End

A single OdontoX user can belong to many clinics. Every layer now respects that.

### Post-Login Clinic Picker

When a user with two or more active clinic memberships signs in, they land on a clinic picker instead of being silently dropped into one. Each card shows the clinic name, address, role badge, and a primary indicator. Single-clinic users skip the picker entirely; superadmins skip it too. Picking a clinic stores the choice locally and sends it with every authenticated request — no re-sign-in, no session disruption.

The picker uses the same shader background and glass-card styling as the rest of the auth pages so it doesn't feel like a hand-off to a different product.

### In-App Clinic Switcher

A clinic switcher dropdown sits in the dashboard top bar for any user with 2+ active memberships. It shows the current clinic's name with a small building icon; clicking it lists every clinic the user belongs to with name, address, role, and a "primary" badge. Selecting another clinic stores the new active clinic and reloads the dashboard so every module and setting refreshes for the chosen clinic. The switcher auto-hides for users with 0 or 1 clinic memberships and for superadmins.

### Real-Time Events Scope to the Active Clinic

Real-time toast notifications and clinic events now route to the active clinic, so a user signed into one clinic no longer sees notifications from another. The browser's event-stream API cannot send custom headers, so the active clinic is passed another way and verified against the user's assignments on the server.

### Multi-Clinic Superadmin Panel

A new **Multi-Clinic** tab on the superadmin User Management page lists every user with 2+ active assignments. Each row expands to show the user's full assignment matrix (clinic name, per-clinic role, primary flag, active/inactive clinic) plus a search/filter and a "mismatches only" toggle. Users whose platform-wide role doesn't match any of their per-clinic roles are flagged with an amber badge — these are worth surfacing so ops can either align the platform role or add a matching assignment. Closes the gap created by shipping multi-clinic without superadmin parity.

### Cross-Clinic Welcome Emails

The `sendWelcomeEmail` heuristic used to look at the user's role and force OdontoX-platform branding for any admin — so an admin invited to a *second* clinic got a "Welcome to OdontoX" email instead of a clinic-branded one. The branding mode is now an explicit `kind: 'clinic' | 'platform'` parameter that the caller passes. The OdontoX-branded welcome is now reserved for one specific moment: when a clinic-founding admin completes their first MFA setup. Every other invite (admin joining a second clinic, doctor, receptionist, patient) gets a clinic-branded welcome.

### Cross-Clinic Invitation Acceptance (Identity Preserved)

Accepting an invitation to an additional clinic no longer changes your password, name, role, or default clinic. Previously the accept handler unconditionally overwrote the existing `users` row with whatever was on the invitation — including hashing the password typed during accept and replacing the stored hash. Their original-clinic password silently stopped working. The accept flow is now split: users with no password yet still get their identity and password set from the invitation, but users who already have a password set are *only* attached to the new clinic via `userClinicAssignments`. Nothing on their `users` row is touched.

### Cross-Clinic Duplicate Checks

Inviting a user, patient, or staff member who already exists at *another* clinic no longer fails with "email already exists". Both the superadmin invitation path and the patient-invite check are now scoped to the current clinic's active assignments. The patient invite modal shows a blue informational note ("Existing OdontoX user — accepting will add portal access for this clinic alongside their existing one") instead of disabling the Send Invitation button. The duplicate-invitation guard is scoped the same way — a pending or accepted invitation at one clinic no longer blocks invites for the same email at another clinic.

***

## Performance — Dashboard Cold Load: 3.5 MB → 320 KB

Until v1.5, opening the dashboard pulled the entire app — every module, every document library, every chart and 3D library — into a single \~3.5 MB initial download. The cold load is now **\~320 KB**.

### Lazy Modules

All four role dashboards now load each authenticated module on demand — only the role's Overview is eager. First navigation into a module shows a brief "Loading…" spinner once, then is cached for the session.

### Granular Library Chunks

Heavy libraries are split into their own download chunks. Heaviest libraries are matched first so they don't get swept into a generic bundle.

### No Eager Preloading

The build no longer tells the browser to preload every possible lazy chunk on first load, so the browser no longer downloads code for modules you haven't opened yet. Lazy chunks now fetch just-in-time on first navigation (\~100 ms one-time cost over a warm CDN).

### Snappier Dental Chart + iPad Layout

Each tooth on the dental chart is now an isolated component, so clicking one tooth doesn't redraw the other 31. The expensive dirty check that ran on every keystroke is gone — replaced with a fast comparison. The dental chart is also loaded on demand so the doctor dashboard's first paint is no longer blocked by the chart bundle.

Dynamic viewport units are now used across the app shell, sticky sidebars, and full-height pages so they stop jumping when Safari's address bar collapses on scroll. The viewport now handles notched iPad Pros correctly, and text inputs scroll into view on iPad/iOS focus so the field stays visible above the keyboard.

### Fast Patient Search

Patient search is now backed by a proper database search index. The index is created automatically on first use, so typing the first letter is instant even in clinics with tens of thousands of patients. Short queries return alphabetical results; longer queries use similarity ranking.

### Bridge Inbox CPU

The Bridge inbox no longer pre-signs file URLs for every connected client. Files are now streamed inline on demand, and standard database queries use a lighter connection path. This removes the CPU spikes that could slow the inbox on busy clinics.

***

## Mobile App v2 — Foundations

The Expo mobile app received its visual and structural reset in preparation for the v2 patient experience.

### Shared Design Tokens

A new shared design-tokens module is now the single source of truth for color palette, spacing, and typography across web and mobile. Both the web app and the mobile app import from it.

### NativeWind + Tailwind Toolchain

NativeWind v4 + Tailwind is installed in the Expo project with the Babel plugin, Metro global CSS pipeline, and a Tailwind config that points at the shared tokens.

### Theme-Aware Logo

A new Logo component renders the correct light/dark logo art based on the device theme. Legacy duplicate assets were removed so the right logo is always used.

### Poppins via Expo Font Config Plugin

The Poppins font moved from a runtime hook to the Expo font config plugin, eliminating the splash-time font flash. All authenticated screens and the themed system pick up Poppins automatically.

### Animated Splash With Dark Variant

The splash screen now animates and ships a dark variant for users in dark mode.

### Bento Home + Slim 5-Tab Bar

The home screen was rewritten as a bento layout: appointments, records, reminders, and messages each get their own card with a clear primary action. The bottom tab bar dropped from a floating cluster to a slim, centered 5-tab layout that respects safe areas on every device. The booking screen was rebuilt as an iOS-grouped list with native pickers.

### HIG Typography + Structured API Errors

The home and booking screens were rewritten to follow Apple HIG type scale and weight conventions. The mobile API layer now surfaces structured errors from the server instead of generic "request failed" toasts, so the UI can render real cause text. The Notes screen no longer crashes on missing patient data.

### Auth State Hardening

The auth context now enriches the session from the profile endpoints so the clinic name and full patient profile are visible immediately after sign-in. Patient dashboards no longer error on routes that need the profile bootstrap. The auth layout now redirects already-authenticated users away from the welcome, onboarding, and sign-in screens.

### Server-Side mPIN + Device Trust Foundations

The backend building blocks for mobile v2 mPIN sign-in and biometric unlock are now in place: per-user mPIN storage with hash and lockout fields, per-device trust tokens that can be revoked individually, and the matching hash/verify helpers. Mobile-app-side wiring is the next step and will land in v1.6.

***

## Sessions, Auth & Account Integrity

### 90-Day Access Tokens

Access tokens previously expired after 15 minutes, which was causing users to be silently signed out on slower devices and edge cases where the auto-refresh round-trip didn't complete cleanly. Token lifetime now matches the existing 90-day refresh token, so a single sign-in carries through for the full window. The 7-day token used by 2FA flows and the 15-minute token used for superadmin impersonation are deliberately left as-is. Refresh tokens are unchanged, so the effective session window is the same — only the access-token half of the pair was the friction point.

### Sessions Stop Dying on Reconnect / Multi-Tab

When the network came back, multiple in-flight requests would race the same refresh token, and one of them would be flagged as a stolen-token replay — killing the whole session. The server now recognises a real reuse versus a brief race window: benign races are handled gracefully, and the family is only revoked on genuine reuse outside the window.

The browser now coordinates across tabs so only one tab refreshes the session at a time. Other tabs wait, then read the freshly stored token. If a sibling tab already rotated the token, the network call is skipped entirely. On a race, the client waits briefly and re-checks storage for the sibling's token before retrying.

### Stable Session Across Refresh Rotations

The refresh used to mint a new session identity on every rotation, which immediately invalidated any in-flight request still using the previous one — visible to users as a concurrent-session error right after a refresh. The session identity is now stable across refresh rotations; only an actual login on a new device changes it.

### Plan-Expiry Gate Stops Misreading New Clinics as Expired

Brand-new clinics no longer get redirected to the "Upgrade Clinic" / suspended-account screen after scanning the 2FA QR code. The account-status page would appear with a missing token because the server treated the freshly-approved clinic as if its plan had expired. The plan-expiry gate now triggers only on real subscription signals — a past trial end date, a past next billing date, or a suspended/cancelled subscription status. A clinic's active flag on its own is no longer misread as "plan expired".

The clinic-approval flow now properly activates the clinic at approval time. Previously, when a superadmin approved a user with a plan, the clinic record stayed inactive and only the subscription status was set to active. A new healing script scans for clinics in the bad state going forward.

### Persistent 2FA Reminder Banner

A non-dismissable red banner now appears on every authenticated page when 2FA is not enabled on the user's account. It directs the user to **Settings → Security** with an "Enable 2FA" button and disappears the moment 2FA is set up. This covers the case where someone bails out of the QR-code step during invite acceptance, or the verification fails and they navigate away — they cannot silently end up with an unprotected account anymore. The banner is hidden on the security settings page itself so it doesn't block the form.

### Invitation Accept No Longer Overwrites Existing Identity

Accepting an invitation to an additional clinic no longer changes your password, name, role, or default clinic. The flow now checks whether you already have a password set: users with existing credentials are only attached to the new clinic, while brand-new users still get their identity and password set from the invitation.

### Email Code Removed From Invite-Accept 2FA

Email Code is no longer offered as a 2FA method during invitation acceptance. MFA is mandatory for the platform; the email-OTP option was a loophole because it grants 2FA via the same channel as the invitation email itself. The invite-accept "Secure Your Account" step now offers only Authenticator App and Passkey (plus "Skip for now"). The email-OTP backend path, the dormant email-setup UI step, and the existing email-2FA-from-settings option are unaffected.

***

## Patient Portal v2

### Requested Tab + Cross-Linked Billing

Patient portal **My Appointments** now has a Requested tab alongside Upcoming and Past — pending requests no longer mix with confirmed bookings. Quotations now shows the linked invoice when a quote has been accepted ("→ Invoice #ODX-…"), and the Invoices list shows where each invoice came from ("← from Quote #…"). Patient invoice PDFs include a "Generated from Quotation #X" line under the totals and in the footer so the conversion path is visible on the printed/shared document too.

### Patient Permission Templates Apply at Runtime

The Permission Templates page in clinic settings has been editable for a while, but admin-saved overrides for the patient/doctor/receptionist roles weren't being read by the permission resolver. They are now — clinic-level template → per-user override → effective permissions, in that order.

### Plan-Tier Matcher

Pro+ paid plans now grant the Pro+ permission set robustly. The plan-tier matcher normalises variations like "Pro+", "pro plus", and "pro-plus" to the same bucket, and Enterprise inherits the full Pro+ permission set instead of silently falling through to Pro defaults.

### Patient Portal Polish

View / Download buttons on every invoice, receipt, and quotation row now show a per-row spinner and disable while the PDF is being prepared. Clicks during the wait are ignored, the resulting blob URL is opened in a new tab and revoked after a minute (was leaking forever before), and toast messages are user-friendly never raw error text.

A patient (or staff) can no longer create a second active appointment with the same patient + date + start time — the server returns a clear conflict error with a friendly message ("You already have an appointment requested for 2026-05-04 at 10:00…"). Cancelled and no-show slots are excluded so re-booking after a cancellation still works.

Out-of-hours error now explains *why*. When a requested appointment falls outside clinic hours, the message includes the appointment's start, computed end (start + duration), and the clinic's open–close window. Patients used to think the start time alone was the issue; the duration is now part of the explanation.

***

## Invoices, Quotations & Finance

### Source Quote Lineage

Exported invoice PDFs (and the on-screen preview) now show the quote they came from. Both the in-app preview and the downloaded PDF carry a "From Quote: #EST-1023 (12 Mar)" line under the invoice header. Same applies to the public shared-link viewer. Invoice timelines now record the quote they came from when the quote is accepted (either by clinic staff or the patient via the shared link).

### Bank Details + Scan-to-Pay QR on Exported PDFs

Bank details and the Scan-to-Pay QR now render on the exported invoice PDF. Previously the QR + bank-account list only showed up in the on-screen preview; the PDF download skipped them entirely. The PDF now mirrors the preview: 96 px QR block (when set), per-account "Bank Transfer" list, mobile wallets, and any extra payment notes from clinic settings.

### Optional Scan-to-Pay QR Per Bank Account

**Settings → Documents → Bank Accounts** lets the clinic admin upload a JPEG/PNG QR code for each account (≤256 KB). When set, the QR appears prominently on every invoice and quotation under a "Scan to Pay" block alongside the bank details — patients can pay by scanning instead of typing the IBAN.

### Per-Clinic Amount Caps + Friendly Errors

**Settings → Documentation** has a new "Document Amount Limits" card where admins set a max invoice total and max quotation total. Anything above the cap is rejected with a friendly message before reaching the database. The default global ceiling is 99,999,999.99 (matches the underlying numeric column precision).

When something goes wrong on the server (e.g. a numeric overflow from a typo'd amount like `110000000`), the user sees a short friendly explanation instead of the failing query and parameter list. The full error is still captured server-side for debugging. The central error handler now recognises numeric overflow, check violation, and invalid format errors and returns a friendly message.

### Real Treatment-Plan Success Rate

The dashboard tile used to show a hardcoded **92%** for every clinic. It now computes `completed ÷ (completed + cancelled) × 100`, and shows "—" until at least one plan has been decided. Plans still pending or in-progress no longer dilute the number.

### Per-Clinic Receipt Numbering

Recording a payment no longer fails with a duplicate-number error in busy clinics. Receipt numbers were globally unique across every clinic on the platform, so once any clinic issued `REC-1003`, no other clinic could ever issue `REC-1003` of their own. Numbers are now unique *within a clinic*, which matches how invoice numbers already work.

### Module-Aware Documentation Settings

**Settings → Documentation** only shows numbering rows for modules the clinic has active. The Insurance Claim row is hidden unless the Insurance module is on, and the Lab Case row is hidden unless Lab Work Tracking is on. Previously these rows always appeared in numbering settings even when the corresponding feature was disabled.

### Decrypted Phone in Internal Documents

Patient phone numbers were showing up as encrypted ciphertext (a long random-looking string) on invoice and quotation list/detail screens. The phone field is encrypted at rest (PHI/HIPAA) and was being passed through to the UI without decryption. The server now decrypts before returning, so internal staff sees the real phone number again.

### Phone Stripped From Public Documents

Patient phone is no longer included in customer-facing documents. The Bill To / Received From block on Invoices, Quotations, and Receipts (both inline document and PDF) now shows only the patient name and email — the patient already knows their own phone number, and printing it on a document the clinic shares with third parties is unnecessary exposure. Public shared-link responses no longer return patient phone at all.

### Double-Submit Guards Across All Create Flows

A hard guard now prevents double-clicks from creating duplicate records across every "Create" / "Save" / "Submit" / "Confirm" button: appointment booking, status changes (including completing with invoice), patient creation, lab case creation, lab share-link, prescription save, prescription save-and-export, insurance claim save, inventory item add/edit, invoice creation, quotation save, payment confirmation. Each handler blocks the second click immediately instead of waiting for the disabled state to render. Most show a spinner during the wait.

***

## Lab Work Module

### Shared-Link Uploads Visible

Files uploaded by the lab via the shared link now appear in the doctor's case detail view. Previously the lab could attach files to a case via the public link and the clinic side never saw them. The data model and every read path were updated so the round-trip works.

### JPEG / PNG Only

Lab image uploads are now JPEG / PNG only (no ZIP / DICOM extraction). Doctors browse and attach images directly with multi-file selection, max 25 MB each. The DICOM-from-ZIP flow that lived under lab cases is removed — the dedicated DICOM ZIP flow on the radiology side covers that path.

### Activity Timeline Auto-Refresh

The activity timeline now subscribes to the existing real-time event bus and refetches whenever an event mentions the same entity (`labCaseId`, `invoiceId`, `appointmentId`, `quotationId`, `treatmentPlanId`, or generic `entityId`). No more manual refresh-button presses. Lab actions performed via the public shared link now record activity entries with `source: 'shared_link'` so the doctor's case-detail timeline shows them too.

***

## Settings & Permissions

### Receipt Numbering Per-Clinic

Receipt numbers are now unique within each clinic instead of across the whole platform. The database constraint is updated automatically on first use, so no manual migration is required.

### Permission Tree Checkbox Crash Fix

Editing staff permissions no longer crashes the dashboard after a few rapid clicks. A controlled checkbox in the Permissions tree could enter a render loop when the parent module's wrapper double-fired the toggle handler. The wrapper now only blocks click bubbling and lets the checkbox itself drive the state change, and the change handler is stabilised so the checkboxes see a stable callback across parent renders.

### 2FA Settings Polish

Removed the **Disable 2FA** option from the Security settings page. MFA is now mandatory; the disable path was a loophole. Added a green "Enabled" badge when 2FA is set up. Replaced the Shield icon with `KeyRound` so the visual matches the "you have a key" mental model.

### Operatory Create Fix

"Failed to add room" in Settings is fixed. The rooms table was created lazily by the appointments module, but the rooms route didn't trigger that setup — so on a fresh server instance the insert failed and the UI swallowed the real error as a generic "Failed to create room". The route now ensures the database schema on every handler, and the UI shows the real server message.

### Dental Chart Auto-Save

The Save button still works for explicit saves, but pending edits now persist on their own — no more "I clicked away and lost my work". A 1.5-second debounce after the last edit triggers auto-save. A `beforeunload` prompt also fires if there are still unsaved changes when the user tries to close the tab. The save indicator next to the button shows "Unsaved changes (auto-save in 1.5s)" → "Saving…" → "Saved 14:32".

***

## Receptionist & Staff List Correctness

The Staff Management list was leaking patient users when a patient's global default role was something other than `patient` (e.g., they previously joined as staff at another clinic). The list now filters on the per-clinic role from the assignment row, so it only shows actual staff at the current clinic. The `owner` role is also included now.

The admin dashboard no longer fires 403s on plans without AI Insights. Previously every admin dashboard load fired `GET /ai/daily-brief` and `GET /ai/revenue-forecast` regardless of plan, generating two console errors per render on Basic and Pro clinics. The AI Insights section is now wrapped in `<RequireModule module="ai_insights" fallback={null}>` — entire section (header, "Full AI Dashboard" button, both widgets) is hidden when the module is not active.

***

## Reliability & Debuggability

### 8-Second DB Query Timeout

A handful of API calls (notifications, dental chart, permission checks) were occasionally hanging for up to a minute when the database connection stalled. They now fail in 8 seconds with a clean error so the page can recover and you can retry instead of waiting on a frozen screen. A query-timeout helper races every wrapped database call against an 8-second hard timeout.

### Debuggable 4xx Errors

Server error logs now include the requesting user, role, active clinic, and requested path for every permission or not-found error. This lets the support team triage a 403 in seconds — the typical cause is a stale resource ID in the URL after switching clinics or impersonating a user. Permission denials are logged with the same context.

### Share Link Toast

The "Share link" button now tells you what happened. Previously, if generating an appointment share link failed (no permission, network error, etc.) the button silently did nothing. It now copies on success and shows a clear toast on failure with the server's actual error message and HTTP status.

### One-Click Cross-Module Navigation

Clicking a patient row, an appointment card, or any cross-module link now opens the target screen on the **first tap**. Previously the URL in the browser bar would update but the page contents wouldn't switch — you had to click two or three times, or refresh. `useDashboardView` was syncing the active module to the URL only on browser back/forward (POP) and intentionally ignoring PUSH/REPLACE. The original guard was added to avoid an imagined "fight" between two state updates, but React 18 batches both updates inside `handleViewChange` — the effect re-runs once and resolves to a no-op. Removing the guard makes URL → state sync the single source of truth for every navigation.

***

## WhatsApp & Notifications

### Confirmation Fires on Booking

WhatsApp confirmation now fires when an appointment is *created* (front-desk booking, patient self-booking, share-link booking). Previously the confirmation hook was wired to the update event only, so a patient who booked through the portal received no WhatsApp confirmation until staff edited their appointment. The send happens in the background so the booking response time is unaffected.

***

## Privacy & PHI

* Patient phone is decrypted before reaching internal Invoice/Quotation views.
* Patient phone is stripped from the Bill To / Received From block on customer-facing Invoices, Quotations, and Receipts.
* Public shared-link responses no longer return patient phone at all.
* The Invite Patient modal no longer discloses that an email is registered at another clinic — the cross-clinic existence note is shown only after the admin has chosen to send the invitation.

***

## Who Benefits

**Doctors** — Faster dental charts (memoized teeth, lazy odontogram bundle), iPad-friendly app shell with `100dvh`, auto-save on the chart, and lab-uploaded files finally visible without a refresh. Treatment-plan success rate is now real instead of a hardcoded 92%.

**Clinic owners and admins** — A single account works at every clinic the user runs or supports, with a top-bar switcher and scoped notifications. New 2FA reminder banner closes the unprotected-account loophole. Per-clinic invoice and quotation amount caps prevent typo-driven overflows. Source-quote lineage is visible on every invoice document.

**Reception / front desk** — Confirming a patient request is now one click ('confirmed' added to allowed transitions from 'requested'). Out-of-hours error explains the duration and clinic window, not just the start time. Staff list no longer leaks patient users. Receptionist dashboard is faster (lazy modules, no AI 403 spam on non-AI plans).

**Lab technicians (external)** — Files uploaded via the shared link land in the doctor's view immediately; activity is reflected on both sides of the relationship.

**Patients** — Dedicated **Requested** tab in their portal so pending bookings don't mix with confirmed ones. Invoice ↔ quote cross-references on every list. Scan-to-Pay QR on every invoice and quotation. View / Download buttons no longer leak blob URLs forever. WhatsApp confirmation actually fires when they book.

**Mobile users** — A redesigned bento home, slim 5-tab bottom bar, animated splash with a dark variant, theme-aware logo art, Poppins font, Apple HIG-aligned typography on home and booking, and structured API errors with real cause text instead of generic toasts.

**Superadmins** — A dedicated **Multi-Clinic** panel surfaces every cross-clinic membership and any role mismatches. Approval flow now correctly activates clinics. Invitations to users who exist at other clinics no longer fail with "email already exists". Healing script for stuck inactive clinics.

***

## Upgrade Notes

* **Active clinic stored locally.** The clinic switcher stores your chosen clinic in the browser. Existing single-clinic users see no change — the value is auto-set to their primary clinic on first login. Multi-clinic users hit the post-login picker once on next sign-in.
* **Authenticated requests carry the active clinic.** The app sends the active clinic with every authenticated request, and the cache key now includes it so a multi-clinic user never sees another clinic's cached response after switching.
* **Real-time event streams are clinic-scoped.** Event-stream connections now pass the active clinic another way because the browser API cannot send custom headers. Both first-party consumers are updated.
* **Access tokens now last 90 days.** Refresh tokens are unchanged. The 7-day token used by 2FA flows and the short-lived token used for superadmin impersonation are deliberately left as-is.
* **Plan-expiry gate now requires real subscription signals.** A clinic's active flag on its own is no longer treated as "plan expired". Run the healing script once after deploy to heal any stuck clinics.
* **Receipt numbers are now per-clinic unique.** The database change is applied automatically the first time the receipts feature is used. No manual migration is required.
* **Patient search index.** The search index is created automatically on the first call to patient search. Cold start on a fresh server pays the index-build cost once; subsequent calls are fast.
* **No eager preloading.** First navigation into each module pays a one-time just-in-time chunk fetch (\~100 ms over a warm CDN). Module chunks are then cached for the session.
* **Mobile mPIN + device-trust foundations are server-side only.** The mobile app does not yet use the new flows — the v1.6 mobile release wires them up.
* **Invitation accept respects existing identity.** Users who already have a password keep their password, name, role, and primary clinic when they accept an invitation to a second clinic. Brand-new email path (no existing user) is unchanged.
