Skip to main content

OdontoX v2.0 Stable — internal release notes

Audience: OdontoX engineering + ops. Window covered: 2026-07-26 → 2026-09-20 (~45 entries in RELEASES.md, 79 commits v1.10..1c43fdc9). Items already written up in v1.10 (revenue analytics, prescription fixes, permission caching, required fields, conflict detection) are not repeated here. Login tag at cut: v2.0 (ui/src/components/ui/sign-in.tsx APP_VERSION; it was still reading v1.9 because the bump was missed at v1.10). Public name: v2.0. Deploy targets: odontox-server Worker (production env), odontox-app Cloudflare Pages canonical, marketplace-app Cloudflare Pages. Database: odontox-prod Neon project, schema app. This document mirrors the public doc plus full technical depth. Sections marked “Superadmin” are internal-only and must never be cross-posted to q.odontox.io or any clinic-visible surface. Why this is a major version: finance moves from hand-maintained balance columns to an append-only ledger with database triggers and closed periods. How money is calculated changes fundamentally. RBAC also moves to a layered resolver with custom roles, and the per-user permission editor is retired as a write surface.

What shipped in v2.0 — feature headlines

New features
  • Append-only money ledger (invoice_ledger), receipt cancellation, Reset to Zero, day close / reopen with forward restatement (§2)
  • Editable issued invoices with staff-only edit trail (§3.1)
  • Configurable day-end report: sections, 12 selectable headline tiles, manual cash lines with audit trail (§3.1)
  • CANCELLED stamp on invoice document and PDF; ledger-derived cost/profit (§3.1)
  • Custom clinic roles, single Roles surface, v2 permission editor, “Primary User” naming, Change Role from the staff list (§3.1)
  • Calendar status filter (multi-select, live counts), persisted Hide cancelled (§3.3)
  • Clinic-wide Clinical Notes with server pagination and search; Add Note from the patient record (§3.2)
  • Dental chart on tablets and phones; chart history and auto-save snapshots; URL-synced deep links (§3.2)
  • Medical History tab rebuilt (§3.2)
  • WhatsApp inbox: X-rays card, per-conversation drafts with DRAFT badge (§3.3)
  • HR documents: blank or letterhead export, re-render of issued documents (§3.1)
  • Mobile push notification backend (outbox, preferences, device logout). No shipping client yet (§4)
  • Landing: separate boot path, Lighthouse mobile 55 → 96 (median 86–96, see §11), hero film (§3.5)
Bugs fixed (headline — detail in §3)
  • Credit notes silently discarded by the next payment’s balance recalculation
  • A 15% early-exit fee withheld from refunds for clinics that never configured one
  • PUT /invoices/:id accepted client-supplied totalAmount / status / patientId
  • Medical History never saved (missing clinic_id column) and GET /patients/:id returned 500
  • Clinical Notes only saw the 50 most recently registered patients
  • Every WhatsApp attachment failed to send from 16 May to 16 Sep 2026
  • WhatsApp auto-confirm sent repeated “couldn’t find an appointment” replies on Meta retries
  • DeepSeek credit drained by timed-out generations × SDK retries × query retries
  • Several permission save paths reported success without persisting; per-user snapshots froze permissions
  • Patient role could be granted invoice-create and send permissions
  • Inventory item delete returned 404 (route never implemented); doctors had no Inventory nav entry
  • Landing white-flash / auto-reload loop, a hero <video> pointing at a file that never existed, 2.4 MB of print-resolution logos

1. Header — scope and deploy artifacts


2. Headline milestone — the money ledger

