Skip to content

feat(sync): persist synchronized schedules and generate bookable slots#53

Open
pierzchala-m wants to merge 9 commits into
mainfrom
schedules-synchronization
Open

feat(sync): persist synchronized schedules and generate bookable slots#53
pierzchala-m wants to merge 9 commits into
mainfrom
schedules-synchronization

Conversation

@pierzchala-m

Copy link
Copy Markdown
Collaborator

Summary

Makes provider-schedule synchronization produce real, bookable availability end-to-end:

  1. Persist synced schedules server-sidePOST /sync-schedules fetches a remote FHIR collection Bundle, parses Practitioner/Location/Schedule resources, and upserts self-contained Schedule resources that survive a page reload.
  2. Generate bookable Slots from synced schedules — each schedule's availableTime + slot-length extensions are converted into an AvailabilityTemplate and expanded into Slot resources, projected forward from today through planningHorizon.end and tagged with the schedule's appointment types (serviceType).
  3. Fix the "No available times for this date" buggetSlots was subtracting the seed date-offset from query bounds while stored slots are never shifted on read, so per-day slot queries silently returned nothing.

Changes

Schedule persistence (a8870d4)

  • New src/scheduling/scheduleSync.ts: parseScheduleBundle() folds Practitioner/Location display names into actor refs, preserves availableTime + slot-length extensions, and stamps a synced-from marker.
  • New POST /sync-schedules route (src/server.ts) with upstream fetch (20s timeout), 502 on upstream failure, 400 on parse error.
  • sqliteStore: schema v6 adds extension column + migration; new upsertSchedule() (replace-by-id, preserves slots/appointments).
  • Auth: /sync-schedules added to public path prefixes.
  • Client: provider view POSTs to /sync-schedules, then reloads persisted schedules via GET /Schedule?active=true.

Slot generation (eabc140)

  • New buildSlotTemplate() converts availableTime (days + start/end) and the slot-length extension into an AvailabilityTemplate, projecting the weekly pattern forward from today through planningHorizon.end.
  • /sync-schedules now expands slots via expandSlots(), tags them with the schedule's serviceType, bulk-inserts via createSlots(), and returns slotsGenerated + notes[] (reporting schedules whose horizon already ended, so they're not silently skipped).

Slot-query fix (9360427)

  • Removed the orphaned unshiftDate() from getSlots. Date shifting is applied once at seed-import time (see importSeedData.ts), so stored data already lives at absolute current dates. The query-time unshift double-counted the offset and broke per-day slot filtering whenever the offset was non-zero — affecting all slot date-filtering, not just synced schedules. Slot queries now filter against stored absolute dates, consistent with getAppointments.

Testing

  • 116 unit tests pass, including 12 in scheduleSync.test.ts (7 parser + 5 buildSlotTemplate).
  • 6 e2e tests in e2e/schedules-sync.spec.ts (including reload persistence).
  • Verified live on fhirtogether.os.mieweb.org: synced Butler's schedule → 320 free slots generated (today → 2026-09-30), each carrying 27 appointment types; the booking calendar now lists available dates and time slots on selection.

Notes

  • Schedules whose source planningHorizon already ended produce no slots by design; they appear in the response notes[].
  • Out of scope here: the provider-card description tweak (show specialty only, not appointment serviceType) is not included in this PR.

Booked appointments and the busy slots they consume were both rendered,
showing every appointment twice (a 'Blocked' card and a 'Busy' card).
Suppress slots referenced by an appointment's slot[] or sharing its
start/end so each booked time appears once. Free/unbooked slots are
unaffected.
Add deploy/fhirtogether.service to run the compiled Node.js server
(node dist/server.js) directly under systemd, reading config from .env.
Document install/update/log commands in QUICKSTART, reference them from
README and copilot-instructions (DRY). Docker files remain for local use.
…y systemd)

Production now runs as a host-local systemd service updated manually, so the
launchpad/Docker auto-deploy on push (deploy-production.yml) is obsolete and
conflicting. Removed it and clarified in copilot-instructions that prod deploys
are manual; pr-preview.yml still uses Docker for ephemeral PR previews.
The reset flow now stops/starts the fhirtogether systemd service rather than
the (removed) docker compose stack, matching the systemd deployment model.
The 'Delete failed container' step crashed when the launchpad API returned a
non-JSON body (jq parse error -> exit 5 under bash -e). Guard the jq calls so a
bad response yields an empty value instead of aborting the preview build.
Synchronize now POSTs to /sync-schedules; the server fetches the remote
FHIR collection Bundle, parses it, and upserts Schedule resources into the
store (availability extensions preserved). The grid reloads from
GET /Schedule so synced schedules survive a page reload. /sync-schedules is
exempt from auth. Adds parser unit tests and reworked e2e tests.
Expand each synced schedule's availableTime extension into FHIR Slots
during /sync-schedules, projecting the weekly pattern forward from today
through planningHorizon.end. Slots are tagged with the schedule's
serviceType (appointment types). Schedules whose horizon already ended
report a skip note instead of producing past-dated slots.
getSlots subtracted getDateOffsetDays() from the start/end query bounds
via unshiftDate, but stored slots are never shifted on read (date
shifting is applied once at seed-import time, per importSeedData). With a
non-zero offset this shifted per-day slot queries backward, so the
booking calendar listed dates (counts use a wide range) but selecting a
day returned no slots ("No available times for this date"). Filter slot
queries against stored absolute dates, consistent with getAppointments.
Copilot AI review requested due to automatic review settings June 11, 2026 20:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes schedule synchronization produce persisted Schedule resources and generates bookable Slot availability from synced schedules, while also fixing a date-offset bug that caused slot queries to return no results.

