diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index daab680..44f7cdc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -31,6 +31,20 @@ src/ - `npm run build` - Compile TypeScript - `npm start` - Run production build +### Deployment +Production runs the compiled Node.js server directly under **systemd** (not Docker). +The unit file is [`deploy/fhirtogether.service`](../deploy/fhirtogether.service). + +- Install: `sudo cp deploy/fhirtogether.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now fhirtogether` +- Deploy update: `git pull && npm ci && npm run build && sudo systemctl restart fhirtogether` +- Logs: `journalctl -u fhirtogether -f` + +Production deploys are **manual** (the steps above) β€” there is no CI job that +auto-deploys production. The `.github/workflows/pr-preview.yml` workflow still +builds Docker images for ephemeral per-PR preview containers via launchpad; the +`Dockerfile`/`docker-compose.yml` support those previews and local containerized +runs. `docker-compose.inferno.yml` is the separate Inferno test harness. + ### API Endpoints | Method | Path | Description | |--------|------|-------------| diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml deleted file mode 100644 index 5496de8..0000000 --- a/.github/workflows/deploy-production.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Build and Deploy Production - -on: - push: - branches: - - master - -env: - REGISTRY: ghcr.io - -jobs: - build-and-push: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - outputs: - image_tag: ${{ steps.image.outputs.tag }} - - steps: - - uses: actions/checkout@v4 - - - uses: docker/setup-buildx-action@v3 - - - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - id: image - run: echo "tag=${{ env.REGISTRY }}/${{ github.repository_owner }}/fhirtogether:latest" | tr '[:upper:]' '[:lower:]' >> $GITHUB_OUTPUT - - - uses: docker/build-push-action@v6 - with: - push: true - tags: ${{ steps.image.outputs.tag }} - - deploy-production: - needs: build-and-push - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: mieweb/launchpad@main - with: - api_key: ${{ secrets.LAUNCHPAD_API_KEY }} - api_url: ${{ secrets.LAUNCHPAD_API_URL }} - template_name: ${{ needs.build-and-push.outputs.image_tag }} - - - name: Refresh container - env: - API_KEY: ${{ secrets.LAUNCHPAD_API_KEY }} - API_URL: ${{ secrets.LAUNCHPAD_API_URL }} - run: | - REPO_NAME=$(basename "${{ github.repository }}") - CONTAINER_NAME="${{ github.repository_owner }}-${REPO_NAME}-master" - CONTAINER_NAME=${CONTAINER_NAME,,} - CONTAINER_NAME=$(echo "$CONTAINER_NAME" | sed 's/[^a-z0-9-]/-/g') - - RESPONSE=$(curl -s -H "Authorization: Bearer $API_KEY" -H "Accept: application/json" \ - "$API_URL/sites/1/containers?hostname=$CONTAINER_NAME") - CONTAINER_ID=$(echo "$RESPONSE" | jq -r '.containers[0].id // empty') - - if [[ -n "$CONTAINER_ID" ]]; then - echo "Refreshing container $CONTAINER_NAME (ID: $CONTAINER_ID)..." - REFRESH_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -X POST -H "Authorization: Bearer $API_KEY" \ - "$API_URL/sites/1/containers/$CONTAINER_ID/refresh") - echo "REFRESH response: $REFRESH_CODE" - else - echo "No container found to refresh." - fi diff --git a/QUICKSTART.md b/QUICKSTART.md index 13a3af3..a738bee 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -210,6 +210,27 @@ npm run build npm start ``` +### Deploy with systemd + +Production runs the compiled Node.js server directly under **systemd** (no Docker). +The unit file lives at [`deploy/fhirtogether.service`](deploy/fhirtogether.service). + +```bash +# One-time install (adjust User/paths in the unit file if needed) +npm ci && npm run build +sudo cp deploy/fhirtogether.service /etc/systemd/system/fhirtogether.service +sudo systemctl daemon-reload +sudo systemctl enable --now fhirtogether.service + +# Deploy a new version +git pull && npm ci && npm run build && sudo systemctl restart fhirtogether + +# Follow logs +journalctl -u fhirtogether -f +``` + +Configuration is read from `.env` in the working directory (see [`.env.example`](.env.example)). + ### Run Linter ```bash npm run lint diff --git a/README.md b/README.md index 1635ca8..e9d9041 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,9 @@ npm run dev Swagger UI: [http://localhost:4010/docs](http://localhost:4010/docs) +> **Production deployment** runs the compiled server directly under **systemd** (not Docker). +> See [Deploy with systemd](QUICKSTART.md#deploy-with-systemd) and the unit file at [`deploy/fhirtogether.service`](deploy/fhirtogether.service). + ## 🧩 Pluggable Store Interface **Important:** The store interface is **not** for connecting directly to your EHR or Practice Management system. Instead, it's a **working data repository** for the scheduling portal that holds appointment data representing schedules for providers/resources. @@ -364,4 +367,3 @@ If you're modernizing a legacy EHR or want to contribute HL7v2 mappings, backend ## πŸ›‘οΈ Project Goal > Bring legacy scheduling infrastructure into the FHIR world β€” one appointment at a time. - diff --git a/deploy/fhirtogether-reset.service b/deploy/fhirtogether-reset.service new file mode 100644 index 0000000..654f05b --- /dev/null +++ b/deploy/fhirtogether-reset.service @@ -0,0 +1,23 @@ +# systemd unit to reset the local FHIRTogether environment. +# +# Thin wrapper around scripts/reset.sh so the exact same steps can be run +# locally (./scripts/reset.sh) and via systemd. +# +# Install (adjust User/paths if your checkout lives elsewhere): +# sudo cp deploy/fhirtogether-reset.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl start fhirtogether-reset.service + +[Unit] +Description=Reset FHIRTogether Environment +Requires=fhirtogether.service +After=fhirtogether.service + +[Service] +Type=oneshot +User=monika +WorkingDirectory=/home/monika/FHIRTogether +ExecStart=/home/monika/FHIRTogether/scripts/reset.sh + +[Install] +WantedBy=multi-user.target diff --git a/deploy/fhirtogether.service b/deploy/fhirtogether.service new file mode 100644 index 0000000..59df9ac --- /dev/null +++ b/deploy/fhirtogether.service @@ -0,0 +1,31 @@ +# systemd unit for FHIRTogether Scheduling Synapse +# +# Runs the compiled Node.js server directly (no Docker). The server reads its +# configuration from the .env file in WorkingDirectory via dotenv. +# +# Install (adjust User/paths if your checkout lives elsewhere): +# sudo cp deploy/fhirtogether.service /etc/systemd/system/fhirtogether.service +# sudo systemctl daemon-reload +# sudo systemctl enable --now fhirtogether.service +# +# Deploy a new version: +# git pull && npm ci && npm run build && sudo systemctl restart fhirtogether +# +# Logs: +# journalctl -u fhirtogether -f + +[Unit] +Description=FHIRTogether Scheduling Synapse (Node.js) +After=network.target + +[Service] +Type=simple +User=monika +WorkingDirectory=/home/monika/FHIRTogether +Environment=NODE_ENV=production +ExecStart=/usr/bin/node dist/server.js +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/e2e/schedules-sync.spec.ts b/e2e/schedules-sync.spec.ts new file mode 100644 index 0000000..efafd4b --- /dev/null +++ b/e2e/schedules-sync.spec.ts @@ -0,0 +1,297 @@ +import { test, expect } from '@playwright/test'; + +/** + * Schedule Synchronization E2E Tests + * + * Loads the Provider View page, mocks the remote FHIR endpoint with a + * WebChart-style Bundle (type=collection), clicks Synchronize, and asserts + * the grid mapping. + */ + +// A complete WebChart-style collection Bundle, mirroring the real payload +// structure (Practitioner + Location + Schedule entries). The omitted +// availableTime / coding details from the captured sample are filled in here. +const SAMPLE_BUNDLE = { + resourceType: 'Bundle', + type: 'collection', + meta: { lastUpdated: '2026-06-11T13:59:44Z' }, + entry: [ + { + resource: { + resourceType: 'Practitioner', + id: '16', + name: [{ use: 'official', family: 'Butler', given: ['Internist', 'E.'] }], + }, + }, + { resource: { resourceType: 'Location', id: 'OFFICE', name: 'Office' } }, + { + resource: { + resourceType: 'Schedule', + id: '5', + actor: [ + { reference: 'Practitioner/16', display: 'Butler, Internist E.' }, + { reference: 'Location/OFFICE' }, + ], + serviceType: [ + { coding: [{ code: 'BERY', display: 'OSHA Beryllium' }], text: 'OSHA Beryllium' }, + { coding: [{ code: 'AUDIO', display: 'Audiogram' }], text: 'Audiogram' }, + ], + planningHorizon: { start: '2026-01-30T13:00:00Z', end: '2026-08-30T21:00:00Z' }, + extension: [ + { + url: 'https://zeus.med-web.com/webchart/wctmpierzchala/webchart.cgi/StructureDefinition/schedule-portal-time-slots', + valueInteger: 15, + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/availableTime', + availableTime: [ + { + daysOfWeek: ['mon', 'tue', 'wed', 'thu', 'fri'], + availableStartTime: '08:00:00', + availableEndTime: '17:00:00', + }, + ], + }, + ], + }, + }, + { + resource: { + resourceType: 'Practitioner', + id: '28', + name: [{ use: 'official', family: 'Lab Testing', given: [''] }], + }, + }, + { + resource: { + resourceType: 'Schedule', + id: '6', + actor: [ + { reference: 'Practitioner/28', display: 'Lab Testing' }, + { reference: 'Location/OFFICE' }, + ], + serviceType: [ + { coding: [{ code: 'PHYS', display: 'Physical Exam' }], text: 'Physical Exam' }, + ], + planningHorizon: { start: '2015-07-27T12:00:00Z', end: '2015-07-27T16:00:00Z' }, + comment: 'Fasting Appointments Only', + extension: [ + { + url: 'https://zeus.med-web.com/webchart/wctmpierzchala/webchart.cgi/StructureDefinition/schedule-portal-time-slots', + valueInteger: 10, + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/availableTime', + availableTime: [ + { + daysOfWeek: ['mon'], + availableStartTime: '07:00:00', + availableEndTime: '11:00:00', + }, + ], + }, + ], + }, + }, + { + resource: { + resourceType: 'Practitioner', + id: '66', + name: [{ use: 'official', family: 'Vaccinator 1', given: [''] }], + }, + }, + { + resource: { + resourceType: 'Schedule', + id: '10', + actor: [ + { reference: 'Practitioner/66', display: 'Vaccinator 1' }, + { reference: 'Location/OFFICE' }, + ], + serviceType: [], + planningHorizon: { start: '2021-02-22T13:00:00Z', end: '2021-02-22T22:00:00Z' }, + comment: 'COVID Vaccine-Injection 1 only', + extension: [ + { + url: 'https://zeus.med-web.com/webchart/wctmpierzchala/webchart.cgi/StructureDefinition/schedule-portal-time-slots', + valueInteger: 10, + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/availableTime', + availableTime: [ + { + daysOfWeek: ['sat', 'sun'], + availableStartTime: '09:00:00', + availableEndTime: '15:00:00', + }, + ], + }, + ], + }, + }, + ], +}; + +const TARGET_URL = + 'https://zeus.med-web.com/webchart/wctmpierzchala/webchart.cgi/rest/schedules'; + +// Synchronization now runs server-side: the browser POSTs to /sync-schedules +// (the server fetches + parses + upserts), then the grid reloads from the +// persisted store via GET /Schedule. Both are mocked here. +const SYNC_GLOB = '**/sync-schedules'; +const SCHEDULE_GLOB = '**/Schedule?*'; + +const SYNC_MARKER_URL = 'https://fhirtogether.org/fhir/StructureDefinition/synced-from'; + +/** + * Build the searchset Bundle that GET /Schedule returns after synchronization: + * the source Schedules with Location displays resolved and a sync-source marker + * extension appended (mirroring what the server persists). + */ +function persistedScheduleBundle() { + const schedules = SAMPLE_BUNDLE.entry + .map((e) => e.resource) + .filter((r) => r.resourceType === 'Schedule') + .map((s) => ({ + ...s, + actor: s.actor.map((a) => + a.reference.startsWith('Location/') ? { ...a, display: 'Office' } : a + ), + extension: [...(s.extension || []), { url: SYNC_MARKER_URL, valueString: TARGET_URL }], + })); + return { + resourceType: 'Bundle', + type: 'searchset', + total: schedules.length, + entry: schedules.map((resource) => ({ resource })), + }; +} + +/** Assert the three sample schedules are mapped correctly into the grid. */ +async function expectSampleGrid(page: import('@playwright/test').Page) { + await expect(page.locator('#grid-count')).toHaveText('3 schedules'); + const rows = page.locator('#grid-body tr'); + await expect(rows).toHaveCount(3); + + // ── Row 1: Schedule/5 (Butler) ── + const r1 = rows.nth(0); + await expect(r1.locator('td').nth(0)).toHaveText('Butler, Internist E.'); + await expect(r1.locator('td').nth(1)).toHaveText('Office'); + await expect(r1.locator('td').nth(2)).toHaveText('01-30-2026 13:00:00'); + await expect(r1.locator('td').nth(3)).toHaveText('08-30-2026 21:00:00'); + await expect(r1.locator('td').nth(5)).toHaveText('08:00'); // start time + await expect(r1.locator('td').nth(6)).toHaveText('17:00'); // end time + await expect(r1.locator('td').nth(7)).toHaveText('15'); // slot minutes + await expect(r1.locator('.dow-chip.dow-active')).toHaveCount(5); + await expect(r1.locator('.type-tag')).toContainText(['OSHA Beryllium', 'Audiogram']); + + // ── Row 2: Schedule/6 (Lab Testing) β€” has comment ── + const r2 = rows.nth(1); + await expect(r2.locator('td').nth(0)).toHaveText('Lab Testing'); + await expect(r2.locator('td').nth(7)).toHaveText('10'); + await expect(r2.locator('td').nth(9)).toHaveText('Fasting Appointments Only'); + await expect(r2.locator('.dow-chip.dow-active')).toHaveCount(1); + + // ── Row 3: Schedule/10 (Vaccinator) β€” empty serviceType ── + const r3 = rows.nth(2); + await expect(r3.locator('td').nth(0)).toHaveText('Vaccinator 1'); + await expect(r3.locator('td').nth(9)).toHaveText('COVID Vaccine-Injection 1 only'); + await expect(r3.locator('.type-empty')).toBeVisible(); + await expect(r3.locator('.dow-chip.dow-active')).toHaveCount(2); +} + +test.describe('Schedule Synchronization', () => { + test.beforeEach(async ({ page }) => { + // Default: no previously-synced schedules (empty searchset on page load). + await page.route(SCHEDULE_GLOB, (route) => + route.fulfill({ + status: 200, + contentType: 'application/fhir+json', + body: JSON.stringify({ resourceType: 'Bundle', type: 'searchset', total: 0, entry: [] }), + }) + ); + await page.goto('/scheduler/provider-view.html'); + // The sync UI lives on its own dedicated tab. + await page.getByRole('tab', { name: 'Synchronize' }).click(); + }); + + test('loads page with sync panel and default endpoint', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Synchronize Schedules' })).toBeVisible(); + await expect(page.locator('#sync-url')).toHaveValue(TARGET_URL); + await expect(page.locator('#grid-count')).toHaveText('0 schedules'); + }); + + test('sync section is hidden until the Synchronize tab is selected', async ({ page }) => { + await page.getByRole('tab', { name: 'Appointments' }).click(); + await expect(page.locator('#sync-section')).toBeHidden(); + await page.getByRole('tab', { name: 'Synchronize' }).click(); + await expect(page.locator('#sync-section')).toBeVisible(); + }); + + test('synchronizes and maps the persisted schedules into the grid', async ({ page }) => { + // Server-side sync succeeds and reports the imported count. + await page.route(SYNC_GLOB, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, imported: 3, source: TARGET_URL }), + }) + ); + // After sync, the grid reloads from the persisted store. + await page.unroute(SCHEDULE_GLOB); + await page.route(SCHEDULE_GLOB, (route) => + route.fulfill({ + status: 200, + contentType: 'application/fhir+json', + body: JSON.stringify(persistedScheduleBundle()), + }) + ); + + await page.getByRole('button', { name: /Synchronize/ }).click(); + + await expect(page.getByText('Successfully synchronized 3 schedules.')).toBeVisible(); + await expectSampleGrid(page); + }); + + test('restores previously synchronized schedules on reload', async ({ page }) => { + // Simulate reopening the page with schedules already persisted. + await page.unroute(SCHEDULE_GLOB); + await page.route(SCHEDULE_GLOB, (route) => + route.fulfill({ + status: 200, + contentType: 'application/fhir+json', + body: JSON.stringify(persistedScheduleBundle()), + }) + ); + await page.reload(); + await page.getByRole('tab', { name: 'Synchronize' }).click(); + + // Grid is populated without clicking Synchronize. + await expectSampleGrid(page); + }); + + test('shows an error toast on server failure', async ({ page }) => { + await page.route(SYNC_GLOB, (route) => + route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ error: 'Upstream fetch failed: getaddrinfo ENOTFOUND' }), + }) + ); + await page.getByRole('button', { name: /Synchronize/ }).click(); + await expect(page.getByText(/Upstream fetch failed/)).toBeVisible(); + await expect(page.locator('#grid-count')).toHaveText('0 schedules'); + }); + + test('rejects a non-collection bundle', async ({ page }) => { + await page.route(SYNC_GLOB, (route) => + route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ error: 'Expected Bundle.type "collection" but received "searchset".' }), + }) + ); + await page.getByRole('button', { name: /Synchronize/ }).click(); + await expect(page.getByText(/Expected Bundle.type "collection"/)).toBeVisible(); + }); +}); diff --git a/packages/fhir-scheduler/docs/SYNC-SLOTS.md b/packages/fhir-scheduler/docs/SYNC-SLOTS.md new file mode 100644 index 0000000..e2fec9f --- /dev/null +++ b/packages/fhir-scheduler/docs/SYNC-SLOTS.md @@ -0,0 +1,42 @@ +# Schedule Sync β€” Bookable Slots (Before / After) + +Visual record for the change that makes **synced schedules generate bookable +slots**, and fixes the date-offset bug that hid available times. + +See also: [scheduleSync](../../../src/scheduling/scheduleSync.ts) Β· +[slotExpander](../../../src/scheduling/slotExpander.ts) Β· +[booking calendar](../src/components/SlotCalendar.tsx) + +## Before β†’ After + +| Before | After | +|---|---| +| ![No available dates for a synced provider](screenshots/sync-before-dates.png) | ![Available dates grid for a synced provider](screenshots/sync-after-dates.png) | +| A synced schedule persisted the provider but generated **no Slots**, so the booking calendar showed _"No available dates found."_ | Sync now expands the provider's `availableTime` into bookable `Slot`s, so every working day shows its open slot count. | + +### Selecting a date now shows times + +![Selectable time slots after picking a date](screenshots/sync-after-times.png) + +Previously, even when dates appeared, selecting one returned _"No available +times for this date"_ because `Slot` queries subtracted the demo date-offset +from the query window. The offset has been removed from `Slot` queries (it is +applied once at seed-import time), so per-day queries match the stored slots. + +## Screenshot metadata + +| Field | Value | +|---|---| +| Feature / screen | Booking calendar (`` widget) | +| Source route | `/scheduler/index.html#calendar/5` (Butler, Internist E.) | +| Viewport | 1100 Γ— 950, desktop (Chromium) | +| Capture date | 2026-06-26 | +| Data | Synthetic sandbox data (synced from a WebChart `rest/schedules` feed) | + +### How to reproduce + +1. Sync a provider whose planning horizon extends into the future + (`POST /sync-schedules` with the source feed `url`). +2. Open `/scheduler/index.html#calendar/` and select the provider. +3. Available dates list working days with slot counts; selecting a date lists + the open times. diff --git a/packages/fhir-scheduler/provider-view.html b/packages/fhir-scheduler/provider-view.html index d568b2f..0baef6f 100644 --- a/packages/fhir-scheduler/provider-view.html +++ b/packages/fhir-scheduler/provider-view.html @@ -57,6 +57,118 @@ font-weight: 600; font-family: system-ui, -apple-system, sans-serif; } + + /* ── Schedule synchronization panel ── */ + .sync-panel { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1.5rem; + } + .sync-panel h2 { font-size: 1rem; font-weight: 600; margin: 0 0 0.35rem; color: #111827; } + .sync-panel-desc { font-size: 0.85rem; color: #6b7280; margin: 0 0 0.9rem; } + .sync-controls { display: flex; gap: 0.6rem; align-items: flex-end; flex-wrap: wrap; } + .sync-url-field { flex: 1 1 420px; min-width: 280px; } + .sync-url-field label { + display: block; font-size: 0.78rem; font-weight: 600; + color: #6b7280; margin-bottom: 0.2rem; + } + .sync-url-field input { + width: 100%; padding: 0.55rem 0.7rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; + border: 1px solid #d1d5db; border-radius: 6px; background: #fff; color: #111827; + } + .sync-url-field input:focus { outline: 2px solid #2563eb; outline-offset: -1px; } + + .btn { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.55rem 1rem; font-size: 0.85rem; font-weight: 600; + border: 1px solid transparent; border-radius: 6px; cursor: pointer; + transition: background 0.15s; white-space: nowrap; + } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + .btn-primary { background: #2563eb; color: white; } + .btn-primary:hover:not(:disabled) { background: #1d4ed8; } + .btn-secondary { background: #f3f4f6; color: #111827; border-color: #d1d5db; } + .btn-secondary:hover:not(:disabled) { background: #e5e7eb; } + + .grid-wrapper { + background: #fff; border: 1px solid #e5e7eb; + border-radius: 12px; overflow: hidden; margin-bottom: 1.5rem; + } + .grid-toolbar { + display: flex; align-items: center; justify-content: space-between; + padding: 0.75rem 1rem; border-bottom: 1px solid #e5e7eb; background: #f9fafb; + } + .grid-toolbar h2 { font-size: 0.95rem; font-weight: 600; margin: 0; color: #111827; } + .grid-count { font-size: 0.8rem; font-weight: 600; color: #6b7280; } + .grid-scroll { overflow-x: auto; } + + table.schedule-grid { width: 100%; border-collapse: collapse; font-size: 0.82rem; } + table.schedule-grid th, table.schedule-grid td { + padding: 0.55rem 0.7rem; text-align: left; vertical-align: top; + border-bottom: 1px solid #e5e7eb; + } + table.schedule-grid thead th { + position: sticky; top: 0; background: #f9fafb; font-weight: 700; + font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.03em; + color: #6b7280; white-space: nowrap; + } + table.schedule-grid tbody tr:hover { background: #f9fafb; } + .cell-provider { font-weight: 600; } + .cell-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem; } + .cell-comment { color: #6b7280; font-style: italic; } + + .dow-group { display: flex; gap: 0.25rem; flex-wrap: wrap; } + .dow-chip { + display: inline-flex; align-items: center; gap: 0.2rem; + font-size: 0.7rem; font-weight: 600; padding: 0.1rem 0.3rem; + border-radius: 6px; border: 1px solid #d1d5db; color: #6b7280; + } + .dow-chip input { margin: 0; accent-color: #2563eb; } + .dow-chip.dow-active { background: #dbeafe; border-color: #2563eb; color: #1e40af; } + + .type-tags { display: flex; gap: 0.25rem; flex-wrap: wrap; max-width: 320px; } + .type-tag { + font-size: 0.7rem; font-weight: 600; padding: 0.1rem 0.45rem; + border-radius: 999px; background: #f3f4f6; border: 1px solid #e5e7eb; color: #111827; + } + .type-empty { color: #9ca3af; font-size: 0.78rem; } + .grid-empty { padding: 2.5rem 1rem; text-align: center; color: #6b7280; font-size: 0.9rem; } + + /* ── Loading overlay ── */ + .loading-overlay { + position: fixed; inset: 0; z-index: 9000; display: none; + align-items: center; justify-content: center; flex-direction: column; gap: 1rem; + background: rgba(15, 23, 42, 0.55); color: white; + } + .loading-overlay.visible { display: flex; } + .spinner { + width: 52px; height: 52px; border: 5px solid rgba(255, 255, 255, 0.3); + border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + .loading-text { font-size: 0.95rem; font-weight: 600; } + + /* ── Toasts ── */ + .toast-stack { + position: fixed; bottom: 1.25rem; right: 1.25rem; z-index: 9500; + display: flex; flex-direction: column; gap: 0.6rem; + max-width: min(380px, calc(100vw - 2rem)); + } + .toast { + display: flex; align-items: flex-start; gap: 0.6rem; + padding: 0.8rem 1rem; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + font-size: 0.86rem; font-weight: 600; color: white; + opacity: 0; transform: translateY(10px); + transition: opacity 0.2s ease, transform 0.2s ease; + } + .toast.visible { opacity: 1; transform: translateY(0); } + .toast-success { background: #16a34a; } + .toast-error { background: #dc2626; } + .toast-info { background: #2563eb; } + .toast-icon { font-size: 1.05rem; line-height: 1.2; } @@ -76,6 +188,386 @@