Symptom that forced it. There were four different formulas for invoices.balance: receipt payment (total − paid), invoice amendment, credit note and store credit. Only the credit-note one was right. Recording any payment after a credit note put the invoice back to its original amount. One live clinic ended up with an 82,500 invoice, an unintended refund and a 15% fee they had never configured, because the only way to correct a mis-keyed receipt was to reshape the invoice and chase it with credit notes. Design.
  • New append-only app.invoice_ledger. The balance is SUM(amount), and the cached invoices.balance / total_cost / profit / status are rollups written by syncInvoiceRollups.
  • Each entry carries three independent signed axes: amount (what the patient owes), cash_amount (what moved through the till) and revenue_effect (what was earned), plus cost_effect. A store-credit note moves the balance and reverses revenue but moves no cash. An early-exit fee moves the balance and books revenue but moves no cash. Collapsing those axes is what made credit notes invisible to every report.
  • Credit-note arithmetic, stated once in lib/finance/movements.ts: post −creditedRevenue, and the excess re-enters as fee + refund + storeCredit. That identity is asserted at write time, so a note that cannot settle to zero is rejected. For a fully settled cash invoice, recognised revenue equals net cash.
  • Opening charges and receipt payments are posted by DB triggers (invoice_ledger_open_charge_trg, invoice_ledger_receipt_payment_trg). Invoices are created from 13 call sites. Posting from application code would hold only until someone added the 14th.
  • Amendments post a charge_adjustment delta instead of rewriting the total. That is what allows an invoice in a closed period to be corrected.
  • Day close. app.day_closes stores the signed-off snapshot. resolvePeriod() refuses to date an entry into a closed day and restates it forward with is_restatement / restates_date. GET /day-close/:date serves the stored snapshot and recomputes alongside it, so drift is loud. Reopen requires a reason.
  • Receipt cancel reverses the payment with a ledger entry and keeps the receipt (status cancelled).
  • Reset to Zero cancels receipts, voids credit notes, returns store credit and reverses the charge. Each reversal is dated to the original movement’s day, and the invoice is then cancelled. Reason required, audit-logged.
  • Voiding a credit note now asks whether refunded cash was actually recovered. Previously it restored the invoice to fully paid while the cash had already left the till. A store-credit shortfall (patient already spent it) is reported instead of being absorbed by a GREATEST(0, …) clamp.
  • Early-exit fee defaults to 0 unless configured. Previously it was 15% on every refund, booked as income.
  • Nothing in finance is hard deleted. Draft credit-note discard is a soft delete (deleted_at / deleted_by / delete_reason). app.invoices cascades to receipts, credit notes, payments and line items, so a hard delete would destroy the record of cash physically in the drawer.
  • Tests: 24 in lib/finance/__tests__/ledger.test.ts, including the live incident and its remedy.

3. All public headlines, plus extra technical detail

3.1 Owners & Admins (Primary User)

Finance — fixes woven in
  • Credit notes lost on the next payment. Root cause: the receipt path’s total − paid formula (§2). Fixed by the ledger.
  • Refunds and credit notes absent from the day-end. reports.ts filtered credit notes by issued_at::date (UTC) and receipts by receipt_date (clinic-local), so an early-morning credit landed in a different period from the payment it reversed. It also subtracted cost_reversal for store-credit notes without subtracting revenue, which inflated profit. Day-end and reports now read from the ledger, with a fallback to legacy receipt-based figures for periods the ledger does not yet cover.
  • Part-paid overdue invoices dropped off the overdue list. Status is re-derived from what has been settled.
  • Store credit could drive a balance negative, which reduced the clinic’s outstanding total.
  • Credit-note numbering could jam. Discarding a draft rewound the counter onto a number already in use.
  • Stale balances across views. Credit-note mutations now invalidate the invoice list, revenue overview and day-end.
  • PUT /invoices/:id was invoiceCreateSchema.partial() spread into the update. It accepted totalAmount, status and patientId, never recomputed the balance, and bypassed every guard on /edit. It is now a strict non-money patch (dueDate, notes, cancel-only status), and cancelling through it triggers a rollup re-sync.
  • metrics/financial.ts excludes cancelled invoices from billed revenue and nets off amount_credited. Before this, exactly one route read that column.
  • Cancelled invoices reported their original profit (profit / total_cost were written once at creation). They are now ledger-derived, and the list also guards at render time for legacy rows not yet re-synced. The cancelled badge fell through to the amber “unpaid” style.
  • CANCELLED stamp: an inline SVG on screen (CSS-rotated text is dropped by print and html-to-canvas pipelines) with print-color-adjust: exact, and react-pdf primitives in components/CancelledWatermark.tsx for the PDF.