Changes:

  • Persist synced schedules by parsing remote collection Bundles and upserting schedules (including extension payloads) into SQLite.
  • Expand synced schedule availability into generated Slot resources through the schedule planning horizon.
  • Fix slot date filtering by removing query-time “unshift” logic so queries match stored absolute dates.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/types/fhir.ts Adds Schedule.extension and FhirStore.upsertSchedule() to support persisted sync metadata.
src/store/sqliteStore.ts Bumps schema to v6, persists schedule extensions, adds upsertSchedule(), and fixes slot query bounds handling.
src/server.ts Adds /sync-proxy and /sync-schedules endpoints to fetch/parse bundles, upsert schedules, and generate slots.
src/scheduling/scheduleSync.ts Implements bundle parsing + slot-template derivation from schedule extensions/horizon.
src/mcp/tools/formatters.ts Makes getSystemName() robust against untyped extension objects.
src/auth/basicAuth.ts Adds sync endpoints to unauthenticated allowlist (security-sensitive).
src/auth/apiKeyAuth.ts Adds sync endpoints to unauthenticated allowlist (security-sensitive).
src/tests/scheduleSync.test.ts Unit tests for bundle parsing, sync marker stamping, and slot-template building.
e2e/schedules-sync.spec.ts E2E coverage for provider-view synchronization UI and persistence reload behavior.
packages/fhir-scheduler/src/provider-view.tsx Adds a “Synchronize” tab and toggles a static HTML sync panel.
packages/fhir-scheduler/provider-view.html Adds the synchronization UI, grid rendering, toasts, and client logic to call /sync-schedules.
packages/fhir-scheduler/src/components/AppointmentList.tsx Prevents rendering duplicate slot entries when an appointment consumes a slot/time window.
scripts/reset.sh Adds a systemd-oriented reset script to rebuild a clean local environment.
deploy/fhirtogether.service Adds a systemd unit for running the compiled server.
deploy/fhirtogether-reset.service Adds a systemd oneshot unit wrapping scripts/reset.sh.
README.md Documents systemd-based production deployment approach.
QUICKSTART.md Adds systemd deployment instructions.
.github/workflows/deploy-production.yml Removes legacy CI production deploy workflow.
.github/copilot-instructions.md Documents manual systemd deploy (and clarifies CI preview images are separate).

Comment thread src/store/sqliteStore.ts
Comment on lines +672 to +682
ON CONFLICT(id) DO UPDATE SET
active = excluded.active,
service_category = excluded.service_category,
service_type = excluded.service_type,
specialty = excluded.specialty,
actor = excluded.actor,
planning_horizon_start = excluded.planning_horizon_start,
planning_horizon_end = excluded.planning_horizon_end,
comment = excluded.comment,
extension = excluded.extension,
meta_last_updated = excluded.meta_last_updated
Comment thread src/server.ts
Comment on lines +435 to +438
for (const schedule of schedules) {
await store.upsertSchedule(schedule);
if (!schedule.id) continue;

Comment thread src/server.ts
Comment on lines +301 to +320
// CORS-bypass proxy for the schedule synchronization feature.
// The browser cannot fetch arbitrary remote FHIR endpoints cross-origin, so
// the Provider View page routes its fetch through this server-side proxy.
fastify.get<{ Querystring: { url?: string } }>('/sync-proxy', async (request, reply) => {
const target = request.query.url;
if (!target) {
return reply.code(400).send({ error: 'Missing "url" query parameter.' });
}

let parsed: URL;
try {
parsed = new URL(target);
} catch {
return reply.code(400).send({ error: 'Invalid URL.' });
}

// Only allow outbound HTTP(S) requests.
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return reply.code(400).send({ error: 'Only http and https URLs are supported.' });
}
Comment thread src/auth/basicAuth.ts
Comment on lines +15 to 16
const PUBLIC_PATH_PREFIXES = ['/health', '/docs', '/demo', '/scheduler/', '/sync-proxy', '/sync-schedules'];
const PUBLIC_EXACT_PATHS = new Set(['/', '/health', '/favicon.ico']);
Comment thread src/auth/apiKeyAuth.ts
Comment on lines 18 to +28
const PUBLIC_PATH_PREFIXES = [
'/health',
'/docs',
'/demo',
'/public/',
'/scheduler/',
'/Directory',
'/System/register',
'/hl7-tester',
'/sync-proxy',
'/sync-schedules',
Comment on lines +173 to +180
const availExt = ext.find((e) => e?.url === AVAILABLE_TIME_EXTENSION_URL);
const availArr = availExt?.availableTime as
| Array<{ daysOfWeek?: string[]; availableStartTime?: string; availableEndTime?: string }>
| undefined;
const avail = Array.isArray(availArr) ? availArr[0] : undefined;
if (!avail || !avail.availableStartTime || !avail.availableEndTime) {
return { template: null, note: 'no availableTime defined' };
}
Comment on lines +191 to +193
<!-- ── Schedule synchronization (shown via the "Synchronize" tab in the React app) ── -->
<div id="sync-section" hidden>
<section class="sync-panel" aria-label="Synchronization controls">
Comment on lines +73 to +80
<button
type="button"
role="tab"
aria-selected={activeTab === 'synchronize'}
aria-controls="sync-section"
className={`fs-demo-tab ${activeTab === 'synchronize' ? 'fs-demo-tab-active' : ''}`}
onClick={() => setActiveTab('synchronize')}
>
Comment on lines +217 to +221
<button class="btn btn-secondary" id="clear-btn" type="button"
onclick="clearGrid()"
aria-label="Clear synchronized schedules">
Clear
</button>
Comment on lines +531 to +535
function clearGrid() {
scheduleRows = [];
renderGrid();
showToast('Cleared synchronized schedules.', 'info');
}
@pierzchala-m pierzchala-m requested a review from horner June 12, 2026 16:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants