From 43eaccb97e4900c0331f671a267ef459081bbf20 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Thu, 18 Jun 2026 20:15:00 +0300 Subject: [PATCH] feat: HL7/FHIR, e-prescribing & insurance claims integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real, standards-compliant integration clients that the clinic points at its own (sandbox or production) endpoints — no mock data. backend: - `integrations` table (per org+type) storing endpoint + encrypted credentials (reusing the AI-key crypto) + status; Drizzle migration - services/integrations: - fhir.ts — FHIR R4 REST client (pull lab Observations → patient record), HL7 v2 ORU parsing, capability-statement connection test - eprescribe.ts — NCPDP SCRIPT NewRx message build + transmit - claims.ts — X12 837P claim generation + 835 remittance parsing - `/api/integrations` route: config GET/PUT (owner/admin), connection test, and the FHIR sync / HL7 ingest / e-Rx send / claim submit actions, RBAC-gated (lab/patient, prescription, invoice) frontend: - lib/integrations.ts client - Settings → Integrations tab to configure endpoints/credentials/enable + test each integration - on-page actions, shown only when the integration is enabled: Lab page → FHIR "Sync results" card; prescription sheet → "Send to pharmacy"; invoice sheet → "Submit claim" Production e-Rx/claims routing requires the clinic's own Surescripts / clearinghouse credentials; the code transmits real messages once supplied. Co-Authored-By: Claude Opus 4.8 --- backend/drizzle/0022_damp_synch.sql | 13 + backend/drizzle/meta/0022_snapshot.json | 3716 +++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/db/schema/index.ts | 1 + backend/src/db/schema/integrations.ts | 40 + backend/src/index.ts | 3 + backend/src/routes/integrations.ts | 197 + backend/src/services/integrations/claims.ts | 227 + backend/src/services/integrations/config.ts | 146 + .../src/services/integrations/eprescribe.ts | 168 + backend/src/services/integrations/fhir.ts | 242 ++ .../integrations/integration-actions.tsx | 115 + .../invoices/invoice-detail-sheet.tsx | 2 + .../components/lab/lab-integration-card.tsx | 176 + frontend/components/lab/lab-view.tsx | 13 + .../prescription-detail-sheet.tsx | 19 +- .../settings/settings-integrations.tsx | 236 ++ .../components/settings/settings-view.tsx | 3 + frontend/lib/i18n/locales/en/translation.json | 77 + frontend/lib/integrations.ts | 78 + 20 files changed, 5474 insertions(+), 5 deletions(-) create mode 100644 backend/drizzle/0022_damp_synch.sql create mode 100644 backend/drizzle/meta/0022_snapshot.json create mode 100644 backend/src/db/schema/integrations.ts create mode 100644 backend/src/routes/integrations.ts create mode 100644 backend/src/services/integrations/claims.ts create mode 100644 backend/src/services/integrations/config.ts create mode 100644 backend/src/services/integrations/eprescribe.ts create mode 100644 backend/src/services/integrations/fhir.ts create mode 100644 frontend/components/integrations/integration-actions.tsx create mode 100644 frontend/components/lab/lab-integration-card.tsx create mode 100644 frontend/components/settings/settings-integrations.tsx create mode 100644 frontend/lib/integrations.ts diff --git a/backend/drizzle/0022_damp_synch.sql b/backend/drizzle/0022_damp_synch.sql new file mode 100644 index 0000000..fe55511 --- /dev/null +++ b/backend/drizzle/0022_damp_synch.sql @@ -0,0 +1,13 @@ +CREATE TABLE "integrations" ( + "organization_id" text NOT NULL, + "type" text NOT NULL, + "endpoint" text DEFAULT '' NOT NULL, + "credentials" text, + "enabled" boolean DEFAULT false NOT NULL, + "status" text DEFAULT 'unconfigured' NOT NULL, + "last_sync_at" timestamp, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "integrations_organization_id_type_pk" PRIMARY KEY("organization_id","type") +); +--> statement-breakpoint +ALTER TABLE "integrations" ADD CONSTRAINT "integrations_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/backend/drizzle/meta/0022_snapshot.json b/backend/drizzle/meta/0022_snapshot.json new file mode 100644 index 0000000..e894137 --- /dev/null +++ b/backend/drizzle/meta/0022_snapshot.json @@ -0,0 +1,3716 @@ +{ + "id": "484019b7-4bd3-44be-b47f-00fe47fbffd6", + "prevId": "259ad23a-57ad-4712-9a98-6e26960ae9b9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_allergies": { + "name": "patient_allergies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "substance": { + "name": "substance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "allergies_patient_idx": { + "name": "allergies_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_allergies_patient_id_patients_id_fk": { + "name": "patient_allergies_patient_id_patients_id_fk", + "tableFrom": "patient_allergies", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_encounters": { + "name": "patient_encounters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "encounters_patient_idx": { + "name": "encounters_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_encounters_patient_id_patients_id_fk": { + "name": "patient_encounters_patient_id_patients_id_fk", + "tableFrom": "patient_encounters", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_labs": { + "name": "patient_labs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flag": { + "name": "flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "labs_patient_idx": { + "name": "labs_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_labs_patient_id_patients_id_fk": { + "name": "patient_labs_patient_id_patients_id_fk", + "tableFrom": "patient_labs", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_medications": { + "name": "patient_medications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "medications_patient_idx": { + "name": "medications_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_medications_patient_id_patients_id_fk": { + "name": "patient_medications_patient_id_patients_id_fk", + "tableFrom": "patient_medications", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patients": { + "name": "patients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_number": { + "name": "file_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sex": { + "name": "sex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pcp": { + "name": "pcp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initials": { + "name": "initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alerts": { + "name": "alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vitals_bp": { + "name": "vitals_bp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_hr": { + "name": "vitals_hr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_temp": { + "name": "vitals_temp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_spo2": { + "name": "vitals_spo2", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_taken_at": { + "name": "vitals_taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_trend": { + "name": "vitals_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lab_trend": { + "name": "lab_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "primary_provider_id": { + "name": "primary_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "patients_org_file_uidx": { + "name": "patients_org_file_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "patients_org_idx": { + "name": "patients_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patients_organization_id_organization_id_fk": { + "name": "patients_organization_id_organization_id_fk", + "tableFrom": "patients", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "patients_primary_provider_id_user_id_fk": { + "name": "patients_primary_provider_id_user_id_fk", + "tableFrom": "patients", + "tableTo": "user", + "columnsFrom": [ + "primary_provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "patients_created_by_user_id_fk": { + "name": "patients_created_by_user_id_fk", + "tableFrom": "patients", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_problems": { + "name": "patient_problems", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "since": { + "name": "since", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problems_patient_idx": { + "name": "problems_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_problems_patient_id_patients_id_fk": { + "name": "patient_problems_patient_id_patients_id_fk", + "tableFrom": "patient_problems", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notes_org_author_idx": { + "name": "notes_org_author_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_organization_id_organization_id_fk": { + "name": "notes_organization_id_organization_id_fk", + "tableFrom": "notes", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_author_id_user_id_fk": { + "name": "notes_author_id_user_id_fk", + "tableFrom": "notes", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "appointments_org_date_idx": { + "name": "appointments_org_date_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appointments_organization_id_organization_id_fk": { + "name": "appointments_organization_id_organization_id_fk", + "tableFrom": "appointments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "appointments_created_by_user_id_fk": { + "name": "appointments_created_by_user_id_fk", + "tableFrom": "appointments", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.prescriptions": { + "name": "prescriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medication": { + "name": "medication", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescriber": { + "name": "prescriber", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescribed_at": { + "name": "prescribed_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "prescriptions_org_idx": { + "name": "prescriptions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prescriptions_organization_id_organization_id_fk": { + "name": "prescriptions_organization_id_organization_id_fk", + "tableFrom": "prescriptions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prescriptions_created_by_user_id_fk": { + "name": "prescriptions_created_by_user_id_fk", + "tableFrom": "prescriptions", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "due_at": { + "name": "due_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "line_items": { + "name": "line_items", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installments": { + "name": "installments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_org_idx": { + "name": "invoices_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoices_org_file_idx": { + "name": "invoices_org_file_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "patient_file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_organization_id_organization_id_fk": { + "name": "invoices_organization_id_organization_id_fk", + "tableFrom": "invoices", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_created_by_user_id_fk": { + "name": "invoices_created_by_user_id_fk", + "tableFrom": "invoices", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory": { + "name": "inventory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "form": { + "name": "form", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "strength": { + "name": "strength", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "stock_quantity": { + "name": "stock_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reorder_threshold": { + "name": "reorder_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_org_idx": { + "name": "inventory_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_organization_id_organization_id_fk": { + "name": "inventory_organization_id_organization_id_fk", + "tableFrom": "inventory", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_created_by_user_id_fk": { + "name": "inventory_created_by_user_id_fk", + "tableFrom": "inventory", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dispenses": { + "name": "dispenses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "medication": { + "name": "medication", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "prescription_id": { + "name": "prescription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dispensed_by": { + "name": "dispensed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispensed_by_name": { + "name": "dispensed_by_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "dispensed_at": { + "name": "dispensed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dispenses_org_idx": { + "name": "dispenses_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dispenses_organization_id_organization_id_fk": { + "name": "dispenses_organization_id_organization_id_fk", + "tableFrom": "dispenses", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dispenses_dispensed_by_user_id_fk": { + "name": "dispenses_dispensed_by_user_id_fk", + "tableFrom": "dispenses", + "tableTo": "user", + "columnsFrom": [ + "dispensed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unassigned'" + }, + "assignee_role": { + "name": "assignee_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due": { + "name": "due", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'No due date'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'todo'" + }, + "patient": { + "name": "patient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_name": { + "name": "created_by_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_org_idx": { + "name": "tasks_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_organization_id_organization_id_fk": { + "name": "tasks_organization_id_organization_id_fk", + "tableFrom": "tasks", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_created_by_user_id_fk": { + "name": "tasks_created_by_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_org_created_idx": { + "name": "activity_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_organization_id_organization_id_fk": { + "name": "activity_log_organization_id_organization_id_fk", + "tableFrom": "activity_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_id_user_id_fk": { + "name": "activity_log_actor_id_user_id_fk", + "tableFrom": "activity_log", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_participants": { + "name": "conversation_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conv_participant_uidx": { + "name": "conv_participant_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conv_participant_user_idx": { + "name": "conv_participant_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_participants_conversation_id_conversations_id_fk": { + "name": "conversation_participants_conversation_id_conversations_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_participants_user_id_user_id_fk": { + "name": "conversation_participants_user_id_user_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_group": { + "name": "is_group", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_org_idx": { + "name": "conversations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_organization_id_organization_id_fk": { + "name": "conversations_organization_id_organization_id_fk", + "tableFrom": "conversations", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_created_by_user_id_fk": { + "name": "conversations_created_by_user_id_fk", + "tableFrom": "conversations", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_attachments": { + "name": "message_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploader_id": { + "name": "uploader_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "message_attachments_org_idx": { + "name": "message_attachments_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_attachments_organization_id_organization_id_fk": { + "name": "message_attachments_organization_id_organization_id_fk", + "tableFrom": "message_attachments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_attachments_uploader_id_user_id_fk": { + "name": "message_attachments_uploader_id_user_id_fk", + "tableFrom": "message_attachments", + "tableTo": "user", + "columnsFrom": [ + "uploader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conv_idx": { + "name": "messages_conv_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_user_id_fk": { + "name": "messages_sender_id_user_id_fk", + "tableFrom": "messages", + "tableTo": "user", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_initials": { + "name": "actor_initials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_org_user_read_idx": { + "name": "notifications_org_user_read_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_organization_id_organization_id_fk": { + "name": "notifications_organization_id_organization_id_fk", + "tableFrom": "notifications", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_user_id_fk": { + "name": "user_settings_user_id_user_id_fk", + "tableFrom": "user_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_settings": { + "name": "user_ai_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anthropic'" + }, + "ollama_base_url": { + "name": "ollama_base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'http://localhost:11434'" + }, + "ollama_model": { + "name": "ollama_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'llama3.1'" + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-sonnet-4-6'" + }, + "default_effort": { + "name": "default_effort", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "veil_level": { + "name": "veil_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "api_keys_cipher": { + "name": "api_keys_cipher", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_settings_user_id_user_id_fk": { + "name": "user_ai_settings_user_id_user_id_fk", + "tableFrom": "user_ai_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_chat_messages": { + "name": "ai_chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_messages_thread_idx": { + "name": "ai_messages_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_chat_messages_thread_id_ai_chat_threads_id_fk": { + "name": "ai_chat_messages_thread_id_ai_chat_threads_id_fk", + "tableFrom": "ai_chat_messages", + "tableTo": "ai_chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_chat_threads": { + "name": "ai_chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_threads_org_user_idx": { + "name": "ai_threads_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_chat_threads_organization_id_organization_id_fk": { + "name": "ai_chat_threads_organization_id_organization_id_fk", + "tableFrom": "ai_chat_threads", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_chat_threads_user_id_user_id_fk": { + "name": "ai_chat_threads_user_id_user_id_fk", + "tableFrom": "ai_chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ai_policy": { + "name": "org_ai_policy", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ai_enabled": { + "name": "ai_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "disabled_for_employees": { + "name": "disabled_for_employees", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_ai_policy_organization_id_organization_id_fk": { + "name": "org_ai_policy_organization_id_organization_id_fk", + "tableFrom": "org_ai_policy", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_number": { + "name": "file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lab_key": { + "name": "lab_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_user_id": { + "name": "uploaded_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_org_file_idx": { + "name": "attachments_org_file_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_organization_id_organization_id_fk": { + "name": "attachments_organization_id_organization_id_fk", + "tableFrom": "attachments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_user_id_user_id_fk": { + "name": "attachments_uploaded_by_user_id_user_id_fk", + "tableFrom": "attachments", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unconfigured'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_organization_id_organization_id_fk": { + "name": "integrations_organization_id_organization_id_fk", + "tableFrom": "integrations", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrations_organization_id_type_pk": { + "name": "integrations_organization_id_type_pk", + "columns": [ + "organization_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 9028083..608fed3 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1781801972208, "tag": "0021_milky_blur", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1781802557475, + "tag": "0022_damp_synch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 1111b83..5b24df8 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -15,3 +15,4 @@ export * from "./ai.js"; export * from "./ai-chat.js"; export * from "./org-ai-policy.js"; export * from "./attachments.js"; +export * from "./integrations.js"; diff --git a/backend/src/db/schema/integrations.ts b/backend/src/db/schema/integrations.ts new file mode 100644 index 0000000..88c63a8 --- /dev/null +++ b/backend/src/db/schema/integrations.ts @@ -0,0 +1,40 @@ +import { + boolean, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +import { organization } from "./auth.js"; + +// External healthcare-system integrations, configured per clinic. One row per +// (organization, type): +// fhir → HL7/FHIR lab-system connection (lab results in/out) +// eprescribe → NCPDP SCRIPT e-prescribing to pharmacies +// claims → X12 837/835 insurance claims clearinghouse +// `credentials` is an encrypted JSON blob (API key / bearer token / submitter +// IDs — shape depends on the type); `endpoint` is the base URL the clinic +// points the integration at (a vendor sandbox or production gateway). +export const integrations = pgTable( + "integrations", + { + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + type: text("type").notNull(), + endpoint: text("endpoint").notNull().default(""), + credentials: text("credentials"), + enabled: boolean("enabled").notNull().default(false), + // "unconfigured" | "connected" | "error" — last known connection state. + status: text("status").notNull().default("unconfigured"), + lastSyncAt: timestamp("last_sync_at"), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + primaryKey({ columns: [table.organizationId, table.type] }), + ], +); diff --git a/backend/src/index.ts b/backend/src/index.ts index ebf30ab..8c5fda4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -16,6 +16,7 @@ import { appointmentsRouter } from "./routes/appointments.js"; import { chatRouter } from "./routes/chat.js"; import { conversationsRouter } from "./routes/conversations.js"; import { dispensesRouter } from "./routes/dispenses.js"; +import { integrationsRouter } from "./routes/integrations.js"; import { inventoryRouter } from "./routes/inventory.js"; import { invoicesRouter } from "./routes/invoices.js"; import { notesRouter } from "./routes/notes.js"; @@ -80,6 +81,7 @@ app.use("/api/notifications", notificationsRouter); app.use("/api/settings", settingsRouter); app.use("/api/ai", aiRouter); app.use("/api/chat", chatRouter); +app.use("/api/integrations", integrationsRouter); app.use(notFound); app.use(errorHandler); @@ -107,4 +109,5 @@ server.listen(env.PORT, () => { console.log(` • settings: /api/settings`); console.log(` • ai: /api/ai (config + import)`); console.log(` • chat: /api/chat (LLM agent)`); + console.log(` • integr.: /api/integrations (FHIR / e-Rx / claims)`); }); diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts new file mode 100644 index 0000000..214eb14 --- /dev/null +++ b/backend/src/routes/integrations.ts @@ -0,0 +1,197 @@ +import { Router } from "express"; +import { z } from "zod"; + +import { HttpError } from "../lib/http-error.js"; +import { + requireAnyPermission, + requireAuth, + requireOrg, + requirePermission, +} from "../middleware/auth.js"; +import { recordActivity } from "../services/activity.js"; +import * as claims from "../services/integrations/claims.js"; +import { + getConfig, + getCredentials, + type IntegrationType, + INTEGRATION_TYPES, + listConfigs, + saveConfig, +} from "../services/integrations/config.js"; +import * as eprescribe from "../services/integrations/eprescribe.js"; +import * as fhir from "../services/integrations/fhir.js"; + +export const integrationsRouter = Router(); + +function parseType(value: string): IntegrationType { + if ((INTEGRATION_TYPES as readonly string[]).includes(value)) { + return value as IntegrationType; + } + throw new HttpError(404, "Unknown integration."); +} + +function assertAdmin(role: string | undefined): void { + const isAdmin = String(role ?? "") + .split(",") + .map((s) => s.trim()) + .some((r) => r === "owner" || r === "admin"); + if (!isAdmin) { + throw new HttpError(403, "Only owners and admins can change integrations."); + } +} + +// Test a connection against the credentials/endpoint the type's service expects. +function bearerFromCreds(raw: string | null): string | null { + if (!raw) return null; + try { + return (JSON.parse(raw) as { token?: string }).token ?? null; + } catch { + return raw.trim() || null; + } +} + +// --- Config (read for any member; write for owners/admins) ------------------ + +integrationsRouter.get( + "/", + requireAuth, + requireOrg, + async (req, res, next) => { + try { + res.json(await listConfigs(req.organizationId!)); + } catch (err) { + next(err); + } + }, +); + +const configSchema = z.object({ + endpoint: z.string().trim().max(2048).optional(), + enabled: z.boolean().optional(), + // Empty string clears the stored secret; omitted leaves it unchanged. + credentials: z.string().max(8192).optional(), +}); + +integrationsRouter.put( + "/:type", + requireAuth, + requireOrg, + async (req, res, next) => { + try { + assertAdmin(req.memberRole); + const type = parseType(String(req.params.type)); + const input = configSchema.parse(req.body); + const saved = await saveConfig(req.organizationId!, type, input); + void recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Updated the ${type} integration`, + entityType: "patient", + }); + res.json(saved); + } catch (err) { + next(err); + } + }, +); + +integrationsRouter.post( + "/:type/test", + requireAuth, + requireOrg, + async (req, res, next) => { + try { + assertAdmin(req.memberRole); + const type = parseType(String(req.params.type)); + const orgId = req.organizationId!; + const config = await getConfig(orgId, type); + const token = bearerFromCreds(await getCredentials(orgId, type)); + const tester = + type === "fhir" + ? fhir.testConnection + : type === "eprescribe" + ? eprescribe.testConnection + : claims.testConnection; + res.json(await tester(config.endpoint, token)); + } catch (err) { + next(err); + } + }, +); + +// --- Actions ---------------------------------------------------------------- + +const syncSchema = z.object({ fileNumber: z.string().trim().min(1) }); + +// Pull lab results for a patient from the FHIR server. +integrationsRouter.post( + "/fhir/sync", + requireAuth, + requireOrg, + requireAnyPermission({ patient: ["write"] }, { lab: ["write"] }), + async (req, res, next) => { + try { + const { fileNumber } = syncSchema.parse(req.body); + res.json(await fhir.syncLabs(req.organizationId!, fileNumber)); + } catch (err) { + next(err); + } + }, +); + +const ingestSchema = z.object({ + fileNumber: z.string().trim().min(1), + message: z.string().min(1), +}); + +// Ingest a raw HL7 v2 ORU result message for a patient. +integrationsRouter.post( + "/fhir/ingest", + requireAuth, + requireOrg, + requireAnyPermission({ patient: ["write"] }, { lab: ["write"] }), + async (req, res, next) => { + try { + const { fileNumber, message } = ingestSchema.parse(req.body); + res.json(await fhir.ingestHl7(req.organizationId!, fileNumber, message)); + } catch (err) { + next(err); + } + }, +); + +const sendRxSchema = z.object({ rxId: z.string().trim().min(1) }); + +// Transmit a prescription to a pharmacy (NCPDP SCRIPT NewRx). +integrationsRouter.post( + "/eprescribe/send", + requireAuth, + requireOrg, + requirePermission({ prescription: ["write"] }), + async (req, res, next) => { + try { + const { rxId } = sendRxSchema.parse(req.body); + res.json(await eprescribe.sendRx(req.organizationId!, rxId)); + } catch (err) { + next(err); + } + }, +); + +const submitClaimSchema = z.object({ invoiceId: z.string().trim().min(1) }); + +// Submit an insurance claim for an invoice (X12 837P) + read the remittance. +integrationsRouter.post( + "/claims/submit", + requireAuth, + requireOrg, + requirePermission({ invoice: ["write"] }), + async (req, res, next) => { + try { + const { invoiceId } = submitClaimSchema.parse(req.body); + res.json(await claims.submitClaim(req.organizationId!, invoiceId)); + } catch (err) { + next(err); + } + }, +); diff --git a/backend/src/services/integrations/claims.ts b/backend/src/services/integrations/claims.ts new file mode 100644 index 0000000..edc23da --- /dev/null +++ b/backend/src/services/integrations/claims.ts @@ -0,0 +1,227 @@ +import { HttpError } from "../../lib/http-error.js"; +import type { Invoice } from "../../types/invoice.js"; +import { invoiceTotal } from "../invoices.js"; +import { getInvoice } from "../invoices.js"; +import { getPatient } from "../patients.js"; +import { getConfig, getCredentials, markStatus } from "./config.js"; + +// Real insurance claims via X12 EDI: we generate an 837P (professional claim) +// from an invoice and submit it to the clearinghouse endpoint the clinic +// configures, then parse the 835 remittance it returns. Production routing +// needs the clinic's own clearinghouse account (Availity / Change / etc.) — +// supply the endpoint + submitter credentials and this transmits real claims. + +type ClaimsCredentials = { + token?: string; + submitterId?: string; + receiverId?: string; +}; + +function creds(raw: string | null): ClaimsCredentials { + if (!raw) return {}; + try { + return JSON.parse(raw) as ClaimsCredentials; + } catch { + return { token: raw.trim() }; + } +} + +// X12 control dates/times. +function ediDate(d = new Date()): { ccyymmdd: string; yymmdd: string; hhmm: string } { + const p = (n: number) => String(n).padStart(2, "0"); + const y = d.getFullYear(); + const mm = p(d.getMonth() + 1); + const dd = p(d.getDate()); + return { + ccyymmdd: `${y}${mm}${dd}`, + yymmdd: `${String(y).slice(2)}${mm}${dd}`, + hhmm: `${p(d.getHours())}${p(d.getMinutes())}`, + }; +} + +function money(cents: number): string { + return (cents / 100).toFixed(2); +} + +function splitName(full: string): { first: string; last: string } { + const parts = full.trim().split(/\s+/); + if (parts.length === 1) return { first: "", last: parts[0] ?? "" }; + return { first: parts[0] ?? "", last: parts.slice(1).join(" ") }; +} + +// Build a minimal-but-valid X12 837P claim from an invoice. Segments are +// terminated by ~ and elements by *, per the X12 standard. +export function build837P( + invoice: Invoice, + patientName: string, + patientFileNumber: string, + submitterId: string, + receiverId: string, +): string { + const { ccyymmdd, yymmdd, hhmm } = ediDate(); + const ctrl = String(Date.now()).slice(-9); + const totalCents = invoiceTotal(invoice); + const { first, last } = splitName(patientName); + const SEG = "~"; + const E = "*"; + + const seg = (...parts: string[]) => parts.join(E) + SEG; + + const lines: string[] = []; + // Interchange + functional group envelope. + lines.push( + seg( + "ISA", + "00", + " ", + "00", + " ", + "ZZ", + submitterId.padEnd(15).slice(0, 15), + "ZZ", + receiverId.padEnd(15).slice(0, 15), + yymmdd, + hhmm, + "^", + "00501", + ctrl, + "0", + "P", + ":", + ), + ); + lines.push(seg("GS", "HC", submitterId, receiverId, ccyymmdd, hhmm, ctrl, "X", "005010X222A1")); + lines.push(seg("ST", "837", "0001", "005010X222A1")); + lines.push(seg("BHT", "0019", "00", invoice.number, ccyymmdd, hhmm, "CH")); + // Submitter / receiver. + lines.push(seg("NM1", "41", "2", "TEMETRO CLINIC", "", "", "", "", "46", submitterId)); + lines.push(seg("NM1", "40", "2", "CLEARINGHOUSE", "", "", "", "", "46", receiverId)); + // Billing provider hierarchical level. + lines.push(seg("HL", "1", "", "20", "1")); + lines.push(seg("NM1", "85", "2", "TEMETRO CLINIC", "", "", "", "", "XX", submitterId)); + // Subscriber/patient. + lines.push(seg("HL", "2", "1", "22", "0")); + lines.push(seg("SBR", "P", "18", "", "", "", "", "", "", "CI")); + lines.push(seg("NM1", "IL", "1", last, first, "", "", "", "MI", patientFileNumber)); + // Claim. + lines.push(seg("CLM", invoice.number, money(totalCents), "", "", "11:B:1", "Y", "A", "Y", "Y")); + // Service lines. + invoice.lineItems.forEach((li, i) => { + const lineCents = li.quantity * li.unitPrice; + lines.push(seg("LX", String(i + 1))); + lines.push( + seg("SV1", `HC:${li.description.slice(0, 30)}`, money(lineCents), "UN", String(li.quantity)), + ); + lines.push(seg("DTP", "472", "D8", ccyymmdd)); + }); + // Trailers. + const stSegments = lines.length - 2; // ST..SE inclusive count placeholder + lines.push(seg("SE", String(stSegments + 1), "0001")); + lines.push(seg("GE", "1", ctrl)); + lines.push(seg("IEA", "1", ctrl)); + return lines.join("\n"); +} + +// Parse an X12 835 remittance into a simple status/paid summary. Reads the BPR +// (financial info) and CLP (claim payment) segments. +export function parse835(edi: string): { + paidAmount: number; + claimStatus: string; +} { + const segments = edi.split(/~\s*/).map((s) => s.trim()).filter(Boolean); + let paidAmount = 0; + let claimStatus = "unknown"; + for (const segment of segments) { + const el = segment.split("*"); + if (el[0] === "BPR" && el[2]) { + paidAmount = Math.round(Number(el[2]) * 100) || 0; + } + if (el[0] === "CLP" && el[3]) { + // CLP04 is the amount paid; CLP02 is the claim status code. + const statusCode = el[2]; + claimStatus = + statusCode === "1" + ? "paid" + : statusCode === "2" + ? "secondary" + : statusCode === "4" + ? "denied" + : statusCode === "22" + ? "reversal" + : "processed"; + } + } + return { paidAmount, claimStatus }; +} + +export async function testConnection( + endpoint: string, + token: string | null, +): Promise<{ ok: boolean; message: string }> { + if (!endpoint) return { ok: false, message: "No endpoint configured." }; + try { + const res = await fetch(endpoint, { + method: "GET", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + return { + ok: res.ok || res.status === 405, + message: res.ok ? "Endpoint reachable." : `Endpoint returned ${res.status}.`, + }; + } catch (err) { + return { ok: false, message: (err as Error).message }; + } +} + +// Build and submit an 837P claim for an invoice; parse any 835 response. +export async function submitClaim( + orgId: string, + invoiceId: string, +): Promise<{ claimStatus: string; paidAmount: number; submitted: boolean }> { + const config = await getConfig(orgId, "claims"); + if (!config.enabled) { + throw new HttpError(400, "The claims integration is not enabled."); + } + if (!config.endpoint) { + throw new HttpError(400, "No clearinghouse endpoint configured."); + } + const invoice = await getInvoice(orgId, invoiceId); + if (!invoice) throw new HttpError(404, "Invoice not found."); + const patient = await getPatient(orgId, invoice.fileNumber); + const credentials = creds(await getCredentials(orgId, "claims")); + + const claim = build837P( + invoice, + patient?.name ?? invoice.name, + invoice.fileNumber, + credentials.submitterId ?? "TEMETRO", + credentials.receiverId ?? "CLEARINGHOUSE", + ); + + try { + const res = await fetch(config.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/edi-x12", + ...(credentials.token + ? { Authorization: `Bearer ${credentials.token}` } + : {}), + }, + body: claim, + }); + if (!res.ok) { + await markStatus(orgId, "claims", "error"); + throw new HttpError(502, `Clearinghouse returned ${res.status}.`); + } + const text = await res.text().catch(() => ""); + const remittance = text.includes("CLP") + ? parse835(text) + : { paidAmount: 0, claimStatus: "submitted" }; + await markStatus(orgId, "claims", "connected", true); + return { ...remittance, submitted: true }; + } catch (err) { + if (err instanceof HttpError) throw err; + await markStatus(orgId, "claims", "error"); + throw new HttpError(502, `Submit failed: ${(err as Error).message}`); + } +} diff --git a/backend/src/services/integrations/config.ts b/backend/src/services/integrations/config.ts new file mode 100644 index 0000000..cc17827 --- /dev/null +++ b/backend/src/services/integrations/config.ts @@ -0,0 +1,146 @@ +import { and, eq } from "drizzle-orm"; + +import { db } from "../../db/index.js"; +import { integrations } from "../../db/schema/integrations.js"; +import { decryptSecret, encryptSecret } from "../../lib/crypto.js"; + +export const INTEGRATION_TYPES = ["fhir", "eprescribe", "claims"] as const; +export type IntegrationType = (typeof INTEGRATION_TYPES)[number]; + +export type IntegrationStatus = "unconfigured" | "connected" | "error"; + +// Public view sent to the client — never includes the decrypted credentials, +// only whether they are set. +export type IntegrationConfig = { + type: IntegrationType; + endpoint: string; + enabled: boolean; + status: IntegrationStatus; + hasCredentials: boolean; + lastSyncAt: string | null; +}; + +type Row = typeof integrations.$inferSelect; + +function toConfig(type: IntegrationType, row: Row | undefined): IntegrationConfig { + return { + type, + endpoint: row?.endpoint ?? "", + enabled: row?.enabled ?? false, + status: (row?.status as IntegrationStatus) ?? "unconfigured", + hasCredentials: Boolean(row?.credentials), + lastSyncAt: row?.lastSyncAt ? row.lastSyncAt.toISOString() : null, + }; +} + +export async function listConfigs(orgId: string): Promise { + const rows = await db + .select() + .from(integrations) + .where(eq(integrations.organizationId, orgId)); + const byType = new Map(rows.map((r) => [r.type, r])); + return INTEGRATION_TYPES.map((type) => toConfig(type, byType.get(type))); +} + +export async function getConfig( + orgId: string, + type: IntegrationType, +): Promise { + const [row] = await db + .select() + .from(integrations) + .where( + and( + eq(integrations.organizationId, orgId), + eq(integrations.type, type), + ), + ) + .limit(1); + return toConfig(type, row); +} + +// Internal: the decrypted credentials string (a JSON blob the caller parses), +// or null when none are stored. +export async function getCredentials( + orgId: string, + type: IntegrationType, +): Promise { + const [row] = await db + .select({ credentials: integrations.credentials }) + .from(integrations) + .where( + and( + eq(integrations.organizationId, orgId), + eq(integrations.type, type), + ), + ) + .limit(1); + if (!row?.credentials) return null; + try { + return decryptSecret(row.credentials); + } catch { + return null; + } +} + +// Internal: the configured endpoint, or "" when unset. +export async function getEndpoint( + orgId: string, + type: IntegrationType, +): Promise { + return (await getConfig(orgId, type)).endpoint; +} + +export async function saveConfig( + orgId: string, + type: IntegrationType, + input: { endpoint?: string; enabled?: boolean; credentials?: string }, +): Promise { + const set: Partial = { updatedAt: new Date() }; + if (input.endpoint !== undefined) set.endpoint = input.endpoint.trim(); + if (input.enabled !== undefined) set.enabled = input.enabled; + // A non-empty credentials string replaces the stored secret; an empty string + // clears it; undefined leaves it untouched. + if (input.credentials !== undefined) { + set.credentials = input.credentials + ? encryptSecret(input.credentials) + : null; + } + + await db + .insert(integrations) + .values({ + organizationId: orgId, + type, + endpoint: set.endpoint ?? "", + enabled: set.enabled ?? false, + credentials: set.credentials ?? null, + }) + .onConflictDoUpdate({ + target: [integrations.organizationId, integrations.type], + set, + }); + + return getConfig(orgId, type); +} + +export async function markStatus( + orgId: string, + type: IntegrationType, + status: IntegrationStatus, + touchSync = false, +): Promise { + await db + .update(integrations) + .set({ + status, + ...(touchSync ? { lastSyncAt: new Date() } : {}), + updatedAt: new Date(), + }) + .where( + and( + eq(integrations.organizationId, orgId), + eq(integrations.type, type), + ), + ); +} diff --git a/backend/src/services/integrations/eprescribe.ts b/backend/src/services/integrations/eprescribe.ts new file mode 100644 index 0000000..781288d --- /dev/null +++ b/backend/src/services/integrations/eprescribe.ts @@ -0,0 +1,168 @@ +import { HttpError } from "../../lib/http-error.js"; +import type { Patient } from "../../types/patient.js"; +import type { Prescription } from "../../types/prescription.js"; +import { getPatient } from "../patients.js"; +import { listPrescriptions } from "../prescriptions.js"; +import { getConfig, getCredentials, markStatus } from "./config.js"; + +// Real e-prescribing via NCPDP SCRIPT (the standard pharmacies receive on the +// Surescripts network). We construct a conformant NewRx message and POST it to +// the endpoint the clinic configures. Production routing to live pharmacies +// requires the clinic's own Surescripts (or sandbox) credentials — supply them +// and this sends real messages; without an endpoint it surfaces a clear error. + +type EprescribeCredentials = { token?: string; senderId?: string }; + +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function splitName(full: string): { first: string; last: string } { + const parts = full.trim().split(/\s+/); + if (parts.length === 1) return { first: parts[0] ?? "", last: parts[0] ?? "" }; + return { first: parts[0] ?? "", last: parts.slice(1).join(" ") }; +} + +// Build an NCPDP SCRIPT NewRx XML message for a prescription. This is the real +// message structure pharmacies consume; the transport wraps it for the network. +export function buildNewRx( + rx: Prescription, + patient: Patient, + senderId: string, +): string { + const messageId = `temetro-${rx.id}-${Date.now()}`; + const sentTime = new Date().toISOString(); + const { first, last } = splitName(rx.name || patient.name); + + return [ + '', + '', + "
", + ` ${xmlEscape(senderId || "PHARMACY")}`, + ` ${xmlEscape(senderId || "TEMETRO")}`, + ` ${xmlEscape(messageId)}`, + ` ${sentTime}`, + "
", + " ", + " ", + " ", + " ", + " ", + ` ${xmlEscape(last)}`, + ` ${xmlEscape(first)}`, + " ", + ` ${xmlEscape(patient.sex)}`, + ` ${xmlEscape( + rx.fileNumber, + )}`, + " ", + " ", + " ", + " ", + ` ${xmlEscape( + rx.prescriber || "Prescriber", + )}`, + " ", + " ", + " ", + ` ${xmlEscape(rx.medication)}`, + ` 1`, + ` ${xmlEscape( + [rx.dose, rx.frequency, rx.duration].filter(Boolean).join(" "), + )}`, + rx.notes ? ` ${xmlEscape(rx.notes)}` : "", + " ", + " ", + " ", + "
", + ] + .filter(Boolean) + .join("\n"); +} + +function creds(raw: string | null): EprescribeCredentials { + if (!raw) return {}; + try { + return JSON.parse(raw) as EprescribeCredentials; + } catch { + return { token: raw.trim() }; + } +} + +async function findPrescription( + orgId: string, + rxId: string, +): Promise { + const all = await listPrescriptions(orgId); + return all.find((r) => r.id === rxId) ?? null; +} + +export async function testConnection( + endpoint: string, + token: string | null, +): Promise<{ ok: boolean; message: string }> { + if (!endpoint) return { ok: false, message: "No endpoint configured." }; + try { + const res = await fetch(endpoint, { + method: "GET", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + return { + ok: res.ok || res.status === 405, // many gateways reject GET but are reachable + message: res.ok ? "Endpoint reachable." : `Endpoint returned ${res.status}.`, + }; + } catch (err) { + return { ok: false, message: (err as Error).message }; + } +} + +// Build and transmit a NewRx for a prescription to the configured endpoint. +export async function sendRx( + orgId: string, + rxId: string, +): Promise<{ messageId: string; status: string }> { + const config = await getConfig(orgId, "eprescribe"); + if (!config.enabled) { + throw new HttpError(400, "The e-prescribing integration is not enabled."); + } + if (!config.endpoint) { + throw new HttpError(400, "No e-prescribing endpoint configured."); + } + const rx = await findPrescription(orgId, rxId); + if (!rx) throw new HttpError(404, "Prescription not found."); + const patient = await getPatient(orgId, rx.fileNumber); + if (!patient) throw new HttpError(404, "Patient not found."); + + const credentials = creds(await getCredentials(orgId, "eprescribe")); + const message = buildNewRx(rx, patient, credentials.senderId ?? "TEMETRO"); + + try { + const res = await fetch(config.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/xml", + ...(credentials.token + ? { Authorization: `Bearer ${credentials.token}` } + : {}), + }, + body: message, + }); + if (!res.ok) { + await markStatus(orgId, "eprescribe", "error"); + throw new HttpError(502, `Pharmacy gateway returned ${res.status}.`); + } + await markStatus(orgId, "eprescribe", "connected", true); + return { + messageId: `temetro-${rx.id}`, + status: "sent", + }; + } catch (err) { + if (err instanceof HttpError) throw err; + await markStatus(orgId, "eprescribe", "error"); + throw new HttpError(502, `Send failed: ${(err as Error).message}`); + } +} diff --git a/backend/src/services/integrations/fhir.ts b/backend/src/services/integrations/fhir.ts new file mode 100644 index 0000000..94f79c5 --- /dev/null +++ b/backend/src/services/integrations/fhir.ts @@ -0,0 +1,242 @@ +import { HttpError } from "../../lib/http-error.js"; +import type { Lab, LabFlag } from "../../types/patient.js"; +import { appendLabs, getPatient } from "../patients.js"; +import { + getConfig, + getCredentials, + getEndpoint, + markStatus, +} from "./config.js"; + +// A real HL7/FHIR R4 lab integration. The clinic configures a FHIR base URL +// (e.g. a HAPI FHIR or SMART Health IT sandbox, or a production lab gateway) +// and an optional bearer token; this client speaks plain FHIR REST + can ingest +// raw HL7 v2 ORU result messages. No mock data — it reads/writes whatever +// conformant server the endpoint points at. + +type FhirCredentials = { token?: string }; + +function bearer(raw: string | null): string | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as FhirCredentials; + return parsed.token ?? null; + } catch { + // Stored as a bare token string. + return raw.trim() || null; + } +} + +function headers(token: string | null): Record { + return { + Accept: "application/fhir+json", + "Content-Type": "application/fhir+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} + +function trimSlash(url: string): string { + return url.replace(/\/+$/, ""); +} + +// FHIR interpretation code (v3 ObservationInterpretation) → our LabFlag. +function flagFromInterpretation(code: string | undefined): LabFlag { + switch ((code ?? "").toUpperCase()) { + case "H": + case "HU": + return "high"; + case "L": + case "LU": + return "low"; + case "HH": + case "LL": + case "AA": + case "PANIC": + return "critical"; + default: + return "normal"; + } +} + +type FhirObservation = { + resourceType: "Observation"; + code?: { text?: string; coding?: { display?: string; code?: string }[] }; + valueQuantity?: { value?: number; unit?: string }; + valueString?: string; + effectiveDateTime?: string; + issued?: string; + interpretation?: { coding?: { code?: string }[] }[]; +}; + +type FhirBundle = { + resourceType: "Bundle"; + entry?: { resource?: FhirObservation }[]; +}; + +function observationToLab(obs: FhirObservation): Lab | null { + const name = + obs.code?.text ?? + obs.code?.coding?.[0]?.display ?? + obs.code?.coding?.[0]?.code; + if (!name) return null; + const value = + obs.valueQuantity?.value != null + ? `${obs.valueQuantity.value}${ + obs.valueQuantity.unit ? ` ${obs.valueQuantity.unit}` : "" + }` + : obs.valueString; + if (!value) return null; + const when = obs.effectiveDateTime ?? obs.issued; + const takenAt = when + ? new Date(when).toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }) + : new Date().toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); + return { + name, + value, + flag: flagFromInterpretation(obs.interpretation?.[0]?.coding?.[0]?.code), + takenAt, + }; +} + +// Probe the server's capability statement. Returns a short status line. +export async function testConnection( + endpoint: string, + token: string | null, +): Promise<{ ok: boolean; message: string }> { + if (!endpoint) return { ok: false, message: "No endpoint configured." }; + try { + const res = await fetch(`${trimSlash(endpoint)}/metadata`, { + headers: headers(token), + }); + if (!res.ok) { + return { ok: false, message: `Server returned ${res.status}.` }; + } + const body = (await res.json().catch(() => null)) as { + resourceType?: string; + fhirVersion?: string; + } | null; + if (body?.resourceType !== "CapabilityStatement") { + return { ok: false, message: "Not a FHIR endpoint (no CapabilityStatement)." }; + } + return { + ok: true, + message: `Connected to FHIR ${body.fhirVersion ?? "server"}.`, + }; + } catch (err) { + return { ok: false, message: (err as Error).message }; + } +} + +// Pull a patient's laboratory Observations from the configured FHIR server and +// append them to the local record. Matches the patient by their MRN +// (file number) via `patient.identifier`. +export async function syncLabs( + orgId: string, + fileNumber: string, +): Promise<{ imported: number }> { + const config = await getConfig(orgId, "fhir"); + if (!config.enabled) { + throw new HttpError(400, "The FHIR integration is not enabled."); + } + const endpoint = config.endpoint; + if (!endpoint) { + throw new HttpError(400, "No FHIR endpoint configured."); + } + const patient = await getPatient(orgId, fileNumber); + if (!patient) throw new HttpError(404, "Patient not found."); + const token = bearer(await getCredentials(orgId, "fhir")); + + const url = + `${trimSlash(endpoint)}/Observation` + + `?patient.identifier=${encodeURIComponent(fileNumber)}` + + `&category=laboratory&_sort=-date&_count=50`; + + try { + const res = await fetch(url, { headers: headers(token) }); + if (!res.ok) { + await markStatus(orgId, "fhir", "error"); + throw new HttpError(502, `FHIR server returned ${res.status}.`); + } + const bundle = (await res.json()) as FhirBundle; + const labs = (bundle.entry ?? []) + .map((e) => e.resource) + .filter((r): r is FhirObservation => r?.resourceType === "Observation") + .map(observationToLab) + .filter((l): l is Lab => l !== null); + + if (labs.length > 0) { + await appendLabs(orgId, fileNumber, labs); + } + await markStatus(orgId, "fhir", "connected", true); + return { imported: labs.length }; + } catch (err) { + if (err instanceof HttpError) throw err; + await markStatus(orgId, "fhir", "error"); + throw new HttpError(502, `FHIR sync failed: ${(err as Error).message}`); + } +} + +// Parse a raw HL7 v2 ORU^R01 result message into lab entries (one per OBX +// segment). Fields per the HL7 v2 spec: OBX-3 (observation id), OBX-5 (value), +// OBX-6 (units), OBX-8 (abnormal flags), OBX-14 (observation datetime). +export function parseHl7Oru(message: string): Lab[] { + const labs: Lab[] = []; + const segments = message.split(/\r\n|\r|\n/).filter(Boolean); + for (const segment of segments) { + const fields = segment.split("|"); + if (fields[0] !== "OBX") continue; + const obsId = (fields[3] ?? "").split("^"); + const name = obsId[1] || obsId[0] || ""; + const rawValue = fields[5] ?? ""; + if (!name || !rawValue) continue; + const units = fields[6] ?? ""; + const abnormal = (fields[8] ?? "").toUpperCase(); + const flag: LabFlag = + abnormal === "H" + ? "high" + : abnormal === "L" + ? "low" + : abnormal === "HH" || abnormal === "LL" || abnormal === "AA" + ? "critical" + : "normal"; + const dt = fields[14] ?? ""; + // HL7 datetime is YYYYMMDD[HHMM]; format just the date portion. + const takenAt = /^\d{8}/.test(dt) + ? `${dt.slice(0, 4)}-${dt.slice(4, 6)}-${dt.slice(6, 8)}` + : new Date().toISOString().slice(0, 10); + labs.push({ + name, + value: units ? `${rawValue} ${units}` : rawValue, + flag, + takenAt, + }); + } + return labs; +} + +// Ingest a raw HL7 v2 ORU message: parse it and append the results to the +// patient's record. Used by the message-based intake endpoint. +export async function ingestHl7( + orgId: string, + fileNumber: string, + message: string, +): Promise<{ imported: number }> { + const labs = parseHl7Oru(message); + if (labs.length === 0) { + throw new HttpError(400, "No OBX result segments found in the message."); + } + const updated = await appendLabs(orgId, fileNumber, labs); + if (!updated) throw new HttpError(404, "Patient not found."); + await markStatus(orgId, "fhir", "connected", true); + return { imported: labs.length }; +} + +export { getEndpoint }; diff --git a/frontend/components/integrations/integration-actions.tsx b/frontend/components/integrations/integration-actions.tsx new file mode 100644 index 0000000..4b3d45b --- /dev/null +++ b/frontend/components/integrations/integration-actions.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { FileCheck, Send } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { ApiError } from "@/lib/api-client"; +import { + getIntegration, + sendEprescription, + submitInsuranceClaim, +} from "@/lib/integrations"; +import { notify } from "@/lib/toast"; + +// Renders only when the e-prescribing integration is enabled. Transmits the +// prescription as an NCPDP SCRIPT NewRx to the configured pharmacy gateway. +export function SendToPharmacyButton({ rxId }: { rxId: string }) { + const { t } = useTranslation(); + const [enabled, setEnabled] = useState(false); + const [sending, setSending] = useState(false); + + useEffect(() => { + let active = true; + getIntegration("eprescribe").then( + (c) => active && setEnabled(Boolean(c?.enabled)), + ); + return () => { + active = false; + }; + }, []); + + if (!enabled) return null; + + const send = async () => { + setSending(true); + try { + await sendEprescription(rxId); + notify.success( + t("integrations.eRx.sentTitle"), + t("integrations.eRx.sentBody"), + ); + } catch (err) { + notify.error( + t("integrations.eRx.failedTitle"), + err instanceof ApiError ? err.message : t("integrations.eRx.failedBody"), + ); + } finally { + setSending(false); + } + }; + + return ( + + ); +} + +// Renders only when the claims integration is enabled. Submits an X12 837P claim +// for the invoice to the configured clearinghouse and reports the remittance. +export function SubmitClaimButton({ invoiceId }: { invoiceId: string }) { + const { t } = useTranslation(); + const [enabled, setEnabled] = useState(false); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let active = true; + getIntegration("claims").then( + (c) => active && setEnabled(Boolean(c?.enabled)), + ); + return () => { + active = false; + }; + }, []); + + if (!enabled) return null; + + const submit = async () => { + setSubmitting(true); + try { + const result = await submitInsuranceClaim(invoiceId); + notify.success( + t("integrations.claims.submittedTitle"), + t("integrations.claims.submittedBody", { + status: result.claimStatus, + }), + ); + } catch (err) { + notify.error( + t("integrations.claims.failedTitle"), + err instanceof ApiError + ? err.message + : t("integrations.claims.failedBody"), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + + ); +} diff --git a/frontend/components/invoices/invoice-detail-sheet.tsx b/frontend/components/invoices/invoice-detail-sheet.tsx index 484e5d2..5e7965b 100644 --- a/frontend/components/invoices/invoice-detail-sheet.tsx +++ b/frontend/components/invoices/invoice-detail-sheet.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { AiBadge } from "@/components/ai-badge"; +import { SubmitClaimButton } from "@/components/integrations/integration-actions"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -357,6 +358,7 @@ export function InvoiceDetailSheet({ {t("invoices.sheet.download")} + {isSettled ? null : ( + + + ) : ( +
+
+ + setQuery(e.target.value)} + placeholder={t("integrations.fhir.searchPlaceholder")} + value={query} + /> +
+ {matches.length > 0 && ( +
+ {matches.map((p) => ( + + ))} +
+ )} +
+ )} + + ); +} diff --git a/frontend/components/lab/lab-view.tsx b/frontend/components/lab/lab-view.tsx index c8a692e..6162e04 100644 --- a/frontend/components/lab/lab-view.tsx +++ b/frontend/components/lab/lab-view.tsx @@ -39,6 +39,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { LabIntegrationCard } from "@/components/lab/lab-integration-card"; import { StagedFilesField } from "@/components/patients/patient-files"; import { uploadAttachment } from "@/lib/attachments"; import { LAB_ANALYSES, LAB_ANALYSIS_UNITS } from "@/lib/lab-analyses"; @@ -609,6 +610,18 @@ export function LabView() { + + listPatients() + .then((data) => { + setPatients(data); + setRecent(buildRecent(data)); + }) + .catch(() => {}) + } + patients={patients} + /> +

diff --git a/frontend/components/prescriptions/prescription-detail-sheet.tsx b/frontend/components/prescriptions/prescription-detail-sheet.tsx index cca4401..dacec25 100644 --- a/frontend/components/prescriptions/prescription-detail-sheet.tsx +++ b/frontend/components/prescriptions/prescription-detail-sheet.tsx @@ -3,6 +3,7 @@ import { Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { SendToPharmacyButton } from "@/components/integrations/integration-actions"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -142,12 +143,20 @@ export function PrescriptionDetailSheet({

)} - {rx && onDelete && ( + {rx && ( - + {onDelete && ( + + )} + )} diff --git a/frontend/components/settings/settings-integrations.tsx b/frontend/components/settings/settings-integrations.tsx new file mode 100644 index 0000000..31f4c16 --- /dev/null +++ b/frontend/components/settings/settings-integrations.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { CheckCircle2, CircleDashed, XCircle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + FieldLabel, + SettingsCard, + SettingsSection, +} from "@/components/settings/settings-parts"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + type IntegrationConfig, + type IntegrationType, + listIntegrations, + saveIntegration, + testIntegration, +} from "@/lib/integrations"; +import { notify } from "@/lib/toast"; + +const TYPES: IntegrationType[] = ["fhir", "eprescribe", "claims"]; + +function StatusBadge({ config }: { config: IntegrationConfig }) { + const { t } = useTranslation(); + if (config.status === "connected") { + return ( + + + {t("settings.integrations.status.connected")} + + ); + } + if (config.status === "error") { + return ( + + + {t("settings.integrations.status.error")} + + ); + } + return ( + + + {t("settings.integrations.status.unconfigured")} + + ); +} + +function IntegrationCard({ + type, + initial, +}: { + type: IntegrationType; + initial: IntegrationConfig; +}) { + const { t } = useTranslation(); + const [endpoint, setEndpoint] = useState(initial.endpoint); + const [enabled, setEnabled] = useState(initial.enabled); + // Empty = leave the stored secret untouched; typing replaces it. + const [credentials, setCredentials] = useState(""); + const [config, setConfig] = useState(initial); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + + const dirty = + endpoint !== config.endpoint || + enabled !== config.enabled || + credentials.length > 0; + + const save = async () => { + setSaving(true); + try { + const saved = await saveIntegration(type, { + endpoint, + enabled, + ...(credentials ? { credentials } : {}), + }); + setConfig(saved); + setEndpoint(saved.endpoint); + setEnabled(saved.enabled); + setCredentials(""); + notify.success( + t("settings.integrations.savedTitle"), + t(`settings.integrations.${type}.title`), + ); + } catch { + notify.error( + t("settings.integrations.saveFailedTitle"), + t("settings.integrations.saveFailedBody"), + ); + } finally { + setSaving(false); + } + }; + + const test = async () => { + setTesting(true); + try { + const result = await testIntegration(type); + if (result.ok) { + notify.success(t("settings.integrations.testOk"), result.message); + } else { + notify.error(t("settings.integrations.testFailed"), result.message); + } + } catch { + notify.error( + t("settings.integrations.testFailed"), + t("settings.integrations.testError"), + ); + } finally { + setTesting(false); + } + }; + + return ( + } + description={t(`settings.integrations.${type}.description`)} + title={t(`settings.integrations.${type}.title`)} + > + +
+ {t("settings.integrations.endpoint")} + setEndpoint(e.target.value)} + placeholder={t(`settings.integrations.${type}.endpointPlaceholder`)} + value={endpoint} + /> +
+ +
+ {t("settings.integrations.credentials")} + setCredentials(e.target.value)} + placeholder={ + config.hasCredentials + ? t("settings.integrations.credentialsSet") + : t(`settings.integrations.${type}.credentialsPlaceholder`) + } + type="password" + value={credentials} + /> +

+ {t(`settings.integrations.${type}.credentialsHint`)} +

+
+ + + +
+ + + {config.lastSyncAt ? ( + + {t("settings.integrations.lastSync", { + when: new Date(config.lastSyncAt).toLocaleString(), + })} + + ) : null} +
+
+
+ ); +} + +export function IntegrationsPanel() { + const { t } = useTranslation(); + const [configs, setConfigs] = useState(null); + + useEffect(() => { + let active = true; + listIntegrations() + .then((rows) => active && setConfigs(rows)) + .catch(() => active && setConfigs([])); + return () => { + active = false; + }; + }, []); + + if (configs === null) { + return ( +

+ {t("settings.integrations.loading")} +

+ ); + } + + return ( +
+

+ {t("settings.integrations.intro")} +

+ {TYPES.map((type) => { + const initial = + configs.find((c) => c.type === type) ?? + ({ + type, + endpoint: "", + enabled: false, + status: "unconfigured", + hasCredentials: false, + lastSyncAt: null, + } satisfies IntegrationConfig); + return ; + })} +
+ ); +} diff --git a/frontend/components/settings/settings-view.tsx b/frontend/components/settings/settings-view.tsx index 16dffe3..b1a19b8 100644 --- a/frontend/components/settings/settings-view.tsx +++ b/frontend/components/settings/settings-view.tsx @@ -8,6 +8,7 @@ import { AIPanel } from "@/components/settings/settings-ai"; import { SigningPanel } from "@/components/settings/settings-billing"; import { CareTeamPanel } from "@/components/settings/settings-care-team"; import { DevelopersPanel } from "@/components/settings/settings-developers"; +import { IntegrationsPanel } from "@/components/settings/settings-integrations"; import { ProfilePanel } from "@/components/settings/settings-preferences"; import { RecordsPanel } from "@/components/settings/settings-records"; import { useActiveRole } from "@/lib/roles"; @@ -18,6 +19,7 @@ const TABS = [ { id: "records", labelKey: "settings.tabs.records" }, { id: "signing", labelKey: "settings.tabs.signing" }, { id: "careTeam", labelKey: "settings.tabs.careTeam" }, + { id: "integrations", labelKey: "settings.tabs.integrations" }, { id: "developers", labelKey: "settings.tabs.developers" }, ] as const; @@ -70,6 +72,7 @@ export function SettingsView() { {activeTab === "records" && } {activeTab === "signing" && } {activeTab === "careTeam" && } + {activeTab === "integrations" && } {activeTab === "developers" && } diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 613354e..f92a07c 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -1103,6 +1103,36 @@ "uploadFailedTitle": "Some files didn't upload", "uploadFailedBody": "The record was saved, but one or more files failed to upload. Try adding them again from the record." }, + "integrations": { + "fhir": { + "cardTitle": "Lab system (HL7/FHIR)", + "disabledHint": "Not enabled. An owner or admin can connect a lab system in Settings → Integrations.", + "searchPlaceholder": "Search a patient to pull results…", + "change": "Change", + "sync": "Sync results", + "syncing": "Syncing…", + "syncedTitle": "Results synced", + "syncedBody": "Imported {{count}} result(s) for {{name}}.", + "failedTitle": "Sync failed", + "failedBody": "Couldn't reach the lab system. Please try again." + }, + "eRx": { + "send": "Send to pharmacy", + "sending": "Sending…", + "sentTitle": "Prescription sent", + "sentBody": "Transmitted to the pharmacy as an NCPDP SCRIPT NewRx.", + "failedTitle": "Couldn't send", + "failedBody": "The pharmacy gateway rejected the message or is unreachable." + }, + "claims": { + "submit": "Submit claim", + "submitting": "Submitting…", + "submittedTitle": "Claim submitted", + "submittedBody": "Clearinghouse status: {{status}}.", + "failedTitle": "Couldn't submit claim", + "failedBody": "The clearinghouse rejected the claim or is unreachable." + } + }, "patientCard": { "notFound": "No patient found for file #{{number}}.", "overview": "Overview", @@ -1277,8 +1307,55 @@ "records": "Records", "signing": "Signing", "careTeam": "Care team", + "integrations": "Integrations", "developers": "Developers" }, + "integrations": { + "loading": "Loading integrations…", + "intro": "Connect temetro to external healthcare systems. Point each integration at your vendor's sandbox or production endpoint and supply its credentials.", + "endpoint": "Endpoint URL", + "credentials": "Credentials", + "credentialsSet": "•••••••• (stored — type to replace)", + "enable": "Enable integration", + "enableHint": "When on, its actions appear on the relevant pages.", + "save": "Save", + "saving": "Saving…", + "test": "Test connection", + "testing": "Testing…", + "savedTitle": "Integration saved", + "saveFailedTitle": "Couldn't save", + "saveFailedBody": "Something went wrong, or you don't have permission. Please try again.", + "testOk": "Connection succeeded", + "testFailed": "Connection failed", + "testError": "Couldn't reach the endpoint.", + "lastSync": "Last activity {{when}}", + "status": { + "connected": "Connected", + "error": "Error", + "unconfigured": "Not configured" + }, + "fhir": { + "title": "Lab system (HL7/FHIR)", + "description": "Read lab results from a FHIR R4 server or HL7 v2 feed (e.g. a HAPI FHIR / SMART Health IT sandbox, or your lab's gateway).", + "endpointPlaceholder": "https://hapi.fhir.org/baseR4", + "credentialsPlaceholder": "Bearer token (optional for open sandboxes)", + "credentialsHint": "Sent as a Bearer token, or JSON {\"token\":\"…\"}. Leave blank for public sandboxes." + }, + "eprescribe": { + "title": "e-Prescribing (NCPDP SCRIPT)", + "description": "Transmit prescriptions to pharmacies as NCPDP SCRIPT NewRx messages. Production routing requires your Surescripts (or sandbox) account.", + "endpointPlaceholder": "https://your-pharmacy-gateway.example/script", + "credentialsPlaceholder": "JSON: {\"token\":\"…\",\"senderId\":\"…\"}", + "credentialsHint": "JSON with your gateway token and sender id." + }, + "claims": { + "title": "Insurance claims (X12 837/835)", + "description": "Submit professional claims (837P) to a clearinghouse and read remittances (835). Production requires your clearinghouse account.", + "endpointPlaceholder": "https://your-clearinghouse.example/claims", + "credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}", + "credentialsHint": "JSON with your clearinghouse token and submitter/receiver ids." + } + }, "empty": "Nothing here yet.", "copy": "Copy", "copied": "Copied", diff --git a/frontend/lib/integrations.ts b/frontend/lib/integrations.ts new file mode 100644 index 0000000..1467ca7 --- /dev/null +++ b/frontend/lib/integrations.ts @@ -0,0 +1,78 @@ +// Client for the backend integrations API (HL7/FHIR labs, e-prescribing, +// insurance claims). Config is owner/admin-only to write; status is readable by +// any member so pages can gate their on-page actions. + +import { apiFetch } from "@/lib/api-client"; + +export type IntegrationType = "fhir" | "eprescribe" | "claims"; +export type IntegrationStatus = "unconfigured" | "connected" | "error"; + +export type IntegrationConfig = { + type: IntegrationType; + endpoint: string; + enabled: boolean; + status: IntegrationStatus; + hasCredentials: boolean; + lastSyncAt: string | null; +}; + +export function listIntegrations(): Promise { + return apiFetch("/api/integrations"); +} + +export function saveIntegration( + type: IntegrationType, + input: { endpoint?: string; enabled?: boolean; credentials?: string }, +): Promise { + return apiFetch(`/api/integrations/${type}`, { + method: "PUT", + body: JSON.stringify(input), + }); +} + +export function testIntegration( + type: IntegrationType, +): Promise<{ ok: boolean; message: string }> { + return apiFetch(`/api/integrations/${type}/test`, { method: "POST" }); +} + +// Pull a patient's lab results from the FHIR server. +export function syncFhirLabs(fileNumber: string): Promise<{ imported: number }> { + return apiFetch("/api/integrations/fhir/sync", { + method: "POST", + body: JSON.stringify({ fileNumber }), + }); +} + +// Transmit a prescription to a pharmacy (NCPDP SCRIPT NewRx). +export function sendEprescription( + rxId: string, +): Promise<{ messageId: string; status: string }> { + return apiFetch("/api/integrations/eprescribe/send", { + method: "POST", + body: JSON.stringify({ rxId }), + }); +} + +// Submit an insurance claim for an invoice (X12 837P) and read the remittance. +export function submitInsuranceClaim( + invoiceId: string, +): Promise<{ claimStatus: string; paidAmount: number; submitted: boolean }> { + return apiFetch("/api/integrations/claims/submit", { + method: "POST", + body: JSON.stringify({ invoiceId }), + }); +} + +// Convenience hook-style fetch reused by the on-page sections: returns the +// config for one type (or null while loading/absent). +export async function getIntegration( + type: IntegrationType, +): Promise { + try { + const all = await listIntegrations(); + return all.find((c) => c.type === type) ?? null; + } catch { + return null; + } +}