medOS ultra

Visit Scheduling Config

How recurring VisitRequests become calendar occurrences via a UI-configurable occurrence projector.

7 min read diagramsUpdated 2026-05-30docs/architecture/visit-scheduling-config.md

How recurring VisitRequests become calendar occurrences, and how every knob is admin-editable instead of hardcoded. Same config-driven philosophy as policy_gates / cds_rules / compounding_configs.

VisitRequest itself (the recurring-visit intent — RRULE + bounds, one row per series, sparse overrides) is covered in the commit history under feat(scheduling). This doc is the config + projection layer on top.

The layers

VisitRequest (Mongo write-truth)  ──┐
visit_occurrence_override (Mongo) ──┤
                                    ▼
              projectVisitOccurrences()  ← pure core, reads VisitSchedulingConfig
                                    │
        ┌───────────────────────────┴───────────────────────────┐
        ▼ read-time (shipped)                                    ▼ materialized (next)
useProjectedVisitOccurrences(window)                  projector job (cron)
   → expands on the client per render                    → upserts public.visit_occurrence
   → no stored rows, always fresh                        → keyed on occurrence_key (idempotent)
        └───────────────────────────┬───────────────────────────┘
                                     ▼
                          appointment calendar UI

Both paths run the identical pure core (projectVisitOccurrences); the only difference is when and where it runs.

Integration with coding + revenue (the contract — read this)

A recurring visit occurrence is the upstream sibling of an Appointment. The appointment → encounter → coding → revenue path already exists end-to-end; the projector feeds it, it does not reinvent it. Hard rules:

  1. An occurrence is pre-clinical — it owes the coding/billing pipeline nothing until the patient is kept. visit_occurrence rows never appear in coding_worklist, v_receivables_unified, or any settlement tab. Don’t create SalesOrders or coding_worklist rows from the projector.
  2. The one debt is paid at check-in. When a VisitRequest occurrence is checked in (workflowState = 'wait-check-in'), visitRequest.acknowledge creates an Encounter (administration.encounter.create, level=AMB, status=PLANNED, carrying patientRef/clinicId/subClinicId/visitType/ doctor participant) and emits medication.encounterJourney.emitClinicalContextUpdated with a visit_context envelope — exactly mirroring appointment.controller.mixin.ts:563-633 CHECKED_IN.
  3. After that, coding + billing fire automatically. fn_auto_populate_coding_worklist queues the encounter once it’s paid (OPD) or discharged (IPD); revenue enters via the SalesOrder born on charge-close. No extra wiring.
  4. Carry scheme/payorPlan onto the encounter for correct coding/debtor-group routing (fn_auto_populate_coding_worklist reads scheme_code). VisitRequest doesn’t capture payorPlan yet → follow-up: add a scheme field to the request/dialog, or resolve coverage at check-in like a walk-in.

What this layer must NOT own

  • Daily-close / per-diem / “close at midnight” (UC/NHSO). There is no midnight cron and no facility_billing_rule row. Per-day close is an evaluative pre-close gate in the rcm-rule-engine edge function’s country pack (infrastructure/medbase/functions/rcm-rule-engine/country-packs/th-vajira.json: billCycleEligibility.{opd,ipd}mode: PER_DAY, cutoffLocalTime, autoClose, grace, reopen), keyed off encounter LOS / discharge — not visit occurrences. Scheduling config must not try to own it.
  • Per-encounter conditional rules. visit_scheduling_config is a catalog + projection-policy object, not a rule evaluator. If conditional projection rules are ever needed (e.g. “skip occurrences when the patient’s scheme has expired”), reuse the cds_rules token grammar (the same one the designed facility_billing_rule mirrors) — don’t invent a new predicate DSL.
  • Ad-hoc cron. projection.cadenceCron registers through the cron_jobs registry (migration 036_cron_jobs_registry.sql), not a bare cron.schedule.

Everything is UI-configurable

VisitSchedulingConfig (web/src/utils/scheduling/visitSchedulingConfig.ts) is one object, edited at /admin/visit-scheduling-config (VisitSchedulingConfigPage), persisted to public.visit_scheduling_config (scope 'global' | clinicId). Nothing in the scheduling surfaces hardcodes these any more:

Knob Drives
projection.windowDays rolling-window size the calendar/job expands
projection.onBlocked (keep/skip/next-open-day) holiday/blackout policy
projection.projectStatuses which VisitRequest statuses appear
projection.defaultDurationMinutes fallback occurrence length
projection.materializeEnabled + cadenceCron read-time vs server job + its schedule
catalog.visitTypes the dialog’s Visit-type dropdown
catalog.recurrencePresets the dialog’s Repeats dropdown
catalog.timezones + defaultTimezone the dialog’s Timezone dropdown

Consumers read it via useVisitSchedulingConfig() (cached, synchronous default so nothing blocks). The service (visit-scheduling-config.service.ts) falls back to DEFAULT_VISIT_SCHEDULING_CONFIG when the row/table is absent — so the whole system works before the migration runs and degrades gracefully offline.

To add/change options

Open /admin/visit-scheduling-config, edit, Save. No code, no deploy. (Adding a brand-new knob = add a field to the model + a control on the page.)

Key files