Editable invoices (PUT /api/v1/protected/invoices/:id/edit)
  • Gated on billing.invoices.edit alone. The route’s generic billing.invoices.create write gate opts out via skipPathSuffixes. billing.invoices.edit is now granted wherever .create is, in both server/src/lib/permissions.ts and ui/src/lib/permissions.ts (the parity test enforces this).
  • Server recomputes subtotal, tax, surcharge, total, cost, profit and balance. Items and the invoice row are written in one transaction. Status is re-derived, including the born-overdue rule.
  • Refused below amount paid. Hidden for cancelled, payment-plan and split invoices. Invoices with a credit note are editable again: they were locked in the first cut and unlocked once amendments posted deltas.
  • New columns edited_at, edited_by, edited_by_role, edit_count, last_edit_reason.
  • Leak protection: stripEditTrail() in routes/invoices.tsx for patient-role reads, and routes/public-documents.ts drops the columns from its getTableColumns(invoices) spread. The PDF, InvoiceDocument and the share email are untouched by design.
  • Audit: an edited row via logInvoiceEvent with a per-field diff plus an items diff (itemsSnapshot is ignored by the timeline differ). Staff are notified; the patient deliberately is not.
Day-end report
  • app.eod_settings (one row per clinic) and app.eod_manual_entries. sections / tiles are opaque JSON. The catalogue lives in ui/src/components/reports/eod-catalogue.ts, so adding a section or tile is a UI-only change. A layout saved before a new section existed shows the new section by default.
  • Layout and manual entries are also returned in the day-end payload, so the PDF and nightly email honour the same configuration as the screen.
  • Manual lines move cash, never revenue. include_in_totals separates cash-moving lines from reference lines. They are soft-deleted, and every create, edit and delete writes a field-level diff to audit_logs (GET /eod-config/entries/:id/history). Blocked on closed days.
  • Fix (09-09b): layout reset on refresh, and a save could wipe config. EODCustomiseSheet seeded from DEFAULT_SECTIONS whenever the report was absent, so saving from a fresh page overwrote the real layout with defaults. It now owns an ['eod-layout'] query, seeds once per open from a resolved response, disables Save while loading, and is mounted at page top level.
  • Fix (09-09d): manual lines didn’t appear until the report refetched. EODManualEntries owns ['eod-manual-entries', date].
  • ui/src/lib/actor.tsx (<Actor>, formatActor(), useIsMe()) provides the “(you)” marker across ActivityTimeline.
Roles & Permissions (08-12 overhaul + 09-03 v2)
  • clinic_roles table, plus role_id on assignments and invitations (migration 0072, mirrored in schema-ensure and /run-migration).
  • Resolver order: plan defaults → role layer (template or custom role) → per-user deltas → role core → superadmin overrides. The per-clinic role beats the global user role.
  • Fixes: staff invite, staff create and one editor save path reported success without persisting. Saving one person’s permissions snapshotted the full set, so later role edits never reached them. Only deltas are stored now, and legacy snapshots were purged (96c0d89e). A false “grants outside plan will stay inactive” warning was removed. Unknown keys return 400 instead of being dropped. All mutations are audit-logged and flush the permission cache.
  • Single write surface (09-03). Roles is the only place permissions are changed. The per-person screen is read-only with a “Reset to role” action; 2 of 25 active assignments carried per-user exceptions at cut, and user_permission_overrides was empty. The editor is one accordion over PERMISSION_TREE, verified to cover all 204 server keys.
  • admin is labelled Primary User via lib/roleLabels.ts. The role key is unchanged.
  • Security fix: the Roles screen offered all 204 keys to the Patient role, and invoices.tsx gates on keys with no role check, so a portal patient could have been granted invoice create/send/share. PATIENT_ALLOWED_PERMISSIONS now caps this in the editor, on template and custom-role writes, and in maskPermissionsForRole after the resolve.
  • ROLE_PERMISSION_FLOORROLE_CORE_PERMISSIONS, now advisory at the owner’s request. It renders “Recommended” and asks for confirmation before switching off. Known trade-off: an admin can now leave a doctor unable to open a chart.
  • New keys: settings.roles.view / settings.roles.manage, inventory.delete (admin-only, deliberately not in the legacy manage_inventory group), and a distinct supplier-delete key. Staff-management actions are individually gated.
  • Change Role guards: not yourself, not the last admin, only an admin can promote to admin.
  • Permission queries refetch on window focus.
