Skip to content

OpenEMR Integration push reliability, auth recovery UX, and optional EMR enrollment#52

Open
sammargolis wants to merge 36 commits intomainfrom
codex/openemr-integration
Open

OpenEMR Integration push reliability, auth recovery UX, and optional EMR enrollment#52
sammargolis wants to merge 36 commits intomainfrom
codex/openemr-integration

Conversation

@sammargolis
Copy link
Collaborator

@sammargolis sammargolis commented Mar 22, 2026

Summary

This PR delivers the OpenEMR integration end-to-end in OpenScribe, including reliability hardening so clinicians can reliably push full notes, understand readiness from the UI, and recover auth without leaving the app.

What changed

  • Added encrypted persisted OpenEMR token state with refresh-token rotation metadata.
  • Added GET /api/integrations/openemr/status preflight endpoint with structured readiness and blockers.
  • Added POST /api/integrations/openemr/auth/setup one-click auth bootstrap endpoint.
  • Upgraded push response payload to include:
    • id
    • uploadedAt
    • verifiedPreview
    • verifiedLength
    • openEMRDocumentUrl
  • Updated Note Editor OpenEMR UX:
    • hard preflight gate for push
    • inline blocker list with stable error codes
    • one-click Set up OpenEMR auth for auth blockers
    • persistent success card with verification metadata
    • no-store status fetch/headers to avoid stale blocker state
  • Updated New Interview form UX:
    • Push to EMR Yes/No toggle (under Note Type)
    • Patient ID required only when push is enabled
    • auth check + retry action when push is enabled
  • Added local helper script and npm script for test patient bootstrap.

Why

Prior behavior had intermittent auth failure modes and ambiguous UI state (especially after token expiry/rotation). Users could see a successful setup message but still be blocked due to stale preflight state or missing remediation flow.

This PR makes push readiness explicit, recovers auth in-place, and gives persistent proof of upload.

API / contract changes

  • New: GET /api/integrations/openemr/status
  • New: POST /api/integrations/openemr/auth/setup
  • Updated: POST /api/integrations/openemr/push success/failure payload shape

Security / operational notes

  • Refresh tokens are now persisted in encrypted local state with metadata.
  • Password-grant fallback is supported for local reliability paths where configured. This remains a non-production path.

Test coverage

Automated

pnpm -s build:test
node --test \
  build/tests-dist/apps/web/src/lib/__tests__/openemr-client.test.js \
  build/tests-dist/apps/web/src/lib/__tests__/openemr-push-handler.test.js \
  build/tests-dist/apps/web/src/lib/__tests__/openemr-auth-state.test.js

Manual (local)

  • Verified auth setup endpoint returns success and status preflight becomes push-ready.
  • Verified push creates OpenEMR document entry and returns document id + verification payload.
  • Verified UI supports opt-in EMR flow (Patient ID only required when push enabled).

Files of interest

  • apps/web/src/lib/openemr-client.ts
  • apps/web/src/lib/openemr-auth-state.ts
  • apps/web/src/app/api/integrations/openemr/status/route.ts
  • apps/web/src/app/api/integrations/openemr/auth/setup/route.ts
  • packages/pipeline/render/src/components/note-editor.tsx
  • packages/ui/src/components/new-encounter-form.tsx
  • apps/web/src/lib/__tests__/openemr-client.test.ts
  • apps/web/src/lib/__tests__/openemr-push-handler.test.ts
  • apps/web/src/lib/__tests__/openemr-auth-state.test.ts

sammargolis and others added 30 commits March 12, 2026 22:27
Spec for push-only FHIR R4 DocumentReference integration between
OpenScribe web app and OpenEMR, using per-clinic client credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Gate patient_id requirement on NEXT_PUBLIC_OPENEMR_ENABLED flag
- Clarify NEXT_PUBLIC_OPENEMR_ENABLED as explicit build-time var
- Document token cache no-op behavior in serverless environments
- Add DocumentReference category element (US Core requirement)
- Add requireAuthenticatedUser as Step 0 in API route
- Clarify .env.local.example uses placeholder values only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TDD plan covering openemr-client module, push API route,
new encounter form patient ID field, and note editor button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add fetchWithTimeout helper (15s AbortController) to openemr-client.ts
- Map AbortError to timeout-specific user message
- Add test asserting DocumentReference payload fields (subject, category,
  contentType, base64 note, description, status)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pure handler extraction

- Add trust boundary map diagram (6 boundaries ①–⑥)
- Extract openemr-push-handler.ts pure function for route testability
- Add 8 handler tests (auth, input guard, identity binding, response shape)
- Add explicit verify-FAIL steps before every implementation task
- Add summary-of-what-changed after every commit
- Expand tsconfig.test.json to include 4 specific new files
- Restructure into 5 chunks: config, FHIR client, handler+route, form, UI button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Document OPENEMR_BASE_URL, OPENEMR_CLIENT_ID, OPENEMR_CLIENT_SECRET, and
  NEXT_PUBLIC_OPENEMR_ENABLED in .env.local.example with setup instructions
