feat(sync): persist synchronized schedules and generate bookable slots#53
Open
pierzchala-m wants to merge 9 commits into
Open
feat(sync): persist synchronized schedules and generate bookable slots#53pierzchala-m wants to merge 9 commits into
pierzchala-m wants to merge 9 commits into
Conversation
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.
…anup" This reverts commit fece602.
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.
There was a problem hiding this comment.
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
Slotresources 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 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 on lines
+435
to
+438
| for (const schedule of schedules) { | ||
| await store.upsertSchedule(schedule); | ||
| if (!schedule.id) continue; | ||
|
|
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 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 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'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes provider-schedule synchronization produce real, bookable availability end-to-end:
POST /sync-schedulesfetches a remote FHIR collection Bundle, parses Practitioner/Location/Schedule resources, and upserts self-containedScheduleresources that survive a page reload.availableTime+ slot-length extensions are converted into anAvailabilityTemplateand expanded intoSlotresources, projected forward from today throughplanningHorizon.endand tagged with the schedule's appointment types (serviceType).getSlotswas 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)src/scheduling/scheduleSync.ts:parseScheduleBundle()folds Practitioner/Location display names into actor refs, preservesavailableTime+ slot-length extensions, and stamps asynced-frommarker.POST /sync-schedulesroute (src/server.ts) with upstream fetch (20s timeout), 502 on upstream failure, 400 on parse error.sqliteStore: schema v6 addsextensioncolumn + migration; newupsertSchedule()(replace-by-id, preserves slots/appointments)./sync-schedulesadded to public path prefixes./sync-schedules, then reloads persisted schedules viaGET /Schedule?active=true.Slot generation (
eabc140)buildSlotTemplate()convertsavailableTime(days + start/end) and the slot-length extension into anAvailabilityTemplate, projecting the weekly pattern forward from today throughplanningHorizon.end./sync-schedulesnow expands slots viaexpandSlots(), tags them with the schedule'sserviceType, bulk-inserts viacreateSlots(), and returnsslotsGenerated+notes[](reporting schedules whose horizon already ended, so they're not silently skipped).Slot-query fix (
9360427)unshiftDate()fromgetSlots. Date shifting is applied once at seed-import time (seeimportSeedData.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 withgetAppointments.Testing
scheduleSync.test.ts(7 parser + 5buildSlotTemplate).e2e/schedules-sync.spec.ts(including reload persistence).Notes
planningHorizonalready ended produce no slots by design; they appear in the responsenotes[].