Skip to main content

v1.4 — Technical Changelog

Release date: 2026-04-30
Branch: main
Commits since v1.3: 322
Features: 130 | Fixes: 140+ | Perf: 5 | Refactors: 10+

Table of Contents

  1. X-Ray Bridge & Radiology Workstation
  2. DICOM AI Pipeline & Quota System
  3. WhatsApp Module (Add-On)
  4. JWT Refresh Token Rotation
  5. Granular Permissions System (151-Key Tree)
  6. Patient Appointment Respond (HMAC Tokens)
  7. Inventory — EOD Reports, Stock Alerts, Full-Page Form
  8. Ruby AI Reports
  9. Lab Cases
  10. SSE Resilience & Durable Object Event Bus
  11. Treatment Plan Overhaul
  12. Superadmin Dashboard
  13. Sentry Integration
  14. Auth & Onboarding
  15. Receptionist Experience
  16. Finance & Billing
  17. Calendar & Appointments
  18. Performance
  19. Cloudflare Worker Logs (OTLP Ingestion)
  20. UI & Settings Polish
  21. Schema Changes Summary
  22. API Changes Summary
  23. Breaking Changes & Migration Notes

1. X-Ray Bridge & Radiology Workstation

Electron Bridge App

Commits: 6dc1617f, aa7db517, de4ef273, 4813896d, a22be82d The Bridge is a full Electron desktop application (Windows / macOS / Linux via electron-builder). Architecture:
  • Watcher (chokidar) monitors a configured folder for new image files.
  • Queue — new files are enqueued with deduplication (tracks file hashes to skip re-uploads).
  • Uploader — sends each file to the OdontoX /upload endpoint with a Bearer API key. Attaches operatoryRoom, patientId (selected from the tray patient picker), source: "bridge", and category: "x-ray".
  • Tray — system tray icon with status indicator (connected/disconnected), patient picker, and a settings window.
  • Patient selector — queries the OdontoX API to list today’s scheduled patients. Operator picks the current patient before each capture session.
Bridge authentication uses a per-device API key (non-JWT, static). The server-side route handler for /upload has a middleware bypass for requests presenting a valid bridge_api_keys credential instead of a clinic session cookie. API Key management:
  • GET /api-keys — list keys
  • POST /api-keys — generate new key (256-bit, hex)
  • DELETE /api-keys/:id — revoke
Keys are stored in bridge_api_keys table with label, keyHash (SHA-256), clinicId, createdAt, lastUsedAt. Schema additions:
patient_files.source  TEXT CHECK (source IN ('manual','bridge','lab'))
patient_files.operatory_room  TEXT
clinical_notes.attached_file_ids  TEXT[]
Commits: 29f82d6e, 19b0e595, 41765742, 11ce4bfe, 03018817

SSE Event Bus (Durable Object)

Commits: 96c12de3, 70ba08ff, c1235351, 92cf2ba7 A new Cloudflare Durable Object ClinicHub serves as the per-clinic SSE event bus. Architecture:
  • Each clinic has a single ClinicHub DO instance (keyed by clinic:{clinicId}).
  • The DO holds a map of open WebSocket connections (one per connected browser tab).
  • When a bridge upload completes, the server calls ClinicHub.publish({ type: "xray_arrived", patientId, fileId, ... }) via Durable Object RPC.
  • The DO fans the event out to all connected WebSocket clients.
  • The React useClinicSSE hook consumes the SSE endpoint and dispatches events into a React context. Components subscribe via useClinicEvent("xray_arrived").
SSE endpoint: GET /sse/clinic — requires valid session, returns text/event-stream. Keepalive ping every 30s. Server closes after 55s to force a clean reconnect.

Radiology Workstation

Commits: 329e6efc, 6284f426, 062b60a1, 28fe590e, 4a4f1f67, dc03c36d /patients/:id now has a Radiology tab that replaces the previous separate DICOM and X-Ray tabs. File type is auto-detected on upload using file-type magic-byte inspection; category is stored and can be edited inline. R2 file streaming: GET /files/:id/stream pipes the R2 object body directly to the response with correct Content-Type. Previously the server was generating S3-signed URLs and redirecting, which caused CPU spikes from URL fan-out in bridge-inbox. DICOM viewer (DWV) initialisation is parallelised with the download button render using Promise.all so neither blocks the other.

Appointment X-Ray Integration

Commits: 0f3c72e0, 97b9c60a, 731c7ebd, ef10c7e9, c7ee686f, 9d49a0ab, aa1b2ee5
  • Appointment detail — a LiveXRayPanel component shows all patient_files with source="bridge" captured on the appointment day in real time. SSE events trigger a re-fetch.
  • Clinical notes form — an X-ray attachment picker lets the doctor link a file from the Radiology Workstation to a clinical note. IDs are stored in clinical_notes.attached_file_ids.
  • Dental chart — teeth with associated X-ray files render a small badge icon. Badge presence is computed from the file store at chart load.
  • Live toastXrayArrivalToast fires when an xray_arrived SSE event lands. The toast is deduplicated with a 30-second TTL cache keyed by fileId to prevent repeat fires on reconnect.

2. DICOM AI Pipeline & Quota System

Model & Pipeline

Commits: adc7116c, 4c54b952, 20409c3f, 770c055a, ce8c5809 DICOM analysis runs through DeepSeek (primary, via Langfuse proxy) with a max_completion_tokens cap of 1000. The pipeline:
  1. Receives the DICOM file IDs for a study.
  2. Extracts up to 30 slices (sliceCount, Zod-clamped max 30).
  3. Sends each slice as a base64 image message to the LLM.
  4. Returns structured findings JSON with per-slice annotations.