People & Payroll — HR documents
  • Blank header/footer by default. Options are Standard, Physical letterhead (gaps) and Digital letterhead. The physical/letterhead top gap is floored at 60 mm (bottom 25 mm), because multi-doctor mastheads are 55–60 mm and the 40 mm document default overlapped them.
  • GET /hr/documents/:id/render re-derives title, body and disclaimer from the stored merge_data. No schema change.
Inventory
  • DELETE /inventory/:id was never implemented, so the UI got a Hono 404. It is now implemented. The blanket requirePermissionByMethod gate collapsed every write onto inventory.create; skipMethods now defers DELETE to per-route middleware. inventory_alerts and stock_transactions have no physical FK in the live DB despite Drizzle’s onDelete: cascade, so dependants are removed explicitly. The UI now gates Edit, Delete and stock actions on the server’s keys.

3.2 Doctors

Clinical Notes
  • Root cause: there was no clinic-wide endpoint. getAllClinicalNotes() fetched page 1 of /patients (50 rows, createdAt DESC) and issued one request per patient: a 50-patient window and 51 round-trips. Each new registration pushed the oldest of the 50 out of view, along with their notes. The New Note and SOAP voice pickers used the same list.
  • New GET /clinical-notes (clinic-scoped, page, limit capped at 200, patientId / doctorId, filter=all|this-week|follow-up, server search over name, patient number, complaint and diagnosis, ordered visit_date DESC, created_at DESC, id DESC) and GET /clinical-notes/stats. GET /:id joins the patient label. GET /patient/:id is now newest-first.
  • Cache invalidation moved into serverComm (create / update / deleteClinicalNote); notes are written from six call sites. Added the missing deleteClinicalNote binding.
  • OdontogramChart resolves the patient via getPatient(id). This fixes the blank panel, and the child-mode detection that never read the age, for patients outside the 50-row window.
  • Index clinical_notes_clinic_visit_idx (drizzle 0074, created by ensureClinicalNotesSchema()). Tests: routes/__tests__/clinical-notes-list.test.ts (6).
  • Add Note from the patient record (c47b9dce), gated on the clinical-notes write permission.
Dental chart
  • Container queries (@container odontogram) replace viewport media queries. Panels switch between slide-over and Vaul drawer by device, and the tablet bottom nav is enabled.
  • Chart history: patient-level auto-save snapshots. odontogram_snapshots.appointment_id is now nullable (ALTER … DROP NOT NULL in schema-ensure), and the table itself is ensured on cold start. odontogram-snapshots-api.ts was committed to stop version regression on clean builds.
  • Bidirectional ?patientId= sync. Deep links skip the empty-state flash.
  • Per-tooth notes trigger auto-save. The dark-mode note popover is appended inside .odontogram-root, so it inherits theme variables.
  • Odontogram v2 Black classification and periodontal panels arrived in the 4ea94cc3 WIP commit (previously live only via working-tree deploys).
Medical History (09-02 hotfix)
  • Root cause: app.patient_medical_history was created without clinic_id, and oral_habits existed only in drizzle 0027 (not applied to prod). Drizzle expands select() to an explicit column list, so every read and write failed with 42703. GET /patients/:id returned 500, and serverComm.getPatient swallowed that to null, which is why the tab rendered empty.
  • ensureMedicalHistorySchema() adds the columns, a clinic_id index and a unique index on patient_id, then backfills. It runs before the patient-create transaction so DDL never runs inside it, with separate try blocks so a pre-existing duplicate cannot block the repair. drizzle/0073 and scripts/apply-0073.ts are committed as a record only.
  • oralHabits added to the zod schema (it was being stripped). smokingStatus maps '' to undefined. Chip drafts are folded into the save. The form re-seeds from the save response instead of a stale cache.
  • Regression test schema/patient_medical_history.test.ts asserts every Drizzle-selected column exists in base_schema.sql.

3.3 Receptionists