File Role
web/src/utils/scheduling/visitSchedulingConfig.ts config model + DEFAULT + merge
web/src/services/visit-scheduling-config.service.ts Supabase load/save (graceful default)
web/src/hooks/useVisitSchedulingConfig.ts cached config read
web/src/containers/admin/visit-scheduling-config/VisitSchedulingConfigPage.tsx admin UI (/admin/visit-scheduling-config)
web/src/utils/scheduling/expandVisitOccurrences.ts pure per-series RRULE expander (tz + holiday aware)
web/src/utils/scheduling/projectVisitOccurrences.ts pure multi-series projector (deterministic occurrence_key)
web/src/hooks/useProjectedVisitOccurrences.ts read-time calendar projection (config-driven)
infrastructure/medbase/migrations/20260529c_visit_scheduling.sql visit_scheduling_config + visit_occurrence tables

Materialized projector job (DEPLOYED + VERIFIED — invoke verified, 48 rows; apply the cron to automate)

Implemented as the Supabase edge function visit-occurrence-projector

  • the overrides access (folded into GET /visitRequests/:_id?withOverrides=true, so no new gateway route). To activate:
  1. Apply 20260529c_visit_scheduling.sql (Supabase SQL editor / psql).
  2. supabase functions deploy visit-occurrence-projector.
  3. Set secrets MOLECULER_API_URL + MOLECULER_AUTH_TOKEN (same as the other *-mongo-sync fns) so it can read VisitRequests via REST.
  4. Register the cron in the cron_jobs registry using visit_scheduling_config.projection.cadenceCron; flip materializeEnabled on.

The edge fn reads the config (window/statuses), fetches active VisitRequests + their overrides via REST, expands with rrule (esm.sh), and upserts visit_occurrence keyed on occurrence_key (idempotent), pruning this-series rows left behind by the run. Design notes below.

Design

When materializeEnabled is on, a cron job stores occurrence rows so other services / SQL consumers can read them (the read-time hook doesn’t need this).

Design (lift the same projectVisitOccurrences core server-side):

  1. Read active VisitRequest series + their VisitOccurrenceOverrides.
  2. projectVisitOccurrences(series, overridesByReq, { start: now, end: now + windowDays }, { onBlocked }, { projectStatuses, defaultDurationMinutes }) — config from visit_scheduling_config.
  3. upsert rows into public.visit_occurrence on occurrence_key (idempotent), stamping window_generated_at.
  4. Prune rows for each series whose window_generated_at < this run AND occurrence_date outside the window.

Two implementation options:

  • Supabase edge function visit-occurrence-projector (Deno) — needs a Deno RRULE port (or npm:rrule); scheduled via the cron_jobs registry using projection.cadenceCron.
  • Administration-service cron action visitRequest.projectOccurrences — reuses Mongo access + the @supabase/supabase-js client the service already has; expose POST /v2/administration/visitRequests/project for manual/cron trigger.

Overrides endpoint (the gap to close first)

useProjectedVisitOccurrences and the job currently project the base RRULE only — per-occurrence cancel/reschedule/no-show live in Mongo visit_occurrence_override but have no read path. Add to the administration visitRequest module:

  • GET /v2/administration/visitRequests/:id/overrides
  • (or a batch GET /v2/administration/visitOccurrenceOverrides?visitRequestIds=…) then pass overridesByRequestId into projectVisitOccurrences (the param already exists).

Deploy checklist for the materialized path

  1. Apply 20260529c_visit_scheduling.sql (Supabase SQL editor or psql -f).
  2. Add the overrides endpoint to the administration visitRequest module → push (backend deploy) → whitelist administration.visitRequest.* is already in the gateway, but a new sub-route still needs the gateway redeploy if aliases changed (see [[gateway-route-exposure]] / docs/architecture/miniapp-modal-override.md).
  3. Ship the projector (edge fn or cron action); register its schedule in the cron_jobs registry (/super-admin/cron-jobs) from projection.cadenceCron.
  4. Flip materializeEnabled on at /admin/visit-scheduling-config.

Status

  • ✅ Config model + service + hook + admin page + route + migration file
  • ✅ Config-driven dialog (catalog) + read-time projector (window/policy/statuses)
  • ✅ Pure projector core, 8 unit tests, deterministic occurrence_key
  • Check-in → encounter → coding/revenue contract (visitRequest.acknowledge creates the Encounter + emits visit_context on wait-check-in, mirroring appointment CHECKED_IN — kept visits now flow into coding/billing automatically)
  • ✅ Overrides access — GET /visitRequests/:_id?withOverrides=true (folded into get, no new route)
  • ✅ Migration applied (visit_scheduling_config seeded + visit_occurrence live)
  • Materialized projector DEPLOYED + VERIFIEDvisit-occurrence-projector edge fn (self-login auth) invoked live: 6 active series → 48 visit_occurrence rows (idempotent on occurrence_key)
  • Cron — registered in the cron_jobs registry via API (managed_by='admin', nightly 0 1 * * *), so it shows + toggles in the unifier at /super-admin/cron-jobs. Created two ways: the SQL 20260529d_visit_occurrence_projector_cron.sql, OR straight through the admin API (cronJobs.service.upsertCronJobcron_jobs → sync trigger → pg_cron) — no migration needed. Seeded disabled like every other edge-fn cron; to run it, set the GUCs app.settings.edge_functions_url + app.settings.service_role_key on the DB, then flip the toggle on the page.
  • ⏳ Cron run-history (“logs”) in the page needs 20260530a_cron_job_run_history.sql (reads cron.job_run_details); the page degrades gracefully until applied
  • ⏳ Scheme/payorPlan capture on VisitRequest (for coding debtor-group routing)
  • ⏳ Unify edge-fn + read-time expansion behind one shared module (DST-precise tz)
Ask Anything