A fresh DeepSeek + Langfuse client is constructed per request (singleton caused stale trace context between requests — 20409c3f). The ClinicHub Durable Object provides an analysis slot semaphore (acquireAnalysisSlot / releaseAnalysisSlot) to prevent concurrent analyses from the same clinic from racing.

Quota Enforcement

Commits: 23631a21, 9f9faab7, c471c803, 4804374b, 3f2a5bd2, 8e19..., 581e10fe, e149eece, e81349a3 Schema:
CREATE TABLE dicom_quota (
  clinic_id     TEXT NOT NULL REFERENCES clinics(id),
  period_start  DATE NOT NULL,       -- first day of billing month
  period_end    DATE NOT NULL,       -- last day of billing month
  scans_used    INTEGER DEFAULT 0,
  scans_limit   INTEGER NOT NULL,    -- Pro: 20, Pro+: 100
  PRIMARY KEY (clinic_id, period_start)
);

-- quota columns on clinic_modules:
ALTER TABLE clinic_modules ADD COLUMN dicom_quota_used    INTEGER DEFAULT 0;
ALTER TABLE clinic_modules ADD COLUMN dicom_quota_limit   INTEGER DEFAULT 20;
ALTER TABLE clinic_modules ADD COLUMN dicom_quota_reset   DATE;
ensureDicomQuotaSchema is called at Worker startup (runtime migration pattern). API endpoints:
  • GET /dicom/quota — returns { used, limit, next_reset } for current clinic.
  • POST /dicom/terms-accept — records one-time acceptance; required before first analysis.
  • POST /dicom/analyze — enforces quota before running pipeline. Returns 429 with quota_exceeded code if at limit.
  • GET /admin/dicom-usage — superadmin, returns per-clinic quota table.
Client-side: DicomUsageCounter component renders a progress bar. DicomTermsModal fires once. Quota error banners appear inline in the workstation when the limit is hit. Zod schema for sliceCount max: z.number().int().min(1).max(30).

3. WhatsApp Module (Add-On)

Module Architecture

Commits: 75c507d4, 2f7b065, 825ed2e2, 0e21b1a4, ba648f8d, cbb98dfc, 61594364 whatsapp_api is added to the module_names enum in the clinic_modules schema. It is superadmin-enabled only (cannot be self-activated). Per-clinic config table:
CREATE TABLE whatsapp_configs (
  clinic_id          TEXT PRIMARY KEY REFERENCES clinics(id),
  phone_number_id    TEXT NOT NULL,
  access_token       TEXT NOT NULL,     -- AES-256-GCM encrypted
  webhook_verify_tok TEXT NOT NULL,     -- AES-256-GCM encrypted
  app_secret         TEXT,             -- AES-256-GCM encrypted, optional
  updated_at         TIMESTAMPTZ DEFAULT now()
);
All secrets are encrypted at rest using the clinic’s per-tenant encryption key via the same AES-GCM pipeline used for patient PHI.

BYOK Webhook Routing

Commits: abd9c546, ba648f8d, d721c4e8 Webhook URL pattern: POST /whatsapp/:clinicId/webhook On receive:
  1. Lookup whatsapp_configs by clinicId.
  2. Verify X-Hub-Signature-256 using appSecret (HMAC-SHA256).
  3. Parse the Meta webhook payload to extract the sender phone, message body, and phoneNumberId.
  4. Route to the clinic’s inbound handler.
A global GET /whatsapp/webhook handles the Meta verification challenge (static webhookVerifyToken in global env for the master webhook registration; per-clinic webhooks use per-clinic tokens).

Patient WhatsApp Phone Field

Commits: 507c5fce, b5d3ceb4, 240ec972, 040b37c6, f5f9dabf, 429c6468, bf58a0ec, a4a5780c, f0d48d4b Schema:
ALTER TABLE patients ADD COLUMN whatsapp_phone TEXT;  -- encrypted, nullable
The whatsapp_phone field is included in the patient PHI encrypt/decrypt pipeline (encryptPatientPHI / decryptPatientPHI). It is accepted in POST /patients and PATCH /patients/:id and returned in the patient detail response (decrypted). Inbound message matching: when a WhatsApp message arrives, the server first matches by whatsapp_phone, then falls back to phone. This handles patients whose WhatsApp number differs from their registered contact number. UI: PhoneInput wrapper uses react-international-phone. Dark-mode CSS variables (--phone-input-background, etc.) are injected via a global style block.

Automated Messaging

Commits: 6fef99ba, 32f923b5, 6d348048, 1d278b40, 037cb3ba Appointment lifecycle hooks that trigger WhatsApp sends:
  • created → booking confirmation
  • updated (datetime change) → reschedule notification
  • missed / no_show (cron or manual status change) → missed visit
  • scheduled_reminder cron → reminder at configured interval
All sends are fire-and-forget (ctx.waitUntil). PHI is decrypted before each send. The module check (requireModule("whatsapp_api")) is applied on the config routes and notification dispatch path. whatsapp_missed was added to scheduledReminderTypeEnum (01fd105d).

4. JWT Refresh Token Rotation

Commits: 17b76001, 22dd52f0, 59581772, e05398d1, 5cf0c1d2, ebbb4adc, 78294713, 0cc0367c, a1c97286, 676115b3, df87de55, 49679f70, e78f0c3c, 2528c9d4, aa0c8845, d8dcc013

Token Lifetimes