Calendar
  • ui/src/lib/appointment-status.ts is the single visibility rule: an explicit selection wins, otherwise cancelled is dropped when hideCancelled is on. The grid, mini-calendar dots, per-doctor counts and utilization all use it.
  • Scheduler context: filterStatus: string | nullfilterStatuses: string[], plus role-persisted hideCancelled. useDayIndex returns hiddenByStatus.
  • bookedMinutesFor skips cancelled, no_show and missed (occupiesChairTime).
  • Fix: missed (a live enum value on historical rows; the auto-marker cron was retired 2026-05-23) was missing from status-style.ts, both event-variant mappers, the Queue badge map and AppointmentStatus. It rendered as blue “scheduled”.
  • Fix: sidebar status pills navigated to the Queue screen, which is not mounted in any dashboard (see §11). They now filter in place.
  • viewRangeKeys() scopes counts to the visible range. 25 new tests.
  • Reschedule dialog: calculateAvailableSlots accepts durationMinutes.
WhatsApp inbox
  • Attachments failed from 16 May to 16 Sep. a92593d9 moved the picker to /files/<id>/download, but PATIENT_FILE_DOWNLOAD_RE in the send path matched only /patient-files. Unmatched URLs fell through to Meta’s link: with a relative, auth-gated path, and deferMetaSend marked the rows failed. Classification now lives in server/src/lib/attachment-urls.ts (13 tests), and the link: fallback throws a named error on a non-https:// URL.
  • ?preview=true uploads previewKey (converted PNG) with the matching mime and filename, instead of raw DICOM/TIFF.
  • GET /patient-files?patientId= now honours fileType, category, source, a new radiology=true, order=desc and limit. It previously ignored every filter. Default ordering is unchanged. The X-ray card fetches once, with a thumbnail concurrency cap of 4, and blob URLs are revoked on switch.
  • Drafts leaked across conversations. Composer was not keyed, so the save effect wrote the previous conversation’s text under the new key. It is now keyed by conversationId. ui/src/lib/chat-drafts.ts stores { text, attachments } metadata in localStorage, with an in-memory preview map and useSyncExternalStore hooks. Legacy chatV2.draft.<id> keys are migrated. 14 tests.
  • Auto-confirm repeat replies. The CONFIRM/CANCEL branches returned before the only insert that stamped messages.wamid, so the idempotency guard never matched a Meta re-delivery. claimIntentMessage() writes the row with its wamid first. Handlers return 'done' | 'noop' | 'none', so “nothing found” is sent once per sender, not once per matching patient row. Already-confirmed is silent. Work runs under waitUntil so Meta is acknowledged immediately. ensureMessagesSchema adds wamid and its index, and is actually invoked on the inbound path (f7905737).

3.4 Ruby (AI)

  • DeepSeek balance drain (09-03). client.ts had timeout: 30_000, while structured briefs take 60–90 s (reception-day-brief succeeded at 75.4 s). A client timeout does not cancel upstream generation, so each timed-out generation was billed in full. That was multiplied by the openai SDK default maxRetries: 2 (Langfuse showed 91.3 s ≈ 3 × 30 s) and by TanStack retry: 1. Over 2–3 Sep there were 96 errors against 10 successes (Langfuse project cmnyttcqe001uad07z13swsqc).
  • Fixes: maxRetries: 0, REQUEST_TIMEOUT_MS = 120_000, and a per-promptName circuit breaker (3 failures → 10-minute cooldown, checked before the prompt or Langfuse is resolved; per-isolate best effort). AIInsightsPage nudges query retry: 0. Tests: lib/ai/__tests__/cost-guards.test.ts.
  • 89cc51a4: reception-day-brief, reception-prep-hints and appointment-nudges are now cached in ai_insight_cache. Nudges are cached per clinic, day-brief per user (the name is in the prompt), and prep-hints by an order-independent hash of appointment IDs. Output caps went from 32000 to MAX_TOKENS_STRUCTURED_BRIEF (8000), and the truncation retry from 64000 to 16000. Cache hit/miss tokens and a per-call USD estimate at peak/off-peak rates (peak is PKT 06:00–09:00 and 11:00–15:00) are emitted to Langfuse and an [ai-cost] log line.
  • WhatsApp voice agent (Dental Square pilot). Fixes shipped: the global webhook now admits field:'calls'; there is a forced agent start after the 5.5 s welcome fallback; caller audio is wired after accept; a duplicate-offer guard stops a Meta retry clobbering a live call; pre_accept is sent before accept; and there is SDP/ICE diagnostics logging. The pilot is not live. ICE never completed in live probes. Keep it off the public notes.

