Skip to main content

OdontoX v1.7 Stable — internal release notes

Audience: OdontoX engineering + ops. Window covered: 2026-04-26 → 2026-05-21 (243 entries in RELEASES.md). Login tag at cut: v1.10.0 (ui/src/components/auth/sign-in.tsx APP_VERSION). Public name: v1.7 stable. Deploy targets: odontox-server Worker (production env), odontox-app Cloudflare Pages canonical, marketplace-app Cloudflare Pages, bridge Windows installer. 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.

1. Header — scope and deploy artifacts

  • Branches merged: all v1.7 → v1.10 work on main. No long-lived feature branches outstanding.
  • Worker version IDs: see Cloudflare dashboard → odontox-server → Deployments. Most recent referenced in entries: hotfix bundle for 0051 + email matrix on 2026-05-21.
  • Pages deploy IDs: odontox-app canonical promotion is enforced after every deploy per feedback_commit_deploy memory. marketplace-app is its own Pages project under marketplace-app/.
  • Bridge artifacts: Bridge-1.1.0-x64.exe, Bridge-1.1.0-arm64.exe (rename + autostart + tray icon fixes shipped 2026-05-11). v1.0.2 earlier in the window (queue dedup + TIFF retry fix). v1.0.1 before that.

2. Headline milestone — Module Marketplace launch

The Module Marketplace is the marquee feature of the v1.7 stable train. It introduces a self-contained app-store experience inside OdontoX — clinics browse and subscribe to optional modules (DICOM viewer, WhatsApp Business API, IPD, Insurance, Lab Workflow, Mobile App, and more), each with its own listing page, versioned release notes, screenshots, dependencies, and recommended-for module hints. The marketplace runs as a separate Cloudflare Pages project (marketplace-app/) deployed to market.odontox.io, sharing the same auth, audit, and billing rails as the main app but kept structurally isolated so the listing experience can evolve independently from the clinic workspace.

Why a separate subdomain

The Marketplace is its own surface because it has its own audience and lifecycle. The clinic workspace at go.odontox.io is task-oriented (today’s calendar, this patient’s chart). The Marketplace at market.odontox.io is exploratory (browsing, comparing, deciding). Splitting the projects keeps each codebase focused, lets us iterate marketplace UI without rebuilding the workspace bundle, and shrinks the go.odontox.io bundle by leaving marketplace artwork and listing logic out of the main path. CORS is already permitted across *.odontox.io so the shared API needs no special handling.

Tables (migration 0045, extended by 0050)

  • app.marketplace_listings — one row per app. Primary key is id (matches the addon AVAILABLE_MODULES.key from the platform module registry, so a listing and its runtime feature flag share an identity). Display columns: display_name, tagline, long_description_md, pricing_summary, hero_color, category (clinical / admin / infra / comms). Visuals: icon_kind + icon_value (lucide icon name or R2 key for an uploaded image), screenshots jsonb (array of { key, alt }). Lifecycle: status (draft / published / archived), version_label, last_updated_at, sort_order. Catalog metadata: what_you_get jsonb (string array), faq jsonb (array of { q, a }), security_badges jsonb. Relationships: related_modules text[] and dependencies text[] (added in migration 0050). Audit: created_at, updated_at, updated_by. Full DDL in section 5.1.
  • app.marketplace_releases — versioned release notes per app. Foreign key listing_idmarketplace_listings.id. Columns: version_label (semver-shaped string), released_at, summary (one-liner), body_md (long-form), is_major flag. Surfaced on the app detail page and at the per-app /apps/<app>/releases feed.

Billing model (revenue gate)

Subscription is gated by manual superadmin approval. The flow:
  1. Tenant clicks Subscribe on a listing → POST /api/v1/protected/marketplace/requests creates an addon_requests row with status='pending'.
  2. OdontoX team issues an invoice via Invoice Studio. The “Invoice ready — pay to activate” email goes to the clinic owner.
  3. Clinic pays through the bank QR / IBAN / EasyPaisa / JazzCash flow already on subscription invoices.
  4. OdontoX team confirms payment → marks request status='approved'. The addon’s module key flips on; the listing’s “Subscribe” button changes to “Open App”; the app_activated email fires with a 3-step quickstart.
  5. Cancellation works the same way in reverse — cancel_requested → manual review → cancelled with effective-date prorate.
This deliberate friction is the anti-competitor gate; we never want self-serve activation of paid modules. See project_marketplace_billing_model memory.

Permissions

  • marketplace.view — see the marketplace in the sidebar and browse listings.
  • marketplace.request — submit a subscribe request.
  • marketplace.cancel — submit a cancel request.
Receptionists default to marketplace.view only. Admins and owners default to all three.

Marketing automation

Six new transactional emails ship with the marketplace, each with first-person-plural voice, centered logo, no wordmark repetition (per feedback_email_voice):
  • Invoice ready — “we’ve issued the invoice; here’s how to pay; once paid we’ll activate within one business day.”
  • App now live — “your app is on; here’s the 3-step quickstart and the link to open it.”
  • New app on Marketplace — broadcast email when a new listing publishes (gated by clinic preference; opt-in).
  • What’s new in v2.0 — per-app version release announcement; sent to subscribers of that app only.
  • Overdue-invoice reminder — automated daily nudge from the marketplace billing cron.
  • 30-day idle nudge — automated weekly nudge for active subscriptions with zero engagement.

Cron jobs

Two new schedules introduced for marketplace billing hygiene (full list in section 8):
  • Daily marketplace overdue-invoice nudge — idempotent, rate-capped.
  • Weekly marketplace idle-subscription nudge — idempotent, lifts when usage signals reappear.

Asset pipeline

  • Icon and screenshot uploadsPOST /api/v1/protected/superadmin/marketplace/upload writes to R2 with a 2 MB cap and a PNG / JPEG / WebP whitelist.
  • Screenshot delivery — auth-gated streaming via /marketplace/screenshots/:listingId/:asset. Screenshots are private to authenticated sessions; nothing is exposed via a public CDN URL.
  • Hero artwork — served from assets.odontox.io with a 1-year immutable cache header (not user-uploaded; provisioned per-deploy).

Eight seed listings

All eight launch listings ship as is_published=false so they only become visible to clinics when an OdontoX team member flips them live from the CMS. This gives marketing room to refine copy and screenshots before public exposure. The eight cover: DICOM Viewer, WhatsApp Business API, IPD, Insurance, Lab Workflow, Mobile App, Multi-Clinic, and Patient Portal Plus. Admins and owners now see a Marketplace entry in the OdontoX sidebar. Clicking it opens a panel listing the clinic’s installed apps with an Explore more ↗ button that jumps to market.odontox.io. The flyout closes on Esc, click-outside, or navigation. The legacy /requests URL keeps working and 301s to /my-apps.

Why this matters operationally

Marketplace turns a long list of one-off “can we add WhatsApp?” tickets into a self-serve discovery experience while keeping the revenue conversation human (the manual approval gate). It also creates a clean pattern for shipping future paid modules — every new addon can launch as a marketplace listing without further platform plumbing.

3. All public headlines, plus extra technical detail

These are the same headlines as the public doc, in the same role-organised order, with internal additions. Skim per role; if you are looking for the schema list jump to section 5.

Clinic Owners & Admins