- Add 4 specific OpenEMR lib files to config/tsconfig.test.json include list
  (specific paths rather than glob to avoid pulling in Next.js-dependent files)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
openemr-client.ts:
- isOpenEMRConfigured() — reads OPENEMR_BASE_URL/CLIENT_ID/CLIENT_SECRET
- pushNoteToOpenEMR(params, fetchFn?) — injectable fetch for testability
- getAccessToken() — client_credentials grant with module-level token cache
  (5-min early expiry buffer; no-op in serverless, acceptable for self-hosted)
- validatePatient() — GET Patient/{id}; 404 stops before FHIR write (boundary ⑤)
- createDocumentReference() — FHIR R4 with US Core clinical-note category (boundary ⑥)
- fetchWithTimeout() — AbortController 15s on all OpenEMR calls
- mapError() — maps sentinel types to user-facing messages

Tests (12 passing):
- Configuration boundary: configured true/false, missing config returns failure
- Auth boundary ④: 401 → auth_failure, only token call made; token reused on 2nd push
- Patient boundary ⑤: 404 → not-found error, no DocumentReference written
- Identity binding ⑥: subject.reference, base64 note, US Core category, success id
- Network failures: ECONNREFUSED → "reach", AbortError → "timed out"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
openemr-push-handler.ts (pure function — no Next.js imports):
- handlePushRequest(auth, body, pushFn) enforces boundaries ①②③
- ① 401 for unauthenticated, pushFn never called
- ② 400 for null body / missing patientId / missing noteMarkdown
- ③ all 4 params forwarded to pushFn unchanged

route.ts (thin wrapper — 20 lines):
- calls requireAuthenticatedUser() → handlePushRequest → NextResponse
- defers to auth.response on 401 (preserves auth guard redirect headers)

Tests (8 passing, covers all 3 boundaries + response shape):
- auth boundary: 401 unauthenticated; 200 authenticated
- input guard: null body, missing patientId, missing noteMarkdown → 400
- identity binding: all 4 fields reach pushFn verbatim
- response: success→200+id, failure→500+error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add OPENEMR_ENABLED const (NEXT_PUBLIC_OPENEMR_ENABLED === "true")
- Show OpenEMR Patient ID input only when flag is true
- Validate non-empty on submit; inline error message on blank
- Pass patient_id to onStart; empty string when flag is false (no-op for
  non-OpenEMR deployments)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add OPENEMR_ENABLED const (NEXT_PUBLIC_OPENEMR_ENABLED === "true")
- Add OpenEMRPushState type (idle | pushing | success | failed)
- Add openEMRPushState / openEMRError state; reset on encounter change
- handleNoteChange: clear push error when state is "failed" on note edit
- handlePushToOpenEMR: POST /api/integrations/openemr/push with all required
  fields; success → 3s "Pushed" then idle; failure → failed + error message
- Button: shown only when activeTab === "note" && OPENEMR_ENABLED
  Disabled: empty note, empty patient_id, push in flight
  Icons: Upload (idle) → Loader2 spinning (pushing) → Check (success)
- Error display: same styling as OpenClaw error block, below textarea
  Dismisses on note edit or encounter change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
sammargolis and others added 6 commits March 17, 2026 13:41
…d doc API

Auth (boundary ④):
- Replace client_secret body auth with RS384 JWT client assertion
  (RFC 7521 / SMART Backend Services — iss=sub=client_id, aud=token_url,
  5-min exp, unique jti, signed with RS384)
- New env: OPENEMR_JWT_PRIVATE_KEY_PEM (inline PEM or file path)
- New env: OPENEMR_TOKEN_URL (optional, derived from OPENEMR_BASE_URL)
- Remove OPENEMR_CLIENT_SECRET

Patient resolution (boundary ⑤):
- Replace GET /apis/default/fhir/Patient/{numericPid} (rejected numeric ids)
  with GET /apis/default/patient/{pid} (standard REST — returns FHIR uuid)
- Fail explicitly when uuid missing from response (no silent FHIR binding errors)

Document creation (boundary ⑥):
- Replace POST /apis/default/fhir/DocumentReference (not available)
  with POST /apis/default/patient/{pid}/document (standard REST, multipart)
- Note uploaded as text/markdown file; patient uuid embedded in filename

Tests (14 passing, 2 new):
- JWT assertion format: client_assertion_type present, 3-part JWT, no client_secret
- Patient endpoint: /apis/default/patient/{pid} not /fhir/Patient/{pid}
- Missing uuid in patient response → patient_not_found (new boundary case)
- FormData upload: file text equals original noteMarkdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sammargolis sammargolis changed the title Improve OpenEMR push reliability, auth recovery UX, and optional EMR enrollment OpenEMR Integration push reliability, auth recovery UX, and optional EMR enrollment Mar 22, 2026
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.

1 participant