3.5 Patients, portal and marketing site

  • Patient-role reads strip the invoice edit trail (§3.1). The portal medical-history save endpoint gets the same schema fix (§3.2).
  • Landing (09-20, six commits).
    • Landing was lazy(), and FullPageLoading ran a 12 s location.reload() watchdog, which caused the white flash and the reload loop on slow mobile. The watchdog is now connection-aware (30 s plus a manual button on saveData / 2g / 3g).
    • main.tsx has two boot paths. isLandingOnlyRoute() (which must stay identical to public/prerender-guard.js) boots <ThemeProvider><Landing/></ThemeProvider>, and App is imported dynamically. Entry JS went from 219 KB to 82 KB gzip.
    • LandingNavbar used hasAuthCookie() instead of importing serverComm.
    • Logos were 6250 px masters (2.4 MB per page). They are re-encoded to 480 px and versioned (logo-v2.webp, logo-light-v2.webp), because _headers pinned a 7-day cache on the unversioned URL.
    • Inline head scripts (theme-init, prerender-guard, chunk-recovery). Session replay and surveys are off on public marketing views (isPublicMarketingView()). GTM is gated to the conversion paths and click-ID traffic, so Ads attribution is preserved.
    • /auth_hero.webm never existed: Pages served SPA HTML for the 404. The hero film is now on R2 (hero-film.{webm,mp4}, 0.52 MB, 0.75×), desktop-only via hidden md:block plus a lazy poster. HeroAppPreview, BrandFilm and LazyVideo are deleted.
    • Mobile heading clip fixed (min-w-0). Touch targets are now at least 36 px. The contact form uses readApiError() and a CONTACT_MESSAGE_MIN counter. Cookie consent moved to localStorage. Smooth scroll uses scroll-padding-top and prefetch-on-intent.ts. The mobile navbar blur was dropped.
    • Brand spelled odontoX on marketing surfaces only (regex-bounded, identifiers untouched). Email, PDF and legal text still read “OdontoX”.

4. Architecture & data-flow notes

  • Finance source of truth is now invoice_ledger. invoices.balance, total_cost, profit and status are caches. Anything reading money should read the ledger or the synced rollups, never recompute total − paid.
  • Triggers own posting for opening charges and receipt payments. Application code posts credit notes, refunds, store credit, fees, amendments and reversals via lib/finance/movements.ts.
  • Closed periods are immutable. Writes resolve their period through resolvePeriod(), and anything dated into a closed day restates forward.
  • Permissions: there is a single write surface (Roles), and the resolver layers are listed in §3.1. The UI mirrors ROLE_CORE_PERMISSIONS and PATIENT_ALLOWED_PERMISSIONS, and permissions-ui-parity.test.ts fails on drift.
  • Push: every in-app notification enqueues to notification_outbox. Delivery runs immediately via executionCtx.waitUntil (processOutbox + checkExpoReceipts). The cron path was removed (985f40bc). Producers: WhatsApp inbound (known and unknown senders), Ruby escalation (high priority), appointment changes, prescriptions and billing.
  • Shared numeric input: <Input> renders controlled numeric fields as type="text" with inputMode. onChange still receives the unformatted value, so all ~103 call sites are unchanged. Precision comes from step and negatives from min, and formatThousands={false} opts out. Nothing in the codebase reads valueAsNumber or uses stepUp/stepDown. 12 tests.
  • Page width: --page-max-width (1600px), --page-pad-x and --page-pad-y on :root, and .page-container / .page-pad utilities. 18 hardcoded caps were replaced across 14 screens. Marketing and onboarding screens keep narrow caps on purpose.

5. Schema changes — migrations + lazy-ensured tables

The drizzle migration pipeline is not applied to production. Everything below self-heals through server/src/lib/schema-ensure.ts on first use, unless noted otherwise.

6. New backend modules / files (significant)

  • server/src/lib/finance/movements.ts, the ledger posting helpers, scripts/reconcile-finance.ts
  • server/src/lib/attachment-urls.ts
  • server/src/lib/ai/client.ts (timeouts, breaker, cost telemetry; rewritten, not new)
  • server/src/lib/push-enqueue.ts and the outbox processor
  • Day-close and /eod-config routes; /clinic/roles routes; HR render route
  • UI: lib/actor.tsx, lib/chat-drafts.ts, lib/appointment-status.ts, lib/roleLabels.ts, components/reports/eod-catalogue.ts, components/CancelledWatermark.tsx, prefetch-on-intent.ts, HeroFilm

