Skip to main content

Overview

v1.5 is a scale and reliability release. Where v1.4 was about new surfaces (radiology, WhatsApp, refresh tokens, 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 stack — middleware, SSE, sessions, branding, modules, settings, billing — has been audited and aligned around the active clinic instead of the user’s JWT primary. The headline addition is true multi-clinic: a post-login picker, an in-app top-bar switcher, a single-line middleware fix that propagates the per-clinic role and clinic id to every downstream route, an SSE query-param fallback so EventSource streams scope correctly, 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 giving each heavy library (react-pdf, three.js, recharts, mammoth, jszip, dwv, html2canvas, framer-motion, gsap, @dnd-kit, Stripe, Sentry, Lucide, Radix) its own on-demand chunk. Vite’s default modulePreload was disabled to stop the browser from eagerly fetching every transitive lazy chunk on cold load. Sessions are now stable across reconnects and tabs. Access tokens were extended to 90 days end-to-end (matching the existing refresh-token horizon), refresh rotation now grants a 30-second race grace via a soft TOKEN_RACED (409) instead of revoking the family, and navigator.locks ensures only one tab calls /auth/refresh at a time. The 60-second worker-wall-time hangs from stuck Neon WebSocket connections are gone — every clinic-context, permission, and notification query now races an 8-second hard timeout. The mobile app got its v2 foundations: NativeWind v4 + Tailwind toolchain, a shared design-tokens module that drives both web and mobile, theme-aware logo art, Poppins via the expo-font config plugin, an animated splash with a dark variant, a slim 5-tab bottom bar, a bento home screen, an iOS-grouped-list booking screen, HIG-aligned typography, and structured API errors. Server-side, the mPIN + device-trust schemas, hashing/verify/lockout helpers, and trust-token generators landed — the foundations 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 SSE 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 gated on ai_insights, debuggable 4xx logs with full actor context, dental chart auto-save, fast patient search backed by a real pg_trgm GIN index, 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 /select-clinic instead of being silently dropped into one (whichever the server happened to pick first). 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 writes the choice to localStorage['odontox-active-clinic-id'] and sends it as the X-Clinic-Id header on every authenticated request — no JWT reissue, 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 writes the new active clinic and triggers a full reload to /dashboard so auth-context, ModuleProvider and BrandingContext bootstrap freshly. The switcher auto-hides for users with 0 or 1 clinic memberships and for superadmins.

Single-Line Middleware Fix (Per-Clinic Role + Clinic ID Everywhere)

The platform had ~28 routes scoping queries by user.clinicId from the JWT (the user’s primary clinic) and ~12 places authorizing on user.role (the user’s global role). Patching each individually wouldn’t scale. Instead, the clinic-context middleware now resolves the per-clinic role and clinic id once and mutates the in-memory user object for the duration of the request:
if (user.role !== 'superadmin' && currentClinicId && currentRole) {
  user.role = currentRole;        // per-clinic role (admin/doctor/receptionist/...)
  user.clinicId = currentClinicId; // current active clinic, not JWT primary
  c.set('user', user);
}
Every downstream route that reads c.get('user').role or c.get('user').clinicId now sees per-clinic-resolved values. Twenty-eight tenant-scoping leaks and twelve role checks become correct without touching those files individually. user.primaryClinicId is left intact so billing, onboarding, and any code that legitimately needs the user’s “home” clinic still works. Superadmins are excluded from the override so they keep platform-wide access. The mutation is on the request-scoped c.get('user') object and cannot leak across requests.

SSE Streams Scope to the Active Clinic

EventSource (the browser API behind /notifications/stream and /sse/clinic-events) cannot send custom HTTP headers, so X-Clinic-Id never reached either SSE endpoint and the server fell back to the JWT primary — which is why a user signed into Hassan’s Clinic was seeing toast notifications from ssh & Associates. The clinic-context middleware now accepts ?clinicId= as a query-param fallback (gated by the same active-assignment check), and both SSE clients append &clinicId=${getActiveClinicId()} to their stream URLs. Real-time toasts and clinic events now route to the right tenant.

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 JWT global role doesn’t match any of their per-clinic roles are flagged with an amber badge — these are the cases where the clinic-context middleware silently downgrades them on every request, which is correct behavior but worth surfacing so ops can either align the global 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 route and the patient-invite check are now scoped to the current clinic via userClinicAssignments(userId, clinicId, status='active'). 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. Same scoping for the duplicate-invitation guard — a pending or accepted invitation at one clinic no longer poisons 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 (Patients, Appointments, Finance, Files, DICOM, Settings, etc.), every PDF/DOCX 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 (AdminDashboard, DoctorDashboard, ReceptionistDashboard, SuperAdminDashboard) now use React.lazy() for every authenticated module — only the role’s Overview is eager. A single <Suspense fallback={<ModuleLoading />}> wraps the activeView switch in each dashboard. First navigation into a module shows a brief “Loading…” spinner once, then is cached for the session.

Granular Vendor Chunks

The small manualChunks map in vite.config.ts was replaced with a path-based function that splits dwv, three, pdf (react-pdf + jspdf), html2canvas, mammoth + docx-preview, recharts + d3, framer-motion + gsap, @dnd-kit, jszip, lightbox + zoom-pan, qrcode, signature_pad, stripe, sentry, posthog, react-odontogram, hugeicons, lucide-react, radix, and react-markdown each into its own chunk. Heaviest libraries are matched first so they don’t get swept into a generic vendor.

modulePreload Disabled

Vite’s default behavior emits <link rel="modulepreload"> for every transitive lazy chunk on cold load — which made the browser eagerly download 2.3 MB of react-pdf and 2.4 MB of three.js before the user opened anything. Disabled. Lazy chunks now fetch JIT 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, memoized component, so clicking one tooth doesn’t redraw the other 31. The JSON.stringify(Array.from(teethData.entries())) dirty check that ran on every keystroke is gone — replaced with an early-exit shallow Map comparison (~5 KB string allocations per render eliminated). DentalChart is also React.lazy()-loaded in DoctorDashboard so the doctor dashboard’s first paint no longer waits on the full odontogram bundle. 100vh was swept to 100dvh across the app shell, sticky sidebars, and full-height pages so they stop jumping when Safari’s address bar collapses on scroll. The viewport meta now opts into viewport-fit=cover for safe-area handling on notched iPad Pros. Text inputs scroll into view on iPad/iOS focus so the field stays visible above the keyboard. The patient search route’s comment claimed a “GIN trigram index” was in use, but no migration ever created it — every keystroke ran a sequential scan + similarity() over every patient row (1–3 s on 15 k+ row clinics). The route now ensures the pg_trgm extension and patients_search_text_trgm_idx GIN index on first call (idempotent, cached per worker). Short queries (1–2 chars) skip the similarity sort entirely and return alphabetical-by-name within the LIMIT, so typing the first letter is instant.

Bridge Inbox CPU

The bridge-inbox worker was hitting the Cloudflare CPU limit because every SSE connection was pre-signing R2 URLs for all patient files (O(n) R2 API calls on connect). Removed. Files are now streamed inline via /files/:id/stream with the HTTP Neon driver replacing the WebSocket driver for standard query paths.

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.ts module is now the single source of truth for color palette, spacing, and typography across web and mobile. ui/tailwind.config.ts and mobile/odontox-mobile/theme.config.js both import from it.

NativeWind + Tailwind Toolchain

NativeWind v4 + Tailwind is installed in the Expo project with the babel.config.js plugin, metro.config.js global CSS pipeline, and a tailwind.config.js that points at the shared tokens. A new Logo component renders the correct light/dark logo art based on the device theme. Logo art was renamed from assets/images/ to assets/brand/ and the legacy duplicates were force-deleted to stop auto-imports from picking the wrong file. The logo went through several iterations during the redesign — final state is the OdontoX wordmark, theme-aware.

Poppins via expo-font Config Plugin

The font swap moved from a runtime useFonts hook to the expo-font config plugin, eliminating the splash-time font flash. All authenticated screens (mfa.tsx, passkey.tsx, signin.tsx, chat/[id].tsx, passkey-setup.tsx) and the AtelierThemed 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 SafeArea on every device. The booking screen was rebuilt as an iOS-grouped list with native pickers and SafeArea.

HIG Typography + Structured API Errors

The home and booking screens were rewritten to follow Apple HIG type scale and weight conventions. The api.ts module 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 undefined patient data.

Auth State Hardening

The auth context now enriches the session from /me and /patients/profile so the clinic name and full patient profile are visible immediately after sign-in. Patient dashboards no longer 400 on /my routes that need the profile bootstrap. The (auth) layout gate now redirects already-authenticated users away from the welcome / onboarding / signin screens.

Native Tabs Migration (Reverted)

A migration to Expo Router native tabs (iOS Liquid Glass) was attempted and shipped, then reverted within the same session because native tabs don’t yet honor several layout requirements the bento home depends on. Tracked for a future attempt.

Server-Side mPIN + Device Trust Foundations

The schemas, hashing primitives, and token generators that the mobile v2 mPIN sign-in and biometric unlock will use are landed:
  • mpin_credentials table (drizzle/0025_add_mpin_credentials.sql) — per-user mPIN with hash + lockout fields.
  • device_trust_tokens table (drizzle/0026_device_trust_tokens.sql) — per-device trust tokens that can be revoked individually.
  • lib/mpin.ts — Argon2id hash + verify + lockout helpers, with unit tests.
  • lib/trust-token.ts — random trust-token generator + hash helpers, with unit tests.
Mobile-app-side wiring (/auth/mpin, /auth/mpin/verify, biometric flow, trusted-device management) 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 vs. a 30-second-window race: the reuse detection grants a soft TOKEN_RACED (409) when the revoked token has a valid replacedBy successor, and only revokes the family on genuine reuse outside the window. The browser uses navigator.locks.request('odontox-refresh-lock', exclusive, …) so only one tab in the same browser ever calls /auth/refresh 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 409 TOKEN_RACED, the client waits 250 ms and re-checks storage for the sibling’s broadcasted token before retrying.

Stable sessionId Across Refresh Rotations

The refresh used to mint a new sessionId on every rotation, which immediately invalidated any in-flight request still flying with the previous one — visible to users as a ConcurrentSession 401 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 ut 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 — trialEndDate < now, nextBillingDate < now, or subscriptionStatus IN ('suspended','cancelled'). A clinic’s is_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 kept is_active=false and only subscriptionStatus was set to active. One stuck clinic (“Hassan’s Clinic”) was healed in production as part of the same change. A new server/scripts/heal-clinic-isactive.ts (dry-run by default; APPLY=1 to commit) 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 discriminator is passwordHash — a non-null hash is the only authoritative signal that the user has set credentials they expect to keep working. Treating any password-bearing record as “do not touch” is defensive even against legacy rows with isOnboarded=false but a real password set.

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 route, the dormant 2fa-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 used to only accept exact pro_plus / pro_plus_trial strings; it now normalises Pro+, pro+, pro plus, pro-plus, and pro_plus_paid 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 409 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 Postgres 22003 (numeric_field_overflow), 23514 (check_violation), and 22P02 (invalid_text_representation) and returns 400 with friendly text.

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 minted 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. The live production DB on Neon was migrated in this same change.

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 useRef-based hard guard now prevents double-clicks from creating duplicate records across every “Create” / “Save” / “Submit” / “Confirm” button: appointment booking, status changes (incl. 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 short-circuits the second click synchronously instead of waiting for React to re-render the disabled state. Most show a spinner during the wait.

Lab Work Module

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 column existed in the database but was missing from the Drizzle model the UI loads from. Adding attachments: jsonb<LabAttachment[]> to the schema (and ensuring the column on every GET) unbreaks the round-trip.

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

Composite UNIQUE(clinic_id, receipt_number) replaces the global UNIQUE on receipt_number. ensureFinanceSchema drops the legacy global constraint and installs the composite unique index on first request, so non-prod Neon environments self-heal without a manual migration.

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 (Sentry JAVASCRIPT-REACT-H, React error #185). The wrapper now only blocks click bubbling and lets the checkbox itself drive the state change. handlePermissionChange is wrapped in useCallback so PermissionTree’s many Radix Checkboxes see a stable onChange identity 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 is created inside ensureAppointmentsSchema, but the rooms route never called any ensure function — so on a worker that hadn’t hit /appointments yet, the insert failed and the UI swallowed the real error as a generic “Failed to create room”. The route now ensures the schema on every handler, and the UI bubbles 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 the full Cloudflare Worker wall-time (~60 s) 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. New withQueryTimeout(query, label) helper races every wrapped DB call against an 8-second hard timeout. Defends against Neon WebSocket connections going zombie on Cloudflare Workers. Applied to: clinic-context middleware, permission resolution (getEffectivePermissions), and the notifications list/count queries — the queries we actually saw hang in production logs.

Debuggable 4xx Errors

handleError now attaches actor: { userId, userEmail, role, impersonatorId, clinicId } to every 4xx structured-error log line. Routes throwing “Patient not found or access denied”, “Insufficient permissions”, “No clinic access”, etc., now show in CF logs with the requesting identity and the clinic context that was active at the time. Combined with the request URL (already logged) this is enough to triage a 403 in seconds — typical cause is a stale resource id in the URL after a clinic-context switch (impersonation enter/exit, multi-tab session). Permission denials log { method, path, userId, role, clinicId, permission } to worker logs. 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 updated lifecycle event only, so a patient who booked through the portal received no WhatsApp confirmation until staff edited their appointment. The send is fire-and-forget (ctx.waitUntil) 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, 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 is_active=false clinics.

Upgrade Notes

  • Active clinic in localStorage. The clinic switcher writes localStorage['odontox-active-clinic-id']. 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.
  • X-Clinic-Id header on every authenticated request. Already wired in fetchWithAuth. The cache key now includes the active clinic id so a multi-clinic user never sees another clinic’s cached response after switching.
  • SSE clients now send ?clinicId= query param. EventSource cannot send custom headers; this is the documented fallback. Both first-party stream consumers (NotificationProvider, useClinicEvents) are updated.
  • Access tokens now last 90 days. Refresh tokens are unchanged. The 7-day token used by 2FA flows and the 15-minute token used for superadmin impersonation are deliberately left as-is. Access tokens are not individually revocable via the KV ban list (only refresh tokens are), so a leaked access token is now valid for the full 90-day window.
  • Plan-expiry gate now requires real subscription signals. A clinic’s is_active flag on its own is no longer treated as “plan expired”. Run server/scripts/heal-clinic-isactive.ts (dry-run by default; APPLY=1 to commit) once after deploy to heal any stuck clinics.
  • Receipt numbers are now per-clinic unique. The migration in ensureFinanceSchema is idempotent and runs on the first request that hits the receipts route. Production Neon was migrated in the same change as the schema commit.
  • Patient search GIN index. patients_search_text_trgm_idx is created on the first call to /patients/search per worker. Cold start on a fresh worker pays the index-build cost once (~2 s on a 50 k-row clinic); subsequent calls are sub-50 ms.
  • modulePreload is disabled. First navigation into each module pays a one-time JIT 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 call the new endpoints — the v1.6 mobile release wires them up.
  • Invitation accept respects existing identity. Users with a non-null passwordHash keep their password, name, role, and primary clinic when they accept an invitation to a second clinic. Brand-new email path (no existing user row) is unchanged.