Notification matrix in Settings → Notifications (v1.10.0, 2026-05-21). Per-event × per-audience opt-in grid replacing the previous 5 toggles. Source-of-truth defaults and role→audience mapping live in server/src/lib/notification-defaults.ts. The single chokepoint helper shouldSendEmail(clinicId, event, audience, prefsHint?) lives in server/src/lib/email-gate.ts. Pre-load hint avoids per-recipient N+1 DB reads in the appointment dispatcher and treatment-plan acceptance route. Migration 0051_notification_event_matrix.sql backfills notificationPreferences.emailMatrix per clinic. Stripe webhook emails (payment.ts:241,443) intentionally left as platform-billing events — always send. Audit log action clinic.notification_preferences.updated carries a before/after diff of the matrix and kill-switch state. Settings grouped into 8 labelled sections. New shell in ui/src/components/settings/SettingsModule.tsx. Sections collapse automatically when nothing in them applies to the role or the active modules. Card descriptions are no longer truncated. My Billing tile restored + canonical PDF + tabs. The over-broad feature gate was relaxed in the My Billing tile (2026-05-18). Preview/Download stream the server-rendered PDF first and silently fall back to the local generator if the server fails (the server PDF route is currently subject to a Workers WASM-init bug — followup tracked, see Known Issues). Invoices/Receipts split tabs added. Trial labels rewritten. Bank QR + welcome-to-active toast. platform_settings.billing_bank_qr stores the R2 key of the current platform QR; each new subscription_invoices row snapshots the pointer into metadata.bankQrR2Key at creation. PDF render falls back to the current pointer when the snapshot is null. New entityType='plan_activated' notification convention consumed by usePlanActivationToast. POST /invoices/:id/send, POST /invoices/:id/portal, GET /invoices/:id/pdf now force-regenerate the PDF on every call (was returning cached R2 PDF and serving stale content). Upgrade-request and license-request approvals are now silent — no auto-email, no notification — the internal team sends the invoice manually from Invoice Studio. Trial countdown banner. Renders globally in AppLayout above the existing 2FA-required banner. Admins get an “Upgrade now” CTA that calls the existing one-time upgrade link generator; replaced with a disabled “Upgrade requested — pending review” pill once a request has been submitted. GET /billing/upgrade-state returns { hasPendingRequest } to keep the CTA state in sync. Trial / billing email accuracy + PKT timezone consistency (2026-05-17). New shared helper server/src/lib/pkt.ts exposes pktMidnight, pktEndOfDay, pktHour, formatDatePKT, formatDateLongPKT as the canonical UTC↔PKT translator. Trial expiry email + 24h grace period implemented across server/src/scheduled.ts. Test accounts are pushed 500 days out on toggle and excluded from both trial and active-subscription dunning paths and from the deactivation pass entirely. Sections 3/4 of the trial / billing cron are gated to 11AM PKT. Deactivation skipped if a “expires tomorrow” reminder went out in the last 24h. See project_trial_cron_pkt_2026_05_17 memory. Auto-pause email when a clinic is suspended (2026-05-21). server/src/lib/email.ts sendAccessPausedEmail({ email, firstName, clinicName }). Fires only on transition (re-suspending is a no-op). server/src/routes/admin.ts /tenants/bulk-action for action === 'suspend' captures prior subscription_status per clinicId, runs the UPDATE, then fires the email for clinics not already suspended. Same pattern in PATCH /clinics/:id/subscription. Uses executionCtx.waitUntil; ZeptoMail blip cannot fail the suspend. Module Marketplace (2026-05-19 launch + 2026-05-21 redesign, v1.9.0). Two new tables (marketplace_listings, marketplace_releases), eight new addon-request statuses, six new addon types, eight seed listings (all draft on launch). Auth-gated screenshot streaming route (/marketplace/screenshots/:listingId/:asset) — screenshots stay private behind your session, no public CDN exposure. Two new crons: daily overdue-invoice nudge + weekly idle-subscription nudge, both idempotent and rate-capped. Permissions: marketplace.view, marketplace.request, marketplace.cancel. Subdomain CORS already permitted *.odontox.io — no worker config change required. The marketplace UI is a standalone Cloudflare Pages project at marketplace-app/ deployed to market.odontox.io. Migration 0050 adds related_modules text[] and dependencies text[] columns. Hero artwork served from assets.odontox.io (1-year immutable cache). Route normalisation: /app/:key/apps/:key (legacy URL redirects automatically). Recommended carousel surfaces apps tailored to active modules; left rail filters by module or category. New POST /api/v1/protected/superadmin/marketplace/upload for R2-backed (2 MB cap, PNG/JPEG/WebP) icon and screenshot uploads. Billing model: manual superadmin approval is the revenue gate; flow is request → invoice → payment → approve → activate (see project_marketplace_billing_model). Marketing emails published: invoice ready, app now live (3-step quickstart), new app on Marketplace, what’s new in v2.0, overdue-invoice reminders, 30-day idle nudge. Website Leads (2026-05-12). Gated as Pro-tier lead_inbox feature key. Public ingest endpoint /api/v1/public/leads with per-token rate limit (30/h) + per-IP (20/h), honeypot field, PHI encryption pipeline. Submissions de-duplicated on (clinic, email/phone/message) within a 5-minute window. Each form has its own rotatable token; allowed-origin lock per form. Pipeline statuses: new / contacted / converted / archived / spam. Convert-to-Patient creates the patient and links the lead row for audit clarity. Doctor + receptionist signature self-management (2026-05-12). Settings → Signatures tab visible to doctor and receptionist accounts. /api/v1/protected/signatures permission checks updated; the upload endpoint already accepted these roles, but the defaults table previously hid the UI. No change to existing document rendering rules — receptionist signatures still render with the “Receptionist” sub-line and never carry the “Dr.” prefix. Portal seats + storage as separate concepts (2026-05-13, migration 0033). clinics.portal_seat_limit, portal_seat_addon, storage_quota_bytes, storage_addon_bytes, storage_used_bytes; patients.portal_access (backfilled from existing user-row links); new addon_requests, addon_pricing, addon_pricing_history tables. addon_requests doubles as the active-addons ledger via the partial index status='approved' AND cancelled_at IS NULL. Centralised plan limits in server/src/lib/plan-limits.ts is the single source of truth — the old patient-quota.ts and the inline limit blocks in AdminBillingSettings.tsx were removed. Legacy POST /license-requests with requestType=patient_addon returns 410 Gone. Storage tracking active on every R2 upload route — patient files, signatures, prescription template, document letterhead, lab cases, clinic branding, public-document attachments, message attachments. Atomic increment on upload, atomic decrement on delete. 413 with {usedBytes, quotaBytes} if over quota. Pro = 100 seats / 100 GB; Pro+ = 250 seats / 250 GB; storage tiers 50/200/500/1000 GB; portal addon as 3-packs (Pro+ discounted). See project_portal_storage_caps memory. Plan-based permissions + Pro+ defaults + admin permission tree. PRO_PERMISSIONS_BY_ROLE and PRO_PLUS_PERMISSIONS_BY_ROLE constants in server/src/lib/permissions.ts. getEffectivePermissions accepts an optional subscriptionPlanId; /me endpoint passes the clinic’s plan ID. Pro+ paid plans now grant the Pro+ permission set robustly — the matcher normalises 'Pro+', 'pro+', 'pro plus', 'pro-plus', 'pro_plus_paid' to the same bucket; Enterprise inherits the full Pro+ permission set instead of silently falling through to PRO defaults. The admin role now goes through the same per-user permission override flow — stored overrides apply instead of being silently ignored. Permission floor for doctors: ROLE_PERMISSION_FLOOR in permissions.ts defines inviolable permissions per role; applied after template and per-user override layers. Live fix: doctor [email protected] at clinic dfffc93f had hit 66 403s on /procedures, /medications, /prescriptions due to a false-value permission template override. Multi-clinic: clinic switcher + scoped notifications + per-clinic role propagation. New /select-clinic page (login picker for users with 2+ clinics). GET /api/v1/protected/users/me/clinics returns active assignments joined to clinics. getActiveClinicId/setActiveClinicId localStorage helpers; fetchWithAuth sends X-Clinic-Id header on every request; cache key includes the active clinic id so a multi-clinic user never sees another clinic’s cached response after switching. SSE streams: EventSource cannot send custom HTTP headers, so X-Clinic-Id never reached SSE — the clinic-context middleware now has a ?clinicId= query-param fallback that is applied to the security gate the same way. NotificationProvider and useClinicEvents append &clinicId=.... The single-line scalable fix in server/src/middleware/clinic-context.ts: after currentClinicId is determined, mutate c.get('user').role and c.get('user').clinicId to the per-clinic-resolved values for the duration of the request. ~28 tenant-scoping leaks and ~12 role-check leaks become correct without touching each route. user.primaryClinicId is left intact. Superadmin is excluded. Bridge tokens handled in their existing fast-path. clinicContext.currentRole added to context type. Multi-clinic invitation accept (auth.ts /invitation/:token/accept) no longer overwrites the invitee’s existing account — discriminates on existingUser.passwordHash: if non-null, only bump updatedAt and refresh the userClinicAssignments row; if null, full identity-and-password update as before. Inviting a user/patient who already exists at another clinic now succeeds — userClinicAssignments-scoped existing-user check in admin.ts /invitations and patients.ts /invite. AI Daily Brief — wider, grounded. daily-brief.ts agent extended with lab case status (active, overdue, due today, urgent, ready-for-pickup) and inventory alerts (out-of-stock, low-stock, expiring, expired). Revenue forecast skips the LLM when recentInvoiceCount < 5. Daily brief no longer flags large CSV imports as “data anomalies”. Churn risk no longer scores patients with zero visits. Patient AI Brief 500s fixed by type-guarding compile() result on Langfuse prompts (chat-type prompts return arrays; we now require a non-empty string and fall back to hardcoded). All AI calls now wrap flushAsync() in try/catch — tracing failures cannot 500 the user-facing response. patient-brief.ts throw new Error(...) replaced with throw new AppError(..., 404) for missing patient. Mobile telemetry foundation (migration 0035). app.user_devices gains device_id, build_number, platform with (user_id, clinic_id, device_id) composite index. app.client_errors gains level, screen, request_id, app_version, build_number, bundle_id, os_version, device_model, platform with three query indexes. POST /api/v1/protected/user-devices upserts on device_id. POST /api/v1/clientLogs accepts level (debug/info/warn/error, defaults to error), screen, requestId, and identity fields. Removed UNIQUE constraint on client_errors.error_code (was blocking info-level events like APP_LAUNCH).

Doctors