πŸ—“οΈ FHIR Scheduler

+ + + + + + + +
+ + diff --git a/packages/fhir-scheduler/src/components/AppointmentList.tsx b/packages/fhir-scheduler/src/components/AppointmentList.tsx index 681bfad..541988d 100644 --- a/packages/fhir-scheduler/src/components/AppointmentList.tsx +++ b/packages/fhir-scheduler/src/components/AppointmentList.tsx @@ -274,13 +274,13 @@ export function AppointmentList({ fhirBaseUrl, initialScheduleId, initialDate, c try { const practitionerRef = selectedProvider!.actor?.[0]?.reference || `Schedule/${selectedProvider!.id}`; const scheduleId = selectedProvider!.id!; - + // Fetch appointments day-by-day const startDate = new Date(dateRange.start + 'T00:00:00'); const endDate = new Date(dateRange.end + 'T23:59:59'); const allAppointments: Appointment[] = []; const current = new Date(startDate); - + while (current <= endDate) { const dateStr = toDateString(current); const params = new URLSearchParams({ date: dateStr, actor: practitionerRef }); @@ -316,13 +316,28 @@ export function AppointmentList({ fhirBaseUrl, initialScheduleId, initialDate, c fetchData(); }, [selectedProvider, dateRange, fhirBaseUrl, headers]); - // Merge appointments and slots into a unified timeline + // Merge appointments and slots into a unified timeline. + // Suppress slots that an appointment already consumes β€” otherwise every booked + // appointment would render twice (once as "Blocked", once as the underlying "Busy" slot). const timeline = useMemo(() => { + const consumedSlotIds = new Set(); + const consumedSlotTimes = new Set(); + for (const appt of appointments) { + for (const ref of appt.slot ?? []) { + const id = ref.reference?.split('/').pop(); + if (id) consumedSlotIds.add(id); + } + if (appt.start && appt.end) { + consumedSlotTimes.add(`${appt.start}|${appt.end}`); + } + } const entries: TimelineEntry[] = []; for (const appt of appointments) { entries.push({ kind: 'appointment', data: appt }); } for (const slot of slots) { + if (slot.id && consumedSlotIds.has(slot.id)) continue; + if (consumedSlotTimes.has(`${slot.start}|${slot.end}`)) continue; entries.push({ kind: 'slot', data: slot }); } return entries; @@ -342,7 +357,7 @@ export function AppointmentList({ fhirBaseUrl, initialScheduleId, initialDate, c } return summary; }, [slots]); - + // Dates to display (either single day or week) const displayDates = useMemo(() => { if (viewMode === 'day') return [toDateString(currentDate)]; diff --git a/packages/fhir-scheduler/src/provider-view.tsx b/packages/fhir-scheduler/src/provider-view.tsx index caef225..0d1f164 100644 --- a/packages/fhir-scheduler/src/provider-view.tsx +++ b/packages/fhir-scheduler/src/provider-view.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { AppointmentList } from './components/AppointmentList'; import { ImportData } from './components/ImportData'; @@ -15,12 +15,21 @@ const urlParams = new URLSearchParams(window.location.search); const initialScheduleId = urlParams.get('schedule') || undefined; const initialDate = urlParams.get('date') || undefined; -type TabType = 'appointments' | 'schedule-setup' | 'import'; +type TabType = 'appointments' | 'schedule-setup' | 'import' | 'synchronize'; function ProviderView() { const [activeTab, setActiveTab] = useState('appointments'); const [refreshKey, setRefreshKey] = useState(0); + // The schedule-synchronization UI lives in static HTML outside the React + // root (#sync-section). Show it only when the "Synchronize" tab is active. + useEffect(() => { + const section = document.getElementById('sync-section'); + if (section) { + section.hidden = activeTab !== 'synchronize'; + } + }, [activeTab]); + const handleImportComplete = useCallback(() => { // Refresh appointments list when import completes setRefreshKey((k) => k + 1); @@ -61,6 +70,17 @@ function ProviderView() { > Import Data + {/* Tab panels */} diff --git a/scripts/reset.sh b/scripts/reset.sh new file mode 100755 index 0000000..72c9ff1 --- /dev/null +++ b/scripts/reset.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Reset the local FHIRTogether environment to a clean, freshly-seeded state. +# +# Steps: +# 1. Stop the fhirtogether systemd service. +# 2. Remove the SQLite database files. +# 3. Start the service back up. +# 4. Wait for the server to become healthy, then regenerate test data. +# +# Run manually: ./scripts/reset.sh +# Run via systemd: see deploy/fhirtogether-reset.service +set -euo pipefail + +# Resolve the repo root regardless of where the script is invoked from. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +echo "==> Stopping fhirtogether service" +sudo systemctl stop fhirtogether + +echo "==> Removing SQLite database files" +rm -f data/fhirtogether.db* + +echo "==> Starting fhirtogether service" +sudo systemctl start fhirtogether + +echo "==> Waiting for server to become healthy" +for i in $(seq 1 30); do + if curl -sf http://localhost:4010/health >/dev/null; then + break + fi + sleep 1 +done + +echo "==> Regenerating test data" +npm run generate-data + +echo "==> Reset complete" diff --git a/src/__tests__/scheduleSync.test.ts b/src/__tests__/scheduleSync.test.ts new file mode 100644 index 0000000..9c19994 --- /dev/null +++ b/src/__tests__/scheduleSync.test.ts @@ -0,0 +1,158 @@ +import { + parseScheduleBundle, + isSyncedSchedule, + buildSlotTemplate, + SYNC_SOURCE_EXTENSION_URL, +} from '../scheduling/scheduleSync'; + +const SOURCE_URL = 'https://example.org/fhir/schedules'; + +const COLLECTION_BUNDLE = { + resourceType: 'Bundle', + type: 'collection', + entry: [ + { + resource: { + resourceType: 'Practitioner', + id: '16', + name: [{ family: 'Butler', given: ['Internist', 'E.'] }], + }, + }, + { resource: { resourceType: 'Location', id: 'OFFICE', name: 'Office' } }, + { + resource: { + resourceType: 'Schedule', + id: '5', + actor: [ + { reference: 'Practitioner/16', display: 'Butler, Internist E.' }, + { reference: 'Location/OFFICE' }, + ], + serviceType: [{ coding: [{ display: 'OSHA Beryllium' }], text: 'OSHA Beryllium' }], + planningHorizon: { start: '2026-01-30T13:00:00Z', end: '2026-08-30T21:00:00Z' }, + comment: 'Test comment', + extension: [ + { + url: 'https://zeus.example/StructureDefinition/schedule-portal-time-slots', + valueInteger: 15, + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/availableTime', + availableTime: [ + { daysOfWeek: ['mon', 'tue'], availableStartTime: '08:00:00', availableEndTime: '17:00:00' }, + ], + }, + ], + }, + }, + ], +}; + +describe('parseScheduleBundle', () => { + it('parses a collection bundle into persistable schedules', () => { + const { schedules } = parseScheduleBundle(COLLECTION_BUNDLE, SOURCE_URL); + expect(schedules).toHaveLength(1); + const s = schedules[0]; + expect(s.resourceType).toBe('Schedule'); + expect(s.id).toBe('5'); + expect(s.comment).toBe('Test comment'); + expect(s.planningHorizon).toEqual({ start: '2026-01-30T13:00:00Z', end: '2026-08-30T21:00:00Z' }); + }); + + it('resolves the Location actor display from the bundle', () => { + const { schedules } = parseScheduleBundle(COLLECTION_BUNDLE, SOURCE_URL); + const locationActor = schedules[0].actor.find((a) => a.reference?.startsWith('Location/')); + expect(locationActor?.display).toBe('Office'); + }); + + it('preserves the availableTime and slot-length extensions', () => { + const { schedules } = parseScheduleBundle(COLLECTION_BUNDLE, SOURCE_URL); + const ext = schedules[0].extension || []; + const avail = ext.find((e) => e.url === 'http://hl7.org/fhir/StructureDefinition/availableTime'); + const slot = ext.find( + (e) => typeof e.url === 'string' && e.url.endsWith('/schedule-portal-time-slots') + ); + expect(avail).toBeDefined(); + expect((slot as { valueInteger?: number })?.valueInteger).toBe(15); + }); + + it('stamps a sync-source marker extension', () => { + const { schedules } = parseScheduleBundle(COLLECTION_BUNDLE, SOURCE_URL); + const marker = (schedules[0].extension || []).find((e) => e.url === SYNC_SOURCE_EXTENSION_URL); + expect((marker as { valueString?: string })?.valueString).toBe(SOURCE_URL); + expect(isSyncedSchedule(schedules[0])).toBe(true); + }); + + it('does not duplicate the marker when re-parsing an already-synced schedule', () => { + const { schedules: first } = parseScheduleBundle(COLLECTION_BUNDLE, SOURCE_URL); + const reBundle = { + resourceType: 'Bundle', + type: 'collection', + entry: [{ resource: { ...first[0] } }], + }; + const { schedules: second } = parseScheduleBundle(reBundle, SOURCE_URL); + const markers = (second[0].extension || []).filter((e) => e.url === SYNC_SOURCE_EXTENSION_URL); + expect(markers).toHaveLength(1); + }); + + it('rejects a non-Bundle payload', () => { + expect(() => parseScheduleBundle({ resourceType: 'Schedule' }, SOURCE_URL)).toThrow( + /not a FHIR Bundle/ + ); + }); + + it('rejects a Bundle that is not a collection', () => { + expect(() => + parseScheduleBundle({ resourceType: 'Bundle', type: 'searchset', entry: [] }, SOURCE_URL) + ).toThrow(/collection/); + }); +}); + +describe('buildSlotTemplate', () => { + const [schedule] = parseScheduleBundle(COLLECTION_BUNDLE, SOURCE_URL).schedules; + + it('converts availableTime + slot-length into a forward-projected template', () => { + const { template } = buildSlotTemplate(schedule, '2026-02-01'); + expect(template).not.toBeNull(); + expect(template!.weekdays).toEqual(['mon', 'tue']); + expect(template!.blocks).toEqual([{ start: '08:00', end: '17:00', duration: 15 }]); + // Starts at today (after the horizon start), ends at the horizon end. + expect(template!.startDate).toBe('2026-02-01'); + expect(template!.endDate).toBe('2026-08-30'); + }); + + it('starts at the horizon start when it is still in the future', () => { + const { template } = buildSlotTemplate(schedule, '2025-01-01'); + expect(template!.startDate).toBe('2026-01-30'); + }); + + it('produces no template when the planning horizon has already ended', () => { + const { template, note } = buildSlotTemplate(schedule, '2027-01-01'); + expect(template).toBeNull(); + expect(note).toMatch(/in the past/); + }); + + it('defaults to weekdays when daysOfWeek is empty', () => { + const noDays = { + ...schedule, + extension: (schedule.extension || []).map((e) => + e.url === 'http://hl7.org/fhir/StructureDefinition/availableTime' + ? { ...e, availableTime: [{ availableStartTime: '08:00:00', availableEndTime: '17:00:00', daysOfWeek: [] }] } + : e + ), + }; + const { template } = buildSlotTemplate(noDays, '2026-02-01'); + expect(template!.weekdays).toEqual(['mon', 'tue', 'wed', 'thu', 'fri']); + }); + + it('returns no template when availableTime is missing', () => { + const noAvail = { + ...schedule, + extension: (schedule.extension || []).filter( + (e) => e.url !== 'http://hl7.org/fhir/StructureDefinition/availableTime' + ), + }; + const { template, note } = buildSlotTemplate(noAvail, '2026-02-01'); + expect(template).toBeNull(); + expect(note).toMatch(/availableTime/); + }); +}); diff --git a/src/auth/apiKeyAuth.ts b/src/auth/apiKeyAuth.ts index f973a8d..3be5d29 100644 --- a/src/auth/apiKeyAuth.ts +++ b/src/auth/apiKeyAuth.ts @@ -24,6 +24,8 @@ const PUBLIC_PATH_PREFIXES = [ '/Directory', '/System/register', '/hl7-tester', + '/sync-proxy', + '/sync-schedules', '/mcp/', '/$bulk-publish', ]; diff --git a/src/auth/basicAuth.ts b/src/auth/basicAuth.ts index 4c9b9bb..2a28d6b 100644 --- a/src/auth/basicAuth.ts +++ b/src/auth/basicAuth.ts @@ -12,7 +12,7 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { timingSafeEqual } from 'crypto'; /** Paths that never require authentication. */ -const PUBLIC_PATH_PREFIXES = ['/health', '/docs', '/demo', '/scheduler/']; +const PUBLIC_PATH_PREFIXES = ['/health', '/docs', '/demo', '/scheduler/', '/sync-proxy', '/sync-schedules']; const PUBLIC_EXACT_PATHS = new Set(['/', '/health', '/favicon.ico']); /** diff --git a/src/mcp/tools/formatters.ts b/src/mcp/tools/formatters.ts index 6b0f461..cefa92a 100644 --- a/src/mcp/tools/formatters.ts +++ b/src/mcp/tools/formatters.ts @@ -4,8 +4,11 @@ import { Schedule, Slot, Appointment, CodeableConcept, Reference } from '../../t * Extract the system name from a Schedule's extension (added by sqliteStore). */ export function getSystemName(schedule: Schedule): string | undefined { - const ext = (schedule as Schedule & { extension?: { url: string; valueString?: string }[] }).extension; - return ext?.find(e => e.url === 'https://fhirtogether.org/fhir/StructureDefinition/system-name')?.valueString; + const match = schedule.extension?.find( + (e) => e.url === 'https://fhirtogether.org/fhir/StructureDefinition/system-name' + ); + const value = match?.valueString; + return typeof value === 'string' ? value : undefined; } /** diff --git a/src/scheduling/scheduleSync.ts b/src/scheduling/scheduleSync.ts new file mode 100644 index 0000000..e5c6d80 --- /dev/null +++ b/src/scheduling/scheduleSync.ts @@ -0,0 +1,225 @@ +/** + * Schedule synchronization parser. + * + * Converts a remote FHIR R4 collection Bundle (e.g. WebChart's + * `/rest/schedules` response) into self-contained Schedule resources that can + * be persisted in the local store and re-rendered after a reload. + * + * The source Bundle carries Practitioner and Location resources alongside the + * Schedules; their display names are folded into each Schedule's `actor` + * references so the persisted Schedule stands on its own (the local store does + * not keep separate Practitioner/Location rows). + */ + +import { Schedule } from '../types/fhir'; +import { AvailabilityTemplate } from './slotExpander'; + +/** Marker extension url stamped on every synced schedule (source endpoint). */ +export const SYNC_SOURCE_EXTENSION_URL = + 'https://fhirtogether.org/fhir/StructureDefinition/synced-from'; + +/** FHIR availableTime extension url (days + start/end time). */ +const AVAILABLE_TIME_EXTENSION_URL = + 'http://hl7.org/fhir/StructureDefinition/availableTime'; + +/** Suffix of WebChart's slot-length extension url (minutes per slot). */ +const SLOT_LENGTH_EXTENSION_SUFFIX = 'schedule-portal-time-slots'; + +interface BundleEntry { + resource?: Record; +} + +interface FhirBundle { + resourceType?: string; + type?: string; + entry?: BundleEntry[]; +} + +/** Build a "Family, Given1 Given2" display name from a Practitioner resource. */ +function practitionerName(resource: Record): string { + const names = resource.name as Array<{ family?: string; given?: string[] }> | undefined; + const name = Array.isArray(names) ? names[0] : undefined; + if (!name) return resource.id ? `Practitioner/${resource.id}` : 'Unknown'; + const given = Array.isArray(name.given) ? name.given.filter(Boolean).join(' ').trim() : ''; + const family = (name.family || '').trim(); + if (family && given) return `${family}, ${given}`; + return family || given || (resource.id ? `Practitioner/${resource.id}` : 'Unknown'); +} + +/** Extract the trailing id from a "Type/id" reference. */ +function refId(reference: unknown): string | undefined { + return typeof reference === 'string' ? reference.split('/').pop() : undefined; +} + +export interface ParsedSyncResult { + schedules: Schedule[]; +} + +/** + * Parse a FHIR collection Bundle into persistable Schedule resources. + * + * @param bundle The parsed remote Bundle (resourceType "Bundle", type "collection"). + * @param sourceUrl The endpoint the bundle came from (stamped as a marker extension). + * @throws Error when the payload is not a collection Bundle. + */ +export function parseScheduleBundle(bundle: unknown, sourceUrl: string): ParsedSyncResult { + const b = bundle as FhirBundle; + if (!b || b.resourceType !== 'Bundle') { + throw new Error('Response is not a FHIR Bundle.'); + } + if (b.type !== 'collection') { + throw new Error(`Expected Bundle.type "collection" but received "${b.type ?? 'undefined'}".`); + } + + const entries = Array.isArray(b.entry) ? b.entry : []; + + // Cross-reference maps for resolving actor display names. + const practitioners: Record = {}; + const locations: Record = {}; + for (const entry of entries) { + const resource = entry?.resource; + if (!resource || resource.id == null) continue; + if (resource.resourceType === 'Practitioner') { + practitioners[String(resource.id)] = practitionerName(resource); + } else if (resource.resourceType === 'Location') { + locations[String(resource.id)] = (resource.name as string) || `Location/${resource.id}`; + } + } + + const schedules: Schedule[] = []; + for (const entry of entries) { + const resource = entry?.resource; + if (resource?.resourceType !== 'Schedule') continue; + + const actorIn = Array.isArray(resource.actor) + ? (resource.actor as Array>) + : []; + + // Enrich actor references with resolved display names so the persisted + // Schedule is self-contained after Practitioner/Location entries are gone. + const actor = actorIn.map((a) => { + const reference = typeof a.reference === 'string' ? a.reference : undefined; + const id = refId(reference); + let display = typeof a.display === 'string' ? a.display : undefined; + if (!display && id && reference?.startsWith('Practitioner/')) { + display = practitioners[id]; + } + if (!display && id && reference?.startsWith('Location/')) { + display = locations[id]; + } + return display ? { reference, display } : { reference }; + }); + + const extension: Array> = Array.isArray(resource.extension) + ? (resource.extension as Array>).filter( + (e) => e && e.url !== SYNC_SOURCE_EXTENSION_URL + ) + : []; + // Stamp the source endpoint so the grid can identify synced schedules. + extension.push({ url: SYNC_SOURCE_EXTENSION_URL, valueString: sourceUrl }); + + const schedule: Schedule = { + resourceType: 'Schedule', + id: resource.id != null ? String(resource.id) : undefined, + active: resource.active === undefined ? true : Boolean(resource.active), + actor: actor as Schedule['actor'], + serviceType: Array.isArray(resource.serviceType) + ? (resource.serviceType as Schedule['serviceType']) + : undefined, + planningHorizon: (resource.planningHorizon as Schedule['planningHorizon']) || undefined, + comment: typeof resource.comment === 'string' ? resource.comment : undefined, + extension, + }; + + schedules.push(schedule); + } + + return { schedules }; +} + +/** Helper used by tests/callers: does this schedule carry the sync marker? */ +export function isSyncedSchedule(schedule: { extension?: Array> }): boolean { + return Array.isArray(schedule.extension) + && schedule.extension.some((e) => e?.url === SYNC_SOURCE_EXTENSION_URL); +} + +/** Format a Date as a local "YYYY-MM-DD" string. */ +function toDateString(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +export interface SlotTemplateResult { + /** The expandable template, or null when no bookable window could be derived. */ + template: AvailabilityTemplate | null; + /** Human-readable reason when no template/slots could be produced. */ + note?: string; +} + +/** + * Convert a synced Schedule's `availableTime` + slot-length extensions into an + * AvailabilityTemplate suitable for {@link expandSlots}. + * + * The weekly availability is projected *forward from today* through the + * schedule's `planningHorizon.end`. WebChart horizons are often historical + * single-day windows, so a schedule whose horizon already ended yields no + * slots β€” surfaced via the returned `note`. + */ +export function buildSlotTemplate( + schedule: Schedule, + today: string = toDateString(new Date()), +): SlotTemplateResult { + const ext = Array.isArray(schedule.extension) ? schedule.extension : []; + + 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' }; + } + + const slotExt = ext.find( + (e) => typeof e?.url === 'string' && (e.url as string).endsWith(SLOT_LENGTH_EXTENSION_SUFFIX), + ); + const slotMinutes = + slotExt && typeof slotExt.valueInteger === 'number' && (slotExt.valueInteger as number) > 0 + ? (slotExt.valueInteger as number) + : 30; + + const weekdays = + Array.isArray(avail.daysOfWeek) && avail.daysOfWeek.length > 0 + ? avail.daysOfWeek + : ['mon', 'tue', 'wed', 'thu', 'fri']; + + const ph = schedule.planningHorizon || {}; + const horizonStart = typeof ph.start === 'string' ? ph.start.slice(0, 10) : ''; + const horizonEnd = typeof ph.end === 'string' ? ph.end.slice(0, 10) : ''; + + // Project forward: never generate slots before today. + const startDate = horizonStart && horizonStart > today ? horizonStart : today; + const endDate = horizonEnd; + + if (!endDate) { + return { template: null, note: 'no planningHorizon end date' }; + } + if (endDate < startDate) { + return { template: null, note: `planning horizon ended ${endDate} (in the past)` }; + } + + const template: AvailabilityTemplate = { + startDate, + endDate, + weekdays, + blocks: [ + { + start: avail.availableStartTime.slice(0, 5), + end: avail.availableEndTime.slice(0, 5), + duration: slotMinutes, + }, + ], + }; + return { template }; +} + +export { AVAILABLE_TIME_EXTENSION_URL }; diff --git a/src/server.ts b/src/server.ts index 563dba1..79c5669 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,8 @@ import { locationRoutes } from './routes/locationRoutes'; import { directoryRoutes } from './routes/directoryRoutes'; import { smartSchedulingRoutes, getSmartSchedulingConfig } from './routes/smartSchedulingRoutes'; import { createMLLPServer, MLLPServer } from './hl7/socket'; +import { parseScheduleBundle, buildSlotTemplate } from './scheduling/scheduleSync'; +import { expandSlots } from './scheduling/slotExpander'; // Basic auth is now handled internally by apiKeyAuth as a fallback import { registerApiKeyAuth } from './auth/apiKeyAuth'; import { createMcpServer } from './mcp/mcpServer'; @@ -296,7 +298,189 @@ async function buildServer() { return reply.sendFile('hl7-tester.html', path.join(__dirname, '..', 'public')); }); - // Graceful shutdown + // 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.' }); + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); + const upstream = await fetch(parsed.toString(), { + method: 'GET', + headers: { Accept: 'application/fhir+json, application/json' }, + signal: controller.signal, + }); + clearTimeout(timeout); + + const body = await upstream.text(); + return reply + .code(upstream.status) + .header('Content-Type', upstream.headers.get('content-type') || 'application/json') + .send(body); + } catch (err) { + // Node's fetch throws a generic "fetch failed"; the real reason (DNS, + // TLS, connection refused, timeout) lives on err.cause. Surface it so the + // operator can tell what is actually blocking the outbound request. + let message = err instanceof Error ? err.message : 'Upstream request failed.'; + const cause = (err as { cause?: unknown })?.cause; + if (cause instanceof Error) { + const code = (cause as { code?: string }).code; + message += ` (${code ? code + ': ' : ''}${cause.message})`; + } + request.log.error({ err, target: parsed.toString() }, 'sync-proxy upstream fetch failed'); + return reply.code(502).send({ error: `Upstream fetch failed: ${message}` }); + } + }); + + // Schedule synchronization: fetch a remote FHIR collection Bundle, parse its + // Schedule/Practitioner/Location resources, and upsert the schedules into the + // local store so they become real resources that survive a page reload. + fastify.post<{ Body: { url?: string } }>( + '/sync-schedules', + { + schema: { + description: 'Synchronize provider schedules from a remote FHIR collection Bundle endpoint.', + tags: ['Schedule'], + body: { + type: 'object', + required: ['url'], + additionalProperties: true, + properties: { url: { type: 'string', description: 'Remote FHIR endpoint returning a collection Bundle.' } }, + }, + response: { + 200: { + type: 'object', + additionalProperties: true, + properties: { + success: { type: 'boolean' }, + imported: { type: 'number' }, + slotsGenerated: { type: 'number' }, + notes: { type: 'array', items: { type: 'string' } }, + source: { type: 'string' }, + }, + }, + 400: { type: 'object', additionalProperties: true, properties: { error: { type: 'string' } } }, + 502: { type: 'object', additionalProperties: true, properties: { error: { type: 'string' } } }, + }, + }, + }, + async (request, reply) => { + const target = request.body?.url; + if (!target) { + return reply.code(400).send({ error: 'Missing "url" in request body.' }); + } + + let parsed: URL; + try { + parsed = new URL(target); + } catch { + return reply.code(400).send({ error: 'Invalid URL.' }); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return reply.code(400).send({ error: 'Only http and https URLs are supported.' }); + } + + // ── Fetch the remote Bundle ── + let bundle: unknown; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); + const upstream = await fetch(parsed.toString(), { + method: 'GET', + headers: { Accept: 'application/fhir+json, application/json' }, + signal: controller.signal, + }); + clearTimeout(timeout); + + if (!upstream.ok) { + return reply + .code(502) + .send({ error: `Upstream responded with HTTP ${upstream.status} ${upstream.statusText}.` }); + } + bundle = await upstream.json(); + } catch (err) { + let message = err instanceof Error ? err.message : 'Upstream request failed.'; + const cause = (err as { cause?: unknown })?.cause; + if (cause instanceof Error) { + const code = (cause as { code?: string }).code; + message += ` (${code ? code + ': ' : ''}${cause.message})`; + } + request.log.error({ err, target: parsed.toString() }, 'sync-schedules upstream fetch failed'); + return reply.code(502).send({ error: `Upstream fetch failed: ${message}` }); + } + + // ── Parse + persist ── + try { + const { schedules } = parseScheduleBundle(bundle, parsed.toString()); + let slotsGenerated = 0; + const notes: string[] = []; + + for (const schedule of schedules) { + await store.upsertSchedule(schedule); + if (!schedule.id) continue; + + const label = + schedule.actor?.find((a) => a.display)?.display || `Schedule/${schedule.id}`; + const { template, note } = buildSlotTemplate(schedule); + + // Replace mode: clear previously-synced free slots before regenerating. + await store.deleteSlotsBySchedule(schedule.id, 'free'); + + if (!template) { + if (note) notes.push(`${label}: ${note}`); + continue; + } + + const { slots, warnings } = expandSlots(template, `Schedule/${schedule.id}`); + // Tag each generated slot with the schedule's appointment types. + if (Array.isArray(schedule.serviceType) && schedule.serviceType.length > 0) { + for (const slot of slots) slot.serviceType = schedule.serviceType; + } + if (slots.length > 0) { + const { count } = await store.createSlots(slots); + slotsGenerated += count; + } else { + notes.push(`${label}: no slots in date range`); + } + for (const w of warnings) notes.push(`${label}: ${w}`); + } + + request.log.info( + { imported: schedules.length, slotsGenerated, source: parsed.toString() }, + 'Schedules synchronized', + ); + return reply.send({ + success: true, + imported: schedules.length, + slotsGenerated, + notes, + source: parsed.toString(), + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to parse schedule bundle.'; + return reply.code(400).send({ error: message }); + } + } + ); + + const closeGracefully = async (signal: string) => { fastify.log.info(`Received signal ${signal}, closing gracefully...`); await store.close(); @@ -347,7 +531,7 @@ async function start() { await runEvaporation(); const evaporationInterval = setInterval(runEvaporation, EVAPORATION_CHECK_INTERVAL_HOURS * 60 * 60 * 1000); evaporationInterval.unref(); - + // Start MLLP socket server if enabled let mllpServer: MLLPServer | null = null; if (HL7_SOCKET_ENABLED) { @@ -362,11 +546,11 @@ async function start() { } : undefined, allowedIPs: HL7_MLLP_ALLOWED_IPS.length > 0 ? HL7_MLLP_ALLOWED_IPS : undefined, }); - + mllpServer.on('listening', (info) => { console.log(`πŸ“¨ HL7 MLLP Socket: ${info.tls ? 'tls' : 'tcp'}://${info.host}:${info.port}`); }); - + mllpServer.on('error', (err) => { console.error('MLLP Server error:', err); }); @@ -374,18 +558,18 @@ async function start() { mllpServer.on('rejected', (info) => { fastify.log.warn(info, 'MLLP connection rejected'); }); - + mllpServer.on('message', (event) => { fastify.log.info({ messageType: event.parsed.messageType, controlId: event.parsed.controlId }, 'HL7 message received via socket'); }); - + mllpServer.on('processed', (info) => { fastify.log.info(info, 'HL7 message processed'); }); - + await mllpServer.start(); } - + console.log('\nπŸš€ FHIRTogether Scheduling Synapse'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(`πŸ“‘ Server running at: http://${HOST}:${PORT}`); @@ -413,11 +597,11 @@ async function start() { } console.log('β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n'); } - + // Update graceful shutdown to include MLLP server process.removeAllListeners('SIGINT'); process.removeAllListeners('SIGTERM'); - + const closeAll = async (signal: string) => { fastify.log.info(`Received signal ${signal}, closing gracefully...`); if (mllpServer) { @@ -427,10 +611,10 @@ async function start() { await fastify.close(); process.exit(0); }; - + process.on('SIGINT', () => closeAll('SIGINT')); process.on('SIGTERM', () => closeAll('SIGTERM')); - + } catch (err) { console.error('Error starting server:', err); process.exit(1); diff --git a/src/store/sqliteStore.ts b/src/store/sqliteStore.ts index 0075364..2587676 100644 --- a/src/store/sqliteStore.ts +++ b/src/store/sqliteStore.ts @@ -51,7 +51,7 @@ function toNaiveISO(date: Date): string { * The store will compare it against the version stored in the database * and report a mismatch so the server can warn or rebuild. */ -export const SCHEMA_VERSION = 5; +export const SCHEMA_VERSION = 6; export interface SchemaStatus { current: number; @@ -107,6 +107,7 @@ export class SqliteStore implements FhirStore { planning_horizon_start TEXT, planning_horizon_end TEXT, comment TEXT, + extension TEXT, meta_last_updated TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); @@ -267,6 +268,14 @@ export class SqliteStore implements FhirStore { this.db.exec('ALTER TABLE schedules ADD COLUMN availability_template TEXT'); } + // Migration v5β†’v6: add extension column to schedules (stores the original + // FHIR extension array for synced schedules: availableTime, slot length, + // and the sync-source marker). + const schedColsV6 = this.db.pragma('table_info(schedules)') as { name: string }[]; + if (!schedColsV6.some(c => c.name === 'extension')) { + this.db.exec('ALTER TABLE schedules ADD COLUMN extension TEXT'); + } + // Stamp the schema version after all migrations succeed const migrated = dbVersion !== SCHEMA_VERSION; this.db.prepare( @@ -619,9 +628,58 @@ export class SqliteStore implements FhirStore { const stmt = this.db.prepare(` INSERT INTO schedules ( id, active, service_category, service_type, specialty, actor, - planning_horizon_start, planning_horizon_end, comment, meta_last_updated, + planning_horizon_start, planning_horizon_end, comment, extension, meta_last_updated, system_id, location_id, availability_template - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + id, + schedule.active ? 1 : 0, + JSON.stringify(schedule.serviceCategory || []), + JSON.stringify(schedule.serviceType || []), + JSON.stringify(schedule.specialty || []), + JSON.stringify(schedule.actor), + schedule.planningHorizon?.start, + schedule.planningHorizon?.end, + schedule.comment, + schedule.extension ? JSON.stringify(schedule.extension) : null, + now, + schedule.system_id || null, + schedule.location_id || null, + schedule.availability_template || null, + ); + + return { ...schedule, id, meta: { lastUpdated: now } }; + } + + /** + * Insert a schedule, or replace it in place if a schedule with the same id + * already exists. Used by schedule synchronization so re-syncing an endpoint + * updates availability without deleting the row (which would cascade-delete + * its slots) and without touching appointments. + */ + async upsertSchedule(schedule: Schedule & { system_id?: string; location_id?: string; availability_template?: string }): Promise { + const id = schedule.id || this.generateId(); + const now = toNaiveISO(new Date()); + + const stmt = this.db.prepare(` + INSERT INTO schedules ( + id, active, service_category, service_type, specialty, actor, + planning_horizon_start, planning_horizon_end, comment, extension, meta_last_updated, + system_id, location_id, availability_template + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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 `); stmt.run( @@ -634,6 +692,7 @@ export class SqliteStore implements FhirStore { schedule.planningHorizon?.start, schedule.planningHorizon?.end, schedule.comment, + schedule.extension ? JSON.stringify(schedule.extension) : null, now, schedule.system_id || null, schedule.location_id || null, @@ -643,6 +702,7 @@ export class SqliteStore implements FhirStore { return { ...schedule, id, meta: { lastUpdated: now } }; } + async getSchedules(query: FhirScheduleQuery & { system_id?: string }): Promise { let sql = 'SELECT s.*, sys.name AS system_name FROM schedules s LEFT JOIN systems sys ON s.system_id = sys.id WHERE 1=1'; const params: SqlParam[] = []; @@ -788,18 +848,6 @@ export class SqliteStore implements FhirStore { return { ...slot, id, meta: { lastUpdated: now } }; } - /** - * Un-shift a date by the offset so it matches raw DB values. - * shiftDate adds offsetDays on read; this subtracts them for queries. - */ - private unshiftDate(isoDate: string): string { - const offsetDays = this.getDateOffsetDays(); - if (offsetDays === 0) return isoDate; - const date = new Date(isoDate); - date.setDate(date.getDate() - offsetDays); - return date.toISOString(); - } - async getSlots(query: FhirSlotQuery): Promise { let sql = 'SELECT * FROM slots WHERE 1=1'; const params: SqlParam[] = []; @@ -817,12 +865,12 @@ export class SqliteStore implements FhirStore { if (query.start) { sql += ' AND start >= ?'; - params.push(this.unshiftDate(query.start)); + params.push(query.start); } if (query.end) { sql += ' AND end <= ?'; - params.push(this.unshiftDate(query.end)); + params.push(query.end); } sql += ' ORDER BY start ASC'; @@ -1324,8 +1372,15 @@ export class SqliteStore implements FhirStore { meta: { lastUpdated: row.meta_last_updated }, }; - // Build FHIR extensions array - const extensions: { url: string; valueString?: string }[] = []; + // Build FHIR extensions array. Start with any extensions stored verbatim + // (synced schedules persist their availableTime / slot-length / source + // marker here), then append the synthesized system/availability extensions. + const extensions: Record[] = []; + + const stored = this.parseJson(row.extension); + if (Array.isArray(stored)) { + extensions.push(...stored); + } if (row.system_name) { extensions.push({ @@ -1342,7 +1397,7 @@ export class SqliteStore implements FhirStore { } if (extensions.length > 0) { - (schedule as Schedule & { extension?: unknown[] }).extension = extensions; + schedule.extension = extensions; } return schedule; diff --git a/src/types/fhir.ts b/src/types/fhir.ts index 162388a..155fb6c 100644 --- a/src/types/fhir.ts +++ b/src/types/fhir.ts @@ -48,6 +48,8 @@ export interface Schedule extends FhirResource { actor: Reference[]; planningHorizon?: Period; comment?: string; + /** FHIR extensions (e.g. availableTime, slot length, sync source marker). */ + extension?: Array>; } /** @@ -299,6 +301,7 @@ export interface FhirStore { getSchedules(query: FhirScheduleQuery): Promise; getScheduleById(id: string): Promise; createSchedule(schedule: Schedule): Promise; + upsertSchedule(schedule: Schedule): Promise; updateSchedule(id: string, schedule: Partial): Promise; deleteSchedule(id: string): Promise; deleteAllSchedules(): Promise;