OdontoX v1.7 Stable — internal release notes
Audience: OdontoX engineering + ops. Window covered: 2026-04-26 → 2026-05-21 (243 entries inRELEASES.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-appcanonical promotion is enforced after every deploy perfeedback_commit_deploymemory.marketplace-appis its own Pages project undermarketplace-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 atgo.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 isid(matches the addonAVAILABLE_MODULES.keyfrom 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(lucideicon 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[]anddependencies 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 keylisting_id→marketplace_listings.id. Columns:version_label(semver-shaped string),released_at,summary(one-liner),body_md(long-form),is_majorflag. Surfaced on the app detail page and at the per-app/apps/<app>/releasesfeed.
Billing model (revenue gate)
Subscription is gated by manual superadmin approval. The flow:- Tenant clicks Subscribe on a listing →
POST /api/v1/protected/marketplace/requestscreates anaddon_requestsrow withstatus='pending'. - OdontoX team issues an invoice via Invoice Studio. The “Invoice ready — pay to activate” email goes to the clinic owner.
- Clinic pays through the bank QR / IBAN / EasyPaisa / JazzCash flow already on subscription invoices.
- OdontoX team confirms payment → marks request
status='approved'. The addon’s module key flips on; the listing’s “Subscribe” button changes to “Open App”; theapp_activatedemail fires with a 3-step quickstart. - Cancellation works the same way in reverse —
cancel_requested→ manual review →cancelledwith effective-date prorate.
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.
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 (perfeedback_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 uploads —
POST /api/v1/protected/superadmin/marketplace/uploadwrites 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.iowith a 1-yearimmutablecache header (not user-uploaded; provisioned per-deploy).
Eight seed listings
All eight launch listings ship asis_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.
Sidebar flyout (main app)
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 tomarket.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 inserver/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. 100vh → 100dvh 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 existingENCRYPTION_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 publishEvent → ClinicHub 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 inserver/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.
| File | Date scope | One-line description | Tables / enums affected |
|---|---|---|---|
0027_add_oral_habits_to_medical_history.sql | 2026-05-06g | Add oral_habits text[] column for per-patient dental habit tags. Encrypted at rest via PHI fields. | app.patient_medical_history |
0028_add_document_letterhead.sql | 2026-05-06i | Add document_image_key and document_template_name for the new Finance Document Letterhead surface (invoices, receipts, quotations). | app.clinics |
0029_mobile_role_permissions.sql | 2026-05-07 | New table for per-clinic, per-role mobile-app module toggles (Phase 1 mobile foundation). | app.mobile_role_permissions (new) |
0030_add_conversion_columns.sql | 2026-05-07 | Add 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.sql | 2026-05-08 | Add 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.sql | 2026-05-08 | Reduce gender enum from four values to two (male, female). Two existing patients migrated. | app.gender enum + app.patients |
0033_portal_seats_storage_addons.sql | 2026-05-13 | Portal 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.sql | 2026-05-13 | WhatsApp full module elevation: events table + quick replies. | app.whatsapp_events (new), app.whatsapp_quick_replies (new) |
0035_mobile_device_telemetry.sql | 2026-05-14 | Device 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.sql | 2026-05-14 | Track where an appointment was created from (web / mobile / whatsapp / shared link). | app.appointments |
0037_chat_v2.sql | 2026-05-14 | Conversations + 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.sql | 2026-05-15 | First-class WhatsApp automations table backing the Automations tab. | app.whatsapp_automations (new) |
0039_whatsapp_media_columns.sql | 2026-05-16 | Inbound 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.sql | 2026-05-16 | phone_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.sql | 2026-05-16 | has_whatsapp flag on patients, derived from inbound WhatsApp activity, used for templating gating. | app.patients |
0042_tenants_audit_and_overrides.sql | 2026-05-17 | Per-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.sql | 2026-05-17 | Drop strict actor FK from audit log so a deleted user doesn’t break audit visibility. | app.audit_logs |
0044_automation_lifecycle_triggers.sql | 2026-05-18 | New trigger kinds (appointment_requested, _scheduled, _confirmed, _cancelled, _rescheduled, _no_show, last_visit_completed). | app.whatsapp_automation_trigger_kind enum |
0045_marketplace.sql | 2026-05-19 | Marketplace 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.sql | 2026-05-17/18 | Lightweight 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.sql | 2026-05-17/18 | Loosen FK on tenant_activity_intel.actor_user_id to allow soft-deleted users without cascading. | app.tenant_activity_intel |
0048_clinic_time_format.sql | 2026-05-20 | Per-clinic 12h/24h time format setting. | app.clinics.time_format |
0049_clinic_appointment_settings.sql | 2026-05-20 | Centralised appointment behaviour (default duration, snap interval, hover indicator, click-to-book, time format) as JSONB. | app.clinics.appointment_settings |
0050_marketplace_related_modules_deps.sql | 2026-05-21 | Related modules + dependencies on listings for cross-linking. | app.marketplace_listings (related_modules text[], dependencies text[]) |
0051_notification_event_matrix.sql | 2026-05-21 | Per-event × per-audience matrix backfilled from previous flat flags; new notificationPreferences.emailMatrix. Idempotent. | app.clinics.notification_preferences |
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 fromserver/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).
app.marketplace_releases — versioned “what’s new” entries per listing (migration 0045).
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).
app.addon_pricing — single-row catalog (id='current') of storage/seat/module addon prices; mutations append to app.addon_pricing_history (migration 0033).
app.conversations — one row per patient (or per ordered staff pair) per clinic; chat-v2 unit of grouping (migration 0037).
app.messages — chat + email + WhatsApp messages. Original messages table extended through migrations 0031 (read_at), 0037 (chat-v2 columns), 0039 (WhatsApp media). Current shape:
app.whatsapp_automations — per-clinic lifecycle automation rules; trigger kinds extended by migration 0044 with inline lifecycle + last_visit_completed.
app.doctor_schedules — per-doctor weekly availability used by appointment validators and the SlotPicker (pre-window table, formalised in this train).
app.mobile_role_permissions — per-clinic, per-role mobile-app module toggles; Phase 1 mobile foundation (migration 0029).
6. New backend modules / files (significant)
server/src/lib/:
email-gate.ts—shouldSendEmail(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 atserver/src/lib/pkt.test.ts.plan-limits.ts— Single source of truth for plan caps (Pro / Pro+ patients, portal seats, storage GB). Supersedespatient-quota.ts.amount-limits.ts—assertAmountWithinClinicLimit(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 viactx.waitUntil().png-encoder.ts— Pure-JS PNG encoder usingCompressionStream('deflate')(RFC 1950 zlib-wrapped) + CRC32. No OffscreenCanvas required.backfill-conversations.ts— Idempotent walker materialisingconversationsrows from existing messages.bank-qr.ts+.test.ts— Bank QR upload + render.automation-templates.ts—TEMPLATE_DEFAULTS,defaultTemplateForTrigger(triggerKind).defaultLanguage = en_US.automation-dispatcher.ts— SharedrunOnepath for cron + inline dispatch.meta-client.ts—markAsRead,sendTemplate,verifyHmac,fetchApprovedTemplates.chat-events.ts+.test.ts— SSE event types and helpers.chat-flags.ts—chat_v2per-clinic flag check.db-rows.ts—toRows<T>()unwrapsdrizzle-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 withtreatmentPlanandprescriptioninDOCUMENT_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 indocs/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 (onlypatientId 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/webhookrewritten 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/announcekept as deprecated alias for one release).
patientNumberon patient create/update.medicalHistorypayload onPOST /patients.appointment_settings,time_formatonPUT /clinics/:id.paymentInstructions.bankAccounts[].qrCodeDataUrlon document settings.documentSettings.limits.{maxInvoiceAmount,maxQuotationAmount}.notificationPreferences.emailMatrix.whatsappPhoneon patient update.kind: 'owner' | 'member'andclinicNameonWelcomeEmail.
POST /license-requestswithrequestType=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
| Job | Schedule | Description |
|---|---|---|
appointment-reminders.ts | hourly | Pre-existing; window now batched with inArray(), gated 10 PM → 8 AM PKT. |
appointment-invoices.ts | hourly | Pre-existing; opens Pool driver lazily, ROLLBACK/DEALLOCATE round-trips eliminated. |
installment-invoices.ts | hourly | Pre-existing; same lazy Pool pattern. |
missed-appointments.ts | hourly | Pre-existing; same lazy Pool pattern + tx-cleanup-in-finally. |
feedback-email.ts | daily | Post-visit feedback nudge. |
inventory-expiry-alerts.ts | 4× / day | handleInventoryExpiryAlerts — expiring, out-of-stock, low-stock. |
eod-email-report.ts | 0 16 * * * UTC (= 21:00 PKT) | EOD report cron. |
automation-reconcile.ts | 0 2 * * * UTC | WhatsApp automation reconcile against Meta’s approved-template list. |
whatsapp-window-closer.ts | hourly | Idempotent window_closed event writer when 24h passes since last inbound. |
rollup-tenant-activity.ts | daily | Tenant activity rollup for superadmin Tenants module. |
| Marketplace overdue-invoice nudge | daily | Idempotent, rate-capped (Marketplace billing). |
| Marketplace idle-subscription nudge | weekly | Idempotent (Marketplace billing). |
| Trial / billing cron | 11 AM PKT | Sections 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=truein both top-level[vars]and[env.production.vars](Stripe paused; webhook handler ACKs every event with 200 immediately without parsing).STRIPE_ENABLED = falsebuild-time constant atui/src/lib/feature-flags.ts.SESSION_TIMEOUTexplicitly set to 7d inwrangler.tomlfor default and production; minimum floor of 7 days enforced so env overrides can never shorten sessions below that.- UAT
uat_auth_tokencookie name (distinct from prod) —wrangler.tomlUAT env var. - Cloudflare Rate Limiter binding bumped from
100/60sto300/60sper 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. _headerson the marketing site: addedhttps://assets.odontox.iotomedia-src; addedhttps://ph.odontox.io,https://*.odontox.io,https://*.sentry.io,https://o4511296397901824.ingest.sentry.iotoconnect-src.
- Access tokens raised from
15mto90d(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 inRELEASES.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_retrymemory. - 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-insightshas 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 withRequireModule(upgrade-prompt fallback) to close the gap. patients.mrndormant column — kept after thepatient_numbermigration; 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
mfaEnabledback to false for an admin, the welcome would re-fire. AplatformWelcomeSentAtcolumn onuserswould 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 —
onManageUserwire-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-setupstep 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.webphero swap to AVIF (est. −1,883 KiB), oversizedlogo.webp. Documented indocs/qa/2026-05-14-mobile-pagespeed-results.md.
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 (perfeedback_no_live_tenant_execution memory, explicit per-action user confirmation required before running against prod):
drizzle_migrations (e.g. shipped via runtime ensureXSchema()), record it explicitly:
B. Deploy the Worker
C. Deploy the UI (Cloudflare Pages)
D. Deploy the Marketplace app
E. Bridge release
Bridge v1.1.0 binaries are not redeployed every release. If a bug fix needs to ship, build frombridge/:
F. Smoke verification
After deploy:- Sign in at id.odontox.io (verifies passkey + password paths, Neon retry).
- Land on
/dashboardand confirm modules load. Check the trial banner state. - Open Settings → Notifications → verify the new matrix renders with backfilled defaults.
- Open Settings → My Billing → confirm the canonical server PDF previews.
- Open the calendar — day, week (with a high-density day), month views.
- Send a WhatsApp message from a test conversation (test tenant ssh & Associates, clinic id
b6d3a3f3-...perfeedback_test_tenant_ssh_associates) — confirm template fires + lifecycle dispatcher runs + status webhook updateswamid. - Open Marketplace at market.odontox.io and confirm listings render with related-modules + dependencies.
- Open
/dashboard/tenants(superadmin) and verify the Tenants module renders, bulk actions, and impersonation audit attribution. - Hit Worker Logs (superadmin) and verify OTLP stream is live.
- 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.