Dental chart always enabled. dental_chart moved from minPlan: 'Pro' to minPlan: 'Basic'. clinic-modules.ts ALWAYS_ON list adds patients, appointments, clinical_notes, treatment_plans, dental_chart and merges them into the response regardless of DB state. Receptionist role hard-blocked from dental chart at the module level — server-side check + /doctor/dental-chart page guards on both (role in {doctor, admin, superadmin}) AND dental_chart module active. AI clinical notes in tooth panel. VoiceRecorder component accepts compact prop (reduces button size, waveform height, spacing). Notes save to patient_clinical_notes via the existing AI structuring path. Dental chart auto-save + responsiveness. 1.5s debounced auto-save effect with handleSaveAllRef so the effect always sees current closure. beforeunload guard. Status text in header: “Unsaved changes (auto-save in 1.5s)” → “Saving…” → “Saved 14:32”. Tooth component memoized in OdontogramDisplay.tsx. handleToothClick is a stable callback using a ref to read latest teethData. The JSON.stringify(Array.from(teethData.entries())) dirty check replaced with an early-exit shallow Map comparison. DentalChart is React.lazy()-loaded in DoctorDashboard. 100vh100dvh swept across App.css, critical.css, appSidebar.tsx, InsuranceClaims.tsx, CommunicationHub.tsx, OdontogramChart.tsx. Viewport meta carries viewport-fit=cover. Input scroll-into-view fires only on iOS UA. Treatment planning overhaul. Service Catalog integration with auto-fill price. before_images, after_images, discount_amount (numeric), discount_policy_label (text) columns added to treatment_plans via schema-ensure. Treatment plan PDF embeds before/after images as base64 data URLs. Public document share endpoint generates 1-hour R2 signed URLs for treatment plan images. plan_number text column added via ensureTreatmentPlanImagesSchema; treatmentPlan and prescription added to DOCUMENT_TYPES in document-numbering.ts. TanStack Query migration with 30s stale window — module loads instantly from cache on revisit. Atomic URL set on row click (was a race between planId+planView=view and a competing selectedPlan side-effect). Prescription print on physical letterhead. PrescriptionPdf.tsx isPhysical flag hides PdfWatermark, PdfHeader, PdfFooter; shows minimal Rx# / date row instead. pdf-generator.ts DOCX and image template paths now fall back to the default prescription template when extraction/fetch returns nothing. Default safe-zone margins changed to 60mm top / 32mm bottom; allowed range widened to 5–120mm / 5–100mm. /clinics/prescription-template/margins validator updated. Signature darkening: the signature compositor re-tints every stroke pixel to pure black at full opacity before embedding. “Dr.” prefix everywhere: shared name formatter used in dashboard header, sidebar greeting, and every issuer/signer PDF block. Admin/doctor signature blocks hide the “Admin” sub-line. Lab cases. lab_cases.attachments jsonb column added via schema-ensure (was the silent-invisibility bug). lab_cases.attachments typed LabAttachment[] in server/src/schema/lab_cases.ts. Public endpoint POST /api/v1/public-documents/:token/lab-attachments accepts lab uploads (JPEG/PNG/WebP/DICOM, 15 MB each on the public path; clinic-side uploads JPEG/PNG only at 25 MB). Lab attachment files mirrored as patient_files rows with source='lab_dicom' and fileType='image'. PUT /public-documents/:token/lab-status emits lab_status_update WebSocket event. ActivityTimeline subscribes to useEventBus() and refetches on entity-id match. recordActivity mirrors shared-link actions to both sides of the relationship (e.g. lab → invoice). Radiology workstation + TIFF/DICOM + auto-conversion. server/src/lib/image-conversion.ts converts TIFF (via utif2) and DICOM (via dicom-parser, dental bone windowing centre 700 / width 3000) to PNG via a pure-JS encoder; runs non-blocking via ctx.waitUntil() inside the upload Worker. server/src/lib/png-encoder.ts pure-JS PNG encoder using CompressionStream('deflate') + CRC32 — note: PNG IDAT requires RFC 1950 (zlib-wrapped) not RFC 1951 (raw) deflate; an earlier broken version used deflate-raw and silently produced un-decodable PNGs. Auto-recovery for already-broken thumbnails: BridgeInbox.tsx validates the downloaded blob via createImageBitmap(blob) (the <img onError> event never fires for corrupt blob data — only for failed network requests); on failure, triggers ?force=true re-convert on /files/:id/convert. conversion_status (none/pending/done/failed) and preview_key columns added (migration 0030, with the corrected app. schema prefix). GET /files/:id/download?preview=true serves the prepared PNG from R2. GET /api/v1/protected/files/:id metadata endpoint. SSE event file_conversion_ready fires on the clinic channel. R2 service allowed-MIME list extended with image/tiff and image/tif with magic-byte validation. Bridge queue dedup changed to only block re-queuing for pending or done items (failed allowed). caches.default.delete() invalidates the bridge-inbox cache after a successful upload for instant visibility. Smart patient picker. pg_trgm extension + search_text column + GIN index on app.patients (migration is idempotent). GET /api/v1/protected/patients/search?q=&limit= returns decrypted minimal fields, ranked by trigram similarity. Patient insert/update routes maintain search_text. Short queries (1-2 chars) skip ORDER BY similarity() and return alphabetical-by-name within the LIMIT. Picker shows recent picks when empty. Doctor schedule. doctor_schedules validation added to POST /appointments, checked after clinic hours and before conflict detection. GET /doctor-schedules returns [] instead of 400 when clinic context is absent. doctorScheduleRowSchema Zod accepts HH:MM:SS and normalises to HH:MM. ensureAppointmentsSchema ALTER TYPE appointment_status ADD VALUE wrapped in try-catch with the correct enum schema. See project_doctor_schedule_features memory. Ruby reports — full analysis + Export Report. Reports tab in DICOM and X-Ray workstations. Export Report button opens a print-ready clinical report with the radiograph image embedded. Report saved to patient record; success toast confirms. PDF redesigned with clinic logo, name, address, phone at top. Patient profile “History” tab renamed “AI Reports”.

Receptionists (Calendar overhaul — 2026-05-20)

Many entries on 2026-05-20. Consolidated: Calendar layout. Overview right-side panel starts collapsed (sidebar 320 → 280px). Day / week / month default to wide layout. print:hidden on the impersonation banner and the document toolbar so prints don’t carry app chrome. Empty-slot click is off. Calendar is display-only for empty slots; bookings flow through “New Appointment” button. Drag-to-reschedule snaps to 30-minute boundaries (:00/:30). 30-min default + simplified duration picker. Tapping the calendar stages a 30-minute appointment by default. Picker shows 30 min (default), 1 hour, Custom… (5–480 min). No-show / cancelled reschedule. Reschedule button visible on cancelled and no_show states. Server PUT /appointments/:id: when existing status is cancelled or no_show and date/time is changing without an explicit new status set by the caller, status is automatically set to scheduled. Per-clinic 12h / 24h time format. clinics.time_format column added (migration 0048). PUT /clinics/:id accepts the new field. Superadmin tenant detail page exposes a per-tenant override. Day view spacious + polished. Hour rows 96px (from 52). Half-hour gridlines. Floating “Now” pill jumps view to current time. Duration pill per card. Empty-day state. Patient names in Title Case. Now indicator anchored to left gutter so it doesn’t sit on top of card content. Week + Month scale. Month view: count badge, density tint, 3 chips, +N more link. Week view auto-switches to agenda mode when a day has more than 24 appointments (chronological list of HH:MM + name + status color chips with N appointments • Agenda header). Appointment Setup as its own Settings card. Time Format, Hover indicator, Allow click-to-book, Default duration, Drag-snap interval. clinics.appointment_settings JSONB column (migration 0049). GET/PUT routes accept the new field with shape validation. 15-min slots in reschedule. Slot picker offers 15-minute increments in both admin reschedule flow and WhatsApp patient self-reschedule. Bookings still default to 30 minutes. New Appointment form fixes. PatientPicker.tsx modal={true} on Radix Popover so it registers as top pointer-events layer inside the Dialog. requestAnimationFrame-deferred .focus() so Popover’s FocusScope registers first. date-picker.tsx pointerEvents: 'auto' on portal overlay. AppointmentCalendar.tsx DialogContent sets onPointerDownOutside, onInteractOutside, onFocusOutside to e.preventDefault(). Receptionist + admin appointment booking fix: Doctor field starts as “Unassigned” for non-doctor roles instead of pre-filling logged-in user ID. Default time respects clinic operating hours; if day already ended, time field starts blank. Save & Book. “Save & Book Appointment” button on New Patient form. Patient Details → Schedule Appointment dialog now honours the clinic’s configured default duration instead of hard-coding 30 min. Editable MRN + sort/filter + stable pagination. patientNumber added to patient validation schema (optional, max 64 chars, trimmed). Both create and update routes check for in-clinic collisions before writing. searchText recomputed whenever patient_number changes. peekDocumentNumber() helper reads current last_number and returns formatted next number without writing — used by GET /patients/next-number endpoint. Patient list ORDER BY created date desc + id tiebreaker — pagination is stable across edits. MRN/patient_number errors thrown as proper 409 with conflicting number; createPatient/updatePatient client helpers now check response.ok and throw the server’s error message. MRN reservation and patient save in a single transaction (was burning numbers on failure). patients.mrn column dormant for now (kept for safety); the mrn UI input was dropped in favour of patient_number for MRN-enabled clinics. clinic_modules row inserted enabling mrn for Dental Square (dfffc93f-…cb80). Medical history at registration. POST /patients accepts an optional medicalHistory payload and inserts into patient_medical_history in the same request. gender PostgreSQL enum reduced from four values to two (male, female) — migration 0032. Drizzle snapshot updated. Two existing patients system-wide migrated automatically. Patient Portal medical-history gate on first login for invited patients. oral_habits text[] column added to patient_medical_history (migration 0027), encrypted at rest consistent with PATIENT_PHI_FIELDS. Cancellation reason dialog. cancellation_reason text column added to appointments via ensureAppointmentsSchema. Status-update route stores the reason for cancelled and no_show transitions. Reception AI Action Items. appointment-nudges.ts agent extended with 5 parallel queries (invoices, lab cases overdue, lab cases due soon, out-of-stock/low-stock, expiring inventory). Aging buckets: 1-7d, 8-14d, 15-21d, 22+d with per-bucket patient-name detail queries. Context passed to LLM is structured JSON; maxTokens raised from 1000 to 1500. All 13 nudge types produce a server-side actionUrl override pointing to a known valid route. appointmentNudges Langfuse prompt updated. Billing tab + payment validation. Three stat cards (Total, Paid, Balance). Clean divided breakdown table. Reception Notes tailored to front desk. Overpayment validation: createReceipt in services/finance.ts checks response.ok and throws server error message. Silent Math.min(balance, amount) cap on blur removed from all three payment forms. ActivityTimeline on invoice detail re-mounts after each successful payment via key prop increment. Web/WhatsApp tabs in appointment timeline (2026-05-17). AppointmentHistoryTab fetches activity itself via TanStack Query (getEntityActivity + getWaEvents). Dedup logic prevents same message appearing twice. New patientId and whatsappEnabled props. Duplicate-appointment block + friendlier hours error. server/src/routes/appointments.ts:appointmentsRoute.post('/') — pre-insert duplicate check matching clinicId + patientId + appointmentDate + appointmentTime with status in {requested, scheduled, confirmed, in_progress}. AppError(409) with role-aware message. Out-of-hours AppError mentions duration-aware end time and clinic window.

