v1.4 — Technical Changelog
Release date: 2026-04-30Branch:
mainCommits since v1.3: 322
Features: 130 | Fixes: 140+ | Perf: 5 | Refactors: 10+
Table of Contents
- X-Ray Bridge & Radiology Workstation
- DICOM AI Pipeline & Quota System
- WhatsApp Module (Add-On)
- JWT Refresh Token Rotation
- Granular Permissions System (151-Key Tree)
- Patient Appointment Respond (HMAC Tokens)
- Inventory — EOD Reports, Stock Alerts, Full-Page Form
- Ruby AI Reports
- Lab Cases
- SSE Resilience & Durable Object Event Bus
- Treatment Plan Overhaul
- Superadmin Dashboard
- Sentry Integration
- Auth & Onboarding
- Receptionist Experience
- Finance & Billing
- Calendar & Appointments
- Performance
- Cloudflare Worker Logs (OTLP Ingestion)
- UI & Settings Polish
- Schema Changes Summary
- API Changes Summary
- 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
/uploadendpoint with a Bearer API key. AttachesoperatoryRoom,patientId(selected from the tray patient picker),source: "bridge", andcategory: "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.
/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 keysPOST /api-keys— generate new key (256-bit, hex)DELETE /api-keys/:id— revoke
bridge_api_keys table with label, keyHash (SHA-256), clinicId, createdAt, lastUsedAt.
Schema additions:
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
ClinicHubDO instance (keyed byclinic:{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
useClinicSSEhook consumes the SSE endpoint and dispatches events into a React context. Components subscribe viauseClinicEvent("xray_arrived").
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
LiveXRayPanelcomponent shows allpatient_fileswithsource="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 toast —
XrayArrivalToastfires when anxray_arrivedSSE event lands. The toast is deduplicated with a 30-second TTL cache keyed byfileIdto 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:
- Receives the DICOM file IDs for a study.
- Extracts up to 30 slices (
sliceCount, Zod-clamped max 30). - Sends each slice as a base64 image message to the LLM.
- Returns structured findings JSON with per-slice annotations.
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:
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 withquota_exceededcode if at limit.GET /admin/dicom-usage— superadmin, returns per-clinic quota table.
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:
BYOK Webhook Routing
Commits:abd9c546, ba648f8d, d721c4e8
Webhook URL pattern: POST /whatsapp/:clinicId/webhook
On receive:
- Lookup
whatsapp_configsbyclinicId. - Verify
X-Hub-Signature-256usingappSecret(HMAC-SHA256). - Parse the Meta webhook payload to extract the sender phone, message body, and
phoneNumberId. - Route to the clinic’s inbound handler.
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:
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 confirmationupdated(datetime change) → reschedule notificationmissed/no_show(cron or manual status change) → missed visitscheduled_remindercron → reminder at configured interval
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
| Token | Previous | v1.4 |
|---|---|---|
| Access JWT | 7 days | 15 minutes |
| Refresh token | — | 30 days |
| Passkey session | 7 days | 7 days + refresh token |
Rotation Protocol
KV keys:rt:{tokenHash}→{ userId, clinicId, issuedAt, expiresAt }— valid token recordrt:{tokenHash}_used→{ rotatedAt, newTokenHash }— used token tombstone (1h TTL)
POST /auth/refresh:
- Decode refresh token → extract hash.
KV.get("rt:{hash}")— if null → 401SESSION_REVOKED.KV.get("rt:{hash}_used")— if present → session hijack detected → revoke all tokens for user → 401SESSION_REVOKED.- Issue new access JWT (15min) + new refresh token.
KV.put("rt:{oldHash}_used", ..., { expirationTtl: 3600 }).KV.delete("rt:{oldHash}").KV.put("rt:{newHash}", ..., { expirationTtl: 2592000 }).
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 arefreshAccessToken() 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 theimpersonatedClinicId 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:LEGACY_COMPAT_MAP in permissions.ts. The migration script (0cb2a5fe) translates existing clinic_permission_templates and user_clinic_assignments.permissions at deployment.
Schema
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)
f087b579):
- Length check before decode (rejects obviously malformed tokens).
parseInt(parts[2])withNaNguard before timestamp comparison.crypto.timingSafeEqualfor the HMAC comparison (XOR fallback on runtimes withoutsubtle.timingSafeEqual—848056...).
API Endpoint
POST /appointments/respondBody:
{ token: string, clinicId: string }Auth: none (public endpoint)
- Look up clinic to get
APPOINTMENT_TOKEN_SECRETfrom KV. - Verify token. 401 on invalid/expired.
- Fetch appointment. 404 if not found or already in terminal state.
- Apply action (
confirmed/cancelled). 409 if already in the requested state. - Decrypt patient phone, fire WhatsApp notification if applicable.
- Return
{ status, appointment }.
Email Injection
The scheduled appointment confirmation email now includes two links: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
notificationPreferences structure:
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
Triggeredfire-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_labstable, assignssupplier_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
appointmentstable grouped bystatus. - 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 hastype, 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.
Real-Time Status Updates via Shared Link
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 typeapplication/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:
- Receives the ZIP as a
ReadableStream. - Extracts using a streaming ZIP parser (
fflate). - For each
.dcmentry, writes to R2 and inserts apatient_filesrecord. - 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:
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
TheuseClinicSSE hook:
- Maintains an
AbortControllerto 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, surfacesparse errorsvia 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:GET /service-catalog— list active services for clinicPOST /service-catalog— create servicePUT /service-catalog/:id— updateDELETE /service-catalog/:id— deactivate (soft delete)
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: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:
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:
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 toPOST /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:
{ 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.editpermission granted - WhatsApp: hidden unless module active AND
whatsapp.configurepermission 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.
Nav Dot-Notation Migration
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: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
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 asclinic-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 usePhoneInput 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
| Table | Change | Commit |
|---|---|---|
patients | ADD COLUMN whatsapp_phone TEXT (encrypted) | 507c5fce |
patient_files | ADD COLUMN source TEXT, operatory_room TEXT | 29f82d6e |
clinical_notes | ADD COLUMN attached_file_ids TEXT[] | 29f82d6e |
clinics | ADD COLUMN notification_preferences JSONB | 5dbb9424 |
clinics | ADD COLUMN is_test_account BOOLEAN DEFAULT false | 8cbbf90f |
clinics | ADD COLUMN trial_started_at TIMESTAMPTZ | e5f0f750 |
clinics | ADD COLUMN dark_logo_url TEXT | 9a967517 |
inventory_alerts | ADD COLUMN email_sent_at TIMESTAMPTZ | 5dbb9424 |
appointments | ADD COLUMN tags TEXT[] | dd4ec842 |
treatment_plans | ADD COLUMN before_photo_file_id TEXT | c3073a2c |
treatment_plans | ADD COLUMN after_photo_file_id TEXT | c3073a2c |
treatment_plans | ADD COLUMN discount_policy_id TEXT | 2d2636cd |
treatment_plan_items | ADD COLUMN service_id TEXT | c3073a2c |
discount_policies | New table | bb57d282 |
dicom_quota | New table | c471c803 |
clinic_modules | ADD COLUMN dicom_quota_used INT, dicom_quota_limit INT, dicom_quota_reset DATE | c471c803 |
whatsapp_configs | New table | 0e21b1a4 |
clinic_permission_templates | New table | f1b275e1 |
bridge_api_keys | New table | 41765742 |
worker_logs | New table | 292ffe17 |
service_catalog | New table | c3073a2c |
clinic_onboarding_milestones | New table | a6ce360f |
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
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /auth/refresh | Refresh token | Rotate refresh token, issue new access JWT |
GET | /sse/clinic | Session | SSE event stream for real-time clinic events |
POST | /appointments/respond | None (HMAC token) | Patient confirm/cancel appointment |
GET | /dicom/quota | Session | Current clinic DICOM usage |
POST | /dicom/terms-accept | Session | Accept DICOM AI terms |
POST | /dicom/analyze | Session (Pro+) | Submit DICOM study for AI analysis |
GET | /admin/dicom-usage | Superadmin | Per-clinic DICOM quota table |
GET/POST/DELETE | /api-keys | Session (admin) | Bridge API key management |
GET/POST/PUT/DELETE | /whatsapp/config | Session (admin) | Per-clinic WhatsApp config |
POST | /whatsapp/:clinicId/webhook | HMAC signature | Meta WhatsApp webhook |
GET | /whatsapp/webhook | Verify token | Meta webhook challenge |
GET/PUT/DELETE | /clinic/permission-templates/:role | Session (admin) | Role permission templates |
GET/POST/PUT/DELETE | /service-catalog | Session | Service catalog CRUD |
GET/PATCH | /clinic/notification-preferences | Session (admin) | Notification preferences |
POST | /invitations/:id/resend | Session (admin) | Resend staff invitation |
GET | /onboarding/milestones | Session | Onboarding checklist status |
GET | /admin/worker-logs | Superadmin | OTLP worker error logs |
POST | /otlp/v1/logs | Bearer secret | OTLP log ingest (public) |
GET | /files/:id/stream | Session | Stream R2 file inline |
POST | /lab/cases/:id/dicom-zip | Session | Upload and extract DICOM ZIP |
POST | /admin/plans/sync | Superadmin | Seed subscription plans |
Modified Endpoints
| Endpoint | Change |
|---|---|
GET /me | Now includes clinic.subscriptionPlanId and effective permissions |
POST /patients, PATCH /patients/:id | Accepts whatsappPhone field |
GET /patients/:id | Returns whatsappPhone (decrypted) when WhatsApp module active |
POST /finance/payments | Validates amount <= outstanding_balance |
POST /auth/ott | Issues refresh token in addition to access JWT |
POST /dicom/analyze | Now enforces quota; returns next_reset in 429 |
23. Breaking Changes & Migration Notes
Permissions (dot-notation)
All server-side route guards now checkdot.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
TheInventoryItemDialog 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
The0 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.