TokenPreviousv1.4
Access JWT7 days15 minutes
Refresh token30 days
Passkey session7 days7 days + refresh token

Rotation Protocol

KV keys:
  • rt:{tokenHash}{ userId, clinicId, issuedAt, expiresAt } — valid token record
  • rt:{tokenHash}_used{ rotatedAt, newTokenHash } — used token tombstone (1h TTL)
On POST /auth/refresh:
  1. Decode refresh token → extract hash.
  2. KV.get("rt:{hash}") — if null → 401 SESSION_REVOKED.
  3. KV.get("rt:{hash}_used") — if present → session hijack detected → revoke all tokens for user → 401 SESSION_REVOKED.
  4. Issue new access JWT (15min) + new refresh token.
  5. KV.put("rt:{oldHash}_used", ..., { expirationTtl: 3600 }).
  6. KV.delete("rt:{oldHash}").
  7. KV.put("rt:{newHash}", ..., { expirationTtl: 2592000 }).
KV TTL floor: 60 seconds (raised from previous 0 to prevent immediate expiry edge case — 0cc0367c).

Cross-Tab Sync

BroadcastChannel("odontox_token_refresh") is used. On successful refresh, the tab posts { accessToken, expiresAt }. Other tabs receive and update their in-memory session without making a redundant API call.

SSE Proactive Refresh

The SSE hook performs a refreshAccessToken() call 12 minutes into each 15-minute token window (i.e., 3 minutes before expiry) before reconnecting. This prevents the SSE reconnect from arriving with an expired token.

OTT Exchange

The one-time-token exchange for cross-subdomain logins (patient portal, public booking) now issues a refresh token alongside the access JWT. This was previously non-fatal (the OTT exchange succeeded even if the refresh token write failed). Impersonation sessions now persist across token refreshes by carrying the impersonatedClinicId in the refresh token payload.

5. Granular Permissions System (151-Key Tree)

Commits: b853b818, 3bca0244, 19f40215, f1b275e1, 0f500483, e084e239 (worktree), 5859d1b2, 0d5627eb, 65e54058 (worktree), fa0c18e1, 7d1c9224 (worktree), 51b459fc, dd9b4b42 (worktree), 860da6ae, d2555504 (worktree), 7e9beb44, 0c5ab674, 1df5c7c7, 0cb2a5fe, dbc15834, 1ea5ffad, b6c32d71, a9d67f09, 7683efdd, feb19185, 66ca798d

PERMISSION_TREE Structure

151 dot-notation keys organised into 15 top-level modules:
appointments.{view,create,edit,delete,confirm,checkin,complete,noshow,cancel}
patients.{view,create,edit,delete,medical_history,portal}
finance.invoices.{view,create,edit,delete,send}
finance.payments.{record,refund}
finance.quotations.{view,create}
finance.installments.{view,create}
finance.receipts.view
clinical_notes.{view,create,edit,delete}
dental_chart.{view,edit}
prescriptions.{view,create,edit,delete,export}
radiology.{view,upload}
radiology.dicom.analyze
lab.{view,create,edit,delete}
inventory.{view,create,edit,delete,receive,consume,adjust}
settings.{view,edit}
settings.permissions.edit
whatsapp.{view,configure}
ai.{insights,ruby,brief}
...
Legacy flat keys are mapped via LEGACY_COMPAT_MAP in permissions.ts. The migration script (0cb2a5fe) translates existing clinic_permission_templates and user_clinic_assignments.permissions at deployment.

Schema

CREATE TABLE clinic_permission_templates (
  clinic_id   TEXT NOT NULL REFERENCES clinics(id),
  role        TEXT NOT NULL,   -- 'doctor','reception','nurse','admin'
  permissions JSONB NOT NULL,  -- { [dotKey]: boolean }
  updated_at  TIMESTAMPTZ DEFAULT now(),
  UNIQUE (clinic_id, role)
);
ensureClinicPermissionTemplatesSchema adds this table at Worker startup.

Plan-Based Defaults

getDefaultsForPlan(plan: 'pro' | 'pro_plus') returns a Record<role, Record<dotKey, boolean>>. The /me endpoint now includes clinic.subscriptionPlanId. On the client, getEffectivePermissions(user, clinic) merges the plan defaults with any per-user overrides. Server-side: getEffectivePermissions(userId, clinicId) is called in the /me route handler and its result merged with the JWT claims.

PermissionTree UI Component

PermissionTree renders a recursive collapsible tree. Parent nodes use an indeterminate checkbox state (via data-state="indeterminate" on Radix) when some but not all children are enabled. PermissionTemplateSelector is a role-switcher dropdown that loads the saved template for the selected role and populates the tree. O(1) key lookup: buildPermissionMap converts the PERMISSION_TREE array to a Set<string> once, then checks membership in O(1) instead of linear scan (19f40215).

6. Patient Appointment Respond (HMAC Tokens)

Commits: 9c28b0de, 4275d1b1, 1d278b40, f087b579, 0e3fe00a, 70b4f27c, 99a17bb8

Token Library (appointmentTokens.ts)

// Token structure: base64url( HMAC-SHA256( `{appointmentId}:{action}:{exp}` ) )
// action: 'confirm' | 'cancel'
// exp: Unix timestamp (seconds)

export function signAppointmentToken(appointmentId: string, action: Action, secret: string): string
export function verifyAppointmentToken(token: string, secret: string): { appointmentId, action, exp } | null
Verification hardening (f087b579):
  • Length check before decode (rejects obviously malformed tokens).
  • parseInt(parts[2]) with NaN guard before timestamp comparison.
  • crypto.timingSafeEqual for the HMAC comparison (XOR fallback on runtimes without subtle.timingSafeEqual848056...).

