From 0d2494d67aaa7c59478eeaa5215ba089956767dc Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sat, 4 Jul 2026 01:27:32 +0300 Subject: [PATCH] feat: read-only FHIR R4 server (share records over /fhir) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose temetro's own records as a read-only FHIR R4 server at /fhir, authenticated with per-clinic API keys (tmf_… bearer tokens, SHA-256 hashed, shown once). Serves Patient, Observation (labs + vitals), AllergyIntolerance, Condition, MedicationRequest, Encounter and Appointment as text-only CodeableConcepts (temetro stores free-text clinical values); CapabilityStatement at /fhir/metadata (unauth). Searchset Bundles with _count/_offset pagination and self/next/prev links; every request is org-scoped and written to the activity log. Keys are created/revoked under Settings → Integrations (owner/admin). Co-Authored-By: Claude Opus 4.8 --- backend/drizzle/0031_stiff_gateway.sql | 15 + backend/drizzle/meta/0031_snapshot.json | 4573 +++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/db/schema/fhir-keys.ts | 30 + backend/src/db/schema/index.ts | 1 + backend/src/index.ts | 7 + backend/src/middleware/fhir-auth.ts | 49 + backend/src/routes/fhir.ts | 275 + backend/src/routes/integrations.ts | 70 + backend/src/services/fhir-server/bundle.ts | 77 + .../src/services/fhir-server/capability.ts | 114 + backend/src/services/fhir-server/keys.ts | 110 + backend/src/services/fhir-server/outcome.ts | 36 + backend/src/services/fhir-server/queries.ts | 168 + backend/src/services/fhir-server/resources.ts | 317 ++ backend/src/types/express.d.ts | 3 + .../settings/settings-integrations.tsx | 218 +- frontend/lib/i18n/locales/ar/translation.json | 26 + frontend/lib/i18n/locales/de/translation.json | 26 + frontend/lib/i18n/locales/en/translation.json | 26 + frontend/lib/i18n/locales/fr/translation.json | 26 + frontend/lib/i18n/locales/so/translation.json | 26 + frontend/lib/integrations.ts | 31 + 23 files changed, 6230 insertions(+), 1 deletion(-) create mode 100644 backend/drizzle/0031_stiff_gateway.sql create mode 100644 backend/drizzle/meta/0031_snapshot.json create mode 100644 backend/src/db/schema/fhir-keys.ts create mode 100644 backend/src/middleware/fhir-auth.ts create mode 100644 backend/src/routes/fhir.ts create mode 100644 backend/src/services/fhir-server/bundle.ts create mode 100644 backend/src/services/fhir-server/capability.ts create mode 100644 backend/src/services/fhir-server/keys.ts create mode 100644 backend/src/services/fhir-server/outcome.ts create mode 100644 backend/src/services/fhir-server/queries.ts create mode 100644 backend/src/services/fhir-server/resources.ts diff --git a/backend/drizzle/0031_stiff_gateway.sql b/backend/drizzle/0031_stiff_gateway.sql new file mode 100644 index 0000000..12d1422 --- /dev/null +++ b/backend/drizzle/0031_stiff_gateway.sql @@ -0,0 +1,15 @@ +CREATE TABLE "fhir_api_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" text NOT NULL, + "name" text NOT NULL, + "key_hash" text NOT NULL, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "last_used_at" timestamp, + "revoked_at" timestamp, + CONSTRAINT "fhir_api_keys_key_hash_unique" UNIQUE("key_hash") +); +--> statement-breakpoint +ALTER TABLE "fhir_api_keys" ADD CONSTRAINT "fhir_api_keys_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "fhir_api_keys" ADD CONSTRAINT "fhir_api_keys_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "fhir_api_keys_org_idx" ON "fhir_api_keys" USING btree ("organization_id"); \ No newline at end of file diff --git a/backend/drizzle/meta/0031_snapshot.json b/backend/drizzle/meta/0031_snapshot.json new file mode 100644 index 0000000..9c60938 --- /dev/null +++ b/backend/drizzle/meta/0031_snapshot.json @@ -0,0 +1,4573 @@ +{ + "id": "0936f805-73ef-4f20-90a5-447e0d6bca1c", + "prevId": "0f4b24cc-a168-473f-80b1-a564f2d03459", + "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'" + }, + "share_origin": { + "name": "share_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "share_expires_at": { + "name": "share_expires_at", + "type": "timestamp", + "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": { + "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 + }, + "assignee_user_id": { + "name": "assignee_user_id", + "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_assignee_user_id_user_id_fk": { + "name": "tasks_assignee_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "assignee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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.email_settings": { + "name": "email_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "from_address": { + "name": "from_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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 + }, + "public.staff_profile": { + "name": "staff_profile", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "specialty": { + "name": "specialty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "staff_profile_org_user_idx": { + "name": "staff_profile_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_profile_organization_id_organization_id_fk": { + "name": "staff_profile_organization_id_organization_id_fk", + "tableFrom": "staff_profile", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "staff_profile_user_id_user_id_fk": { + "name": "staff_profile_user_id_user_id_fk", + "tableFrom": "staff_profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_rooms": { + "name": "meeting_rooms", + "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 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_rooms_org_idx": { + "name": "meeting_rooms_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_rooms_organization_id_organization_id_fk": { + "name": "meeting_rooms_organization_id_organization_id_fk", + "tableFrom": "meeting_rooms", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_rooms_created_by_user_id_fk": { + "name": "meeting_rooms_created_by_user_id_fk", + "tableFrom": "meeting_rooms", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_meetings": { + "name": "scheduled_meetings", + "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 + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participants": { + "name": "participants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_meetings_org_idx": { + "name": "scheduled_meetings_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_meetings_organization_id_organization_id_fk": { + "name": "scheduled_meetings_organization_id_organization_id_fk", + "tableFrom": "scheduled_meetings", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scheduled_meetings_created_by_user_id_fk": { + "name": "scheduled_meetings_created_by_user_id_fk", + "tableFrom": "scheduled_meetings", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clinic_signing_keys": { + "name": "clinic_signing_keys", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ed25519'" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_enc": { + "name": "private_key_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "clinic_signing_keys_organization_id_organization_id_fk": { + "name": "clinic_signing_keys_organization_id_organization_id_fk", + "tableFrom": "clinic_signing_keys", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_share_requests": { + "name": "wallet_share_requests", + "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 + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_number": { + "name": "wallet_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ephemeral_pub_key": { + "name": "ephemeral_pub_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ephemeral_priv_enc": { + "name": "ephemeral_priv_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "share_mode": { + "name": "share_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'permanent'" + }, + "share_expires_at": { + "name": "share_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "committed_file_number": { + "name": "committed_file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "wallet_share_org_idx": { + "name": "wallet_share_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_share_wallet_idx": { + "name": "wallet_share_wallet_idx", + "columns": [ + { + "expression": "wallet_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_share_requests_organization_id_organization_id_fk": { + "name": "wallet_share_requests_organization_id_organization_id_fk", + "tableFrom": "wallet_share_requests", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wallet_share_requests_requested_by_user_id_fk": { + "name": "wallet_share_requests_requested_by_user_id_fk", + "tableFrom": "wallet_share_requests", + "tableTo": "user", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_record_updates": { + "name": "wallet_record_updates", + "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 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_number": { + "name": "file_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_number": { + "name": "wallet_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload_sealed": { + "name": "payload_sealed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clinic_signature": { + "name": "clinic_signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clinic_public_key": { + "name": "clinic_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clinic_fingerprint": { + "name": "clinic_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "wallet_updates_org_idx": { + "name": "wallet_updates_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_updates_wallet_idx": { + "name": "wallet_updates_wallet_idx", + "columns": [ + { + "expression": "wallet_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_record_updates_organization_id_organization_id_fk": { + "name": "wallet_record_updates_organization_id_organization_id_fk", + "tableFrom": "wallet_record_updates", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wallet_record_updates_created_by_user_id_fk": { + "name": "wallet_record_updates_created_by_user_id_fk", + "tableFrom": "wallet_record_updates", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fhir_api_keys": { + "name": "fhir_api_keys", + "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 + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "fhir_api_keys_org_idx": { + "name": "fhir_api_keys_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fhir_api_keys_organization_id_organization_id_fk": { + "name": "fhir_api_keys_organization_id_organization_id_fk", + "tableFrom": "fhir_api_keys", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fhir_api_keys_created_by_user_id_fk": { + "name": "fhir_api_keys_created_by_user_id_fk", + "tableFrom": "fhir_api_keys", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fhir_api_keys_key_hash_unique": { + "name": "fhir_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "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 db2e36f..73375e6 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1783093188246, "tag": "0030_medical_blur", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1783117115021, + "tag": "0031_stiff_gateway", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema/fhir-keys.ts b/backend/src/db/schema/fhir-keys.ts new file mode 100644 index 0000000..7d9f39b --- /dev/null +++ b/backend/src/db/schema/fhir-keys.ts @@ -0,0 +1,30 @@ +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +import { organization, user } from "./auth.js"; + +// Per-organization API keys for the read-only FHIR R4 server (`/fhir`). These +// are machine-to-machine credentials (no Better Auth session): a caller sends +// `Authorization: Bearer tmf_` and every query is scoped to the owning +// clinic. Only the SHA-256 *hash* of the secret is stored — the plaintext key is +// shown once at creation and never again. Revoking sets `revokedAt` (kept for +// audit rather than hard-deleted). +export const fhirApiKeys = pgTable( + "fhir_api_keys", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + name: text("name").notNull(), + // Hex SHA-256 of the full `tmf_…` secret. Unique so a lookup is a single + // indexed probe and two keys can never collide. + keyHash: text("key_hash").notNull().unique(), + createdBy: text("created_by").references(() => user.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + lastUsedAt: timestamp("last_used_at"), + revokedAt: timestamp("revoked_at"), + }, + (t) => [index("fhir_api_keys_org_idx").on(t.organizationId)], +); diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 84382e5..4756f7b 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -22,3 +22,4 @@ export * from "./meetings.js"; export * from "./signing.js"; export * from "./wallet-share.js"; export * from "./wallet-updates.js"; +export * from "./fhir-keys.js"; diff --git a/backend/src/index.ts b/backend/src/index.ts index 9316c33..62f0132 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,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 { fhirRouter } from "./routes/fhir.js"; import { integrationsRouter } from "./routes/integrations.js"; import { inventoryRouter } from "./routes/inventory.js"; import { invoicesRouter } from "./routes/invoices.js"; @@ -110,6 +111,11 @@ app.use("/api/integrations", integrationsRouter); app.use("/api/portal", portalRouter); app.use("/api/auth-helpers", authHelpersRouter); +// Read-only FHIR R4 server, mounted OUTSIDE /api. Bearer-only (per-clinic API +// keys), no Better Auth session/cookie coupling. Errors are FHIR +// OperationOutcomes, not our standard error JSON. +app.use("/fhir", fhirRouter); + app.use(notFound); app.use(errorHandler); @@ -146,6 +152,7 @@ server.listen(env.PORT, () => { console.log(` • chat: /api/chat (LLM agent)`); console.log(` • integr.: /api/integrations (FHIR / e-Rx / claims)`); console.log(` • portal: /api/portal (public clinic kiosk)`); + console.log(` • fhir: /fhir (read-only FHIR R4 server, API-key auth)`); console.log(` • signing: /api/signing (Ed25519 clinic key)`); console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`); }); diff --git a/backend/src/middleware/fhir-auth.ts b/backend/src/middleware/fhir-auth.ts new file mode 100644 index 0000000..15db6d8 --- /dev/null +++ b/backend/src/middleware/fhir-auth.ts @@ -0,0 +1,49 @@ +import type { NextFunction, Request, Response } from "express"; + +import { resolveKey } from "../services/fhir-server/keys.js"; +import { + FHIR_CONTENT_TYPE, + operationOutcome, +} from "../services/fhir-server/outcome.js"; + +// Bearer-token auth for the read-only FHIR server. Unlike the rest of the API +// (Better Auth session cookies), the `/fhir` endpoints authenticate with a +// per-clinic API key: `Authorization: Bearer tmf_`. On success the +// caller's organization is attached to `req.organizationId` and every downstream +// query is scoped to it. Failures return a FHIR OperationOutcome, not our +// standard error JSON. +export async function requireFhirKey( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const header = req.headers.authorization ?? ""; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + const secret = match?.[1]?.trim(); + + const unauthorized = (diagnostics: string) => { + res + .status(401) + .type(FHIR_CONTENT_TYPE) + .set("WWW-Authenticate", "Bearer") + .json(operationOutcome("error", "login", diagnostics)); + }; + + if (!secret) { + unauthorized("Missing bearer token. Send Authorization: Bearer tmf_…"); + return; + } + + try { + const resolved = await resolveKey(secret); + if (!resolved) { + unauthorized("Invalid or revoked API key."); + return; + } + req.organizationId = resolved.orgId; + req.fhirKey = { id: resolved.keyId, name: resolved.keyName }; + next(); + } catch (err) { + next(err); + } +} diff --git a/backend/src/routes/fhir.ts b/backend/src/routes/fhir.ts new file mode 100644 index 0000000..a734b44 --- /dev/null +++ b/backend/src/routes/fhir.ts @@ -0,0 +1,275 @@ +import { createRequire } from "node:module"; + +import { Router } from "express"; +import type { Request, Response } from "express"; +import type { ParsedQs } from "qs"; + +import { env } from "../env.js"; +import { requireFhirKey } from "../middleware/fhir-auth.js"; +import { recordActivity } from "../services/activity.js"; +import { + paginate, + parseCount, + parseOffset, + searchsetBundle, +} from "../services/fhir-server/bundle.js"; +import { capabilityStatement } from "../services/fhir-server/capability.js"; +import { + FHIR_CONTENT_TYPE, + operationOutcome, + type IssueCode, + type IssueSeverity, +} from "../services/fhir-server/outcome.js"; +import * as q from "../services/fhir-server/queries.js"; +import { + allergyResource, + appointmentResource, + conditionResource, + encounterResource, + labObservation, + medicationRequestResource, + patientResource, + vitalObservations, + type FhirResource, +} from "../services/fhir-server/resources.js"; + +const require = createRequire(import.meta.url); +const pkg = require("../../package.json") as { version?: string }; +const VERSION = env.APP_VERSION ?? pkg.version ?? "0.0.0"; + +export const fhirRouter = Router(); + +// --- helpers ---------------------------------------------------------------- + +function baseUrl(req: Request): string { + return `${req.protocol}://${req.get("host")}/fhir`; +} + +function sendResource(res: Response, resource: unknown): void { + res.type(FHIR_CONTENT_TYPE).json(resource); +} + +function sendOutcome( + res: Response, + status: number, + severity: IssueSeverity, + code: IssueCode, + diagnostics: string, +): void { + res + .status(status) + .type(FHIR_CONTENT_TYPE) + .json(operationOutcome(severity, code, diagnostics)); +} + +function qstr(v: string | ParsedQs | (string | ParsedQs)[] | undefined): string | undefined { + if (typeof v === "string") return v.trim() || undefined; + if (Array.isArray(v) && typeof v[0] === "string") return v[0].trim() || undefined; + return undefined; +} + +// Best-effort audit: every FHIR request is logged with the key name + result +// count, scoped to the org. Access to PHI over the API must leave a trail. +function audit(req: Request, resourceType: string, count: number): void { + void recordActivity({ + orgId: req.organizationId!, + actor: { name: `FHIR API · ${req.fhirKey?.name ?? "key"}` }, + action: `Read ${resourceType} via the FHIR API (${count} result${count === 1 ? "" : "s"})`, + entityType: "patient", + }); +} + +// Materialize a page from a full resource array + emit a searchset Bundle. +function respondSearch( + req: Request, + res: Response, + resourceType: string, + all: FhirResource[], +): void { + const count = parseCount(qstr(req.query._count as never)); + const offset = parseOffset(qstr(req.query._offset as never)); + const { page, total } = paginate(all, count, offset); + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(req.query)) { + if (k === "_count" || k === "_offset") continue; + const s = qstr(v as never); + if (s !== undefined) params.set(k, s); + } + audit(req, resourceType, total); + sendResource( + res, + searchsetBundle({ baseUrl: baseUrl(req), resourceType, page, total, count, offset, params }), + ); +} + +// Resolve the `patient` / `patient.identifier` search parameter to a patient row +// (org-scoped). Returns undefined when the param is absent or matches nobody. +async function patientFromQuery(req: Request) { + const patientId = qstr(req.query.patient as never); + const identifier = qstr(req.query["patient.identifier"] as never); + if (!patientId && !identifier) return undefined; + return q.resolvePatientRef(req.organizationId!, { patientId, identifier }); +} + +// --- CapabilityStatement (unauthenticated, per FHIR convention) ------------- + +fhirRouter.get("/metadata", (req, res) => { + sendResource(res, capabilityStatement(baseUrl(req), VERSION)); +}); + +// Everything below requires a valid per-clinic API key. +fhirRouter.use(requireFhirKey); + +// --- Patient ---------------------------------------------------------------- + +fhirRouter.get("/Patient", async (req, res, next) => { + try { + const count = parseCount(qstr(req.query._count as never)); + const offset = parseOffset(qstr(req.query._offset as never)); + const { rows, total } = await q.searchPatients(req.organizationId!, { + identifier: qstr(req.query.identifier as never), + name: qstr(req.query.name as never), + limit: count, + offset, + }); + const params = new URLSearchParams(); + if (qstr(req.query.identifier as never)) + params.set("identifier", qstr(req.query.identifier as never)!); + if (qstr(req.query.name as never)) params.set("name", qstr(req.query.name as never)!); + audit(req, "Patient", total); + sendResource( + res, + searchsetBundle({ + baseUrl: baseUrl(req), + resourceType: "Patient", + page: rows.map(patientResource), + total, + count, + offset, + params, + }), + ); + } catch (err) { + next(err); + } +}); + +fhirRouter.get("/Patient/:id", async (req, res, next) => { + try { + const row = await q.patientById(req.organizationId!, String(req.params.id)); + if (!row) { + sendOutcome(res, 404, "error", "not-found", "Patient not found."); + return; + } + audit(req, "Patient", 1); + sendResource(res, patientResource(row)); + } catch (err) { + next(err); + } +}); + +// --- Observation (labs + vitals) -------------------------------------------- + +fhirRouter.get("/Observation", async (req, res, next) => { + try { + const patient = await patientFromQuery(req); + if (!patient) { + respondSearch(req, res, "Observation", []); + return; + } + const category = qstr(req.query.category as never); + const all: FhirResource[] = []; + if (category !== "vital-signs") { + const rows = await q.labsForPatient(patient.id); + all.push(...rows.map((r) => labObservation(r, patient))); + } + if (category !== "laboratory") { + all.push(...vitalObservations(patient)); + } + respondSearch(req, res, "Observation", all); + } catch (err) { + next(err); + } +}); + +// --- AllergyIntolerance ----------------------------------------------------- + +fhirRouter.get("/AllergyIntolerance", async (req, res, next) => { + try { + const patient = await patientFromQuery(req); + if (!patient) return respondSearch(req, res, "AllergyIntolerance", []); + const rows = await q.allergiesForPatient(patient.id); + respondSearch(req, res, "AllergyIntolerance", rows.map((r) => allergyResource(r, patient))); + } catch (err) { + next(err); + } +}); + +// --- Condition -------------------------------------------------------------- + +fhirRouter.get("/Condition", async (req, res, next) => { + try { + const patient = await patientFromQuery(req); + if (!patient) return respondSearch(req, res, "Condition", []); + const rows = await q.problemsForPatient(patient.id); + respondSearch(req, res, "Condition", rows.map((r) => conditionResource(r, patient))); + } catch (err) { + next(err); + } +}); + +// --- MedicationRequest ------------------------------------------------------ + +fhirRouter.get("/MedicationRequest", async (req, res, next) => { + try { + const patient = await patientFromQuery(req); + if (!patient) return respondSearch(req, res, "MedicationRequest", []); + const rows = await q.prescriptionsForFile(req.organizationId!, patient.fileNumber); + respondSearch( + req, + res, + "MedicationRequest", + rows.map((r) => medicationRequestResource(r, patient)), + ); + } catch (err) { + next(err); + } +}); + +// --- Encounter -------------------------------------------------------------- + +fhirRouter.get("/Encounter", async (req, res, next) => { + try { + const patient = await patientFromQuery(req); + if (!patient) return respondSearch(req, res, "Encounter", []); + const rows = await q.encountersForPatient(patient.id); + respondSearch(req, res, "Encounter", rows.map((r) => encounterResource(r, patient))); + } catch (err) { + next(err); + } +}); + +// --- Appointment ------------------------------------------------------------ + +fhirRouter.get("/Appointment", async (req, res, next) => { + try { + const patient = await patientFromQuery(req); + if (!patient) return respondSearch(req, res, "Appointment", []); + const rows = await q.appointmentsForFile(req.organizationId!, patient.fileNumber); + respondSearch(req, res, "Appointment", rows.map((r) => appointmentResource(r, patient))); + } catch (err) { + next(err); + } +}); + +// --- Unknown resource / path -> OperationOutcome ---------------------------- + +fhirRouter.use((req, res) => { + sendOutcome( + res, + 404, + "error", + "not-supported", + `Unsupported FHIR path or resource: ${req.method} ${req.path}.`, + ); +}); diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts index 214eb14..28b0750 100644 --- a/backend/src/routes/integrations.ts +++ b/backend/src/routes/integrations.ts @@ -18,6 +18,7 @@ import { listConfigs, saveConfig, } from "../services/integrations/config.js"; +import { createKey, listKeys, revokeKey } from "../services/fhir-server/keys.js"; import * as eprescribe from "../services/integrations/eprescribe.js"; import * as fhir from "../services/integrations/fhir.js"; @@ -119,6 +120,75 @@ integrationsRouter.post( }, ); +// --- FHIR server API keys (owner/admin only) -------------------------------- +// These credential the read-only /fhir server. The plaintext secret is returned +// exactly once (on creation) and only its hash is stored. + +integrationsRouter.get( + "/fhir-server/keys", + requireAuth, + requireOrg, + async (req, res, next) => { + try { + assertAdmin(req.memberRole); + res.json(await listKeys(req.organizationId!)); + } catch (err) { + next(err); + } + }, +); + +const createKeySchema = z.object({ name: z.string().trim().min(1).max(120) }); + +integrationsRouter.post( + "/fhir-server/keys", + requireAuth, + requireOrg, + async (req, res, next) => { + try { + assertAdmin(req.memberRole); + const { name } = createKeySchema.parse(req.body); + const { secret, key } = await createKey( + req.organizationId!, + name, + req.user!.id, + ); + void recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Created a FHIR API key ("${key.name}")`, + entityType: "settings", + }); + // `secret` is present only in this response — the client must show it now. + res.status(201).json({ ...key, secret }); + } catch (err) { + next(err); + } + }, +); + +integrationsRouter.delete( + "/fhir-server/keys/:id", + requireAuth, + requireOrg, + async (req, res, next) => { + try { + assertAdmin(req.memberRole); + const revoked = await revokeKey(req.organizationId!, String(req.params.id)); + if (!revoked) throw new HttpError(404, "API key not found."); + void recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: "Revoked a FHIR API key", + entityType: "settings", + }); + res.json({ revoked: true }); + } catch (err) { + next(err); + } + }, +); + // --- Actions ---------------------------------------------------------------- const syncSchema = z.object({ fileNumber: z.string().trim().min(1) }); diff --git a/backend/src/services/fhir-server/bundle.ts b/backend/src/services/fhir-server/bundle.ts new file mode 100644 index 0000000..71540f4 --- /dev/null +++ b/backend/src/services/fhir-server/bundle.ts @@ -0,0 +1,77 @@ +import type { FhirResource } from "./resources.js"; + +// searchset Bundle assembly + offset/limit pagination for the FHIR server. + +export const DEFAULT_COUNT = 50; +export const MAX_COUNT = 200; + +// Clamp a client-supplied `_count` into [1, MAX_COUNT], defaulting when absent. +export function parseCount(raw: string | undefined): number { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return DEFAULT_COUNT; + return Math.min(Math.floor(n), MAX_COUNT); +} + +export function parseOffset(raw: string | undefined): number { + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.floor(n); +} + +// Slice an already-materialized resource array to the requested page. +export function paginate( + all: T[], + count: number, + offset: number, +): { page: T[]; total: number } { + return { page: all.slice(offset, offset + count), total: all.length }; +} + +export type SearchsetBundle = { + resourceType: "Bundle"; + type: "searchset"; + total: number; + link: { relation: string; url: string }[]; + entry: { fullUrl: string; resource: FhirResource; search: { mode: "match" } }[]; +}; + +// Build a FHIR searchset Bundle. `page` is the current slice; `total` the full +// match count; `params` the effective query (already carrying `_count`/`_offset`) +// used to derive self/next/prev links. +export function searchsetBundle(opts: { + baseUrl: string; // e.g. https://host/fhir + resourceType: string; + page: FhirResource[]; + total: number; + count: number; + offset: number; + params: URLSearchParams; +}): SearchsetBundle { + const { baseUrl, resourceType, page, total, count, offset, params } = opts; + + const linkFor = (nextOffset: number): string => { + const q = new URLSearchParams(params); + q.set("_count", String(count)); + q.set("_offset", String(nextOffset)); + return `${baseUrl}/${resourceType}?${q.toString()}`; + }; + + const link: { relation: string; url: string }[] = [ + { relation: "self", url: linkFor(offset) }, + ]; + if (offset + count < total) link.push({ relation: "next", url: linkFor(offset + count) }); + if (offset > 0) + link.push({ relation: "previous", url: linkFor(Math.max(0, offset - count)) }); + + return { + resourceType: "Bundle", + type: "searchset", + total, + link, + entry: page.map((resource) => ({ + fullUrl: `${baseUrl}/${resource.resourceType}/${resource.id}`, + resource, + search: { mode: "match" }, + })), + }; +} diff --git a/backend/src/services/fhir-server/capability.ts b/backend/src/services/fhir-server/capability.ts new file mode 100644 index 0000000..5ca84dc --- /dev/null +++ b/backend/src/services/fhir-server/capability.ts @@ -0,0 +1,114 @@ +// A static CapabilityStatement describing exactly what this read-only FHIR R4 +// server supports. It is intentionally honest: only the resources and search +// params implemented below are listed, everything is `read`/`search-type` only, +// and clinical concepts are text-only (no SNOMED/LOINC coding). + +type ResourceCapability = { + type: string; + interaction: { code: "read" | "search-type" }[]; + searchParam?: { name: string; type: "token" | "string" | "reference" }[]; +}; + +const RESOURCES: ResourceCapability[] = [ + { + type: "Patient", + interaction: [{ code: "read" }, { code: "search-type" }], + searchParam: [ + { name: "identifier", type: "token" }, + { name: "name", type: "string" }, + ], + }, + { + type: "Observation", + interaction: [{ code: "read" }, { code: "search-type" }], + searchParam: [ + { name: "patient", type: "reference" }, + { name: "patient.identifier", type: "token" }, + { name: "category", type: "token" }, + ], + }, + { + type: "AllergyIntolerance", + interaction: [{ code: "read" }, { code: "search-type" }], + searchParam: [ + { name: "patient", type: "reference" }, + { name: "patient.identifier", type: "token" }, + ], + }, + { + type: "Condition", + interaction: [{ code: "read" }, { code: "search-type" }], + searchParam: [ + { name: "patient", type: "reference" }, + { name: "patient.identifier", type: "token" }, + ], + }, + { + type: "MedicationRequest", + interaction: [{ code: "read" }, { code: "search-type" }], + searchParam: [ + { name: "patient", type: "reference" }, + { name: "patient.identifier", type: "token" }, + ], + }, + { + type: "Encounter", + interaction: [{ code: "read" }, { code: "search-type" }], + searchParam: [ + { name: "patient", type: "reference" }, + { name: "patient.identifier", type: "token" }, + ], + }, + { + type: "Appointment", + interaction: [{ code: "read" }, { code: "search-type" }], + searchParam: [ + { name: "patient", type: "reference" }, + { name: "patient.identifier", type: "token" }, + ], + }, +]; + +export function capabilityStatement( + baseUrl: string, + version: string, +): Record { + return { + resourceType: "CapabilityStatement", + status: "active", + date: new Date().toISOString(), + publisher: "temetro", + kind: "instance", + implementation: { description: "temetro FHIR server", url: baseUrl }, + software: { name: "temetro", version }, + fhirVersion: "4.0.1", + format: ["application/fhir+json", "json"], + rest: [ + { + mode: "server", + documentation: + "Read-only FHIR R4 server. Authenticate with a per-clinic API key: " + + "Authorization: Bearer tmf_…. Clinical values are text-only " + + "CodeableConcepts (no SNOMED/LOINC). Patients expose age (extension), " + + "not birthDate. Pagination via _count (default 50, max 200) and _offset.", + security: { + description: "Bearer token (per-organization API key, tmf_ prefix).", + service: [ + { + coding: [ + { + system: + "http://terminology.hl7.org/CodeSystem/restful-security-service", + code: "OAuth", + display: "OAuth", + }, + ], + text: "API key bearer token", + }, + ], + }, + resource: RESOURCES, + }, + ], + }; +} diff --git a/backend/src/services/fhir-server/keys.ts b/backend/src/services/fhir-server/keys.ts new file mode 100644 index 0000000..6e9c0df --- /dev/null +++ b/backend/src/services/fhir-server/keys.ts @@ -0,0 +1,110 @@ +import { createHash, randomBytes } from "node:crypto"; + +import { and, desc, eq, isNull } from "drizzle-orm"; + +import { db } from "../../db/index.js"; +import { fhirApiKeys } from "../../db/schema/fhir-keys.js"; + +// FHIR-server API keys. The secret is `tmf_` + 32 random bytes (base64url); we +// persist only its SHA-256 hash, so a leaked database never yields usable keys +// and the plaintext is returned exactly once (at creation). + +const PREFIX = "tmf_"; + +export type FhirKeyView = { + id: string; + name: string; + createdAt: string; + lastUsedAt: string | null; + revoked: boolean; +}; + +function hashKey(secret: string): string { + return createHash("sha256").update(secret).digest("hex"); +} + +function toView(row: typeof fhirApiKeys.$inferSelect): FhirKeyView { + return { + id: row.id, + name: row.name, + createdAt: row.createdAt.toISOString(), + lastUsedAt: row.lastUsedAt ? row.lastUsedAt.toISOString() : null, + revoked: row.revokedAt !== null, + }; +} + +// List a clinic's keys (active first, then revoked), newest first. Never +// exposes the hash. +export async function listKeys(orgId: string): Promise { + const rows = await db + .select() + .from(fhirApiKeys) + .where(eq(fhirApiKeys.organizationId, orgId)) + .orderBy(desc(fhirApiKeys.createdAt)); + return rows.map(toView); +} + +// Mint a new key. Returns the one-time plaintext secret alongside the stored +// view — the caller must surface the secret to the user immediately; it is not +// recoverable afterwards. +export async function createKey( + orgId: string, + name: string, + createdBy: string, +): Promise<{ secret: string; key: FhirKeyView }> { + const secret = PREFIX + randomBytes(32).toString("base64url"); + const [row] = await db + .insert(fhirApiKeys) + .values({ + organizationId: orgId, + name: name.trim() || "FHIR key", + keyHash: hashKey(secret), + createdBy, + }) + .returning(); + return { secret, key: toView(row!) }; +} + +// Revoke a key (idempotent). Scoped to the org so one clinic can't revoke +// another's. Returns false if no such active key exists. +export async function revokeKey(orgId: string, id: string): Promise { + const result = await db + .update(fhirApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(fhirApiKeys.id, id), + eq(fhirApiKeys.organizationId, orgId), + isNull(fhirApiKeys.revokedAt), + ), + ) + .returning({ id: fhirApiKeys.id }); + return result.length > 0; +} + +export type ResolvedKey = { orgId: string; keyId: string; keyName: string }; + +// Resolve a presented bearer secret to its owning organization (plus the key's +// identity, for the audit log), or null when it is unknown or revoked. Bumps +// `lastUsedAt` (throttled to once a minute) so the key list can show recent +// activity without a write on every request. +export async function resolveKey(secret: string): Promise { + if (!secret.startsWith(PREFIX)) return null; + const [row] = await db + .select() + .from(fhirApiKeys) + .where(eq(fhirApiKeys.keyHash, hashKey(secret))) + .limit(1); + if (!row || row.revokedAt) return null; + + const now = Date.now(); + const last = row.lastUsedAt?.getTime() ?? 0; + if (now - last > 60_000) { + void db + .update(fhirApiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(fhirApiKeys.id, row.id)) + .catch(() => {}); + } + return { orgId: row.organizationId, keyId: row.id, keyName: row.name }; +} diff --git a/backend/src/services/fhir-server/outcome.ts b/backend/src/services/fhir-server/outcome.ts new file mode 100644 index 0000000..e828c8f --- /dev/null +++ b/backend/src/services/fhir-server/outcome.ts @@ -0,0 +1,36 @@ +// FHIR OperationOutcome helpers. Errors on a FHIR endpoint are returned as an +// OperationOutcome resource (not our usual `{ error }` JSON), with the +// `application/fhir+json` content type, so conformant clients can parse them. + +export const FHIR_CONTENT_TYPE = "application/fhir+json"; + +export type IssueSeverity = "fatal" | "error" | "warning" | "information"; +export type IssueCode = + | "not-found" + | "not-supported" + | "security" + | "login" + | "forbidden" + | "invalid" + | "processing" + | "exception"; + +export type OperationOutcome = { + resourceType: "OperationOutcome"; + issue: { + severity: IssueSeverity; + code: IssueCode; + diagnostics?: string; + }[]; +}; + +export function operationOutcome( + severity: IssueSeverity, + code: IssueCode, + diagnostics: string, +): OperationOutcome { + return { + resourceType: "OperationOutcome", + issue: [{ severity, code, diagnostics }], + }; +} diff --git a/backend/src/services/fhir-server/queries.ts b/backend/src/services/fhir-server/queries.ts new file mode 100644 index 0000000..98c7992 --- /dev/null +++ b/backend/src/services/fhir-server/queries.ts @@ -0,0 +1,168 @@ +import { and, asc, count, eq, ilike, sql } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; + +import { db } from "../../db/index.js"; +import { appointments } from "../../db/schema/appointments.js"; +import { + allergies, + encounters, + labs, + medications, + patients, + problems, +} from "../../db/schema/patients.js"; +import { prescriptions } from "../../db/schema/prescriptions.js"; + +// Narrow, org-scoped reads for the FHIR server. Deliberately separate from the +// app's `services/patients.ts` (which returns the reshaped canonical Patient and +// applies role redaction): the FHIR layer needs raw rows *with their UUIDs* to +// mint stable resource ids, and offset/limit pagination the app service doesn't +// expose. Every function is scoped to a single organization. + +export type PatientRow = typeof patients.$inferSelect; +export type LabRow = typeof labs.$inferSelect; +export type AllergyRow = typeof allergies.$inferSelect; +export type ProblemRow = typeof problems.$inferSelect; +export type EncounterRow = typeof encounters.$inferSelect; +export type PrescriptionRow = typeof prescriptions.$inferSelect; +export type AppointmentRow = typeof appointments.$inferSelect; + +// --- Patient ---------------------------------------------------------------- + +// Paginated Patient search. `identifier` matches the MRN (file number) exactly; +// `name` is a case-insensitive substring. Returns the page plus the full total +// for the searchset Bundle. +export async function searchPatients( + orgId: string, + opts: { identifier?: string; name?: string; limit: number; offset: number }, +): Promise<{ rows: PatientRow[]; total: number }> { + const filters: SQL[] = [eq(patients.organizationId, orgId)]; + if (opts.identifier) filters.push(eq(patients.fileNumber, opts.identifier)); + if (opts.name) filters.push(ilike(patients.name, `%${opts.name}%`)); + const where = and(...filters); + + const [rows, [totalRow]] = await Promise.all([ + db + .select() + .from(patients) + .where(where) + .orderBy(asc(patients.fileNumber)) + .limit(opts.limit) + .offset(opts.offset), + db.select({ value: count() }).from(patients).where(where), + ]); + + return { rows, total: totalRow?.value ?? 0 }; +} + +// A single patient by FHIR logical id (the row UUID), scoped to the org. +export async function patientById( + orgId: string, + id: string, +): Promise { + // Guard against a non-UUID id: Postgres would otherwise error on the cast. + if (!/^[0-9a-f-]{36}$/i.test(id)) return undefined; + const [row] = await db + .select() + .from(patients) + .where(and(eq(patients.organizationId, orgId), eq(patients.id, id))) + .limit(1); + return row; +} + +// Resolve a `patient` search parameter to a patient row. Accepts either the FHIR +// logical id (`patient=`) or the MRN (`patient.identifier=`). +export async function resolvePatientRef( + orgId: string, + ref: { patientId?: string; identifier?: string }, +): Promise { + if (ref.patientId) { + // A reference may arrive as "Patient/" or a bare id. + const id = ref.patientId.replace(/^Patient\//, ""); + return patientById(orgId, id); + } + if (ref.identifier) { + const [row] = await db + .select() + .from(patients) + .where( + and( + eq(patients.organizationId, orgId), + eq(patients.fileNumber, ref.identifier), + ), + ) + .limit(1); + return row; + } + return undefined; +} + +// --- Clinical child rows (by patient UUID) ---------------------------------- + +export function labsForPatient(patientId: string): Promise { + return db + .select() + .from(labs) + .where(eq(labs.patientId, patientId)) + .orderBy(asc(labs.position)); +} + +export function allergiesForPatient(patientId: string): Promise { + return db + .select() + .from(allergies) + .where(eq(allergies.patientId, patientId)) + .orderBy(asc(allergies.position)); +} + +export function problemsForPatient(patientId: string): Promise { + return db + .select() + .from(problems) + .where(eq(problems.patientId, patientId)) + .orderBy(asc(problems.position)); +} + +export function encountersForPatient( + patientId: string, +): Promise { + return db + .select() + .from(encounters) + .where(eq(encounters.patientId, patientId)) + .orderBy(asc(encounters.position)); +} + +// --- Denormalized resources (linked to the patient by MRN / file number) ---- + +export function prescriptionsForFile( + orgId: string, + fileNumber: string, +): Promise { + return db + .select() + .from(prescriptions) + .where( + and( + eq(prescriptions.organizationId, orgId), + eq(prescriptions.patientFileNumber, fileNumber), + ), + ) + .orderBy(sql`${prescriptions.prescribedAt} desc`); +} + +export function appointmentsForFile( + orgId: string, + fileNumber: string, +): Promise { + return db + .select() + .from(appointments) + .where( + and( + eq(appointments.organizationId, orgId), + eq(appointments.patientFileNumber, fileNumber), + ), + ) + .orderBy(sql`${appointments.date} desc, ${appointments.time} desc`); +} diff --git a/backend/src/services/fhir-server/resources.ts b/backend/src/services/fhir-server/resources.ts new file mode 100644 index 0000000..a1a26e8 --- /dev/null +++ b/backend/src/services/fhir-server/resources.ts @@ -0,0 +1,317 @@ +import type { LabFlag } from "../../types/patient.js"; +import type { + AllergyRow, + AppointmentRow, + EncounterRow, + LabRow, + PatientRow, + PrescriptionRow, + ProblemRow, +} from "./queries.js"; + +// Pure mappers from temetro rows to FHIR R4 JSON. temetro stores clinical values +// as **free text** (no SNOMED/LOINC coding), so every CodeableConcept here is +// `text`-only — valid FHIR, deliberately un-coded (documented in the +// CapabilityStatement and API docs). Resource ids are the rows' own UUIDs so +// they are stable; synthesized vital-sign Observations derive their id from the +// patient UUID. + +export type FhirResource = { + resourceType: string; + id?: string; + [key: string]: unknown; +}; + +// System URIs. +const MRN_SYSTEM = "urn:temetro:mrn"; +const INTERPRETATION_SYSTEM = + "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation"; +const OBS_CATEGORY_SYSTEM = + "http://terminology.hl7.org/CodeSystem/observation-category"; +const AGE_EXTENSION = + "https://temetro.app/fhir/StructureDefinition/patient-age-years"; + +// A FHIR dateTime from our stored strings. Passes date-only values (`YYYY-MM-DD`) +// through unchanged (valid FHIR dateTime), otherwise parses display strings like +// "Jun 28, 2025" to a full instant. Returns undefined when unparseable. +function fhirDateTime(value: string | null | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed; + const parsed = new Date(trimmed); + return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); +} + +function humanName(full: string): Record[] { + const parts = full.trim().split(/\s+/).filter(Boolean); + if (parts.length < 2) return [{ text: full }]; + return [{ text: full, family: parts.at(-1), given: parts.slice(0, -1) }]; +} + +function subjectRef(patient: PatientRow) { + return { reference: `Patient/${patient.id}`, display: patient.name }; +} + +// --- Patient ---------------------------------------------------------------- + +export function patientResource(row: PatientRow): FhirResource { + return { + resourceType: "Patient", + id: row.id, + identifier: [{ system: MRN_SYSTEM, value: row.fileNumber }], + active: row.status !== "discharged", + name: humanName(row.name), + gender: row.sex === "M" ? "male" : "female", + // temetro records age, not date of birth; expose it as an extension rather + // than fabricate a birthDate. + extension: [{ url: AGE_EXTENSION, valueInteger: row.age }], + }; +} + +// --- Observation ------------------------------------------------------------ + +function interpretation(flag: LabFlag) { + const map: Record = { + normal: { code: "N", display: "Normal" }, + high: { code: "H", display: "High" }, + low: { code: "L", display: "Low" }, + critical: { code: "HH", display: "Critical high" }, + }; + const { code, display } = map[flag]; + return [{ coding: [{ system: INTERPRETATION_SYSTEM, code, display }] }]; +} + +export function labObservation(row: LabRow, patient: PatientRow): FhirResource { + const effective = fhirDateTime(row.takenAt); + return { + resourceType: "Observation", + id: row.id, + status: "final", + category: [ + { + coding: [ + { + system: OBS_CATEGORY_SYSTEM, + code: "laboratory", + display: "Laboratory", + }, + ], + }, + ], + code: { text: row.name }, + subject: subjectRef(patient), + ...(effective ? { effectiveDateTime: effective } : {}), + valueString: row.value, + interpretation: interpretation(row.flag), + }; +} + +// Synthesize vital-sign Observations from the denormalized columns on the +// patient row. Returns an empty array when vitals are blank (e.g. a +// reception-registered patient with clinical fields stripped). +export function vitalObservations(patient: PatientRow): FhirResource[] { + const effective = fhirDateTime(patient.vitalsTakenAt); + const base = (idSuffix: string, text: string) => ({ + resourceType: "Observation" as const, + id: `${patient.id}-vital-${idSuffix}`, + status: "final", + category: [ + { + coding: [ + { + system: OBS_CATEGORY_SYSTEM, + code: "vital-signs", + display: "Vital Signs", + }, + ], + }, + ], + code: { text }, + subject: subjectRef(patient), + ...(effective ? { effectiveDateTime: effective } : {}), + }); + + const out: FhirResource[] = []; + + if (patient.vitalsBp) { + const bp = base("bp", "Blood pressure"); + const m = /^(\d+)\s*\/\s*(\d+)/.exec(patient.vitalsBp.trim()); + if (m) { + out.push({ + ...bp, + component: [ + { + code: { text: "Systolic blood pressure" }, + valueQuantity: { value: Number(m[1]), unit: "mmHg" }, + }, + { + code: { text: "Diastolic blood pressure" }, + valueQuantity: { value: Number(m[2]), unit: "mmHg" }, + }, + ], + }); + } else { + out.push({ ...bp, valueString: patient.vitalsBp }); + } + } + if (patient.vitalsHr) + out.push({ ...base("hr", "Heart rate"), valueString: patient.vitalsHr }); + if (patient.vitalsTemp) + out.push({ + ...base("temp", "Body temperature"), + valueString: patient.vitalsTemp, + }); + if (patient.vitalsSpo2) + out.push({ + ...base("spo2", "Oxygen saturation"), + valueString: patient.vitalsSpo2, + }); + + return out; +} + +// --- AllergyIntolerance ----------------------------------------------------- + +export function allergyResource( + row: AllergyRow, + patient: PatientRow, +): FhirResource { + return { + resourceType: "AllergyIntolerance", + id: row.id, + clinicalStatus: { + coding: [ + { + system: + "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", + code: "active", + }, + ], + }, + code: { text: row.substance }, + patient: subjectRef(patient), + criticality: row.severity === "severe" ? "high" : "low", + reaction: [ + { manifestation: [{ text: row.reaction }], severity: row.severity }, + ], + }; +} + +// --- Condition -------------------------------------------------------------- + +export function conditionResource( + row: ProblemRow, + patient: PatientRow, +): FhirResource { + return { + resourceType: "Condition", + id: row.id, + clinicalStatus: { + coding: [ + { + system: "http://terminology.hl7.org/CodeSystem/condition-clinical", + code: "active", + }, + ], + }, + code: { text: row.label }, + subject: subjectRef(patient), + ...(row.since ? { onsetString: row.since } : {}), + }; +} + +// --- MedicationRequest ------------------------------------------------------ + +export function medicationRequestResource( + row: PrescriptionRow, + patient: PatientRow, +): FhirResource { + const status = + row.status === "completed" + ? "completed" + : row.status === "expired" + ? "stopped" + : "active"; + const dosageText = [row.dose, row.frequency].filter(Boolean).join(" ").trim(); + return { + resourceType: "MedicationRequest", + id: row.id, + status, + intent: "order", + medicationCodeableConcept: { text: row.medication }, + subject: subjectRef(patient), + ...(row.prescribedAt ? { authoredOn: row.prescribedAt } : {}), + requester: { display: row.prescriber }, + ...(dosageText ? { dosageInstruction: [{ text: dosageText }] } : {}), + }; +} + +// --- Encounter -------------------------------------------------------------- + +function narrative(text: string): Record { + const escaped = text + .replace(/&/g, "&") + .replace(//g, ">"); + return { + status: "generated", + div: `
${escaped}
`, + }; +} + +export function encounterResource( + row: EncounterRow, + patient: PatientRow, +): FhirResource { + const start = fhirDateTime(row.date); + return { + resourceType: "Encounter", + id: row.id, + ...(row.summary ? { text: narrative(row.summary) } : {}), + status: "finished", + class: { + system: "http://terminology.hl7.org/CodeSystem/v3-ActCode", + code: "AMB", + display: "ambulatory", + }, + type: [{ text: row.type }], + subject: subjectRef(patient), + ...(start ? { period: { start } } : {}), + participant: [{ individual: { display: row.provider } }], + }; +} + +// --- Appointment ------------------------------------------------------------ + +export function appointmentResource( + row: AppointmentRow, + patient: PatientRow, +): FhirResource { + const statusMap: Record = { + confirmed: "booked", + "checked-in": "arrived", + completed: "fulfilled", + cancelled: "cancelled", + }; + // Combine local date + time into an instant; omit when unparseable. + const startDate = + row.date && row.time ? new Date(`${row.date}T${row.time}:00`) : null; + const start = + startDate && !Number.isNaN(startDate.getTime()) + ? startDate.toISOString() + : undefined; + return { + resourceType: "Appointment", + id: row.id, + status: statusMap[row.status] ?? "booked", + description: row.type, + ...(start ? { start } : {}), + participant: [ + { actor: subjectRef(patient), status: "accepted" }, + ...(row.provider + ? [{ actor: { display: row.provider }, status: "accepted" }] + : []), + ], + }; +} diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts index 5bd74a5..7c7ed9e 100644 --- a/backend/src/types/express.d.ts +++ b/backend/src/types/express.d.ts @@ -15,6 +15,9 @@ declare global { }; organizationId?: string; memberRole?: string; + // Set by the FHIR bearer-auth middleware (machine-to-machine API key) + // instead of a Better Auth session; used for org scoping + audit. + fhirKey?: { id: string; name: string }; } } } diff --git a/frontend/components/settings/settings-integrations.tsx b/frontend/components/settings/settings-integrations.tsx index cad5d2f..0cf89f4 100644 --- a/frontend/components/settings/settings-integrations.tsx +++ b/frontend/components/settings/settings-integrations.tsx @@ -1,6 +1,6 @@ "use client"; -import { CheckCircle2, CircleDashed, XCircle } from "lucide-react"; +import { CheckCircle2, CircleDashed, Copy, KeyRound, Trash2, XCircle } from "lucide-react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -13,10 +13,15 @@ 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 { API_BASE_URL } from "@/lib/api-client"; import { + createFhirKey, + type FhirApiKey, type IntegrationConfig, type IntegrationType, + listFhirKeys, listIntegrations, + revokeFhirKey, saveIntegration, testIntegration, } from "@/lib/integrations"; @@ -191,6 +196,216 @@ function IntegrationCard({ ); } +// The read-only FHIR R4 server. Unlike the integration cards above (which make +// temetro a FHIR *client*), this exposes temetro's own records over `/fhir` to +// external systems, authenticated with per-clinic API keys. Owner/admin only: +// the component self-gates by hiding when the keys fetch is forbidden. +function FhirServerCard() { + const { t } = useTranslation(); + const [keys, setKeys] = useState(null); + const [allowed, setAllowed] = useState(true); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [freshSecret, setFreshSecret] = useState(null); + const [confirmRevoke, setConfirmRevoke] = useState(null); + + const baseUrl = `${API_BASE_URL}/fhir`; + + useEffect(() => { + let active = true; + listFhirKeys() + .then((rows) => active && setKeys(rows)) + .catch(() => { + if (active) { + setAllowed(false); + setKeys([]); + } + }); + return () => { + active = false; + }; + }, []); + + const create = async () => { + if (!name.trim() || creating) return; + setCreating(true); + try { + const created = await createFhirKey(name.trim()); + setFreshSecret(created.secret); + setKeys((prev) => [created, ...(prev ?? [])]); + setName(""); + } catch { + notify.error( + t("settings.integrations.fhirServer.createFailed"), + t("settings.integrations.fhirServer.createFailedBody"), + ); + } finally { + setCreating(false); + } + }; + + const revoke = async (id: string) => { + try { + await revokeFhirKey(id); + setKeys((prev) => + (prev ?? []).map((k) => (k.id === id ? { ...k, revoked: true } : k)), + ); + } catch { + notify.error( + t("settings.integrations.fhirServer.revokeFailed"), + t("settings.integrations.fhirServer.revokeFailedBody"), + ); + } finally { + setConfirmRevoke(null); + } + }; + + const copy = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + notify.success(label, ""); + } catch { + // Clipboard blocked — no-op; the value is visible for manual copy. + } + }; + + if (!allowed) return null; + + return ( + + +
+ {t("settings.integrations.fhirServer.baseUrl")} +
+ + +
+

+ {t("settings.integrations.fhirServer.baseUrlHint")} +

+
+ + {freshSecret ? ( +
+

+ {t("settings.integrations.fhirServer.secretTitle")} +

+

+ {t("settings.integrations.fhirServer.secretHint")} +

+
+ + {freshSecret} + + +
+ +
+ ) : null} + +
+ {t("settings.integrations.fhirServer.newKey")} +
+ setName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && create()} + placeholder={t("settings.integrations.fhirServer.newKeyPlaceholder")} + value={name} + /> + +
+
+ + {keys && keys.length > 0 ? ( +
    + {keys.map((k) => ( +
  • +
    +

    {k.name}

    +

    + {k.lastUsedAt + ? t("settings.integrations.fhirServer.lastUsed", { + when: new Date(k.lastUsedAt).toLocaleString(), + }) + : t("settings.integrations.fhirServer.neverUsed")} +

    +
    + {k.revoked ? ( + + {t("settings.integrations.fhirServer.revoked")} + + ) : confirmRevoke === k.id ? ( +
    + + +
    + ) : ( + + )} +
  • + ))} +
+ ) : ( +

+ {t("settings.integrations.fhirServer.noKeys")} +

+ )} +
+
+ ); +} + export function IntegrationsPanel() { const { t } = useTranslation(); const [configs, setConfigs] = useState(null); @@ -231,6 +446,7 @@ export function IntegrationsPanel() { } satisfies IntegrationConfig); return ; })} + ); } diff --git a/frontend/lib/i18n/locales/ar/translation.json b/frontend/lib/i18n/locales/ar/translation.json index baf5bf2..40bfb0d 100644 --- a/frontend/lib/i18n/locales/ar/translation.json +++ b/frontend/lib/i18n/locales/ar/translation.json @@ -1692,6 +1692,32 @@ "endpointPlaceholder": "https://your-clearinghouse.example/claims", "credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}", "credentialsHint": "JSON مع رمز مركز المقاصة ومعرّفات المرسل/المستقبل." + }, + "fhirServer": { + "title": "خادم FHIR (مشاركة سجلاتك)", + "description": "اعرض سجلات هذه العيادة للأنظمة الخارجية كخادم FHIR R4 للقراءة فقط، مع المصادقة عبر مفاتيح API خاصة بكل عيادة.", + "baseUrl": "عنوان URL الأساسي", + "baseUrlHint": "وجّه عميل FHIR إلى هذا العنوان. يقدّم Patient وObservation وCondition وAllergyIntolerance وMedicationRequest وEncounter وAppointment.", + "copiedUrl": "تم نسخ العنوان الأساسي", + "newKey": "إنشاء مفتاح API", + "newKeyPlaceholder": "اسم المفتاح (مثل مستودع الأبحاث)", + "create": "إنشاء", + "creating": "جارٍ الإنشاء…", + "createFailed": "تعذّر إنشاء المفتاح", + "createFailedBody": "يرجى المحاولة مرة أخرى.", + "secretTitle": "انسخ مفتاح API الآن", + "secretHint": "هذه هي المرة الوحيدة التي يظهر فيها السر. احفظه في مكان آمن — لا يمكن استرجاعه لاحقًا.", + "copiedSecret": "تم نسخ مفتاح API", + "dismissSecret": "تم", + "noKeys": "لا توجد مفاتيح API بعد. أنشئ واحدًا للسماح لعميل FHIR بالاتصال.", + "lastUsed": "آخر استخدام {{when}}", + "neverUsed": "لم يُستخدم قط", + "revoke": "إبطال", + "confirmRevoke": "إبطال المفتاح", + "revoked": "تم الإبطال", + "revokeFailed": "تعذّر إبطال المفتاح", + "revokeFailedBody": "يرجى المحاولة مرة أخرى.", + "cancel": "إلغاء" } }, "empty": "لا شيء هنا بعد.", diff --git a/frontend/lib/i18n/locales/de/translation.json b/frontend/lib/i18n/locales/de/translation.json index 8b21b63..cac1926 100644 --- a/frontend/lib/i18n/locales/de/translation.json +++ b/frontend/lib/i18n/locales/de/translation.json @@ -1672,6 +1672,32 @@ "endpointPlaceholder": "https://ihre-verrechnungsstelle.example/claims", "credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}", "credentialsHint": "JSON mit Ihrem Token und Absender-/Empfänger-IDs der Verrechnungsstelle." + }, + "fhirServer": { + "title": "FHIR-Server (Datensätze teilen)", + "description": "Stellen Sie die Datensätze dieser Praxis externen Systemen als schreibgeschützten FHIR-R4-Server bereit, authentifiziert über praxiseigene API-Schlüssel.", + "baseUrl": "Basis-URL", + "baseUrlHint": "Richten Sie einen FHIR-Client auf diese URL. Sie liefert Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter und Appointment.", + "copiedUrl": "Basis-URL kopiert", + "newKey": "API-Schlüssel erstellen", + "newKeyPlaceholder": "Schlüsselname (z. B. Forschungslager)", + "create": "Erstellen", + "creating": "Wird erstellt…", + "createFailed": "Schlüssel konnte nicht erstellt werden", + "createFailedBody": "Bitte erneut versuchen.", + "secretTitle": "Kopieren Sie Ihren API-Schlüssel jetzt", + "secretHint": "Das Geheimnis wird nur dieses eine Mal angezeigt. Bewahren Sie es sicher auf — es kann später nicht abgerufen werden.", + "copiedSecret": "API-Schlüssel kopiert", + "dismissSecret": "Fertig", + "noKeys": "Noch keine API-Schlüssel. Erstellen Sie einen, damit sich ein FHIR-Client verbinden kann.", + "lastUsed": "Zuletzt verwendet {{when}}", + "neverUsed": "Nie verwendet", + "revoke": "Widerrufen", + "confirmRevoke": "Schlüssel widerrufen", + "revoked": "Widerrufen", + "revokeFailed": "Schlüssel konnte nicht widerrufen werden", + "revokeFailedBody": "Bitte erneut versuchen.", + "cancel": "Abbrechen" } }, "empty": "Hier gibt es noch nichts.", diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 922f41d..84db43e 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -1672,6 +1672,32 @@ "endpointPlaceholder": "https://your-clearinghouse.example/claims", "credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}", "credentialsHint": "JSON with your clearinghouse token and submitter/receiver ids." + }, + "fhirServer": { + "title": "FHIR server (share your records)", + "description": "Expose this clinic's records to external systems as a read-only FHIR R4 server, authenticated with per-clinic API keys.", + "baseUrl": "Base URL", + "baseUrlHint": "Point a FHIR client at this URL. It serves Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter and Appointment.", + "copiedUrl": "Base URL copied", + "newKey": "Create an API key", + "newKeyPlaceholder": "Key name (e.g. Research warehouse)", + "create": "Create", + "creating": "Creating…", + "createFailed": "Couldn't create the key", + "createFailedBody": "Please try again.", + "secretTitle": "Copy your API key now", + "secretHint": "This is the only time the secret is shown. Store it somewhere safe — it can't be retrieved later.", + "copiedSecret": "API key copied", + "dismissSecret": "Done", + "noKeys": "No API keys yet. Create one to let a FHIR client connect.", + "lastUsed": "Last used {{when}}", + "neverUsed": "Never used", + "revoke": "Revoke", + "confirmRevoke": "Revoke key", + "revoked": "Revoked", + "revokeFailed": "Couldn't revoke the key", + "revokeFailedBody": "Please try again.", + "cancel": "Cancel" } }, "empty": "Nothing here yet.", diff --git a/frontend/lib/i18n/locales/fr/translation.json b/frontend/lib/i18n/locales/fr/translation.json index b66f4a3..e7b63e6 100644 --- a/frontend/lib/i18n/locales/fr/translation.json +++ b/frontend/lib/i18n/locales/fr/translation.json @@ -1672,6 +1672,32 @@ "endpointPlaceholder": "https://votre-chambre-compensation.example/claims", "credentialsPlaceholder": "JSON : {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}", "credentialsHint": "JSON avec le jeton de votre chambre de compensation et les identifiants d'expéditeur/destinataire." + }, + "fhirServer": { + "title": "Serveur FHIR (partager vos dossiers)", + "description": "Exposez les dossiers de cette clinique à des systèmes externes via un serveur FHIR R4 en lecture seule, authentifié par des clés d'API propres à la clinique.", + "baseUrl": "URL de base", + "baseUrlHint": "Pointez un client FHIR vers cette URL. Elle expose Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter et Appointment.", + "copiedUrl": "URL de base copiée", + "newKey": "Créer une clé d'API", + "newKeyPlaceholder": "Nom de la clé (ex. Entrepôt de recherche)", + "create": "Créer", + "creating": "Création…", + "createFailed": "Impossible de créer la clé", + "createFailedBody": "Veuillez réessayer.", + "secretTitle": "Copiez votre clé d'API maintenant", + "secretHint": "Le secret n'est affiché qu'une seule fois. Conservez-le en lieu sûr — il ne pourra pas être récupéré ensuite.", + "copiedSecret": "Clé d'API copiée", + "dismissSecret": "Terminé", + "noKeys": "Aucune clé d'API pour l'instant. Créez-en une pour qu'un client FHIR puisse se connecter.", + "lastUsed": "Dernière utilisation {{when}}", + "neverUsed": "Jamais utilisée", + "revoke": "Révoquer", + "confirmRevoke": "Révoquer la clé", + "revoked": "Révoquée", + "revokeFailed": "Impossible de révoquer la clé", + "revokeFailedBody": "Veuillez réessayer.", + "cancel": "Annuler" } }, "empty": "Rien ici pour le moment.", diff --git a/frontend/lib/i18n/locales/so/translation.json b/frontend/lib/i18n/locales/so/translation.json index 432aa16..5f1ab40 100644 --- a/frontend/lib/i18n/locales/so/translation.json +++ b/frontend/lib/i18n/locales/so/translation.json @@ -1672,6 +1672,32 @@ "endpointPlaceholder": "https://xarunta-xisaabintaada.example/claims", "credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}", "credentialsHint": "JSON leh token-ka xarunta xisaabinta iyo aqoonsiyada diraha/qaataha." + }, + "fhirServer": { + "title": "Serfarka FHIR (la wadaag diiwaannadaada)", + "description": "U soo bandhig diiwaannada rugtan nidaamyada dibadda ah sida serfar FHIR R4 akhris-oo-keliya, oo lagu ansixiyo furayaal API oo rug walba gaar u ah.", + "baseUrl": "URL-ka aasaasiga ah", + "baseUrlHint": "U tilmaam macmiil FHIR URL-kan. Wuxuu adeegaa Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter iyo Appointment.", + "copiedUrl": "URL-ka aasaasiga waa la koobiyeeyay", + "newKey": "Samee fure API", + "newKeyPlaceholder": "Magaca furaha (tusaale, Bakhaarka cilmi-baarista)", + "create": "Samee", + "creating": "Waa la samaynayaa…", + "createFailed": "Furaha lama abuuri karin", + "createFailedBody": "Fadlan mar kale isku day.", + "secretTitle": "Hadda koobiyee furahaaga API", + "secretHint": "Tanu waa markii kaliya ee sirta la muujiyo. Meel ammaan ah ku kaydi — lama soo ceshan karo dabadeed.", + "copiedSecret": "Furaha API waa la koobiyeeyay", + "dismissSecret": "Diyaar", + "noKeys": "Weli ma jiraan furayaal API ah. Mid samee si macmiil FHIR u xidho.", + "lastUsed": "Markii ugu dambeysay la isticmaalay {{when}}", + "neverUsed": "Weligeed lama isticmaalin", + "revoke": "Baabbi'i", + "confirmRevoke": "Baabbi'i furaha", + "revoked": "La baabbi'iyay", + "revokeFailed": "Furaha lama baabbi'in karin", + "revokeFailedBody": "Fadlan mar kale isku day.", + "cancel": "Jooji" } }, "empty": "Weli halkan waxba ma jiraan.", diff --git a/frontend/lib/integrations.ts b/frontend/lib/integrations.ts index 1467ca7..16caa05 100644 --- a/frontend/lib/integrations.ts +++ b/frontend/lib/integrations.ts @@ -64,6 +64,37 @@ export function submitInsuranceClaim( }); } +// --- FHIR server API keys (owner/admin only) -------------------------------- + +export type FhirApiKey = { + id: string; + name: string; + createdAt: string; + lastUsedAt: string | null; + revoked: boolean; +}; + +// A freshly created key includes the one-time plaintext secret; it is never +// returned again. +export type CreatedFhirApiKey = FhirApiKey & { secret: string }; + +export function listFhirKeys(): Promise { + return apiFetch("/api/integrations/fhir-server/keys"); +} + +export function createFhirKey(name: string): Promise { + return apiFetch("/api/integrations/fhir-server/keys", { + method: "POST", + body: JSON.stringify({ name }), + }); +} + +export function revokeFhirKey(id: string): Promise<{ revoked: boolean }> { + return apiFetch(`/api/integrations/fhir-server/keys/${id}`, { + method: "DELETE", + }); +} + // 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(