diff --git a/CLAUDE.md b/CLAUDE.md index 353b837..40495ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,10 +23,28 @@ repository (published as `temetro`). > (login/signup/reset/onboarding), route protection, clinic switching, and patient data fetched > over the API — the old in-memory fixture is gone (`frontend/lib/patients.ts` now calls the backend). > -> **Still vision, not built:** the patient companion app and the blockchain-style **signing / -> patient-owned storage / approval** flow. The AI chat itself is still **mock replies** (no LLM -> call yet — a `/chat` endpoint is the next planned step). Email verification is wired but -> currently **not enforced** at sign-in (see `backend/CLAUDE.md`). +> **Now built (thin slice):** a **patient wallet app** (`~/Desktop/temetro-app`, sibling repo — see +> "Patient wallet app" below) and an end-to-end **encrypted share / patient-approval** flow: +> clinics hold a real **Ed25519 signing key** (Settings → Signing, `backend/src/services/signing.ts`), +> and "Import from a patient app" on the Patients page relays an encrypted request to the wallet over +> a **`/wallet` Socket.io namespace**, the patient approves on their phone, and the sealed record is +> imported (with optional **temporary share + auto-delete**). See `backend/src/routes/{signing,patients-wallet}.ts`. +> +> **Still vision, not built:** clinic→wallet push of signed record updates, in-app record editing, +> QR pairing, and cryptographic time-boxing of temporary shares. The AI chat is still **mock replies**. +> Email verification is wired but currently **not enforced** at sign-in (see `backend/CLAUDE.md`). + +## Patient wallet app (sibling repo `~/Desktop/temetro-app`) + +The **patient companion app** is its own git repo on the Desktop (not in this monorepo): an **Expo +SDK 56** app that **must be built with `@expo/ui`** (real native SwiftUI / Jetpack Compose UI) — this +is a hard requirement; see its `CLAUDE.md`. It stores the patient's record **encrypted on-device**, +the patient's identity is an **Ed25519 keypair** whose public key (base58check, `tmw_…`) is their +**wallet number**, and it shares records by sealing them to a clinic's ephemeral key over the +backend relay. The crypto wire format mirrors `backend/src/lib/wallet-crypto.ts` exactly. "Decentralization" +here means keys + data live on the patient's device and the relay only ever forwards ciphertext — it +is **not** a literal blockchain (records are off-chain, which is also what lets a temporary share be +deleted). Commit/push that app inside its own repo, separately from this one. ## Layout diff --git a/backend/drizzle/0028_military_cyclops.sql b/backend/drizzle/0028_military_cyclops.sql new file mode 100644 index 0000000..20f786c --- /dev/null +++ b/backend/drizzle/0028_military_cyclops.sql @@ -0,0 +1,33 @@ +CREATE TABLE "clinic_signing_keys" ( + "organization_id" text PRIMARY KEY NOT NULL, + "algorithm" text DEFAULT 'ed25519' NOT NULL, + "public_key" text NOT NULL, + "fingerprint" text NOT NULL, + "private_key_enc" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "rotated_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "wallet_share_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" text NOT NULL, + "requested_by" text NOT NULL, + "wallet_number" text NOT NULL, + "ephemeral_pub_key" text NOT NULL, + "ephemeral_priv_enc" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "share_mode" text DEFAULT 'permanent' NOT NULL, + "share_expires_at" timestamp, + "draft" jsonb, + "committed_file_number" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "resolved_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "patients" ADD COLUMN "share_origin" text;--> statement-breakpoint +ALTER TABLE "patients" ADD COLUMN "share_expires_at" timestamp;--> statement-breakpoint +ALTER TABLE "clinic_signing_keys" ADD CONSTRAINT "clinic_signing_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 "wallet_share_requests" ADD CONSTRAINT "wallet_share_requests_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallet_share_requests" ADD CONSTRAINT "wallet_share_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "wallet_share_org_idx" ON "wallet_share_requests" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "wallet_share_wallet_idx" ON "wallet_share_requests" USING btree ("wallet_number"); \ No newline at end of file diff --git a/backend/drizzle/meta/0028_snapshot.json b/backend/drizzle/meta/0028_snapshot.json new file mode 100644 index 0000000..b8ce1e8 --- /dev/null +++ b/backend/drizzle/meta/0028_snapshot.json @@ -0,0 +1,4300 @@ +{ + "id": "802f6a65-bb4b-4eed-a6e3-8e566f962bc1", + "prevId": "49718c46-d105-4a8c-8fbb-7e99b94bc070", + "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": true + }, + "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 + } + }, + "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 44ec41d..8250c36 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1781973588708, "tag": "0027_romantic_kylun", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1782052852524, + "tag": "0028_military_cyclops", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 71ad67a..3f826df 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,9 @@ "@ai-sdk/google": "^3.0.82", "@ai-sdk/openai": "^3.0.71", "@ai-sdk/openai-compatible": "^2.0.50", + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", "@types/multer": "^2.1.0", "ai": "^6.0.204", "better-auth": "^1.6.13", @@ -2183,6 +2186,21 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 6349088..313e685 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,9 @@ "@ai-sdk/google": "^3.0.82", "@ai-sdk/openai": "^3.0.71", "@ai-sdk/openai-compatible": "^2.0.50", + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", "@types/multer": "^2.1.0", "ai": "^6.0.204", "better-auth": "^1.6.13", diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index e959864..5ec0c53 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -19,3 +19,5 @@ export * from "./attachments.js"; export * from "./integrations.js"; export * from "./staff-profile.js"; export * from "./meetings.js"; +export * from "./signing.js"; +export * from "./wallet-share.js"; diff --git a/backend/src/db/schema/patients.ts b/backend/src/db/schema/patients.ts index ecc87e5..781db76 100644 --- a/backend/src/db/schema/patients.ts +++ b/backend/src/db/schema/patients.ts @@ -54,6 +54,11 @@ export const patients = pgTable( // with auto-generated file numbers or placeholder fields) and are flagged // for clinician review/edit. source: text("source").$type<"manual" | "ai">().notNull().default("manual"), + // Provenance for records imported from a patient wallet app, plus the + // auto-delete deadline for a *temporary* share. When `shareExpiresAt` is set + // and passes, a scheduled sweep hard-deletes the row (services/wallet-share). + shareOrigin: text("share_origin").$type<"wallet">(), + shareExpiresAt: timestamp("share_expires_at"), createdBy: text("created_by").references(() => user.id, { onDelete: "set null", }), diff --git a/backend/src/db/schema/signing.ts b/backend/src/db/schema/signing.ts new file mode 100644 index 0000000..735b472 --- /dev/null +++ b/backend/src/db/schema/signing.ts @@ -0,0 +1,21 @@ +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +import { organization } from "./auth.js"; + +// One Ed25519 signing key per clinic (organization). The clinician's edits to a +// patient record are signed with this key so patients (and other clinics) can +// verify a change really came from this clinic before approving it. The private +// key is stored encrypted at rest (lib/crypto.ts `encryptSecret`); the public +// key + fingerprint are shown in Settings → Signing. Rotating replaces the row. +export const clinicSigningKeys = pgTable("clinic_signing_keys", { + organizationId: text("organization_id") + .primaryKey() + .references(() => organization.id, { onDelete: "cascade" }), + algorithm: text("algorithm").notNull().default("ed25519"), + publicKey: text("public_key").notNull(), + fingerprint: text("fingerprint").notNull(), + // Encrypted (lib/crypto.ts) hex of the Ed25519 private key. + privateKeyEnc: text("private_key_enc").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + rotatedAt: timestamp("rotated_at"), +}); diff --git a/backend/src/db/schema/wallet-share.ts b/backend/src/db/schema/wallet-share.ts new file mode 100644 index 0000000..ccb374e --- /dev/null +++ b/backend/src/db/schema/wallet-share.ts @@ -0,0 +1,50 @@ +import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +import type { Patient } from "../../types/patient.js"; +import { organization, user } from "./auth.js"; + +export type WalletShareStatus = + | "pending" + | "approved" + | "denied" + | "expired"; +export type WalletShareMode = "permanent" | "temporary"; + +// One row per "import from a patient app" request. A clinician enters a wallet +// number; we mint a per-request ephemeral X25519 keypair (the phone seals the +// record bundle to its public key) and relay a `share:request` to the device +// over the /wallet Socket.io namespace. The patient approves on their phone; the +// sealed bundle comes back, we decrypt it with `ephemeralPrivEnc` and hand the +// clinic a draft Patient to review. Nothing here is the record itself — only the +// transient handshake + the decrypted draft cached until the clinic commits it. +export const walletShareRequests = pgTable( + "wallet_share_requests", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + requestedBy: text("requested_by") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + walletNumber: text("wallet_number").notNull(), + ephemeralPubKey: text("ephemeral_pub_key").notNull(), + // Encrypted (lib/crypto.ts) hex of the ephemeral X25519 private key. + ephemeralPrivEnc: text("ephemeral_priv_enc").notNull(), + status: text("status").$type().notNull().default("pending"), + shareMode: text("share_mode").$type().notNull().default("permanent"), + // For temporary shares: when the imported record should be auto-deleted. + shareExpiresAt: timestamp("share_expires_at"), + // The decrypted, verified draft record, cached between approval and commit. + draft: jsonb("draft").$type(), + // Set once the clinic commits the draft — the imported patient's file number, + // so a later patient "revoke" from the app can delete exactly that record. + committedFileNumber: text("committed_file_number"), + createdAt: timestamp("created_at").defaultNow().notNull(), + resolvedAt: timestamp("resolved_at"), + }, + (t) => [ + index("wallet_share_org_idx").on(t.organizationId), + index("wallet_share_wallet_idx").on(t.walletNumber), + ], +); diff --git a/backend/src/index.ts b/backend/src/index.ts index b42ed76..c3ad156 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,11 +24,14 @@ import { meetingsRouter } from "./routes/meetings.js"; import { notesRouter } from "./routes/notes.js"; import { notificationsRouter } from "./routes/notifications.js"; import { patientsRouter } from "./routes/patients.js"; +import { patientsWalletRouter } from "./routes/patients-wallet.js"; import { portalRouter } from "./routes/portal.js"; import { prescriptionsRouter } from "./routes/prescriptions.js"; import { settingsRouter } from "./routes/settings.js"; +import { signingRouter } from "./routes/signing.js"; import { staffRouter } from "./routes/staff.js"; import { tasksRouter } from "./routes/tasks.js"; +import { sweepExpiredShares } from "./services/wallet-share.js"; const app = express(); @@ -67,7 +70,11 @@ app.get("/health", (_req, res) => { res.json({ status: "ok" }); }); +// Mount the wallet import routes BEFORE the generic patients router so +// `/api/patients/wallet/...` isn't matched by patients' `/:fileNumber`. +app.use("/api/patients/wallet", patientsWalletRouter); app.use("/api/patients", patientsRouter); +app.use("/api/signing", signingRouter); app.use("/api/attachments", attachmentsRouter); app.use("/api/notes", notesRouter); app.use("/api/appointments", appointmentsRouter); @@ -96,6 +103,14 @@ app.use(errorHandler); const server = createServer(app); initRealtime(server); +// Sweep expired temporary patient-wallet shares (auto-delete) every 5 minutes. +const SHARE_SWEEP_INTERVAL = 5 * 60 * 1000; +setInterval(() => { + sweepExpiredShares().catch((err) => + console.error("Wallet share sweep failed:", err), + ); +}, SHARE_SWEEP_INTERVAL).unref(); + server.listen(env.PORT, () => { console.log(`temetro backend listening on ${env.BETTER_AUTH_URL}`); console.log(` • auth: /api/auth/* (frontend origin: ${env.FRONTEND_URL})`); @@ -117,4 +132,6 @@ 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(` • signing: /api/signing (Ed25519 clinic key)`); + console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`); }); diff --git a/backend/src/lib/wallet-crypto.ts b/backend/src/lib/wallet-crypto.ts new file mode 100644 index 0000000..ac79858 --- /dev/null +++ b/backend/src/lib/wallet-crypto.ts @@ -0,0 +1,202 @@ +import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; +import { ed25519, x25519 } from "@noble/curves/ed25519.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { + bytesToHex, + concatBytes, + hexToBytes, + randomBytes, +} from "@noble/hashes/utils.js"; + +// Cryptographic primitives shared (by convention — the wire format is mirrored +// in the mobile wallet app's src/lib/crypto.ts) between the clinic backend and +// the patient wallet. Identity is an Ed25519 keypair; the patient's public key, +// base58check-encoded with a `tmw_` prefix, is their human-typeable **wallet +// number**. Record bundles are sealed to a recipient's ephemeral X25519 key +// (sealed-box: ephemeral sender key + X25519 ECDH + XChaCha20-Poly1305) so the +// relay only ever forwards ciphertext, and signed with the wallet's Ed25519 key +// so the recipient can verify the bundle truly came from that wallet number. +// +// Both apps use @noble so the byte layout is identical on every platform. + +export const WALLET_PREFIX = "tmw_"; + +const B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +function base58Encode(bytes: Uint8Array): string { + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + const digits: number[] = []; + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i] as number; + for (let j = 0; j < digits.length; j++) { + carry += (digits[j] as number) << 8; + digits[j] = carry % 58; + carry = (carry / 58) | 0; + } + while (carry > 0) { + digits.push(carry % 58); + carry = (carry / 58) | 0; + } + } + let out = "1".repeat(zeros); + for (let i = digits.length - 1; i >= 0; i--) { + out += B58_ALPHABET[digits[i] as number]; + } + return out; +} + +function base58Decode(str: string): Uint8Array { + let zeros = 0; + while (zeros < str.length && str[zeros] === "1") zeros++; + const bytes: number[] = []; + for (let i = zeros; i < str.length; i++) { + const value = B58_ALPHABET.indexOf(str[i] as string); + if (value < 0) throw new Error("Invalid base58 character."); + let carry = value; + for (let j = 0; j < bytes.length; j++) { + carry += (bytes[j] as number) * 58; + bytes[j] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + const out = new Uint8Array(zeros + bytes.length); + for (let i = 0; i < bytes.length; i++) { + out[zeros + bytes.length - 1 - i] = bytes[i] as number; + } + return out; +} + +function checksum(payload: Uint8Array): Uint8Array { + return sha256(sha256(payload)).slice(0, 4); +} + +// --- Ed25519 identity ------------------------------------------------------- + +export function newSigningKeypair(): { privateKeyHex: string; publicKeyHex: string } { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + return { + privateKeyHex: bytesToHex(privateKey), + publicKeyHex: bytesToHex(publicKey), + }; +} + +export function signMessage(privateKeyHex: string, message: Uint8Array): string { + return bytesToHex(ed25519.sign(message, hexToBytes(privateKeyHex))); +} + +export function verifySignature( + publicKey: Uint8Array, + signatureHex: string, + message: Uint8Array, +): boolean { + try { + return ed25519.verify(hexToBytes(signatureHex), message, publicKey); + } catch { + return false; + } +} + +// `ed25519:9f86 d081 …` — a short, human-comparable fingerprint of a public key +// (first 16 bytes of its SHA-256, grouped in fours). Matches the panel format. +export function fingerprint(publicKey: Uint8Array): string { + const hex = bytesToHex(sha256(publicKey)).slice(0, 32); + const groups = hex.match(/.{1,4}/g) ?? []; + return `ed25519:${groups.join(" ")}`; +} + +// --- Wallet number (base58check of the Ed25519 public key) ------------------ + +export function encodeWalletNumber(publicKey: Uint8Array): string { + const payload = concatBytes(publicKey, checksum(publicKey)); + return WALLET_PREFIX + base58Encode(payload); +} + +// Decode + validate a wallet number back to its 32-byte Ed25519 public key. +// Throws on a bad prefix, bad base58, wrong length, or checksum mismatch. +export function decodeWalletNumber(walletNumber: string): Uint8Array { + const trimmed = walletNumber.trim(); + if (!trimmed.startsWith(WALLET_PREFIX)) { + throw new Error("Wallet number must start with tmw_."); + } + const decoded = base58Decode(trimmed.slice(WALLET_PREFIX.length)); + if (decoded.length !== 36) { + throw new Error("Wallet number has an invalid length."); + } + const publicKey = decoded.slice(0, 32); + const check = decoded.slice(32); + const expected = checksum(publicKey); + if (check.some((b, i) => b !== expected[i])) { + throw new Error("Wallet number checksum mismatch (likely a typo)."); + } + return publicKey; +} + +export function isValidWalletNumber(walletNumber: string): boolean { + try { + decodeWalletNumber(walletNumber); + return true; + } catch { + return false; + } +} + +// --- Sealed box (anonymous sender -> recipient X25519 public key) ----------- + +export function newEncryptionKeypair(): { + privateKeyHex: string; + publicKeyHex: string; +} { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + return { + privateKeyHex: bytesToHex(privateKey), + publicKeyHex: bytesToHex(publicKey), + }; +} + +function deriveKey( + shared: Uint8Array, + ephemeralPub: Uint8Array, + recipientPub: Uint8Array, +): Uint8Array { + return sha256(concatBytes(shared, ephemeralPub, recipientPub)); +} + +// Seal `plaintext` to `recipientPublicKeyHex` (X25519). Returns base64 of +// `ephemeralPub(32) || nonce(24) || ciphertext`. +export function seal( + recipientPublicKeyHex: string, + plaintext: Uint8Array, +): string { + const recipientPub = hexToBytes(recipientPublicKeyHex); + const ephemeralPriv = x25519.utils.randomSecretKey(); + const ephemeralPub = x25519.getPublicKey(ephemeralPriv); + const shared = x25519.getSharedSecret(ephemeralPriv, recipientPub); + const key = deriveKey(shared, ephemeralPub, recipientPub); + const nonce = randomBytes(24); + const ciphertext = xchacha20poly1305(key, nonce).encrypt(plaintext); + return Buffer.from(concatBytes(ephemeralPub, nonce, ciphertext)).toString( + "base64", + ); +} + +export function open( + recipientPrivateKeyHex: string, + sealedBase64: string, +): Uint8Array { + const recipientPriv = hexToBytes(recipientPrivateKeyHex); + const recipientPub = x25519.getPublicKey(recipientPriv); + const blob = new Uint8Array(Buffer.from(sealedBase64, "base64")); + const ephemeralPub = blob.slice(0, 32); + const nonce = blob.slice(32, 56); + const ciphertext = blob.slice(56); + const shared = x25519.getSharedSecret(recipientPriv, ephemeralPub); + const key = deriveKey(shared, ephemeralPub, recipientPub); + return xchacha20poly1305(key, nonce).decrypt(ciphertext); +} diff --git a/backend/src/realtime.ts b/backend/src/realtime.ts index 4e84d06..0a25aef 100644 --- a/backend/src/realtime.ts +++ b/backend/src/realtime.ts @@ -1,13 +1,16 @@ import type { Server as HttpServer } from "node:http"; import { fromNodeHeaders } from "better-auth/node"; +import { bytesToHex, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js"; import { Server, type Socket } from "socket.io"; import { auth } from "./auth.js"; import { env } from "./env.js"; +import { decodeWalletNumber, verifySignature } from "./lib/wallet-crypto.js"; import * as meetings from "./services/meetings.js"; import * as messaging from "./services/messaging.js"; import { createNotification } from "./services/notifications.js"; +import * as walletShare from "./services/wallet-share.js"; import type { MessageAttachment } from "./types/messaging.js"; let io: Server | null = null; @@ -16,6 +19,7 @@ const userRoom = (userId: string) => `user:${userId}`; const convRoom = (conversationId: string) => `conv:${conversationId}`; const callRoom = (roomId: string) => `call:${roomId}`; const orgRoom = (orgId: string) => `org:${orgId}`; +const walletRoom = (walletNumber: string) => `wallet:${walletNumber}`; // Mesh WebRTC tops out around four peers (each sends its stream to every other); // past that the room is closed to new joiners. @@ -39,6 +43,17 @@ export function emitToConversation( io?.to(convRoom(conversationId)).emit(event, data); } +// Relay an end-to-end-encrypted message to a patient wallet device (the /wallet +// namespace, room keyed by wallet number). The relay only ever forwards +// ciphertext — it cannot read the record bundle. +export function emitToWallet( + walletNumber: string, + event: string, + data: unknown, +): void { + io?.of("/wallet").to(walletRoom(walletNumber)).emit(event, data); +} + type Ack = (response: { ok: boolean; [key: string]: unknown }) => void; export function initRealtime(httpServer: HttpServer): Server { @@ -270,5 +285,106 @@ export function initRealtime(httpServer: HttpServer): Server { }); }); + // --- Patient wallet relay (/wallet namespace) ---------------------------- + // Devices have no clinic session, so this namespace is NOT cookie-gated. + // Instead a device proves control of its wallet keypair: the server issues a + // random challenge, the device signs it with its Ed25519 key, and only then + // may it join its own wallet room. The relay forwards encrypted share + // requests/responses without ever reading the record bundle. + const walletNs = io.of("/wallet"); + walletNs.on("connection", (socket: Socket) => { + const challenge = bytesToHex(randomBytes(32)); + socket.data.challenge = challenge; + socket.data.walletNumber = null as string | null; + socket.emit("wallet:challenge", { challenge }); + + socket.on( + "wallet:auth", + (payload: { walletNumber?: string; signature?: string }, ack?: Ack) => { + try { + const walletNumber = String(payload?.walletNumber ?? ""); + const signature = String(payload?.signature ?? ""); + const publicKey = decodeWalletNumber(walletNumber); + const ok = verifySignature( + publicKey, + signature, + utf8ToBytes(socket.data.challenge as string), + ); + if (!ok) { + ack?.({ ok: false }); + return; + } + socket.data.walletNumber = walletNumber; + socket.join(walletRoom(walletNumber)); + ack?.({ ok: true }); + } catch { + ack?.({ ok: false }); + } + }, + ); + + // The patient approved/denied a share on their device; the sealed bundle (if + // approved) rides along and is decrypted + verified server-side. + socket.on( + "wallet:share-response", + async ( + payload: { + requestId?: string; + walletNumber?: string; + decision?: "approved" | "denied"; + sealed?: string; + signature?: string; + }, + ack?: Ack, + ) => { + try { + if ( + !socket.data.walletNumber || + socket.data.walletNumber !== payload?.walletNumber + ) { + ack?.({ ok: false }); + return; + } + const view = await walletShare.applyShareResponse( + String(payload?.requestId ?? ""), + String(payload?.walletNumber ?? ""), + payload?.decision === "approved" ? "approved" : "denied", + payload?.sealed, + payload?.signature, + ); + ack?.({ ok: !!view }); + } catch (err) { + ack?.({ ok: false, error: (err as Error).message }); + } + }, + ); + + // The patient revoked a previously shared record; delete it from the clinic. + socket.on( + "wallet:revoke", + async ( + payload: { requestId?: string; walletNumber?: string }, + ack?: Ack, + ) => { + try { + if ( + !socket.data.walletNumber || + socket.data.walletNumber !== payload?.walletNumber + ) { + ack?.({ ok: false }); + return; + } + const result = await walletShare.revokeShare( + String(payload?.requestId ?? ""), + String(payload?.walletNumber ?? ""), + ); + ack?.({ ok: !!result }); + } catch { + ack?.({ ok: false }); + } + }, + ); + }); + return io; } diff --git a/backend/src/routes/patients-wallet.ts b/backend/src/routes/patients-wallet.ts new file mode 100644 index 0000000..479c43b --- /dev/null +++ b/backend/src/routes/patients-wallet.ts @@ -0,0 +1,131 @@ +import { eq } from "drizzle-orm"; +import { Router } from "express"; +import { z } from "zod"; + +import { db } from "../db/index.js"; +import { organization } from "../db/schema/auth.js"; +import { HttpError } from "../lib/http-error.js"; +import { patientInputSchema } from "../lib/patient-validation.js"; +import { isReceptionOnly } from "../lib/role-scope.js"; +import { + requireAuth, + requireOrg, + requirePermission, +} from "../middleware/auth.js"; +import { emitToWallet } from "../realtime.js"; +import { recordActivity } from "../services/activity.js"; +import * as patientService from "../services/patients.js"; +import * as walletShare from "../services/wallet-share.js"; + +export const patientsWalletRouter = Router(); + +patientsWalletRouter.use(requireAuth, requireOrg); + +const requestSchema = z.object({ + walletNumber: z.string().trim().min(1), + mode: z.enum(["permanent", "temporary"]).default("permanent"), + durationHours: z.number().positive().max(8760).optional(), +}); + +// Start an import: validate the wallet number, mint a per-request ephemeral key, +// and relay an encrypted-share request to the patient's device. The clinician +// then polls the request until the patient approves on their phone. +patientsWalletRouter.post( + "/request-share", + requirePermission({ patient: ["write"] }), + async (req, res, next) => { + try { + const input = requestSchema.parse(req.body); + const { view, ephemeralPubKey } = await walletShare.createShareRequest( + req.organizationId!, + req.user!.id, + input.walletNumber, + input.mode, + input.durationHours, + ); + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, req.organizationId!)); + emitToWallet(input.walletNumber, "wallet:share-request", { + requestId: view.id, + clinicName: org?.name ?? "A clinic", + requestedBy: req.user!.name, + ephemeralPubKey, + mode: input.mode, + durationHours: input.durationHours ?? null, + }); + res.status(201).json(view); + } catch (err) { + next(err); + } + }, +); + +// Poll a request's status (and, once approved, the decrypted draft record). +patientsWalletRouter.get( + "/request-share/:id", + requirePermission({ patient: ["read"] }), + async (req, res, next) => { + try { + const view = await walletShare.getShareRequest( + req.organizationId!, + req.params.id as string, + ); + if (!view) throw new HttpError(404, "Share request not found."); + res.json(view); + } catch (err) { + next(err); + } + }, +); + +// Commit the (possibly clinician-edited) draft into a real patient record. The +// temporary-share metadata (origin + auto-delete deadline) is taken from the +// request server-side, so the clinic can't quietly keep a temporary record. +patientsWalletRouter.post( + "/request-share/:id/commit", + requirePermission({ patient: ["write"] }), + async (req, res, next) => { + try { + const id = req.params.id as string; + const request = await walletShare.getShareRequest(req.organizationId!, id); + if (!request) throw new HttpError(404, "Share request not found."); + if (request.status !== "approved") { + throw new HttpError(409, "This share has not been approved yet."); + } + const input = patientInputSchema.parse(req.body); + const created = await patientService.createPatient( + req.organizationId!, + req.user!.id, + input, + isReceptionOnly(req.memberRole), + { + shareOrigin: "wallet", + shareExpiresAt: request.shareExpiresAt + ? new Date(request.shareExpiresAt) + : null, + }, + ); + await walletShare.markCommitted( + req.organizationId!, + id, + created.fileNumber, + ); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Imported patient ${created.name} from a wallet${ + request.shareMode === "temporary" ? " (temporary)" : "" + }`, + entityType: "patient", + entityId: created.fileNumber, + patientName: created.name, + patientFileNumber: created.fileNumber, + }); + res.status(201).json(created); + } catch (err) { + next(err); + } + }, +); diff --git a/backend/src/routes/signing.ts b/backend/src/routes/signing.ts new file mode 100644 index 0000000..a93cae7 --- /dev/null +++ b/backend/src/routes/signing.ts @@ -0,0 +1,63 @@ +import { Router } from "express"; + +import { + requireAuth, + requireOrg, + requirePermission, +} from "../middleware/auth.js"; +import { recordActivity } from "../services/activity.js"; +import * as signing from "../services/signing.js"; +import * as walletShare from "../services/wallet-share.js"; + +export const signingRouter = Router(); + +signingRouter.use(requireAuth, requireOrg); + +// The clinic's Ed25519 signing key (public key + fingerprint). Created lazily on +// first read so the panel always shows a real key. Readable by any clinician. +signingRouter.get( + "/key", + requirePermission({ patient: ["read"] }), + async (req, res, next) => { + try { + res.json(await signing.getOrCreateKey(req.organizationId!)); + } catch (err) { + next(err); + } + }, +); + +// Rotate the signing key — owner/admin only (gated on the org-update statement, +// which only owner/admin hold). +signingRouter.post( + "/key/rotate", + requirePermission({ organization: ["update"] }), + async (req, res, next) => { + try { + const key = await signing.rotateKey(req.organizationId!); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: "Rotated the clinic signing key", + entityType: "settings", + }); + res.json(key); + } catch (err) { + next(err); + } + }, +); + +// Recent records shared from patient wallets — feeds the panel's shared-records +// list. +signingRouter.get( + "/records", + requirePermission({ patient: ["read"] }), + async (req, res, next) => { + try { + res.json(await walletShare.listShareRequests(req.organizationId!)); + } catch (err) { + next(err); + } + }, +); diff --git a/backend/src/services/patients.ts b/backend/src/services/patients.ts index fc6f04c..948bcb8 100644 --- a/backend/src/services/patients.ts +++ b/backend/src/services/patients.ts @@ -69,6 +69,7 @@ function toPatient(row: PatientRow, children: Children): Patient { labTrend: row.labTrend, encounters: children.encounters, source: row.source, + shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null, }; } @@ -426,6 +427,9 @@ export async function createPatient( userId: string, rawInput: PatientInput, demographicsOnly = false, + // Extra columns set on import from a patient wallet (provenance + the + // auto-delete deadline for a temporary share). + extra?: { shareOrigin?: "wallet" | null; shareExpiresAt?: Date | null }, ): Promise { // Auto-assign a file number when one wasn't supplied (e.g. AI imports). const input: PatientInput = rawInput.fileNumber @@ -438,13 +442,13 @@ export async function createPatient( if (demographicsOnly) { const [row] = await tx .insert(patients) - .values(demographicColumns(orgId, input, userId)) + .values({ ...demographicColumns(orgId, input, userId), ...extra }) .returning(); return toPatient(row!, emptyChildren()); } const [row] = await tx .insert(patients) - .values(patientColumns(orgId, input, userId)) + .values({ ...patientColumns(orgId, input, userId), ...extra }) .returning(); await insertChildren(tx, row!.id, input); return toPatient(row!, childrenFromInput(input)); diff --git a/backend/src/services/signing.ts b/backend/src/services/signing.ts new file mode 100644 index 0000000..7b911b9 --- /dev/null +++ b/backend/src/services/signing.ts @@ -0,0 +1,102 @@ +import { hexToBytes } from "@noble/hashes/utils.js"; +import { eq } from "drizzle-orm"; + +import { db } from "../db/index.js"; +import { clinicSigningKeys } from "../db/schema/signing.js"; +import { decryptSecret, encryptSecret } from "../lib/crypto.js"; +import { + fingerprint, + newSigningKeypair, + signMessage, +} from "../lib/wallet-crypto.js"; + +export type SigningKeyView = { + algorithm: string; + publicKey: string; + fingerprint: string; + createdAt: string; + rotatedAt: string | null; +}; + +type SigningKeyRow = typeof clinicSigningKeys.$inferSelect; + +function toView(row: SigningKeyRow): SigningKeyView { + return { + algorithm: row.algorithm, + publicKey: row.publicKey, + fingerprint: row.fingerprint, + createdAt: row.createdAt.toISOString(), + rotatedAt: row.rotatedAt ? row.rotatedAt.toISOString() : null, + }; +} + +// Generate a fresh Ed25519 keypair and upsert it as the clinic's signing key +// (rotating overwrites the row and stamps `rotatedAt`). The private key is only +// ever stored encrypted (lib/crypto.ts). +async function mintKey(orgId: string, rotating: boolean): Promise { + const { privateKeyHex, publicKeyHex } = newSigningKeypair(); + const fp = fingerprint(hexToBytes(publicKeyHex)); + const [row] = await db + .insert(clinicSigningKeys) + .values({ + organizationId: orgId, + algorithm: "ed25519", + publicKey: publicKeyHex, + fingerprint: fp, + privateKeyEnc: encryptSecret(privateKeyHex), + rotatedAt: rotating ? new Date() : null, + }) + .onConflictDoUpdate({ + target: clinicSigningKeys.organizationId, + set: { + publicKey: publicKeyHex, + fingerprint: fp, + privateKeyEnc: encryptSecret(privateKeyHex), + rotatedAt: new Date(), + }, + }) + .returning(); + return toView(row!); +} + +// The clinic's signing key, creating one on first read so the panel always has +// a real key + fingerprint to show. +export async function getOrCreateKey(orgId: string): Promise { + const [existing] = await db + .select() + .from(clinicSigningKeys) + .where(eq(clinicSigningKeys.organizationId, orgId)); + if (existing) return toView(existing); + return mintKey(orgId, false); +} + +export async function rotateKey(orgId: string): Promise { + return mintKey(orgId, true); +} + +// Sign a message with the clinic's signing key (creating one if needed). Returns +// the signature + public key so a verifier can check provenance. +export async function signWithClinicKey( + orgId: string, + message: Uint8Array, +): Promise<{ signature: string; publicKey: string }> { + const [row] = await db + .select() + .from(clinicSigningKeys) + .where(eq(clinicSigningKeys.organizationId, orgId)); + if (!row) { + const view = await mintKey(orgId, false); + const [fresh] = await db + .select() + .from(clinicSigningKeys) + .where(eq(clinicSigningKeys.organizationId, orgId)); + return { + signature: signMessage(decryptSecret(fresh!.privateKeyEnc), message), + publicKey: view.publicKey, + }; + } + return { + signature: signMessage(decryptSecret(row.privateKeyEnc), message), + publicKey: row.publicKey, + }; +} diff --git a/backend/src/services/wallet-share.ts b/backend/src/services/wallet-share.ts new file mode 100644 index 0000000..aedd45b --- /dev/null +++ b/backend/src/services/wallet-share.ts @@ -0,0 +1,235 @@ +import { utf8ToBytes } from "@noble/hashes/utils.js"; +import { and, eq, isNotNull, lte } from "drizzle-orm"; + +import { db } from "../db/index.js"; +import { patients } from "../db/schema/patients.js"; +import { + walletShareRequests, + type WalletShareMode, +} from "../db/schema/wallet-share.js"; +import { decryptSecret, encryptSecret } from "../lib/crypto.js"; +import { HttpError } from "../lib/http-error.js"; +import { + decodeWalletNumber, + newEncryptionKeypair, + open, + verifySignature, +} from "../lib/wallet-crypto.js"; +import type { Patient } from "../types/patient.js"; +import { recordActivity } from "./activity.js"; +import { deletePatient } from "./patients.js"; + +type ShareRow = typeof walletShareRequests.$inferSelect; + +export type ShareRequestView = { + id: string; + walletNumber: string; + status: ShareRow["status"]; + shareMode: WalletShareMode; + shareExpiresAt: string | null; + draft: Patient | null; +}; + +function toView(row: ShareRow): ShareRequestView { + return { + id: row.id, + walletNumber: row.walletNumber, + status: row.status, + shareMode: row.shareMode, + shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null, + draft: row.draft ?? null, + }; +} + +// Create an import request: validate the wallet number, mint a per-request +// ephemeral X25519 keypair (the phone seals the bundle to its public key) and +// store the request. Returns the row + the ephemeral public key to relay to the +// device. Throws 400 on a malformed wallet number. +export async function createShareRequest( + orgId: string, + userId: string, + walletNumber: string, + mode: WalletShareMode, + durationHours?: number, +): Promise<{ view: ShareRequestView; ephemeralPubKey: string }> { + try { + decodeWalletNumber(walletNumber); + } catch (err) { + throw new HttpError(400, (err as Error).message); + } + const { privateKeyHex, publicKeyHex } = newEncryptionKeypair(); + const shareExpiresAt = + mode === "temporary" && durationHours + ? new Date(Date.now() + durationHours * 3_600_000) + : null; + const [row] = await db + .insert(walletShareRequests) + .values({ + organizationId: orgId, + requestedBy: userId, + walletNumber: walletNumber.trim(), + ephemeralPubKey: publicKeyHex, + ephemeralPrivEnc: encryptSecret(privateKeyHex), + shareMode: mode, + shareExpiresAt, + }) + .returning(); + return { view: toView(row!), ephemeralPubKey: publicKeyHex }; +} + +export async function getShareRequest( + orgId: string, + id: string, +): Promise { + const [row] = await db + .select() + .from(walletShareRequests) + .where( + and( + eq(walletShareRequests.id, id), + eq(walletShareRequests.organizationId, orgId), + ), + ); + return row ? toView(row) : null; +} + +// Recent import requests for the clinic — feeds the Signing panel's "Signed +// records" / shared-records list. +export async function listShareRequests( + orgId: string, + limit = 20, +): Promise { + const rows = await db + .select() + .from(walletShareRequests) + .where(eq(walletShareRequests.organizationId, orgId)) + .orderBy(walletShareRequests.createdAt) + .limit(limit); + return rows.map(toView); +} + +// Apply a response relayed back from the patient's device. On approval we +// decrypt the sealed bundle with the request's ephemeral private key and verify +// the wallet's Ed25519 signature over it (provenance: it really came from that +// wallet number). Returns the resolved view, or null when the request is unknown +// / already resolved. Throws on a tampered/forged bundle. +export async function applyShareResponse( + requestId: string, + walletNumber: string, + decision: "approved" | "denied", + sealed?: string, + signatureHex?: string, +): Promise { + const [row] = await db + .select() + .from(walletShareRequests) + .where(eq(walletShareRequests.id, requestId)); + if (!row || row.status !== "pending") return null; + if (row.walletNumber !== walletNumber.trim()) return null; + + if (decision === "denied") { + const [updated] = await db + .update(walletShareRequests) + .set({ status: "denied", resolvedAt: new Date() }) + .where(eq(walletShareRequests.id, requestId)) + .returning(); + return updated ? toView(updated) : null; + } + + if (!sealed || !signatureHex) { + throw new HttpError(400, "Approval is missing the sealed record bundle."); + } + const plaintext = open(decryptSecret(row.ephemeralPrivEnc), sealed); + const publicKey = decodeWalletNumber(walletNumber); + if (!verifySignature(publicKey, signatureHex, plaintext)) { + throw new HttpError(400, "Bundle signature did not match the wallet number."); + } + const bundle = JSON.parse(Buffer.from(plaintext).toString("utf8")) as { + patient: Patient; + }; + + const [updated] = await db + .update(walletShareRequests) + .set({ status: "approved", resolvedAt: new Date(), draft: bundle.patient }) + .where(eq(walletShareRequests.id, requestId)) + .returning(); + return updated ? toView(updated) : null; +} + +// Record the file number once a clinic commits the imported draft, so a later +// revoke from the device can delete exactly that record. +export async function markCommitted( + orgId: string, + id: string, + fileNumber: string, +): Promise { + await db + .update(walletShareRequests) + .set({ committedFileNumber: fileNumber }) + .where( + and( + eq(walletShareRequests.id, id), + eq(walletShareRequests.organizationId, orgId), + ), + ); +} + +// Patient-initiated revoke from the app: find the committed import for this +// request + wallet and hard-delete that patient from the clinic. Returns the +// org/fileNumber deleted (for an activity entry), or null. +export async function revokeShare( + requestId: string, + walletNumber: string, +): Promise<{ orgId: string; fileNumber: string } | null> { + const [row] = await db + .select() + .from(walletShareRequests) + .where(eq(walletShareRequests.id, requestId)); + if (!row || row.walletNumber !== walletNumber.trim()) return null; + if (!row.committedFileNumber) return null; + const ok = await deletePatient(row.organizationId, row.committedFileNumber); + if (!ok) return null; + await recordActivity({ + orgId: row.organizationId, + actor: { id: row.requestedBy, name: "Patient wallet" }, + action: `Patient revoked shared record #${row.committedFileNumber}`, + entityType: "patient", + entityId: row.committedFileNumber, + patientFileNumber: row.committedFileNumber, + }).catch(() => {}); + return { orgId: row.organizationId, fileNumber: row.committedFileNumber }; +} + +// Deployment-wide sweep: hard-delete any temporarily-shared patient whose +// share window has passed. Called on an interval from index.ts. +export async function sweepExpiredShares(): Promise { + const expired = await db + .select({ + organizationId: patients.organizationId, + fileNumber: patients.fileNumber, + }) + .from(patients) + .where( + and( + isNotNull(patients.shareExpiresAt), + lte(patients.shareExpiresAt, new Date()), + ), + ); + for (const p of expired) { + await deletePatient(p.organizationId, p.fileNumber); + await recordActivity({ + orgId: p.organizationId, + actor: { id: "system", name: "temetro" }, + action: `Temporary shared record #${p.fileNumber} expired and was deleted`, + entityType: "patient", + entityId: p.fileNumber, + patientFileNumber: p.fileNumber, + }).catch(() => {}); + } + return expired.length; +} + +// Build the canonical bytes a wallet signs / the clinic verifies for a bundle. +export function bundleBytes(patient: Patient): Uint8Array { + return utf8ToBytes(JSON.stringify({ patient })); +} diff --git a/backend/src/types/patient.ts b/backend/src/types/patient.ts index 4ad21d8..a68d95d 100644 --- a/backend/src/types/patient.ts +++ b/backend/src/types/patient.ts @@ -71,4 +71,7 @@ export type Patient = { labTrend: Trend; // headline lab plotted as a sparkline encounters: Encounter[]; source?: "manual" | "ai"; // "ai" = imported/drafted by the chat agent + // Set when this record was imported from a patient wallet as a *temporary* + // share — the ISO deadline after which it is auto-deleted from the clinic. + shareExpiresAt?: string | null; }; diff --git a/frontend/components/patients/import-from-wallet-dialog.tsx b/frontend/components/patients/import-from-wallet-dialog.tsx new file mode 100644 index 0000000..23c72af --- /dev/null +++ b/frontend/components/patients/import-from-wallet-dialog.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { Check, Loader2, Smartphone, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { ApiError } from "@/lib/api-client"; +import { + commitWalletShare, + type Patient, + pollWalletShare, + requestWalletShare, + type WalletShareRequest, +} from "@/lib/patients"; +import { notify } from "@/lib/toast"; +import { cn } from "@/lib/utils"; + +type Phase = + | "form" + | "requesting" + | "waiting" + | "approved" + | "denied" + | "expired" + | "error"; + +const DURATIONS = [ + { hours: 1, key: "hours", count: 1 }, + { hours: 24, key: "days", count: 1 }, + { hours: 168, key: "days", count: 7 }, +] as const; + +const POLL_INTERVAL = 2500; +const POLL_TIMEOUT = 3 * 60 * 1000; + +export function ImportFromWalletDialog({ + open, + onOpenChange, + onImported, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onImported?: (fileNumber: string) => void; +}) { + const { t } = useTranslation(); + const [walletNumber, setWalletNumber] = useState(""); + const [temporary, setTemporary] = useState(false); + const [durationHours, setDurationHours] = useState(24); + const [phase, setPhase] = useState("form"); + const [error, setError] = useState(null); + const [request, setRequest] = useState(null); + const [reviewOpen, setReviewOpen] = useState(false); + const pollTimer = useRef | null>(null); + + const stopPolling = () => { + if (pollTimer.current) { + clearInterval(pollTimer.current); + pollTimer.current = null; + } + }; + + // Reset everything whenever the dialog is (re)opened. + useEffect(() => { + if (open) { + setWalletNumber(""); + setTemporary(false); + setDurationHours(24); + setPhase("form"); + setError(null); + setRequest(null); + setReviewOpen(false); + } + return stopPolling; + }, [open]); + + // Poll the request until the patient approves/denies on their device. + useEffect(() => { + if (phase !== "waiting" || !request) return; + const startedAt = Date.now(); + pollTimer.current = setInterval(async () => { + try { + const next = await pollWalletShare(request.id); + if (next.status === "approved") { + stopPolling(); + setRequest(next); + setPhase("approved"); + } else if (next.status === "denied") { + stopPolling(); + setPhase("denied"); + } else if ( + next.status === "expired" || + Date.now() - startedAt > POLL_TIMEOUT + ) { + stopPolling(); + setPhase("expired"); + } + } catch { + /* transient — keep polling until timeout */ + if (Date.now() - startedAt > POLL_TIMEOUT) { + stopPolling(); + setPhase("expired"); + } + } + }, POLL_INTERVAL); + return stopPolling; + }, [phase, request]); + + const sendRequest = async () => { + setPhase("requesting"); + setError(null); + try { + const req = await requestWalletShare({ + walletNumber: walletNumber.trim(), + mode: temporary ? "temporary" : "permanent", + durationHours: temporary ? durationHours : undefined, + }); + setRequest(req); + setPhase("waiting"); + } catch (err) { + if (err instanceof ApiError && err.status === 400) { + setError(t("patients.importApp.invalidWallet")); + } else { + setError(t("patients.importApp.error")); + } + setPhase("error"); + } + }; + + const commitDraft = async (record: Patient) => { + if (!request) return; + try { + const saved = await commitWalletShare(request.id, record); + setReviewOpen(false); + onOpenChange(false); + onImported?.(saved.fileNumber); + notify.success( + t("patients.importApp.savedTitle"), + t("patients.importApp.savedBody", { name: saved.name }), + ); + } catch (err) { + notify.error( + t("patients.importApp.errorTitle"), + err instanceof Error ? err.message : t("patients.importApp.error"), + ); + } + }; + + const durationLabel = (d: (typeof DURATIONS)[number]) => + t(`patients.importApp.${d.key}`, { count: d.count }); + + return ( + <> + + + + + + {t("patients.importApp.title")} + + + {t("patients.importApp.description")} + + + + + {phase === "waiting" ? ( +
+ +