Patients & Portal users

Appointment respond from email (2026-04-30). HMAC-SHA256 signed tokens (48h TTL), derived from the existing ENCRYPTION_KEY — no new secrets or DB tables. POST /api/v1/appointments/respond rate-limited (10 req/min per IP), fires in-app and email notifications to clinic staff on response. Reusable requireModule('whatsapp_api') middleware applied to all WhatsApp config/logs routes. WhatsApp reschedule for cancelled/no-show (2026-05-17). Webhook reschedule handler picks up the most recent cancelled or no-show appointment as fallback if no upcoming visit exists. Parameter names corrected (newDate/newTime instead of date/time). Available slots drawn from each clinic’s configured hours and doctor schedule. Inbound WhatsApp media (2026-05-16, migration 0039). New worker pipeline downloads inbound WhatsApp media (image, sticker, video, audio, document) from Meta’s Graph API into the clinic’s R2 bucket under whatsapp-inbound/ prefix; served back through an authed /api/v1/protected/wa-media/:clinicId/:wamidExt route. Storage usage counts against the clinic’s quota. POST /api/v1/protected/conversations initiates a WhatsApp conversation by sending a template (records whatsapp.conversation.create audit log). GET /api/v1/protected/patients/search-by-phone returns up to 5 patient matches by normalised phone, using the same encrypted-phone scan-and-decrypt path. Migration 0039_whatsapp_media_columns.sql adds 'sticker' to app.message_format, r2_key text, size_bytes bigint, plus partial index messages_clinic_r2_idx for the storage recompute query. MessageFormat Zod enum gains 'sticker'. MessageDto exposes r2Key and sizeBytes. Real WhatsApp delivery + read receipts + Chat v2 (2026-05-14, migration 0037). wamid, whatsapp_status, status_updated_at, failure_code, failure_reason, reply_to_id, format, conversation_id added to app.messages. message_direction and message_type enums extended with internal. New whatsapp_status and message_format enums. Tables: app.conversations, app.message_reactions, app.conversation_labels, app.saved_replies. feature_flags jsonb added to app.clinics. lib/backfill-conversations.ts walks every existing message and materialises one conversations row per (clinic_id, patient_id) and one per ordered (clinic_id, staff_a_id, staff_b_id) pair, then links every message’s conversation_id. Already executed against shared Neon DB (12 conversations created, 76 of 80 messages linked). 22 new REST endpoints under /api/conversations/*, /api/messages/*, /api/labels, /api/saved-replies, /api/whatsapp/templates, gated by new requireChatV2 middleware. WhatsApp webhook broadcasts message.new and message.status events over SSE via publishEventClinicHub Durable Object. Webhook signature verification extracted into shared lib/meta-client.ts (markAsRead, sendTemplate, verifyHmac, fetchApprovedTemplates). Shared zod-types module shared/src/chat-types.ts defines MessageDto, ConversationDto, ChatEvent (discriminated union of 6 SSE event types). Tests: vitest + jsdom + Testing Library, 14 chat-v2 UI unit tests; server suite at 88 passing. Docs at server/docs/chat-v2-api.md (529 lines) and server/docs/chat-v2-rollout.md (397 lines). Hotfix batch (2026-05-15): ::text casts on ${userId} interpolation for last_read_at ->> operator (PG 42P18). arrayFromQuery zod preprocessor for single-value channel/label query params. Chat-v2 API call paths corrected from /api/... to /api/v1/protected/.... <ReplyProvider> hoisted above useChatShortcuts(). WhatsApp lifecycle automation (2026-05-18, migration 0044). New trigger kinds: appointment_requested, appointment_scheduled, appointment_confirmed, appointment_cancelled, appointment_rescheduled, appointment_no_show, last_visit_completed. dispatchInlineAutomations() shares the same runOne path as the cron dispatcher. Per-tab toggle layer: clinic_modules.config.tabs jsonb + isWhatsappTabEnabled + requireTab middleware + WhatsappTabDisabledError → clean 403. Existing clinics default to all-tabs-on. GET /api/v1/protected/whatsapp-module/tabs discovery endpoint (no requireTab guard so UI can always reach it; falls back to all-false when no clinic context). peekAndScheduleInline atomic INSERT ... WHERE NOT EXISTS against app.whatsapp_automations keyed on (clinic_id, trigger_kind). TEMPLATE_DEFAULTS.defaultLanguage = en_US. defaultTemplateForTrigger(triggerKind) exported from automation-templates.ts as the canonical lookup. Daily reconcile cron at 02:00 UTC compares stored automation templates against Meta’s approved list per clinic. Egress reductions: narrowed SELECT * on patients (was 19K calls/window pulling encrypted PHI + JSONB blobs); messages page size 50 → 20 on appointment detail rail; switched two non-transactional cron helpers from getTxDb (pg.Pool) to getReadDb (Neon HTTP) collapsing ~29K ROLLBACK/DEALLOCATE round-trips. 5-minute in-memory cache helper at server/src/lib/short-lived-cache.ts for hot reads (plans, modules, tab map). Worker-isolate local, no KV. See project_wa_lifecycle_2026_05_18 memory. Global WhatsApp notifications + connection awareness (2026-05-16). useGlobalInboundNotifier hook mounted at app root next to ClinicEventsMount. Owns chime, OS Notification, sonner toast, and inbox-row flash for any message.new event with direction === 'inbound'. Deduplicates by message id with a useRef. useInboxFlash module-scoped store: Set<conversationId> + useSyncExternalStore. Paired with .inbox-row-flash 2.8s linear-infinite gradient pulse with prefers-reduced-motion static fallback. useConnectionStatusToast subscribes to online/offline window events; sticky offline toast (duration: Infinity); 3.5s “Back online” on reconnect; first-mount-while-online stays silent. MessageList thread auto-pin rewritten: removed totalSize dep that caused the scroll-up regression; trigger is now (conversationOpened) || (messageCountGrew && pinnedToBottomRef.current). Unseen-count state for the “N new messages” pill. nav-registry.ts adds whatsapp: ['c'] so clearModuleParams strips conversation id when navigating away from ?view=whatsapp. Pakistan phone normalisation everywhere. PhoneInput.tsx rewritten: dropped react-international-phone; lightweight custom component with +92 prefix badge + 10-digit inputMode="numeric" input, maxLength=10, digits-only filter, parses incoming values from any stored format. 16 files updated. CSV importer normalises 10-digit Pakistan mobiles to +92.... Backfilled 12,582 already-imported patient phones from +3...+923... across Beeba Clinic and Dental Square tenants. Date of Birth optional + N/A display. patientBaseSchema.dateOfBirth relaxed to optional. POST /api/v1/protected/patients and the two patient update routes normalise empty-string DOB to NULL instead of encrypting an empty string. POST /api/v1/auth/invitation/:token/accept validates DOB and gender when role is patient. Patient.dateOfBirth typed string | null end-to-end. Removed 1970-01-01 fallback in invitation accept path. patients.date_of_birth schema column is nullable. Patient phone privacy on documents. decryptPatientPHI({ phone }) called on invoice list/detail and quotation detail handlers. Public document responses: patientPhone set to null on invoice / quotation / treatment-plan responses. Removed Phone line from InvoiceDocument, QuotationDocument, the Invoice/Quotation/Receipt PDF templates, and templates/InvoicePdf.tsx.

4. Architecture & data-flow diagrams

These diagrams describe the v1.7-era topology and the major flows that landed in this release train. Render with any Mermaid-aware viewer (GitHub, VS Code, Obsidian, the Mermaid Live Editor).

4.1 Deploy topology

4.2 Marketplace request → activate (revenue gate)

4.3 Notification event matrix — send-side gating

4.4 WhatsApp lifecycle automation

4.5 v1.7-era data model (new and extended tables)


5. Schema changes — migrations 0027 → 0051

All migrations live in server/drizzle/. Forward-only. Idempotent where the entry says so (most use IF NOT EXISTS / DO $$ duplicate_object guards). Applied to prod Neon (odontox-prod) during the window.
FileDate scopeOne-line descriptionTables / enums affected
0027_add_oral_habits_to_medical_history.sql2026-05-06gAdd oral_habits text[] column for per-patient dental habit tags. Encrypted at rest via PHI fields.app.patient_medical_history
0028_add_document_letterhead.sql2026-05-06iAdd document_image_key and document_template_name for the new Finance Document Letterhead surface (invoices, receipts, quotations).app.clinics
0029_mobile_role_permissions.sql2026-05-07New table for per-clinic, per-role mobile-app module toggles (Phase 1 mobile foundation).app.mobile_role_permissions (new)
0030_add_conversion_columns.sql2026-05-07Add conversion_status (none/pending/done/failed) and preview_key for TIFF/DICOM → PNG server-side conversion. (Schema prefix corrected from clinic.app. in this same window.)app.patient_files
0031_messages_read_at.sql2026-05-08Add read_at timestamp for WhatsApp-style read receipts. SSE message_received / message_read events broadcast from ClinicHub DO.app.messages
0032_gender_enum_male_female_only.sql2026-05-08Reduce gender enum from four values to two (male, female). Two existing patients migrated.app.gender enum + app.patients
0033_portal_seats_storage_addons.sql2026-05-13Portal seats vs records split + storage tiers + addon ledger. Includes patients.portal_access backfilled from existing user-row links.app.clinics, app.patients, app.addon_requests (new), app.addon_pricing (new), app.addon_pricing_history (new)
0034_whatsapp_module.sql2026-05-13WhatsApp full module elevation: events table + quick replies.app.whatsapp_events (new), app.whatsapp_quick_replies (new)
0035_mobile_device_telemetry.sql2026-05-14Device fingerprint (vendor id, model, OS, app version, build, bundle id) on every mobile launch; client-error logger widened.app.user_devices, app.client_errors
0036_appointment_source.sql2026-05-14Track where an appointment was created from (web / mobile / whatsapp / shared link).app.appointments
0037_chat_v2.sql2026-05-14Conversations + reactions + labels + saved replies; messages extended with wamid, whatsapp_status, reply_to_id, format, conversation_id; new enums (whatsapp_status, message_format); feature_flags jsonb on clinics. Includes one-time lib/backfill-conversations.ts.app.conversations (new), app.message_reactions (new), app.conversation_labels (new), app.saved_replies (new), app.messages, app.clinics, message_direction enum, message_type enum
0038_whatsapp_automations.sql2026-05-15First-class WhatsApp automations table backing the Automations tab.app.whatsapp_automations (new)
0039_whatsapp_media_columns.sql2026-05-16Inbound WhatsApp media; sticker added to message_format. Storage attribution via r2_key, size_bytes, messages_clinic_r2_idx.app.messages + message_format enum
0040_phone_hash_and_wa_phone_idx.sql2026-05-16phone_hash + index for efficient encrypted-phone scan via getEncryptionSearchHash. Used by patient-search-by-phone for the new-conversation flow.app.patients
0041_patients_has_whatsapp.sql2026-05-16has_whatsapp flag on patients, derived from inbound WhatsApp activity, used for templating gating.app.patients
0042_tenants_audit_and_overrides.sql2026-05-17Per-user permission overrides + impersonation accountability columns. Backfill of historical rows.app.user_permission_overrides (new), app.audit_logs (actor_user_id, on_behalf_of_user_id, impersonation)
0043_audit_drop_actor_fk.sql2026-05-17Drop strict actor FK from audit log so a deleted user doesn’t break audit visibility.app.audit_logs
0044_automation_lifecycle_triggers.sql2026-05-18New trigger kinds (appointment_requested, _scheduled, _confirmed, _cancelled, _rescheduled, _no_show, last_visit_completed).app.whatsapp_automation_trigger_kind enum
0045_marketplace.sql2026-05-19Marketplace listings + releases; addon request status set + addon-type set extended.app.marketplace_listings (new), app.marketplace_releases (new), addon-request status enum
0046_tenant_activity_intel.sql2026-05-17/18Lightweight rollup table for tenant activity feed in the new superadmin Tenants module.app.tenant_activity_intel (new) + rollup cron
0047_tenant_activity_soft_user_ref.sql2026-05-17/18Loosen FK on tenant_activity_intel.actor_user_id to allow soft-deleted users without cascading.app.tenant_activity_intel
0048_clinic_time_format.sql2026-05-20Per-clinic 12h/24h time format setting.app.clinics.time_format
0049_clinic_appointment_settings.sql2026-05-20Centralised appointment behaviour (default duration, snap interval, hover indicator, click-to-book, time format) as JSONB.app.clinics.appointment_settings
0050_marketplace_related_modules_deps.sql2026-05-21Related modules + dependencies on listings for cross-linking.app.marketplace_listings (related_modules text[], dependencies text[])
0051_notification_event_matrix.sql2026-05-21Per-event × per-audience matrix backfilled from previous flat flags; new notificationPreferences.emailMatrix. Idempotent.app.clinics.notification_preferences
Migrations 0025 and 0026 (add_mpin_credentials, device_trust_tokens) are pre-window and listed in server/drizzle/ for completeness.

5.1 DDL excerpts — headline new tables

The migration table above lists the migration filenames; the column-level shape below is the truth as of v1.7 cut (derived from server/src/schema/*.ts). Indexes are omitted — table shape only. Postgres types shown directly (the Drizzle text(), boolean(), jsonb(), etc. translate one-for-one). app.marketplace_listings — one row per app on the marketplace; the CMS-editable catalog (migration 0045, extended by 0050).
CREATE TABLE app.marketplace_listings (
  id                       text PRIMARY KEY,
  display_name             text NOT NULL,
  tagline                  text NOT NULL,
  icon_kind                text NOT NULL,                       -- 'lucide' | 'upload'
  icon_value               text NOT NULL,                       -- lucide name or R2 key
  hero_color               text NOT NULL,                       -- tailwind token
  long_description_md      text NOT NULL DEFAULT '',
  what_you_get             jsonb NOT NULL DEFAULT '[]',         -- string[]
  screenshots              jsonb NOT NULL DEFAULT '[]',         -- {key, alt}[]
  faq                      jsonb NOT NULL DEFAULT '[]',         -- {q, a}[]
  version_label            text NOT NULL DEFAULT 'v1.0.0',
  last_updated_at          timestamp NOT NULL DEFAULT now(),
  pricing_summary          text NOT NULL DEFAULT '',
  category                 text NOT NULL,                       -- 'clinical'|'admin'|'infra'|'comms'
  security_badges          jsonb NOT NULL DEFAULT '[]',         -- string[]
  related_modules          text[] NOT NULL DEFAULT '{}',        -- 0050
  dependencies             text[] NOT NULL DEFAULT '{}',        -- 0050
  status                   text NOT NULL DEFAULT 'draft',       -- 'draft'|'published'|'archived'
  sort_order               integer NOT NULL DEFAULT 0,
  created_at               timestamp NOT NULL DEFAULT now(),
  updated_at               timestamp NOT NULL DEFAULT now(),
  updated_by               text
);
app.marketplace_releases — versioned “what’s new” entries per listing (migration 0045).
CREATE TABLE app.marketplace_releases (
  id              text PRIMARY KEY,
  listing_id      text NOT NULL REFERENCES app.marketplace_listings(id) ON DELETE CASCADE,
  version_label   text NOT NULL,
  released_at     timestamp NOT NULL DEFAULT now(),
  is_major        boolean NOT NULL DEFAULT false,
  summary         text NOT NULL DEFAULT '',
  body_md         text NOT NULL DEFAULT '',
  created_by      text
);
app.addon_requests — the marketplace subscription ledger; doubles as active-addons via partial index WHERE status='approved' AND cancelled_at IS NULL (migration 0033, status set extended by 0045).
CREATE TABLE app.addon_requests (
  id                      text PRIMARY KEY,
  clinic_id               text NOT NULL,                        -- FK to clinics.id (enforced in SQL)
  requested_by            text NOT NULL,                        -- users.id
  addon_type              app.addon_type NOT NULL,              -- enum: storage|portal_seats|dicom_imaging|whatsapp_api|ipd|insurance|marketing|mrn
  quantity                integer NOT NULL,
  unit_price_pkr          integer NOT NULL,
  total_price_pkr         integer NOT NULL,
  status                  app.addon_request_status NOT NULL DEFAULT 'pending',  -- pending|invoiced|paid|approved|rejected|cancel_requested|cancelled
  approved_by             text,
  approved_at             timestamp,
  cancelled_at            timestamp,
  cancelled_by            text,
  rejected_reason         text,
  invoiced_at             timestamp,
  invoice_id              text,
  paid_at                 timestamp,
  cancel_requested_at     timestamp,
  cancel_reason           text,
  last_overdue_email_at   timestamp,
  last_idle_nudge_at      timestamp,
  notes                   text,
  created_at              timestamp NOT NULL DEFAULT now(),
  updated_at              timestamp NOT NULL DEFAULT now()
);
app.addon_pricing — single-row catalog (id='current') of storage/seat/module addon prices; mutations append to app.addon_pricing_history (migration 0033).
CREATE TABLE app.addon_pricing (
  id                                  text PRIMARY KEY,           -- always 'current'
  storage_50gb_pkr                    integer NOT NULL,
  storage_200gb_pkr                   integer NOT NULL,
  storage_500gb_pkr                   integer NOT NULL,
  storage_1tb_pkr                     integer NOT NULL,
  portal_seats_3pack_pro_pkr          integer NOT NULL,
  portal_seats_3pack_pro_plus_pkr     integer NOT NULL,
  module_addon_pricing                jsonb NOT NULL DEFAULT '{}', -- {moduleKey: {pro, proPlus}}
  updated_at                          timestamp NOT NULL DEFAULT now(),
  updated_by                          text
);
app.conversations — one row per patient (or per ordered staff pair) per clinic; chat-v2 unit of grouping (migration 0037).
CREATE TABLE app.conversations (
  id                    text PRIMARY KEY,
  clinic_id             text NOT NULL REFERENCES app.clinics(id) ON DELETE CASCADE,
  type                  text NOT NULL,                          -- 'patient' | 'staff_peer'
  patient_id            text REFERENCES app.patients(id) ON DELETE CASCADE,
  staff_a_id            text REFERENCES app.users(id) ON DELETE CASCADE,
  staff_b_id            text REFERENCES app.users(id) ON DELETE CASCADE,
  assigned_to_user_id   text REFERENCES app.users(id) ON DELETE SET NULL,
  is_pinned             boolean NOT NULL DEFAULT false,
  is_archived           boolean NOT NULL DEFAULT false,
  is_muted              boolean NOT NULL DEFAULT false,
  snoozed_until         timestamp,
  labels                text[] NOT NULL DEFAULT '{}',
  last_message_at       timestamp,
  last_read_at          jsonb NOT NULL DEFAULT '{}',            -- {userId: timestamp}
  created_at            timestamp NOT NULL DEFAULT now(),
  updated_at            timestamp NOT NULL DEFAULT now()
);
app.messages — chat + email + WhatsApp messages. Original messages table extended through migrations 0031 (read_at), 0037 (chat-v2 columns), 0039 (WhatsApp media). Current shape:
CREATE TABLE app.messages (
  id                  text PRIMARY KEY,
  patient_id          text REFERENCES app.patients(id) ON DELETE CASCADE,
  recipient_id        text REFERENCES app.users(id) ON DELETE SET NULL,
  clinic_id           text NOT NULL REFERENCES app.clinics(id) ON DELETE CASCADE,
  conversation_id     text,                                                -- 0037
  type                app.message_type NOT NULL,                           -- sms|email|portal|call|whatsapp|internal (0037 added internal)
  direction           app.message_direction NOT NULL,                      -- inbound|outbound|internal (0037)
  subject             text,
  message             text NOT NULL,
  status              app.message_status NOT NULL DEFAULT 'unread',
  is_starred          boolean NOT NULL DEFAULT false,
  sent_by             text REFERENCES app.users(id) ON DELETE SET NULL,
  read_at             timestamp,                                           -- 0031
  wamid               text,                                                -- 0037
  whatsapp_status     app.whatsapp_status,                                 -- 0037: queued|sent|delivered|read|failed
  status_updated_at   timestamp,                                           -- 0037
  failure_code        text,                                                -- 0037
  failure_reason      text,                                                -- 0037
  reply_to_id         text,                                                -- 0037
  format              app.message_format NOT NULL DEFAULT 'text',          -- 0037 + 0039 sticker: text|image|document|audio|video|template|sticker
  r2_key              text,                                                -- 0039
  size_bytes          bigint,                                              -- 0039
  created_at          timestamp NOT NULL DEFAULT now()
);
app.whatsapp_automations — per-clinic lifecycle automation rules; trigger kinds extended by migration 0044 with inline lifecycle + last_visit_completed.
CREATE TABLE app.whatsapp_automations (
  id                        text PRIMARY KEY,
  clinic_id                 text NOT NULL REFERENCES app.clinics(id) ON DELETE CASCADE,
  name                      text NOT NULL,
  trigger_kind              app.automation_trigger_kind NOT NULL,
    -- enum: appointment_upcoming | appointment_missed | last_visit_recall | invoice_overdue
    --     | appointment_requested | appointment_scheduled | appointment_confirmed
    --     | appointment_cancelled | appointment_rescheduled | appointment_no_show
    --     | last_visit_completed
  trigger_offset_minutes    integer NOT NULL,
  template_name             text NOT NULL,
  template_language         text NOT NULL DEFAULT 'en',
  param_mapping             jsonb NOT NULL DEFAULT '{}',   -- {"1": "name", "2": "nextAppointmentTime", ...}
  reconcile_notes           text,
  reconciled_at             timestamp,
  is_enabled                boolean NOT NULL DEFAULT false,
  created_at                timestamp NOT NULL DEFAULT now(),
  updated_at                timestamp NOT NULL DEFAULT now()
);
app.doctor_schedules — per-doctor weekly availability used by appointment validators and the SlotPicker (pre-window table, formalised in this train).
CREATE TABLE app.doctor_schedules (
  id            text PRIMARY KEY,
  clinic_id     text NOT NULL,
  doctor_id     text NOT NULL,
  day_of_week   text NOT NULL,        -- 'monday' | 'tuesday' | ... | 'sunday'
  start_time    time NOT NULL,
  end_time      time NOT NULL,
  is_off        boolean NOT NULL DEFAULT false,
  created_at    timestamp NOT NULL DEFAULT now(),
  updated_at    timestamp NOT NULL DEFAULT now(),
  UNIQUE (clinic_id, doctor_id, day_of_week)
);
app.mobile_role_permissions — per-clinic, per-role mobile-app module toggles; Phase 1 mobile foundation (migration 0029).
CREATE TABLE app.mobile_role_permissions (
  id           text PRIMARY KEY,
  clinic_id    text NOT NULL REFERENCES app.clinics(id) ON DELETE CASCADE,
  role         text NOT NULL,                 -- 'patient' | 'doctor' | 'admin' | 'receptionist'
  module       text NOT NULL,                 -- e.g. 'appointments' | 'records.treatment_plans' | 'billing.invoices'
  enabled      boolean NOT NULL DEFAULT true,
  updated_at   timestamptz NOT NULL DEFAULT now(),
  UNIQUE (clinic_id, role, module)
);

6. New backend modules / files (significant)

server/src/lib/:
  • email-gate.tsshouldSendEmail(clinicId, event, audience, prefsHint?) chokepoint, used by every clinic-operational email site.
  • notification-defaults.ts — Source-of-truth defaults + role→audience mapping.
  • pkt.ts — Canonical Asia/Karachi date helpers (pktMidnight, pktEndOfDay, pktHour, formatDatePKT, formatDateLongPKT). 5 unit tests at server/src/lib/pkt.test.ts.
  • plan-limits.ts — Single source of truth for plan caps (Pro / Pro+ patients, portal seats, storage GB). Supersedes patient-quota.ts.
  • amount-limits.tsassertAmountWithinClinicLimit(db, clinicId, kind, total, currency). 60s TTL cache. Default ceiling 99,999,999.99 (matches numeric column precision).
  • short-lived-cache.ts — Worker-isolate 5-minute in-memory cache for hot reads.
  • image-conversion.ts — TIFF (utif2) + DICOM (dicom-parser, dental bone windowing 700/3000) → PNG via pure-JS encoder. Runs non-blocking via ctx.waitUntil().
  • png-encoder.ts — Pure-JS PNG encoder using CompressionStream('deflate') (RFC 1950 zlib-wrapped) + CRC32. No OffscreenCanvas required.
  • backfill-conversations.ts — Idempotent walker materialising conversations rows from existing messages.
  • bank-qr.ts + .test.ts — Bank QR upload + render.
  • automation-templates.tsTEMPLATE_DEFAULTS, defaultTemplateForTrigger(triggerKind). defaultLanguage = en_US.
  • automation-dispatcher.ts — Shared runOne path for cron + inline dispatch.
  • meta-client.tsmarkAsRead, sendTemplate, verifyHmac, fetchApprovedTemplates.
  • chat-events.ts + .test.ts — SSE event types and helpers.
  • chat-flags.tschat_v2 per-clinic flag check.
  • db-rows.tstoRows<T>() unwraps drizzle-neon-http .execute() { rows: [...] } shape.
  • audit-helper.ts — Actor + on-behalf-of helpers for impersonation-aware audit rows.
  • audit-impersonation.test.ts — Tests covering the impersonation filter.
  • email-appointment-dispatch.ts, email-appointment-gate.test.ts, email-senders-appointment.ts — Appointment status email dispatcher infra.
  • addon-pricing.ts + .test.ts — Storage and seat addon pricing.
  • dicom-quota.ts + .test.ts — DICOM AI quota tracking + next_reset + 30-slice clamp.
  • document-numbering.ts + .test.ts — Extended with treatmentPlan and prescription in DOCUMENT_TYPES. peekDocumentNumber() helper (read-only preview).
  • endpoint-registry.ts — Registry of all API endpoints (used for the auto-generated docs).
server/src/scheduled/:
  • appointment-invoices.ts — Auto-generation of appointment invoices.
  • appointment-reminders.ts — Per-clinic reminder cron, hour batched.
  • automation-reconcile.ts — Daily reconcile (02:00 UTC) against Meta’s approved template list per clinic.
  • eod-email-report.ts — Daily EOD report cron at 21:00 PKT.
  • feedback-email.ts — Post-visit feedback nudge cron.
  • installment-invoices.ts — Installment-plan invoice generation.
  • inventory-expiry-alerts.ts — Inventory expiry + low-stock cron, 4× per day.
  • missed-appointments.ts — Missed-appointment follow-up cron.
  • rollup-tenant-activity.ts — Tenant activity rollup for the superadmin Tenants module.
  • whatsapp-window-closer.ts — 24h window-close event writer (idempotent).

7. API surface changes (selected)

Full canonical list lives in docs/api-reference.md — per feedback_api_documentation_discipline, that file is updated alongside endpoint changes. The summary of what shifted in this window: New endpoints:
  • GET /api/v1/protected/users/me/clinics — multi-clinic membership list.
  • GET /api/v1/protected/admin/users/multi-clinic — superadmin cross-clinic visibility.
  • GET /api/v1/protected/admin/tenants, /tenants/:id/summary, /tenants/bulk-action, /tenants/:clinicId/users/:userId/permission-overrides (GET/POST/DELETE), /tenants/:clinicId/transfer-admin/:newAdminUserId — Tenants module.
  • POST /api/v1/protected/conversations — start a WhatsApp conversation by sending a template.
  • GET /api/v1/protected/wa-media/:clinicId/:wamidExt — authed inbound WhatsApp media stream.
  • GET /api/v1/protected/patients/search-by-phone — encrypted-phone scan via hash + decrypt.
  • GET /api/v1/protected/whatsapp-module/tabs — per-clinic tab map.
  • GET /api/v1/protected/files/patients-summary — JOIN+GROUP BY aggregation (replaces N+1).
  • POST /api/v1/protected/files/:id/convert — TIFF/DICOM PNG conversion (accepts ?force=true).
  • GET /api/v1/protected/files/:id — file metadata.
  • DELETE /api/v1/protected/files/:id — hard-delete Bridge Inbox file (only patientId IS NULL).
  • POST /api/v1/protected/superadmin/marketplace/upload — R2 image upload for marketplace listings.
  • GET /api/v1/protected/billing/upgrade-state{ hasPendingRequest } for the trial banner.
  • POST /api/v1/protected/admin/clinics/:clinicId/extend-trial — 3 / 7 / up to 365 days extension.
  • POST /api/v1/protected/admin/invoices/:id/resend-receipt — resend receipt after a paid-mark with no receipt email.
  • POST /api/v1/public-documents/:token/lab-attachments — lab uploads via shared link.
  • GET /api/v1/protected/lab-cases/:id/attachments/:key — authed lab attachment fetch.
  • POST /api/v1/public/leads — Website Leads public ingest.
  • GET/POST/DELETE /api/v1/protected/leads/* — Website Leads protected surface.
  • POST /api/v1/appointments/respond — HMAC-signed patient confirm/cancel from email.
  • 22 new chat-v2 routes under /api/conversations/*, /api/messages/*, /api/labels, /api/saved-replies, /api/whatsapp/templates.
  • POST /api/v1/protected/whatsapp/:clinicId/webhook rewritten media-types branch.
  • /api/v1/protected/billing/{usage,addon-pricing,addon-requests} (POST/GET/DELETE) + matching superadmin routes.
  • /api/v1/protected/marketplace/* listing API + media surface.
  • /api/v1/protected/admin/denco/maintenance/{save,announce,status} — maintenance mode actions (with /admin/maintenance/announce kept as deprecated alias for one release).
New / changed request body fields:
  • patientNumber on patient create/update.
  • medicalHistory payload on POST /patients.
  • appointment_settings, time_format on PUT /clinics/:id.
  • paymentInstructions.bankAccounts[].qrCodeDataUrl on document settings.
  • documentSettings.limits.{maxInvoiceAmount,maxQuotationAmount}.
  • notificationPreferences.emailMatrix.
  • whatsappPhone on patient update.
  • kind: 'owner' | 'member' and clinicName on WelcomeEmail.
Deprecations:
  • POST /license-requests with requestType=patient_addon → 410 Gone with redirect to /billing/addon-requests.
  • Legacy /admin/maintenance/announce — alias for one release.
  • Legacy /messages/conversations*, /contacts, /receptionists, /staff/*, /can-initiate/* chat endpoints → 404.

8. Cron jobs added / changed

JobScheduleDescription
appointment-reminders.tshourlyPre-existing; window now batched with inArray(), gated 10 PM → 8 AM PKT.
appointment-invoices.tshourlyPre-existing; opens Pool driver lazily, ROLLBACK/DEALLOCATE round-trips eliminated.
installment-invoices.tshourlyPre-existing; same lazy Pool pattern.
missed-appointments.tshourlyPre-existing; same lazy Pool pattern + tx-cleanup-in-finally.
feedback-email.tsdailyPost-visit feedback nudge.
inventory-expiry-alerts.ts4× / dayhandleInventoryExpiryAlerts — expiring, out-of-stock, low-stock.
eod-email-report.ts0 16 * * * UTC (= 21:00 PKT)EOD report cron.
automation-reconcile.ts0 2 * * * UTCWhatsApp automation reconcile against Meta’s approved-template list.
whatsapp-window-closer.tshourlyIdempotent window_closed event writer when 24h passes since last inbound.
rollup-tenant-activity.tsdailyTenant activity rollup for superadmin Tenants module.
Marketplace overdue-invoice nudgedailyIdempotent, rate-capped (Marketplace billing).
Marketplace idle-subscription nudgeweeklyIdempotent (Marketplace billing).
Trial / billing cron11 AM PKTSections 3/4 gated to 11 AM PKT; deactivation skipped if reminder sent in last 24h.

9. Configuration / env vars

New / changed in this window:
  • STRIPE_DISABLED=true in both top-level [vars] and [env.production.vars] (Stripe paused; webhook handler ACKs every event with 200 immediately without parsing).
  • STRIPE_ENABLED = false build-time constant at ui/src/lib/feature-flags.ts.
  • SESSION_TIMEOUT explicitly set to 7d in wrangler.toml for default and production; minimum floor of 7 days enforced so env overrides can never shorten sessions below that.
  • UAT uat_auth_token cookie name (distinct from prod) — wrangler.toml UAT env var.
  • Cloudflare Rate Limiter binding bumped from 100/60s to 300/60s per user per route prefix (top-level, env.production, env.uat).
  • WHATSAPP_WEBHOOK_VERIFY_TOKEN — global webhook verification for Meta.
  • Marketplace assets served from assets.odontox.io (1-year immutable cache); domain not new.
  • _headers on the marketing site: added https://assets.odontox.io to media-src; added https://ph.odontox.io, https://*.odontox.io, https://*.sentry.io, https://o4511296397901824.ingest.sentry.io to connect-src.
JWT issuance defaults:
  • Access tokens raised from 15m to 90d (matching the existing 90d refresh-token window). Refresh tokens already at 90d, so the effective session window is unchanged — only the access-token half was the friction point. The 7-day token used by 2FA flows and the 15-minute token used for superadmin impersonation are deliberately left as-is — short-lived is the right security default for those.

10. Superadmin tooling (internal-only)

This section is the whole reason this internal doc exists. Public doc must remain free of these items. Tenants module (2026-05-17). New /dashboard/tenants consolidating clinic, user, and subscription management. Deep-linked tabs (Overview, Subscription, Users, Modules & Addons, Activity, Settings) — /dashboard/tenants/<id>?tab=billing. Bulk actions: suspend, unsuspend, mark/unmark as test, extend trial. Per-user permission overrides (slide-in panel, reason required, recorded in audit log). Transfer admin ownership (multi-admin remains supported). TanStack Query for caching. Filters live in the URL — bookmarkable, shareable, survive refresh. Deprecation banners on the old Clinic Subscriptions and User Management pages. Deep links from Billing Management and Audit Logs into Tenants. New endpoints (8): see section 7. Per-user app.user_permission_overrides table. app.audit_logs gains actor_user_id, on_behalf_of_user_id, impersonation (migration 0042 backfilled historical rows). See project_tenants_redesign_2026_05_17 memory. Impersonation accountability (2026-05-17). Real superadmin recorded as actor, impersonated user as on-behalf-of, row flagged impersonation=true. Tenant-facing audit views filter these rows by default; only superadmins can opt-in. /auth/refresh carries impersonatorId into the rotated access token and new refresh token KV record. Skip lastSessionId DB update during impersonation token rotation to prevent invalidating the real clinic user’s active session. clearImpersonationState and exitImpersonation correctly restore the superadmin’s own refresh token on exit. Impersonation refresh token uses its own KV slot. Marketplace CMS (2026-05-19). Listing editor in superadmin. File picker replaces R2-key paste for icons and screenshots. Multi-select for recommended modules. Chip input with datalist suggestions for dependencies. Eight seed listings, all draft on launch — none visible to clinics until explicitly published. Listing-aware versioned releases (per-app, not per-OdontoX). Subscription approval flow runs through Invoice Studio + payment confirmation gate. Maintenance mode (2026-05-18). Three endpoints under /admin/denco/maintenance/{save,announce,status}. Old /admin/maintenance/announce kept as a deprecated alias for one release. 5-minute same-kind dedup guard. Auto-engage is “next-request” — rides on the existing 30s middleware cache, no new cron. Recipient filter excludes patients, suspended clinics, test tenants, inactive users, and the explicit no-email blocklist. 19 new unit tests covering email template rendering, recipient filtering, and scheduled-window active-state calc. See project_denco_naming memory — platform plumbing (toggles, event routing, maintenance) is branded Denco layer. Force-promote canonical (per session-start discipline). No code change; operational reminder per feedback_commit_deploy — after every Pages deploy, force-promote CF canonical. Multi-clinic users panel (superadmin, 2026-05-01). New “Multi-Clinic” tab on User Management. Each row is a user with 2+ active clinic assignments. Role-mismatch detection (amber badge when JWT global role doesn’t match any per-clinic role). Read-only for now; assign/revoke delegated to existing user-detail modal endpoints. Known follow-up: wire onManageUser prop. Mobile Users panel (superadmin, 2026-05-14). Platform-overview screen for SaaS owners. One row per registered device with user/clinic/plan/tier/status/platform/version/build/OS/model/bundle id/last-seen/24h-errors. Five-card KPI strip. Filters: platform/plan/status/app version/errors-only. GET /api/v1/protected/superadmin/mobile-users + /summary joins user_devices × users × user_clinic_assignments × clinics × subscription_plans with a 24h client_errors aggregate. Filterable, paginated. Worker Logs viewer. Real-time Cloudflare Worker log streaming via OTLP ingest endpoint, stored in worker_logs table. Filtering by date, severity, keyword. Scheduled pruning prevents unbounded storage growth. Invoice Studio improvements. Plan template catalog (Trial Pro, Trial Pro+, Pro Monthly/Yearly, Pro+ Monthly/Yearly). Invoice number series: INV10-YYYYMMDD-X, RC10-YYYYMMDD-X, EST10-YYYYMMDD-X. Test-account banner. Sidebar shortcut bypassing Billing Management. Collapsible “Bill From” section with custom logo + company details. Resend Receipt action on paid invoices. Plan price + addon pricing override panels. Direct DB control for tier and per-clinic addon pricing. Per feedback_plan_changes_no_side_effects memory: direct SQL only for plan/license overrides; never app-layer code that triggers emails or events. Test account tagging. is_test_account boolean on clinics (DB default true so all pre-existing rows were tagged; new clinics default false via application schema). Billing stats filter is_test_account = false. updateClinicSubscription route and frontend accept isTestAccount toggle. Invoice Studio + auth-status hot path resilience. Force-regenerate PDF on every send / portal / get-pdf call (was returning cached R2 PDF). caches.default.delete() on Bridge Inbox after upload.

11. Known issues / follow-ups

Carried forward from “Known limitation” entries in RELEASES.md:
  • Neon HTTP 1006 bursts — some bursts exceed 5 seconds total backoff; a small fraction of requests still 500. Follow-up tracked separately — likely needs Neon support engagement or a switch to the WebSocket driver for hot paths. See project_neon_1006_retry memory.
  • Server-side PDF renderer — Workers WASM-instantiation bug currently breaks the server PDF endpoint intermittently; My Billing falls back to the local generator. Mark Paid is wrapped in try/catch so the email goes out without an attachment if PDF fails and the response returns pdfPending: true — the rest of the flow still fires.
  • AIInsightsPage direct navigation/dashboard/ai-insights has no module gate; users on Basic/Pro who navigate directly will see 403s. Sidebar entry is hidden by other module checks in normal flows. Wrap with RequireModule (upgrade-prompt fallback) to close the gap.
  • patients.mrn dormant column — kept after the patient_number migration; drop once we confirm no other code paths read it.
  • Marketplace product mockup — single OdontoX-themed illustration; can be refreshed from the listing editor by uploading a new asset and updating the hero image URL.
  • Platform welcome guard — relies on absence of an in-product MFA-disable flow. If a future admin-reset path ever flips mfaEnabled back to false for an admin, the welcome would re-fire. A platformWelcomeSentAt column on users would harden this. Tracked.
  • Remember-this-clinic on /select-clinic — power users with >2 clinics re-prompt on every login. Optional follow-up.
  • Multi-Clinic Users panel — onManageUser wire-up — endpoints exist; needs prop pass-through and a modal-open trigger.
  • Access token revocation ceiling — access tokens are not individually revocable via the KV ban list; a leaked access token is now valid for the full 90-day window. If a tighter ceiling is needed later, 7d access + 90d refresh is the usual middle ground.
  • From-name dedup in Gmail — “From: ssh & Associates” can show on a Hassan’s Clinic invite email because Gmail caches sender display name per address. Mitigation would be unique sender per clinic (e.g. [email protected]) requiring Zepto sender configuration changes; out of scope for code-only fix.
  • Dormant 2FA email-OTP step — code paths (handleStartEmailSetup, 2fa-email-setup step block) left in place but unreachable from the UI; cleanup follow-up.
  • Bun build / lighthouse follow-ups — route-level code-splitting (est. −1,418 KiB JS / −7,200 ms on mobile), ruby.webp hero swap to AVIF (est. −1,883 KiB), oversized logo.webp. Documented in docs/qa/2026-05-14-mobile-pagespeed-results.md.
Items I couldn’t cleanly attribute to a specific role section (and therefore omitted from the public doc beyond the “reliability” section): the 2026-05-14 marketing-site-performance batch (Sentry deferral, GTM deferral, critical CSS reordering, Poppins preload, etc.) — all internal-facing build-time tuning with no user-visible behaviour change beyond first-paint speed.

12. Deploy checklist

Run from the repo root unless noted.

A. Apply database migrations

All migrations in this window are idempotent. To apply against prod (per feedback_no_live_tenant_execution memory, explicit per-action user confirmation required before running against prod):
cd server
pnpm drizzle:generate                  # verify no pending diffs
pnpm tsx scripts/list-pending-migrations.ts   # sanity check
# Confirm with user before running:
pnpm drizzle:migrate                   # applies 0027 → 0051
pnpm tsx scripts/verify-migration-state.ts
If a migration was previously applied but not recorded in drizzle_migrations (e.g. shipped via runtime ensureXSchema()), record it explicitly:
pnpm tsx scripts/record-migration-applied.ts 0042

B. Deploy the Worker

cd server
pnpm test                              # 88+ tests, including PKT, automation-vars, audit-impersonation, email-gate
pnpm typecheck
pnpm wrangler deploy --env production

C. Deploy the UI (Cloudflare Pages)

cd ui
pnpm build
pnpm wrangler pages deploy dist --project-name=odontox-app --branch=main
# Force-promote canonical (per commit/deploy discipline):
pnpm tsx ../scripts/force-promote-canonical.ts

D. Deploy the Marketplace app

cd marketplace-app
pnpm build
pnpm wrangler pages deploy dist --project-name=marketplace-app --branch=main

E. Bridge release

Bridge v1.1.0 binaries are not redeployed every release. If a bug fix needs to ship, build from bridge/:
cd bridge
pnpm package:win
# Upload Bridge-1.1.0-x64.exe and Bridge-1.1.0-arm64.exe to the release surface.

F. Smoke verification

After deploy:
  1. Sign in at id.odontox.io (verifies passkey + password paths, Neon retry).
  2. Land on /dashboard and confirm modules load. Check the trial banner state.
  3. Open Settings → Notifications → verify the new matrix renders with backfilled defaults.
  4. Open Settings → My Billing → confirm the canonical server PDF previews.
  5. Open the calendar — day, week (with a high-density day), month views.
  6. Send a WhatsApp message from a test conversation (test tenant ssh & Associates, clinic id b6d3a3f3-... per feedback_test_tenant_ssh_associates) — confirm template fires + lifecycle dispatcher runs + status webhook updates wamid.
  7. Open Marketplace at market.odontox.io and confirm listings render with related-modules + dependencies.
  8. Open /dashboard/tenants (superadmin) and verify the Tenants module renders, bulk actions, and impersonation audit attribution.
  9. Hit Worker Logs (superadmin) and verify OTLP stream is live.
  10. Schedule a future maintenance window and confirm the auto-engage / auto-release timing.

End

That’s the full picture. For per-day detail (incremental wins, hotfixes, individual bug context), RELEASES.md remains the source of truth — this document is the consolidated milestone view.