7. API surface changes (selected)

Update docs/api-reference.md with the rows above.

8. Cron jobs added / changed

  • Removed: push outbox delivery and receipt-check cron handlers (scheduled.ts). Delivery is immediate via waitUntil, and token cleanup happens as a side effect of delivery.
  • Nightly day-end email is unchanged in schedule. It now honours eod_settings and reads from the ledger.

9. Configuration / env vars

  • No new required production env vars in this window.
  • Not in this release: DB_DRIVER=neon-http for the Oracle UAT neon-compat-proxy is an uncommitted change in server/src/lib/db.ts at cut time.

10. Superadmin tooling (internal-only)

  • Superadmin permission overrides remain the top resolver layer (§3.1).
  • GET /api/v1/protected/push/diagnostics (routes/push-diagnostics.ts, e7a8f06a) for inspecting push delivery state.
  • Generated role and permission reference at internal-docs/rbac-reference.md (regenerate with server/scripts/generate-rbac-doc.ts).

11. Known issues / follow-ups

  1. scripts/reconcile-finance.ts has not been run against production. Until --apply runs, pre-ledger invoices fall back to legacy figures, and rows raised before 09-09 may still carry stale profit / total_cost (it reports these as cancelled-with-profit). Run it read-only first and review the drift report.
  2. The Queue & Action Required screen is orphaned. AppointmentQueueView is reachable only via AppointmentsFullPage, which nothing imports (dropped in a6bed91b). The queue fixes (cancelled excluded, Missed tab, virtualization) are correct but unreachable. Remounting it is a product call.
  3. 50-row patient cap still in seven pickers: InsuranceClaims, BillingModule, AppointmentInvoiceDialog, AppointmentInvoiceSheet, LabWorkTracking, StaffManagement, and the PatientsTab report. They need to move to PatientPicker.
  4. ui/src/components/patients/PatientClinicNotes.tsx is orphaned and does not compile (it imports five non-existent serverComm members).
  5. messages_wamid_idx is non-unique, so two simultaneous deliveries can both pass the claim. The already-confirmed guard makes this harmless in practice. A unique index needs a duplicate cleanup first.
  6. Push has no shipping client. Known gaps: channelId, per-category preferences, and notificationId on payloads; several producers still missing.
  7. WhatsApp voice agent is not live (ICE never completes; see §3.4).
  8. Landing mobile LCP is bimodal (medians 86–96). createRoot() discards the prerendered DOM and re-creates the <h1>. The fix is hydration. The 562 KB render-blocking stylesheet is still shared with the app.
  9. The R2 assets.odontox.io bucket still holds the 285 KB logo master for getStaticAssetUrl() callers (in-app onboarding and upgrade pages).
  10. ROLE_CORE_PERMISSIONS is advisory, so an admin can lock a doctor out of charts. This trade-off was accepted.
  11. The circuit breaker is per-isolate, not global.

12. Deploy checklist

A. Migrations. None to apply by hand. Confirm schema-ensure ran on prod by checking that app.invoice_ledger, app.day_closes, app.eod_settings, app.eod_manual_entries and app.clinic_roles exist, along with patient_medical_history.clinic_id and messages.wamid. Then run scripts/reconcile-finance.ts read-only and review before any --apply. B. Worker. Deploy odontox-server production. Tail for [ai-cost] lines and ledger assertion errors. C. UI. Build odontox-app with APP_VERSION = 'v2.0', deploy, and force-promote canonical. Hard-reload and confirm the login tag reads v2.0 and logo-v2.webp is served. D. Marketplace. No change. E. Bridge. No change. F. Smoke.
  • Invoice: take a payment, raise a credit note, take another payment, and confirm the balance holds.
  • Cancel a receipt.
  • Reset to Zero on a test invoice.
  • Close and reopen a day.
  • Add a manual line.
  • Save a custom role.
  • Filter the calendar by status.
  • Send an X-ray over WhatsApp.
  • Save Medical History and reload.
  • Open Clinical Notes for a long-standing patient.
  • Run Lighthouse against odontox.io.

End