+ {t("patients.importApp.waitingTitle")} +

+

+ {t("patients.importApp.waitingBody")} +

+
+ ) : phase === "approved" ? ( +
+ +

+ {t("patients.importApp.approvedTitle")} +

+

+ {t("patients.importApp.approvedBody")} +

+ +
+ ) : phase === "denied" || phase === "expired" ? ( +
+ +

+ {t(`patients.importApp.${phase}Title`)} +

+

+ {t(`patients.importApp.${phase}Body`)} +

+
+ ) : ( + <> + + +
+
+

+ {t("patients.importApp.tempLabel")} +

+

+ {t("patients.importApp.tempHint")} +

+
+ setTemporary(v)} + /> +
+ + {temporary ? ( +
+ + {t("patients.importApp.durationLabel")} + +
+ {DURATIONS.map((d) => ( + + ))} +
+
+ ) : null} + + {error ? ( +

{error}

+ ) : null} + + )} +
+ + + }> + {phase === "approved" || phase === "denied" || phase === "expired" + ? t("patients.importApp.close") + : t("patients.importApp.cancel")} + + {phase === "form" || phase === "requesting" || phase === "error" ? ( + + ) : null} + +
+
+ + {/* Review the shared record in the full patient form (review mode — the + form emits the draft, we commit it via the wallet-share endpoint). */} + {request?.draft ? ( + + ) : null} + + ); +} diff --git a/frontend/components/patients/patients-view.tsx b/frontend/components/patients/patients-view.tsx index 323a6af..fe5206b 100644 --- a/frontend/components/patients/patients-view.tsx +++ b/frontend/components/patients/patients-view.tsx @@ -1,12 +1,13 @@ "use client"; -import { Plus, Search } from "lucide-react"; +import { Plus, Search, Smartphone } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AiBadge } from "@/components/ai-badge"; import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; +import { ImportFromWalletDialog } from "@/components/patients/import-from-wallet-dialog"; import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -25,6 +26,7 @@ export function PatientsView() { const { t } = useTranslation(); const [query, setQuery] = useState(""); const [addOpen, setAddOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); // Bumped on open so the create dialog remounts with a fresh file # / form. const [addKey, setAddKey] = useState(0); @@ -110,6 +112,15 @@ export function PatientsView() { value={query} /> +

Ed25519

- {t("settings.signing.createdAt")} + {key + ? t("settings.signing.createdAtLabel", { + date: formatDate(key.createdAt), + }) + : loading + ? t("settings.signing.loading") + : error ?? ""}

+ {key?.rotatedAt ? ( +

+ {t("settings.signing.rotatedAtLabel", { + date: formatDate(key.rotatedAt), + })} +

+ ) : null}
@@ -49,7 +138,7 @@ export function SigningPanel() { @@ -98,11 +187,41 @@ export function SigningPanel() { description={t("settings.signing.signedRecordsDescription")} title={t("settings.signing.signedRecordsTitle")} > - -

- {t("settings.signing.noPending")} -

-
+ {records.length === 0 ? ( + +

+ {t("settings.signing.noPending")} +

+
+ ) : ( + + {records.map((record) => ( +
+
+

+ {record.walletNumber} +

+

+ {record.shareMode === "temporary" + ? t("settings.signing.records.temporary") + : t("settings.signing.records.permanent")} + {record.shareExpiresAt + ? ` · ${t("settings.signing.records.expires", { + date: formatDate(record.shareExpiresAt), + })}` + : ""} +

+
+ + {recordStatusLabel(record.status)} + +
+ ))} +
+ )} ); diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 35e708a..c961bf6 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -180,6 +180,40 @@ "title": "Patients", "searchPlaceholder": "Search name or MRN", "add": "Add patient", + "newPatient": "New patient", + "importFromApp": "Import from a patient app", + "tempBadge": "Temporary", + "importApp": { + "title": "Import from a patient app", + "description": "Enter the patient's wallet number. They'll get a request on their phone to approve sharing their record with this clinic.", + "walletLabel": "Patient wallet number", + "walletPlaceholder": "tmw_…", + "tempLabel": "Share temporarily", + "tempHint": "The record is automatically deleted from this clinic when the share window ends.", + "durationLabel": "Share for", + "hours": "{{count}} hour", + "hours_other": "{{count}} hours", + "days": "{{count}} day", + "days_other": "{{count}} days", + "request": "Send request", + "requesting": "Sending…", + "waitingTitle": "Waiting for the patient", + "waitingBody": "We've sent a request to the wallet. Approve it on the patient's device to continue.", + "approvedTitle": "Patient approved", + "approvedBody": "Review the shared record, then save it to this clinic.", + "review": "Review record", + "deniedTitle": "Request declined", + "deniedBody": "The patient declined to share their record.", + "expiredTitle": "Request expired", + "expiredBody": "The request timed out before the patient responded.", + "invalidWallet": "That doesn't look like a valid wallet number.", + "errorTitle": "Couldn't reach the wallet", + "error": "Please check the wallet number and try again.", + "savedTitle": "Patient imported", + "savedBody": "{{name}} was added to this clinic.", + "cancel": "Cancel", + "close": "Close" + }, "loading": "Loading patients…", "empty": "No patients found.", "loadError": "Failed to load patients.", @@ -1761,7 +1795,25 @@ "active": "Active", "keyDescription": "Every change you make to a patient record is signed with this key, so patients can verify it came from you before approving it.", "rotateKey": "Rotate key", + "rotating": "Rotating…", + "rotateSuccessTitle": "Signing key rotated", + "rotateSuccessBody": "A new Ed25519 key is now active for this clinic.", + "rotateErrorTitle": "Couldn't rotate the key", + "rotateError": "Please try again.", + "loadError": "Couldn't load the signing key.", + "loading": "Loading signing key…", "createdAt": "Created May 28, 2026", + "createdAtLabel": "Created {{date}}", + "rotatedAtLabel": "Rotated {{date}}", + "records": { + "permanent": "Permanent", + "temporary": "Temporary", + "expires": "Expires {{date}}", + "statusPending": "Awaiting approval", + "statusApproved": "Shared", + "statusDenied": "Declined", + "statusExpired": "Expired" + }, "identityTitle": "Signing identity", "identityDescription": "The public key patients use to verify your signatures", "fingerprintLabel": "Public key fingerprint", diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts index abb644b..73ec465 100644 --- a/frontend/lib/patients.ts +++ b/frontend/lib/patients.ts @@ -76,6 +76,9 @@ export type Patient = { labTrend: Trend; // headline lab plotted as a sparkline encounters: Encounter[]; source?: "manual" | "ai"; // "ai" = imported/drafted by the chat agent + // Set when imported from a patient wallet as a temporary share — the ISO + // deadline after which the record is auto-deleted from the clinic. + shareExpiresAt?: string | null; }; // Fetch one patient in the active clinic. Returns null when not found (404). @@ -180,3 +183,56 @@ export async function transferPatient( export function generateFileNumber(): string { return String(10000 + Math.floor(Math.random() * 89999)); } + +// --- Import from a patient wallet app -------------------------------------- +// A clinician enters a wallet number; we relay an encrypted-share request to the +// patient's device, the patient approves on their phone, and the decrypted draft +// record comes back for review before it's committed. + +export type WalletShareMode = "permanent" | "temporary"; + +export type WalletShareRequest = { + id: string; + walletNumber: string; + status: "pending" | "approved" | "denied" | "expired"; + shareMode: WalletShareMode; + shareExpiresAt: string | null; + draft: Patient | null; +}; + +// Start an import: relays a share request to the wallet and returns the pending +// request to poll. Throws ApiError(400) on a malformed wallet number. +export async function requestWalletShare(input: { + walletNumber: string; + mode: WalletShareMode; + durationHours?: number; +}): Promise { + return apiFetch("/api/patients/wallet/request-share", { + method: "POST", + body: JSON.stringify(input), + }); +} + +// Poll a request until the patient approves/denies on their device. +export async function pollWalletShare( + id: string, +): Promise { + return apiFetch( + `/api/patients/wallet/request-share/${encodeURIComponent(id)}`, + ); +} + +// Commit the (possibly clinician-edited) draft into a real patient record. The +// temporary-share deadline is applied server-side from the original request. +export async function commitWalletShare( + id: string, + patient: Patient, +): Promise { + return apiFetch( + `/api/patients/wallet/request-share/${encodeURIComponent(id)}/commit`, + { + method: "POST", + body: JSON.stringify(patient), + }, + ); +} diff --git a/frontend/lib/signing.ts b/frontend/lib/signing.ts new file mode 100644 index 0000000..aa378be --- /dev/null +++ b/frontend/lib/signing.ts @@ -0,0 +1,40 @@ +// Client for the clinic signing key (Settings → Signing) and the "import from a +// patient app" wallet-share flow. Both call the backend over the shared fetch +// wrapper (session cookie sent automatically). + +import { apiFetch } from "@/lib/api-client"; + +export type SigningKey = { + algorithm: string; + publicKey: string; + fingerprint: string; + createdAt: string; // ISO + rotatedAt: string | null; +}; + +export type WalletShareMode = "permanent" | "temporary"; + +export type SharedRecord = { + id: string; + walletNumber: string; + status: "pending" | "approved" | "denied" | "expired"; + shareMode: WalletShareMode; + shareExpiresAt: string | null; + // The draft is only returned by the request-share poll, not the list. +}; + +// The clinic's Ed25519 signing key. The backend creates one lazily on first +// read, so this always resolves to a real key + fingerprint. +export async function getSigningKey(): Promise { + return apiFetch("/api/signing/key"); +} + +// Rotate the signing key (owner/admin only). Returns the new key. +export async function rotateSigningKey(): Promise { + return apiFetch("/api/signing/key/rotate", { method: "POST" }); +} + +// Recent records shared from patient wallets — feeds the panel's list. +export async function listSignedRecords(): Promise { + return apiFetch("/api/signing/records"); +}