Skip to content

OscarPro Schedule/Slot Converter — Migration Spec

Audience: the Oscar-side converter owners and anyone touching ca.kai.fhir.converter.r4.{ScheduleConverter, SlotConverter, ScheduleTemplateCodeConverter} in the oscar-pro repo. Status: Provisional — the deltas below are settled against the published profiles; the items under Open questions are not, and several turn on the database queries named in oscar-template-schema-notes.md. Derived from those schema findings and the NexusEmrCoreSchedule + NexusEmrCoreSlot profile design. Scope: This is a migration spec, not a greenfield contract. OscarPro already has partial FHIR Schedule/Slot converter code committed. This doc enumerates the deltas required to bring it into compliance with NexusEmrCoreSchedule + NexusEmrCoreSlot once those profiles publish in IG 1.10.0.


TL;DR — what changes

# Change File Severity
1 Set Slot.status (currently never set; base FHIR requires 1..1) SlotConverter.java Blocker
2 Set Slot.schedule reference (currently never set; base FHIR requires 1..1) SlotConverter.java Blocker
3 Change Schedule.actor reference from Practitioner/<id> to PractitionerRole/<id> ScheduleConverter.java:82 Blocker
4 Add appointment-overlay logic to mark slots busy when an appointment row covers the window SlotConverter.java (new dependency) Blocker
5 Migrate extension URLs from https://apps.health/ to https://fhir.apps.health/ (canonical IG base) All three converters Blocker
6 Map holiday days (scheduleholiday) to either omit-emit OR Slot.status = busy-unavailable (decision below) ScheduleConverter.java High
7 Map within-day blockout (ScheduleDate.available != '1') to Slot.status = busy-unavailable ScheduleConverter.java High
8 Flatten the nested Bundle-of-Bundles output structure ScheduleConverter.toSchedule() Medium
9 Set Slot.serviceType.coding[rawCode] from scheduletemplatecode lookup (currently only set as an extension) SlotConverter.java:50-55 Medium
10 Add Nexus EMR ID + raw-code identifiers per the IG's identifier-slicing pattern Both converters Medium

Hard prerequisite: IG 1.10.0 must publish first. The IG defines the target profile; this spec defines how to emit conformant resources.


Existing-state summary

Three converters exist today in oscar-pro/src/main/java/ca/kai/fhir/converter/r4/:

  • ScheduleConverter.java (179 lines) — toSchedule(List<ScheduleDate>) → Bundle. Groups schedule-dates by provider, emits one Schedule per provider + per-day bundles of Slots.
  • SlotConverter.java (89 lines) — toSlot(Date, String timeCode) → List<Slot>. Walks the timecode string, emits one Slot per non-_ character.
  • ScheduleTemplateCodeConverter.java (83 lines) — toFhirObject(List<ScheduleTemplateCode>) → ValueSet. Emits the per-clinic template-code lookup as a FHIR ValueSet.

These produce output today, but it doesn't validate against base FHIR R4 (let alone the Nexus EMR profiles) — see §"Migration deltas" below for the specific gaps.


Migration deltas (per file)

SlotConverter.java

Today:

public List<Slot> toSlot(Date date, String timeCode) {
  // ...
  for (int ii = 0; ii < timeCode.length(); ii++) {
    char timeCodeChar = timeCode.charAt(ii);
    if ('_' == timeCodeChar) continue;
    Slot slot = createSlot(date, ii, slotDuration);   // sets .start, .end
    ScheduleTemplateCode scheduleTemplateCode = scheduleTemplateCodeRepository.findByCode(...);
    if (scheduleTemplateCode != null) {
      // sets an extension URL https://apps.health/Slot/schedule-template-code
    }
    slots.add(slot);
  }
  return slots;
}