API Endpoint

POST /appointments/respond
Body: { token: string, clinicId: string }
Auth: none (public endpoint)
  1. Look up clinic to get APPOINTMENT_TOKEN_SECRET from KV.
  2. Verify token. 401 on invalid/expired.
  3. Fetch appointment. 404 if not found or already in terminal state.
  4. Apply action (confirmed / cancelled). 409 if already in the requested state.
  5. Decrypt patient phone, fire WhatsApp notification if applicable.
  6. Return { status, appointment }.

Email Injection

The scheduled appointment confirmation email now includes two links:
https://app.odontox.io/appointments/respond?token=<confirm_token>&clinicId=<id>
https://app.odontox.io/appointments/respond?token=<cancel_token>&clinicId=<id>
Tokens are signed at email send time with a 48-hour expiry.

Landing Page

/appointments/respond is a public Cloudflare Pages route (no auth required). It calls POST /appointments/respond, shows a success/error state, and offers a “Book Another” link.

URL Sync Fix

99a17bb8 — used navigation.type === "POP" (Navigation API) to guard URL sync. The rxView query parameter was leaking between patients when navigating back; now cleared on POP events.

7. Inventory — EOD Reports, Stock Alerts, Full-Page Form

Commits: 9a6536db, 6c4c9248, 5dbb9424, b8912518, 555575d1, 12d175c3, 22f178e1, 75f19181, 3d706f2f, f6aa304b

Schema

ALTER TABLE clinics ADD COLUMN notification_preferences JSONB DEFAULT '{}';
ALTER TABLE inventory_alerts ADD COLUMN email_sent_at TIMESTAMPTZ;
notificationPreferences structure:
{
  eodEmail: boolean;
  stockAlerts: boolean;
  labUpdates: boolean;
}

EOD Email

Cron: 0 16 * * * (4 PM PKT / 11 AM UTC). Handler calls fetchEODReportData(clinicId) — a shared helper that queries:
  • Items consumed today (with quantities)
  • Current low-stock items (below reorder_threshold)
  • Recent stock receives (last 24h)
  • Pending lab cases
sendEODReportEmail(clinicOwnerEmail, data, clinicName) renders an HTML template and sends via Cloudflare Email Workers. Template is branded with clinic name and OdontoX footer. Cron was trimmed to stay within the Cloudflare free tier 5-trigger limit (e9ae938e): the 0 19 * * * duplicate was removed.

Stock Alert Email

Triggered fire-and-forget when inventory_items.current_stock falls below reorder_threshold after a consume operation. Deduplication: UPDATE inventory_alerts SET email_sent_at = now() WHERE id = ? AND email_sent_at IS NULL — returns early if already sent. Reset: when receive brings stock above threshold, email_sent_at is nulled.

InventoryItemFormPage

Route: /inventory/items/new and /inventory/items/:id/edit. Replaces InventoryItemDialog modal. Includes:
  • Lab preset dropdown (queries external_labs table, assigns supplier_id)
  • Unit of measure selector (enum: ml, g, units, boxes, vials, sheets)
  • Reorder threshold number input with inline low-stock preview
  • Category selector

SWR Cache

Cache key pattern: inventory:items:{clinicId}. TTL: 90 seconds stale-while-revalidate. Invalidated on: create, update, receive, consume, adjust mutations. The shouldUseCache(endpoint) fix (cc10d115) — was incorrectly called as shouldCache(method) which always returned false.

8. Ruby AI Reports

Commits: d05ae080, be36929f, 3a030b91, b152f534, fdb14abd

Expandable History

RubyReportsHistory component: queries GET /ai/ruby-reports?clinicId={id}&limit=20, renders an accordion with one entry per past report. Each entry shows generatedAt (formatted) and the report summary. Expand to see full report.

PDF Export

POST /ai/ruby-reports/:id/export — renders the report as HTML using the same template as the screen view, passes through Puppeteer (via Cloudflare Browser Rendering API) to produce A4 PDF. Blank page fix (3a030b91): the last page was blank due to a trailing margin-bottom on the report container — removed. X-ray MIME type fix: MIME detection was falling back to application/octet-stream for .dcm files. Added explicit check for application/dicom before generic fallback.

Analytics Overhaul

fetchRubyReportData recalculates all analytics fresh per generation:
  • Billing summaries use SUM(invoices.total) WHERE date >= period_start.
  • Appointment throughput from appointments table grouped by status.
  • Revenue trend: 6-week rolling window.
  • Lab day calculations: diff(completedAt, createdAt) in UTC days — previously used local time, causing off-by-one errors at midnight (b152f534).

Nudge Context

AI nudges (Morning Brief, Reception AI) now include structured JSON context objects instead of natural language descriptions. Each context object has type, id, deepLink, and relevant fields. The LLM receives the JSON and generates natural language action items with embedded links (fdb14abd).

9. Lab Cases

Commits: 572b89fb, 0e7db6a7, 8e23c97f, e8f2cd75, 4f9809ec, 377e57f0

Full-Page Form

LabCaseFormPage replaces the slide-out panel. Route: /lab/cases/new and /lab/cases/:id. Supports file attachments (multi-upload), priority, status, technician notes, and patient/appointment linking. CORS fix (572b89fb): the S3 presigned URL PUT request was being rejected because the X-File-Name header was not in the CORS AllowedHeaders list in the R2 bucket policy. Added X-File-Name to the allowlist. When an external lab updates a case via the shared link (/lab-share/:token), the case update handler publishes a lab_case_updated event to the clinic’s ClinicHub Durable Object. The clinic dashboard’s LabDashboard component subscribes to lab_case_updated events and calls mutate() on the SWR key to refresh the case list.

