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 oneScheduleper provider + per-day bundles of Slots.SlotConverter.java(89 lines) —toSlot(Date, String timeCode) → List<Slot>. Walks thetimecodestring, 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:
- Accept a
Schedulereference 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) { ... } - Set
slot.setSchedule(scheduleRef)for every emitted Slot. - Set
slot.setStatus(SlotStatus.FREE)by default, then overlay:- If any
dayAppointmentsentry overlaps[slot.start, slot.end): setSlotStatus.BUSY. - The booking system (separate concern) is responsible for
BUSY_TENTATIVEwrites — the converter never writes that value.
- If any
- Migrate the template-code extension URL from
https://apps.health/Slot/schedule-template-codetohttps://fhir.apps.health/Slot/schedule-template-code(or align with whatever the IG canonicalizes — see Extension URL Reconciliation below). - Populate
slot.setServiceType()with aCodeableConceptwhose.coding[]includes:- A
rawCodeslice from thescheduletemplatecodelookup (system =https://fhir.apps.health/NamingSystem/<instance-id>-slot-servicetype-raw-code, code = the char, display = scheduleTemplateCode.description).
- A
- 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).
- value = stable per-(provider, date, char-index) hash. Proposal:
- 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:
- Change actor reference type — from
Practitioner/<providerNo>toPractitionerRole/<providerNo>. The Nexus EMR profile constrainsSchedule.actor[PractitionerRole]toReference(NexusEmrCorePractitionerRole).schedule.addActor().setReference("PractitionerRole/" + providerNo); - Optional: add Location actor. If the provider has a primary location, add a second
actorentry referencingNexusEmrCoreLocation. (Confirm with DB query Q3 whether providers have a clean 1:1 location relationship.) - Add Nexus EMR ID identifier on the Schedule:
- value =
<provider_no>(or another stable per-provider identifier).
- value =
- Set
schedule.setActive(true)by default; consider deriving fromScheduleDate.statusif any rows have non-'A'status. -
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); -
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]) -
Migrate extension URLs:
https://apps.health/List/schedule-date→https://fhir.apps.health/Schedule/schedule-date(or remove if redundant with Bundle structure)https://apps.health/List/schedule-date-is-holiday→ out-of-scope (useSlot.status=busy-unavailableinstead)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:
- Migrate extension URLs from
https://apps.health/ValueSet/schedule-template-code/...tohttps://fhir.apps.health/ValueSet/schedule-template-code/.... - Fix the
CONFIRMextension type — line 75 wrapsscheduleTemplateCode.getConfirm()(a String like"N") in anIntegerType, which will fail at runtime. UseStringTypeor convert to boolean. - Fix the
DURATIONextension type — line 77 wraps the duration String inIntegerType. This throws on non-numeric values. Guard with a numeric check, or change the field type. - The
ValueSetitself can stay — it complements the Slot.serviceType coding by giving consumers a discoverable list of all codes a clinic uses.
Design decisions¶
Holiday handling — recommended approach¶
Decision: Omit Slot emission entirely on holiday dates. Rationale:
- Emitting
Slot.status = busy-unavailablewould 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) orbusy(when anappointmentrow overlaps) orbusy-unavailable(within-day blockout) - Booking system writes:
busy-tentative(during hold flow), and transitionsbusy-tentative→free(after timeout) orbusy-tentative→busy(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:
- Migrate all existing URLs to
https://fhir.apps.health/...(clean break; recommended) - 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
- 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 asSlot.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:
- Empty calendar — Schedule with planningHorizon but no Slots (clinic not yet bookable)
- Single-day, 2 free + 1 busy — typical post-overlay output
- Multi-day — 3 consecutive days, demonstrating per-day Slot grouping
- Holiday + blockout mix — one holiday (no Slots), one within-day blockout (
busy-unavailable), one normal day - 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:
- All 10 deltas in the TL;DR table are addressed
- The 5 fixture bundles (once published) round-trip through the converter → HAPI ResourceProvider → external FHIR validator without errors
- A representative slice of OscarPro production-shape data passes IG profile validation
- CHANGELOG entry in
oscar-prodocuments the URL migration and behavioral changes - 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.rawCodecardinality,availablefield 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.)