Required changes:

  1. Accept a Schedule reference parameter — caller (ScheduleConverter) must pass the parent Schedule resource so each Slot can set .schedule.
    public List<Slot> toSlot(Date date, String timeCode, Reference scheduleRef, List<Appointment> dayAppointments) { ... }
    
  2. Set slot.setSchedule(scheduleRef) for every emitted Slot.
  3. Set slot.setStatus(SlotStatus.FREE) by default, then overlay:
    • If any dayAppointments entry overlaps [slot.start, slot.end): set SlotStatus.BUSY.
    • The booking system (separate concern) is responsible for BUSY_TENTATIVE writes — the converter never writes that value.
  4. Migrate the template-code extension URL from https://apps.health/Slot/schedule-template-code to https://fhir.apps.health/Slot/schedule-template-code (or align with whatever the IG canonicalizes — see Extension URL Reconciliation below).
  5. Populate slot.setServiceType() with a CodeableConcept whose .coding[] includes:
    • A rawCode slice from the scheduletemplatecode lookup (system = https://fhir.apps.health/NamingSystem/<instance-id>-slot-servicetype-raw-code, code = the char, display = scheduleTemplateCode.description).
  6. Add Nexus EMR ID identifier to each Slot:
    • value = stable per-(provider, date, char-index) hash. Proposal: <provider_no>-<yyyyMMdd>-<index> (deterministic so re-emission produces the same ID).
  7. Skip '_' chars as today'_' is documented in the IG as the "no slot here" marker.

Edge case: midnight overflow. LocalTime.plusMinutes() wraps at midnight. For the last slot in a full-day timecode, slot.end will read 00:00 of the same day, not 00:00 of the next day. Fix: convert via LocalDateTime arithmetic with Duration.ofMinutes(...) instead, so the end timestamp correctly advances the date.

ScheduleConverter.java

Today:

schedule.addActor().setReference(Practitioner.class.getSimpleName() + "/" + providerNo);

Required changes:

  1. Change actor reference type — from Practitioner/<providerNo> to PractitionerRole/<providerNo>. The Nexus EMR profile constrains Schedule.actor[PractitionerRole] to Reference(NexusEmrCorePractitionerRole).
    schedule.addActor().setReference("PractitionerRole/" + providerNo);
    
  2. Optional: add Location actor. If the provider has a primary location, add a second actor entry referencing NexusEmrCoreLocation. (Confirm with DB query Q3 whether providers have a clean 1:1 location relationship.)
  3. Add Nexus EMR ID identifier on the Schedule:
    • value = <provider_no> (or another stable per-provider identifier).
  4. Set schedule.setActive(true) by default; consider deriving from ScheduleDate.status if any rows have non-'A' status.
  5. Pass holiday + appointment context into SlotConverter:

    // Before delegating to SlotConverter, fetch holidays + appointments for the date
    boolean isHoliday = scheduleHolidayRepository.findOne(date) != null;
    List<Appointment> dayAppointments = appointmentRepository.findByProviderAndDate(providerNo, date);
    
    if (isHoliday) {
      // emit no Slots for this day (recommended) OR emit Slots with status=busy-unavailable
      // see "Holiday handling" decision below
      continue;
    }
    
    val slots = slotConverter.toSlot(date, scheduleTemplate.getTimeCode(),
                                     scheduleReference, dayAppointments);
    

  6. Replace the nested Bundle-of-Bundles output with a flat Bundle:

    Bundle.entry[0] = Schedule
    Bundle.entry[1..N] = Slot   (one per slot, with .schedule → Bundle.entry[0])
    

  7. Migrate extension URLs:

    • https://apps.health/List/schedule-datehttps://fhir.apps.health/Schedule/schedule-date (or remove if redundant with Bundle structure)
    • https://apps.health/List/schedule-date-is-holiday → out-of-scope (use Slot.status=busy-unavailable instead)
    • https://apps.health/List/slotDuration → out-of-scope (computable from Slot.start/end on the receiver)

ScheduleTemplateCodeConverter.java

Today: Emits a ValueSet with extensions on each concept (booking-limit, color, confirm, duration).

Required changes:

  1. Migrate extension URLs from https://apps.health/ValueSet/schedule-template-code/... to https://fhir.apps.health/ValueSet/schedule-template-code/....
  2. Fix the CONFIRM extension type — line 75 wraps scheduleTemplateCode.getConfirm() (a String like "N") in an IntegerType, which will fail at runtime. Use StringType or convert to boolean.
  3. Fix the DURATION extension type — line 77 wraps the duration String in IntegerType. This throws on non-numeric values. Guard with a numeric check, or change the field type.
  4. The ValueSet itself can stay — it complements the Slot.serviceType coding by giving consumers a discoverable list of all codes a clinic uses.

Design decisions

Decision: Omit Slot emission entirely on holiday dates. Rationale:

  • Emitting Slot.status = busy-unavailable would create dozens of phantom Slot resources per provider per holiday, with no booking value.
  • The absence of Slot resources is the natural signal that the clinic isn't bookable that day.
  • Booking UIs can detect "no slots returned for date X" without needing an explicit status.

If a future booking UI needs an explicit "closed for holiday" signal, that's a follow-up — emit a single Schedule.note extension naming the holiday, but don't manufacture Slots.

Within-day blockouts (ScheduleDate.available != '1'): Different semantics from holidays — the clinic IS open but specific dates are administratively blocked. Decision: emit Slots with status = busy-unavailable and include the ScheduleDate.reason text in Slot.comment.

Slot.status writer-ownership

Per the IG's NexusEmrCoreSlot profile narrative:

  • Converter (you) writes: free (default) or busy (when an appointment row overlaps) or busy-unavailable (within-day blockout)
  • Booking system writes: busy-tentative (during hold flow), and transitions busy-tentativefree (after timeout) or busy-tentativebusy (after confirmation)
  • Converter NEVER writes busy-tentative. That value is the booking system's exclusively.

Extension URL reconciliation

The existing converter uses https://apps.health/... URLs. The Nexus EMR IG canonical base is https://fhir.apps.health/.... Three options:

  1. Migrate all existing URLs to https://fhir.apps.health/... (clean break; recommended)
  2. Dual-publish (emit both URLs on each resource for a transition period) — only worth it if there's a known consumer pinned to the old URLs
  3. Leave the old URLs in place and add new ones — clutter, not recommended

Recommended: option 1, with the migration noted in the OscarPro CHANGELOG. Confirm whether any known consumer reads the old URLs today.

Slot identifier strategy

Slots are derived from templates, not stored. They need stable, deterministic IDs so re-emission produces identical resources. Proposal:

  • Slot.id = <provider_no>-<yyyyMMdd>-<slot-index> (e.g. prov-42-20260601-36)
  • Slot.identifier.value = same as Slot.id

This is deterministic: re-running the converter for the same (provider, date) produces the same Slot IDs. Booking systems can safely cache Slot references.


Search parameter expectations (HAPI ResourceProvider)

Once the converters emit conformant resources, HAPI ResourceProviders should support:

  • Schedule: search by actor (PractitionerRole reference), active, date (planningHorizon range), identifier
  • Slot: search by schedule (Schedule reference), status, start (instant range), slot-type (== appointmentType), identifier

These are all base FHIR R4 SearchParameters — no custom params needed for Phase 1. A slot-window composite (find slots overlapping a window) is deferred until a booking UI confirms need.

The IG's CapabilityStatement declares these search params as supported. Until that lands, the Oscar HAPI server can already support them via base FHIR registration.


JSON fixture scenarios

Once NexusEmrCoreSlot + NexusEmrCoreSchedule are published, this section will be populated with 5 fixture bundles for end-to-end testing:

  1. Empty calendar — Schedule with planningHorizon but no Slots (clinic not yet bookable)
  2. Single-day, 2 free + 1 busy — typical post-overlay output
  3. Multi-day — 3 consecutive days, demonstrating per-day Slot grouping
  4. Holiday + blockout mix — one holiday (no Slots), one within-day blockout (busy-unavailable), one normal day
  5. Mid-hold busy-tentative — produced by the booking system, not the converter; included so the converter team knows what NOT to overwrite

Fixtures will land alongside this page (docs/guide/features/scheduling/fixtures/) once the profiles are published. Until then, this section is a placeholder.


Acceptance / handoff completion criteria

The Oscar converter team's work is considered complete when:

  1. All 10 deltas in the TL;DR table are addressed
  2. The 5 fixture bundles (once published) round-trip through the converter → HAPI ResourceProvider → external FHIR validator without errors
  3. A representative slice of OscarPro production-shape data passes IG profile validation
  4. CHANGELOG entry in oscar-pro documents the URL migration and behavioral changes
  5. The booking-UI team confirms the output is consumable for the patient-facing flow

Timezone derivation

Slot.start and Slot.end are FHIR instant — UTC with timezone offset. The current SlotConverter.createSlot() uses ZoneId.systemDefault(), which is brittle (depends on the JVM's TZ).

Proposed rule:

  • Derive timezone from the clinic's primary Location.address.state (province code → IANA TZ via a lookup table)
  • For Ontario (ON): America/Toronto
  • For Alberta (AB): America/Edmonton
  • For BC: America/Vancouver
  • ...etc.

Hardcoded province→TZ mapping is acceptable; Canada has a stable set of provincial TZs. Document the map in the converter's source. Do not use ZoneId.systemDefault() in production code.


Open questions

  • Any known external consumer pinned to the https://apps.health/... URLs? (Affects extension-URL migration strategy.)
  • Is the existing converter currently invoked in production, or is it dormant code? (Affects whether the migration is a hot path or a cold rewrite.)
  • Confirm the DB queries Q1–Q6 in oscar-template-schema-notes.md — those results refine some of the decisions above (e.g. rawCode cardinality, available field usage).
  • Preferred sequencing: ship IG 1.10.0 first, then converter migration; or coordinate a single release? (Recommended: IG first, then converter — they're decoupled.)