Upload → Radiology Pipeline

Files attached to a lab case with MIME type application/dicom or image/* are automatically added to the patient’s patient_files with source="lab" and category="radiology" (or "x-ray" for JPEG/PNG images).

DICOM ZIP Upload

DicomZipUploader component: accepts .zip files, sends to POST /lab/cases/:id/dicom-zip. The server:
  1. Receives the ZIP as a ReadableStream.
  2. Extracts using a streaming ZIP parser (fflate).
  3. For each .dcm entry, writes to R2 and inserts a patient_files record.
  4. Returns the list of created file IDs.

10. SSE Resilience & Durable Object Event Bus

Commits: 233a0f78, 3ddc3f9e, f4a9ad81, 4cc82a29, 03e4eeda (partial), ebbb4adc

DO WebSocket Push

ClinicHub Durable Object:
class ClinicHub implements DurableObject {
  sockets: Map<string, WebSocket> = new Map();

  async fetch(request: Request) {
    // Upgrade to WebSocket for SSE clients
    // OR handle RPC publish calls
  }

  publish(event: ClinicEvent) {
    for (const [id, ws] of this.sockets) {
      if (ws.readyState === WS_READY) ws.send(JSON.stringify(event));
      else this.sockets.delete(id);
    }
  }
}
The SSE endpoint (GET /sse/clinic) connects to the clinic’s ClinicHub DO via WebSocket, then re-encodes each message as an SSE event to the browser. This hybrid approach avoids the 30-second Cloudflare Worker CPU limit on long-lived SSE connections.

Client Reconnect

The useClinicSSE hook:
  • Maintains an AbortController to cancel inflight reconnects on unmount.
  • Reconnects every 55 seconds (before the server-side 60s timeout).
  • Proactively refreshes the access token 12 minutes into each 15-minute window.
  • Parses data: lines from the SSE stream, surfaces parse errors via console warn.
  • memBus (in-memory event emitter) is pruned on unmount to prevent listener leaks.

QUIC Drop Fix

4cc82a29: Cloudflare’s QUIC transport was dropping SSE connections mid-stream. Switched the SSE endpoint to use HTTP/1.1 keep-alive explicitly by setting Connection: keep-alive and Transfer-Encoding: chunked. The DO WebSocket push is unaffected.

11. Treatment Plan Overhaul

Commits: c3073a2c, a1ad21a6, 0ecaae3f, 2d2636cd, 816449da (docs), bfa82fea

Service Catalog

New table:
CREATE TABLE service_catalog (
  id          TEXT PRIMARY KEY,
  clinic_id   TEXT NOT NULL REFERENCES clinics(id),
  name        TEXT NOT NULL,
  category    TEXT,
  default_price NUMERIC(10,2),
  is_active   BOOLEAN DEFAULT true,
  created_at  TIMESTAMPTZ DEFAULT now()
);
API:
  • GET /service-catalog — list active services for clinic
  • POST /service-catalog — create service
  • PUT /service-catalog/:id — update
  • DELETE /service-catalog/:id — deactivate (soft delete)
Treatment plan line items: service_id foreign key added to treatment_plan_items. default_price is copied to treatment_plan_items.unit_price on selection; overridable per item.

Before/After Photos

treatment_plans.before_photo_file_id and treatment_plans.after_photo_file_id added as nullable TEXT foreign keys to patient_files. The treatment plan detail view renders both slots with upload buttons. File IDs are written via PATCH /treatment-plans/:id.

Discount Policies

treatment_plans.discount_policy_id links to the discount_policies table. On plan create/edit, the applicable policy is evaluated against the plan total and pre-populated. Discount state is now correctly restored when editing (0ecaae3f fixed a bug where the discount was reset to 0 on re-open). Zod schema: z.union([z.literal("percentage"), z.literal("flat")]) for discount_policies.type. The Radix sentinel value ("none") in the dropdown was causing a Zod parse error — replaced with undefined (816449da).

12. Superadmin Dashboard

Commits: ce3ad95c, 39a63cfd, ccd40870, 0f59c129, 8cbbf90f, d0b662eb, d8e70f7d, d121f7a8, 5236226f, 9e0da156, a76abb1cd, 3dc85eb3, d2fafe97, 5501e5d8, a7f6b26a, 3f00eea4, 904d42f0

Invoice Studio

InvoiceStudio is now a standalone page (/superadmin/invoice-studio). It accepts a clinicId query param to pre-select a clinic. The OdontoX logo is pre-fetched from R2 CDN before rendering to ensure it appears in PDF exports (d121f7a8).

Revenue Tab

SubscriptionsPanel now renders two sub-lists: Active (non-trial) and Trial (amber card border). Each list shows MRR contribution. Trial clinics that have not converted after 14 days are highlighted.

Test Account Tagging

Schema:
ALTER TABLE clinics ADD COLUMN is_test_account BOOLEAN DEFAULT false;
Superadmin can toggle is_test_account on any clinic. All revenue queries and clinic count queries add WHERE is_test_account = false. getEffectiveUserStatus was updated to handle the test account flag correctly (9e0da156).

Plan Assignment Dialog

PlanAssignmentDialog improvements (a7f6b26a):
  • Shows current plan name and assignment date.
  • Per-plan feature bullets (Pro: inventory, lab, X-ray; Pro+: DICOM AI, WhatsApp, Ruby).
  • Optional internal note field (stored in clinic_plan_assignments.admin_note).
  • Submit button relabelled from “Upgrade Clinic” to “Assign Plan” (3f00eea4).

Plans Seed

POST /admin/plans/sync endpoint seeds Pro, Pro+, Pro Trial, and Pro+ Trial plans from a hardcoded manifest if they do not already exist. Idempotent. Used to initialise production plans table after DB resets (904d42f0).

13. Sentry Integration

Commits: 28ca1db6, 10ff6e5b

Cloudflare Workers

Uses @sentry/cloudflare. Initialised in the Worker fetch handler:
import { withSentry } from "@sentry/cloudflare";
export default withSentry(
  (env) => ({ dsn: env.SENTRY_DSN, release: env.RELEASE_VERSION }),
  { fetch: handler }
);

React (Vite)

Uses @sentry/react. Wraps the app root in Sentry.ErrorBoundary. Sentry.init called before ReactDOM.createRoot. Release tag set from import.meta.env.VITE_RELEASE_VERSION (injected by Cloudflare Pages build).

14. Auth & Onboarding

Commits: e5f0f750, 30f2f2ec, a6ce360f, 88a6d88f, 23db1c1b, 5c910bfb, b5bcf270, 284a91ce, d6883798, 7ff3f191, ce282b57, 676115b3, f230c907, e3da0487, 5ecbbaa6, 5cf72911

Trial Clock

clinics.trial_started_at is now set on first successful login (not on superadmin approval). POST /auth/login and POST /auth/ott handlers call setTrialStartIfNull(clinicId)UPDATE clinics SET trial_started_at = now() WHERE id = ? AND trial_started_at IS NULL.

Resend Invite

POST /invitations/:id/resend — available for invitations where accepted_at IS NULL. Generates a new OTT and sends the invite email again. On hard-delete of the user record (DELETE /users/:id), re-inviting is now permitted (a76abb1cd).

Onboarding Milestone Trail

clinic_onboarding_milestones table:
CREATE TABLE clinic_onboarding_milestones (
  clinic_id   TEXT PRIMARY KEY REFERENCES clinics(id),
  logo_set    BOOLEAN DEFAULT false,
  staff_added BOOLEAN DEFAULT false,
  first_appt  BOOLEAN DEFAULT false,
  first_patient BOOLEAN DEFAULT false,
  completed_at TIMESTAMPTZ
);
Client OnboardingTrail component polls GET /onboarding/milestones and hides itself when completed_at IS NOT NULL.

Patient Invite Acceptance

POST /invitations/accept (patient flow) now accepts { dob, gender, phone } in addition to password. These fields are written to the patients record atomically with the invitation acceptance. DOB input replaced date picker with Day/Month/Year dropdowns to avoid browser-native date picker inconsistencies on mobile (284a91ce).

Passkeys

Rate limiting added to POST /auth/passkeys/authenticate — max 5 attempts per minute per IP. Legacy backup codes UI removed from Security Settings (48356ff8) — backup codes were deprecated in v1.2; the UI stub was the only remaining reference.

15. Receptionist Experience

Commits: ef204bce, 89655906, 89a9a08a, bb4108a7, 040056c2, ce659709, ab83a89e, 66ca798d, feb19185

AI Action Items (Dashboard)

ReceptionistAiPanel component calls POST /ai/nudge with a structured context object:
{
  role: "reception",
  overdueInvoices: Invoice[],
  staleAppointments: Appointment[],
  pendingLabCases: LabCase[],
  lowStockItems: InventoryItem[]
}
Response is a JSON array of nudge objects { type, message, deepLink }. Rendered as an actionable card with one-click navigation. Deep links use navigate() (React Router) not window.location to preserve session state.

Settings Access

89655906: SettingsPage now renders for reception role. Module-level gates:
  • Clinic Info: read-only
  • Laboratories: read-only (view only, no edit)
  • Notification Preferences: editable if settings.notifications.edit permission granted
  • WhatsApp: hidden unless module active AND whatsapp.configure permission granted

403 Suppression

bb4108a7: fetchClinicalNotes was called unconditionally in the appointment detail. Added if (!can("clinical_notes.view")) return before the fetch. This eliminated ~10 403 errors per page load for receptionist sessions. 66ca798d: all if (permissions.includes("lab")) checks updated to can("lab.view") in receptionist nav and dashboard components.

16. Finance & Billing

Commits: 03e4eeda, 05cda667, d0b662eb, d8e70f7d, d121f7a8, bfa82fea, 0e20b58b, 9679c821, 904d42f0, 33c235f9, 9971d4e1

Overpayment Prevention

Server: POST /finance/payments — added Zod refinement: amount <= invoice.outstanding_balance. Returns 400 { code: "OVERPAYMENT", message: "..." } if exceeded. Client: RecordPaymentForm sets max={outstandingBalance} on the amount input and shows an inline error before submit.

Invoice Email Changes

d8e70f7d: auto-send of invoice email on creation disabled. Clinics were reporting patients receiving unexpected emails. Invoice email is now only sent when the “Send Email” button is explicitly clicked. Receipt PDF is now attached to the receipt confirmation email. PDF is generated using the existing generateReceiptPDF helper and attached as application/pdf in the Cloudflare Email Workers payload. Buffer/WebP errors in PDF generation fixed (05cda667 — replaced Buffer.from with Uint8Array for Workers compatibility).

PKR & Live Prices

33c235f9: all $ literals in plan price displays replaced with PKR. ReferralCalculator calls GET /plans and uses live prices rather than the hardcoded 153000 value (9971d4e1).

17. Calendar & Appointments

Commits: 0c89ddf5, dd4ec842, a1e783bd, 58bc256b, 6471f2c8, 56d8344b

Closed-Day Indicators

GET /clinics/operating-hours returns a list of closed dates and recurring closed days of week. The weekly calendar renders a cross-hatch SVG pattern on closed-day columns. The monthly calendar renders a ClosedDayCell component (muted background, “Closed” label).

Appointment Color Tags

Schema:
ALTER TABLE appointments ADD COLUMN tags TEXT[];  -- array of color hex strings
AppointmentTagPicker renders a grid of 12 preset colors. Tags are stored and returned in the appointment detail response. Appointment cards in week/day view render a 3px left border in the first tag color.

Mini Calendar & Drag-Drop

a1e783bd: mini calendar navigation arrows were mispositioned (absolute instead of relative to the calendar header). Fixed to position: relative on the header with position: absolute arrows. Drag-drop in week view was snapping to the nearest hour boundary instead of the exact drop target. Fixed by computing Math.round(deltaMinutes / slotMinutes) * slotMinutes where slotMinutes = 15.

Time Indicator

6471f2c8: the current time indicator line was rendering outside operating hours (e.g., at 8 PM in a 9-5 clinic). Now hidden (display: none) when currentMinutes falls outside [openTime, closeTime].

18. Performance

Commits: dc03c36d, 4a4f1f67, cc10d115, ac40d728, 062b60a1

HTTP Neon Driver

dc03c36d: replaced @neondatabase/serverless WebSocket driver with HTTP driver for standard query paths. WebSocket driver kept only for transactions that require it. Reduces per-request CPU overhead by ~40ms on bridge-inbox (eliminates WebSocket handshake).

R2 File Streaming

4a4f1f67: removed the signed URL pre-generation fan-out in bridge-inbox. Previously, every SSE connection was pre-signing URLs for all patient files — O(n) R2 API calls on connect. Now files are streamed on demand via /files/:id/stream.

SWR Cache Fix

cc10d115: shouldUseCache was receiving the HTTP method string but checking endpoint patterns. Fixed to pass endpoint (the URL path) to shouldCache(). Added 90-second stale-while-revalidate header on all cached responses. ac40d728: GET-only guard — shouldUseCache now short-circuits to false for non-GET requests. Added correct Cache-Control header reuse in background refresh to prevent stale headers.

19. Cloudflare Worker Logs (OTLP Ingestion)

Commits: 292ffe17, 0c7dfbdd, 143cc857, 35714fa0, be1f1a0b, efe2d901, 23a624c8, 22124a9f, 2817daa3, d02ca0ac, 4123f7bb, 1cb80af3, 848056...

Schema

CREATE TABLE worker_logs (
  id          TEXT PRIMARY KEY,
  timestamp   TIMESTAMPTZ NOT NULL,
  level       TEXT,          -- 'error','warn','info'
  message     TEXT,
  attributes  JSONB,
  trace_id    TEXT,
  span_id     TEXT,
  created_at  TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ON worker_logs (timestamp DESC);
90-day purge cron: DELETE FROM worker_logs WHERE created_at < NOW() - INTERVAL '90 days'.

OTLP Ingest Endpoint

POST /otlp/v1/logs — public endpoint (authenticated by Authorization: Bearer {OTLP_INGEST_SECRET}). Uses getEnv("OTLP_INGEST_SECRET") pattern (not env.X direct access — consistent with other secret reads). Auth failure returns 200 (to avoid OTLP exporter retry storms — 23a624c8). HMAC comparison uses XOR loop for timing safety (848056...). Parser (143cc857): parses OTLP Protobuf JSON format (ResourceLogs[] → ScopeLogs[] → LogRecord[]). Hardened against malformed input — returns empty array on parse error rather than throwing.

Superadmin UI

WorkerLogsPage renders a paginated table (50 logs/page) with level badge, timestamp, message, and expandable attributes JSON. Row click opens detail dialog. Reset/refetch uses setTimeout properly (not a useEffect cleanup race).

20. UI & Settings Polish

Commits: 9a967517, 6f0f58c6, 4901f38e, 928f7367, 5ecbbaa6, ea92055e, d61967a2, 5584e023, 0384f72b

Dark Logo Variant

Clinics can upload a separate dark-mode logo from Settings → Clinic Info. Stored in R2 as clinic-logo-dark-{clinicId}. The sidebar logo switcher reads prefers-color-scheme and swaps to the dark variant automatically. Falls back to the light logo if dark variant is not set.

Phone Standardisation

All phone number input fields across the platform (patient create/edit, staff invite, booking form) now use PhoneInput with country code selector. Default country: Pakistan (+92). Stored as E.164 format in DB.

Notification Preferences

GET /clinic/notification-preferences{ eodEmail, stockAlerts, labUpdates }
PATCH /clinic/notification-preferences → partial update
UI: a NotificationPreferences card in Settings renders three toggles. Preferences are per-clinic (stored in clinics.notification_preferences JSONB column).

Add/Edit Patient — Full Page

5ecbbaa6: PatientFormModal replaced by PatientFormPage at /patients/new and /patients/:id/edit. Preserves all fields. Navigation back after save returns to the patient list or the referring appointment context.

Image Preview Fix

0384f72b: image preview dialog footer (with Download and Close buttons) was clipped on smaller viewports due to overflow: hidden on the container. Changed container to flex flex-col max-h-[90vh] with flex-1 overflow-auto on the image area.

Passkey Icon

ea92055e: CSS filter: invert(1) was applied in light mode (inverting the dark icon to white on a white background — invisible). Fixed to apply invert(1) only in dark mode via @media (prefers-color-scheme: dark).

21. Schema Changes Summary

TableChangeCommit
patientsADD COLUMN whatsapp_phone TEXT (encrypted)507c5fce
patient_filesADD COLUMN source TEXT, operatory_room TEXT29f82d6e
clinical_notesADD COLUMN attached_file_ids TEXT[]29f82d6e
clinicsADD COLUMN notification_preferences JSONB5dbb9424
clinicsADD COLUMN is_test_account BOOLEAN DEFAULT false8cbbf90f
clinicsADD COLUMN trial_started_at TIMESTAMPTZe5f0f750
clinicsADD COLUMN dark_logo_url TEXT9a967517
inventory_alertsADD COLUMN email_sent_at TIMESTAMPTZ5dbb9424
appointmentsADD COLUMN tags TEXT[]dd4ec842
treatment_plansADD COLUMN before_photo_file_id TEXTc3073a2c
treatment_plansADD COLUMN after_photo_file_id TEXTc3073a2c
treatment_plansADD COLUMN discount_policy_id TEXT2d2636cd
treatment_plan_itemsADD COLUMN service_id TEXTc3073a2c
discount_policiesNew tablebb57d282
dicom_quotaNew tablec471c803
clinic_modulesADD COLUMN dicom_quota_used INT, dicom_quota_limit INT, dicom_quota_reset DATEc471c803
whatsapp_configsNew table0e21b1a4
clinic_permission_templatesNew tablef1b275e1
bridge_api_keysNew table41765742
worker_logsNew table292ffe17
service_catalogNew tablec3073a2c
clinic_onboarding_milestonesNew tablea6ce360f
All new tables are created via ensureXxxSchema() runtime migration functions called in the Worker fetch handler before routing, so they are created lazily on first request rather than requiring a manual migration step.

22. API Changes Summary

New Endpoints

MethodPathAuthDescription
POST/auth/refreshRefresh tokenRotate refresh token, issue new access JWT
GET/sse/clinicSessionSSE event stream for real-time clinic events
POST/appointments/respondNone (HMAC token)Patient confirm/cancel appointment
GET/dicom/quotaSessionCurrent clinic DICOM usage
POST/dicom/terms-acceptSessionAccept DICOM AI terms
POST/dicom/analyzeSession (Pro+)Submit DICOM study for AI analysis
GET/admin/dicom-usageSuperadminPer-clinic DICOM quota table
GET/POST/DELETE/api-keysSession (admin)Bridge API key management
GET/POST/PUT/DELETE/whatsapp/configSession (admin)Per-clinic WhatsApp config
POST/whatsapp/:clinicId/webhookHMAC signatureMeta WhatsApp webhook
GET/whatsapp/webhookVerify tokenMeta webhook challenge
GET/PUT/DELETE/clinic/permission-templates/:roleSession (admin)Role permission templates
GET/POST/PUT/DELETE/service-catalogSessionService catalog CRUD
GET/PATCH/clinic/notification-preferencesSession (admin)Notification preferences
POST/invitations/:id/resendSession (admin)Resend staff invitation
GET/onboarding/milestonesSessionOnboarding checklist status
GET/admin/worker-logsSuperadminOTLP worker error logs
POST/otlp/v1/logsBearer secretOTLP log ingest (public)
GET/files/:id/streamSessionStream R2 file inline
POST/lab/cases/:id/dicom-zipSessionUpload and extract DICOM ZIP
POST/admin/plans/syncSuperadminSeed subscription plans

Modified Endpoints

EndpointChange
GET /meNow includes clinic.subscriptionPlanId and effective permissions
POST /patients, PATCH /patients/:idAccepts whatsappPhone field
GET /patients/:idReturns whatsappPhone (decrypted) when WhatsApp module active
POST /finance/paymentsValidates amount <= outstanding_balance
POST /auth/ottIssues refresh token in addition to access JWT
POST /dicom/analyzeNow enforces quota; returns next_reset in 429

23. Breaking Changes & Migration Notes

Permissions (dot-notation)

All server-side route guards now check dot.notation.keys. The migration script at 0cb2a5fe translates existing records, but any external tooling or scripts that read user_clinic_assignments.permissions must be updated to use the new key format.

Access Token Lifetime

Access tokens expire after 15 minutes. Any client code that assumed long-lived tokens (7 days) will break. The /auth/refresh endpoint must be called proactively. All first-party clients (web app, Bridge) have been updated.

Bridge API Keys

The Bridge desktop app now requires an API key from Settings → Bridge Devices. Any clinic using the Bridge during the beta period (direct env-var credentials) must generate a key and reconfigure.

WhatsApp Webhooks

The old global env-var based WhatsApp config (WHATSAPP_TOKEN, etc.) has been removed. All WhatsApp configuration is now per-clinic in the database. Existing WhatsApp configurations must be re-entered via the settings UI.

Inventory Item Create/Edit

The InventoryItemDialog component has been removed. Deep links or programmatic navigation to the old modal route will 404. Use /inventory/items/new and /inventory/items/:id/edit instead.

Patient Add/Edit

PatientFormModal removed. Use /patients/new and /patients/:id/edit full-page routes.

Cron Schedule

The 0 19 * * * cron trigger was removed to stay within the Cloudflare free-tier 5-trigger limit. If your deployment added a custom cron at that slot, it will conflict with the EOD email cron at 0 16 * * *. Review your wrangler.toml cron list.