From 6a0fab97aea850df8ef7e7a6da1993144d8342be Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Tue, 9 Jun 2026 19:51:17 +0300 Subject: [PATCH 1/2] backend: per-doctor patient visibility, transfer, provider picker & scoped activity - Add patients.primary_provider_id (FK to user) + migration; persist it through create/update and surface it on the Patient shape. - Scope patient list/get for the `doctor` role to their own panel (with a createdBy fallback for legacy rows); admin/owner/member/reception/viewer keep seeing every patient. - Add POST /api/patients/:fileNumber/transfer to reassign a chart (updates the provider link + PCP label, records activity, notifies the clinic). - Add GET /api/staff/providers (any member) listing clinical-capable members for the PCP picker and transfer dialog. - Scope the activity feed: non-admins see only their own actions; owners/admins see the whole clinic. Co-Authored-By: Claude Opus 4.8 --- backend/drizzle/0009_melted_puck.sql | 2 + backend/drizzle/meta/0009_snapshot.json | 2485 +++++++++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/db/schema/patients.ts | 6 + backend/src/lib/patient-validation.ts | 5 + backend/src/routes/activity.ts | 12 +- backend/src/routes/patients.ts | 82 + backend/src/routes/staff.ts | 32 +- backend/src/services/activity.ts | 17 +- backend/src/services/patients.ts | 54 +- backend/src/types/patient.ts | 3 +- 11 files changed, 2697 insertions(+), 8 deletions(-) create mode 100644 backend/drizzle/0009_melted_puck.sql create mode 100644 backend/drizzle/meta/0009_snapshot.json diff --git a/backend/drizzle/0009_melted_puck.sql b/backend/drizzle/0009_melted_puck.sql new file mode 100644 index 0000000..47dda18 --- /dev/null +++ b/backend/drizzle/0009_melted_puck.sql @@ -0,0 +1,2 @@ +ALTER TABLE "patients" ADD COLUMN "primary_provider_id" text;--> statement-breakpoint +ALTER TABLE "patients" ADD CONSTRAINT "patients_primary_provider_id_user_id_fk" FOREIGN KEY ("primary_provider_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/backend/drizzle/meta/0009_snapshot.json b/backend/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..8efc8c9 --- /dev/null +++ b/backend/drizzle/meta/0009_snapshot.json @@ -0,0 +1,2485 @@ +{ + "id": "0f52e156-3afe-48d3-83c4-9e232afe8d49", + "prevId": "0ce438e7-5c83-40d2-9833-d1e19866f94a", + "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 + }, + "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 + }, + "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()" + }, + "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 + }, + "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.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unassigned'" + }, + "assignee_role": { + "name": "assignee_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due": { + "name": "due", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'No due date'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient": { + "name": "patient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_name": { + "name": "created_by_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_org_idx": { + "name": "tasks_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_organization_id_organization_id_fk": { + "name": "tasks_organization_id_organization_id_fk", + "tableFrom": "tasks", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_created_by_user_id_fk": { + "name": "tasks_created_by_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_org_created_idx": { + "name": "activity_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_organization_id_organization_id_fk": { + "name": "activity_log_organization_id_organization_id_fk", + "tableFrom": "activity_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_id_user_id_fk": { + "name": "activity_log_actor_id_user_id_fk", + "tableFrom": "activity_log", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_participants": { + "name": "conversation_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conv_participant_uidx": { + "name": "conv_participant_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conv_participant_user_idx": { + "name": "conv_participant_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_participants_conversation_id_conversations_id_fk": { + "name": "conversation_participants_conversation_id_conversations_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_participants_user_id_user_id_fk": { + "name": "conversation_participants_user_id_user_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_group": { + "name": "is_group", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_org_idx": { + "name": "conversations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_organization_id_organization_id_fk": { + "name": "conversations_organization_id_organization_id_fk", + "tableFrom": "conversations", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_created_by_user_id_fk": { + "name": "conversations_created_by_user_id_fk", + "tableFrom": "conversations", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "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 + } + }, + "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 3a662db..d25888d 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1780937195597, "tag": "0008_luxuriant_blue_blade", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1781022080228, + "tag": "0009_melted_puck", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema/patients.ts b/backend/src/db/schema/patients.ts index d853fa4..89c142a 100644 --- a/backend/src/db/schema/patients.ts +++ b/backend/src/db/schema/patients.ts @@ -44,6 +44,12 @@ export const patients = pgTable( vitalsTakenAt: text("vitals_taken_at").notNull(), vitalsTrend: jsonb("vitals_trend").$type().notNull(), labTrend: jsonb("lab_trend").$type().notNull(), + // The clinician responsible for this chart (the patient's "PCP"). Used to + // scope what each doctor sees and to transfer a patient between providers. + // Nullable: legacy/unassigned rows and patients registered by reception. + primaryProviderId: text("primary_provider_id").references(() => user.id, { + onDelete: "set null", + }), createdBy: text("created_by").references(() => user.id, { onDelete: "set null", }), diff --git a/backend/src/lib/patient-validation.ts b/backend/src/lib/patient-validation.ts index de4e158..b786a93 100644 --- a/backend/src/lib/patient-validation.ts +++ b/backend/src/lib/patient-validation.ts @@ -55,6 +55,11 @@ export const patientInputSchema = z.object({ age: z.number().int().min(0).max(150), sex: z.enum(["M", "F"]), pcp: z.string(), + // Optional link to the responsible clinician (user id). Empty string ⇒ null. + primaryProviderId: z.preprocess( + (v) => (v === "" ? null : v), + z.string().nullable().optional(), + ), status: z.enum(["active", "inpatient", "discharged"]), initials: z.string().trim().min(1).max(4), allergies: z.array(allergySchema).default([]), diff --git a/backend/src/routes/activity.ts b/backend/src/routes/activity.ts index 68c294e..88dae62 100644 --- a/backend/src/routes/activity.ts +++ b/backend/src/routes/activity.ts @@ -8,9 +8,19 @@ export const activityRouter = Router(); // The audit feed is readable by any clinic member. activityRouter.use(requireAuth, requireOrg); +// Whether the caller runs the clinic (owner/admin) and may therefore see the +// whole feed. Everyone else is scoped to their own actions. +function isClinicAdmin(memberRole: string | undefined): boolean { + return String(memberRole ?? "") + .split(",") + .map((s) => s.trim()) + .some((r) => r === "owner" || r === "admin"); +} + activityRouter.get("/", async (req, res, next) => { try { - res.json(await service.listActivity(req.organizationId!)); + const actorId = isClinicAdmin(req.memberRole) ? undefined : req.user!.id; + res.json(await service.listActivity(req.organizationId!, { actorId })); } catch (err) { next(err); } diff --git a/backend/src/routes/patients.ts b/backend/src/routes/patients.ts index f1112b1..3a9b1c8 100644 --- a/backend/src/routes/patients.ts +++ b/backend/src/routes/patients.ts @@ -1,5 +1,9 @@ +import { and, eq } from "drizzle-orm"; import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { member, user } from "../db/schema/auth.js"; import { HttpError } from "../lib/http-error.js"; import { patientInputSchema } from "../lib/patient-validation.js"; import { @@ -15,6 +19,29 @@ import * as service from "../services/patients.js"; export const patientsRouter = Router(); +const transferInputSchema = z.object({ + providerId: z.string().trim().min(1), +}); + +// Only the `doctor` role is scoped to its own panel of patients. Any elevated +// clinical role (owner / admin / member) sees the whole clinic, so scoping never +// applies when the caller also holds one of those. Returns the user id to scope +// by, or undefined for "see everything". +function providerScope( + memberRole: string | undefined, + userId: string, +): string | undefined { + const names = String(memberRole ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (!names.includes("doctor")) return undefined; + if (names.some((r) => ["owner", "admin", "member"].includes(r))) { + return undefined; + } + return userId; +} + // The `reception` role is scoped to scheduling + registration: it sees and // writes patient demographics only, never clinical PHI. True only when the // caller's role set is reception without any clinical-capable role. @@ -66,6 +93,7 @@ patientsRouter.get( await service.listPatients( req.organizationId!, isReceptionOnly(req.memberRole), + providerScope(req.memberRole, req.user!.id), ), ); } catch (err) { @@ -83,6 +111,7 @@ patientsRouter.get( req.organizationId!, req.params.fileNumber as string, isReceptionOnly(req.memberRole), + providerScope(req.memberRole, req.user!.id), ); if (!patient) throw new HttpError(404, "Patient not found."); res.json(patient); @@ -161,6 +190,59 @@ patientsRouter.put( }, ); +// Reassign a patient to another clinician ("transfer"). Gated on patient:write +// like any edit; the new provider must be a member of the same clinic. +patientsRouter.post( + "/:fileNumber/transfer", + requirePermission({ patient: ["write"] }), + async (req, res, next) => { + try { + const { providerId } = transferInputSchema.parse(req.body); + const [provider] = await db + .select({ name: user.name }) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .where( + and( + eq(member.organizationId, req.organizationId!), + eq(member.userId, providerId), + ), + ); + if (!provider) { + throw new HttpError(400, "Selected provider is not a member of this clinic."); + } + + const updated = await service.transferPatient( + req.organizationId!, + req.params.fileNumber as string, + providerId, + provider.name, + providerScope(req.memberRole, req.user!.id), + ); + if (!updated) throw new HttpError(404, "Patient not found."); + + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Transferred patient ${updated.name} to ${provider.name}`, + entityType: "patient", + entityId: updated.fileNumber, + patientName: updated.name, + patientFileNumber: updated.fileNumber, + }); + await notifyClinic( + req.organizationId!, + { id: req.user!.id, name: req.user!.name }, + `${req.user!.name} transferred ${updated.name} to ${provider.name}`, + updated.fileNumber, + ); + res.json(updated); + } catch (err) { + next(err); + } + }, +); + patientsRouter.delete( "/:fileNumber", requirePermission({ patient: ["delete"] }), diff --git a/backend/src/routes/staff.ts b/backend/src/routes/staff.ts index 74edbd0..8ca3601 100644 --- a/backend/src/routes/staff.ts +++ b/backend/src/routes/staff.ts @@ -1,4 +1,4 @@ -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq, inArray } from "drizzle-orm"; import { Router } from "express"; import { z } from "zod"; @@ -45,6 +45,36 @@ const staffInputSchema = z.object({ staffRouter.use(requireAuth, requireOrg); +// Clinical-capable roles that can be a patient's primary provider. Reception +// (front desk) and viewer (read-only) are excluded. +const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const; + +// List clinicians who can be assigned as a patient's primary provider. Readable +// by ANY clinic member (no `member:create` gate) so doctors and reception can +// pick a provider when registering/transferring a patient. Returns user ids. +staffRouter.get("/providers", async (req, res, next) => { + try { + const rows = await db + .select({ + userId: member.userId, + name: user.name, + role: member.role, + }) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .where( + and( + eq(member.organizationId, req.organizationId!), + inArray(member.role, PROVIDER_ROLES as unknown as string[]), + ), + ) + .orderBy(asc(user.name)); + res.json(rows); + } catch (err) { + next(err); + } +}); + // List the clinic's members with their usernames (the org client's // getFullOrganization doesn't expose username). Owner/admin only. staffRouter.get( diff --git a/backend/src/services/activity.ts b/backend/src/services/activity.ts index 132a863..2f91069 100644 --- a/backend/src/services/activity.ts +++ b/backend/src/services/activity.ts @@ -1,4 +1,4 @@ -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import { db } from "../db/index.js"; import { activityLog } from "../db/schema/activity.js"; @@ -54,14 +54,25 @@ export async function recordActivity(params: { } } +// Lists the clinic's audit feed. When `actorId` is given, only that user's own +// actions are returned (each employee sees their own activity); admins/owners +// call without it to see the whole clinic. export async function listActivity( orgId: string, - limit = 100, + options: { actorId?: string; limit?: number } = {}, ): Promise { + const { actorId, limit = 100 } = options; const rows = await db .select() .from(activityLog) - .where(eq(activityLog.organizationId, orgId)) + .where( + actorId + ? and( + eq(activityLog.organizationId, orgId), + eq(activityLog.actorId, actorId), + ) + : eq(activityLog.organizationId, orgId), + ) .orderBy(desc(activityLog.createdAt)) .limit(limit); return rows.map(toEntry); diff --git a/backend/src/services/patients.ts b/backend/src/services/patients.ts index 46c8ee7..9dade0e 100644 --- a/backend/src/services/patients.ts +++ b/backend/src/services/patients.ts @@ -1,4 +1,5 @@ -import { and, asc, eq, inArray } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, or } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; import { db } from "../db/index.js"; import { @@ -46,6 +47,7 @@ function toPatient(row: PatientRow, children: Children): Patient { age: row.age, sex: row.sex, pcp: row.pcp, + primaryProviderId: row.primaryProviderId, status: row.status, initials: row.initials, allergies: children.allergies, @@ -106,6 +108,7 @@ function patientColumns(orgId: string, input: PatientInput, createdBy?: string) age: input.age, sex: input.sex, pcp: input.pcp, + primaryProviderId: input.primaryProviderId ?? null, status: input.status, initials: input.initials, alerts: input.alerts, @@ -135,6 +138,7 @@ function demographicColumns( age: input.age, sex: input.sex, pcp: input.pcp, + primaryProviderId: input.primaryProviderId ?? null, status: input.status, initials: input.initials, alerts: [] as string[], @@ -158,11 +162,23 @@ function demographicUpdateColumns(input: PatientInput) { age: input.age, sex: input.sex, pcp: input.pcp, + primaryProviderId: input.primaryProviderId ?? null, status: input.status, initials: input.initials, }; } +// Scope clinical reads to a single provider's panel: their own patients plus any +// legacy/unassigned rows they created (so a freshly scoped doctor isn't left +// with an empty list). Returns undefined when no scoping should apply. +function providerScopeFilter(providerId?: string): SQL | undefined { + if (!providerId) return undefined; + return or( + eq(patients.primaryProviderId, providerId), + and(isNull(patients.primaryProviderId), eq(patients.createdBy, providerId)), + ); +} + // Loads and groups child rows for a set of patients in one round-trip each. async function loadChildren( patientIds: string[], @@ -280,11 +296,15 @@ function isUniqueViolation(err: unknown): boolean { export async function listPatients( orgId: string, demographicsOnly = false, + providerId?: string, ): Promise { + const scope = providerScopeFilter(providerId); const rows = await db .select() .from(patients) - .where(eq(patients.organizationId, orgId)) + .where( + scope ? and(eq(patients.organizationId, orgId), scope) : eq(patients.organizationId, orgId), + ) .orderBy(asc(patients.name)); const children = await loadChildren(rows.map((r) => r.id)); return rows.map((r) => { @@ -297,7 +317,9 @@ export async function getPatient( orgId: string, fileNumber: string, demographicsOnly = false, + providerId?: string, ): Promise { + const scope = providerScopeFilter(providerId); const [row] = await db .select() .from(patients) @@ -305,6 +327,7 @@ export async function getPatient( and( eq(patients.organizationId, orgId), eq(patients.fileNumber, fileNumber), + ...(scope ? [scope] : []), ), ); if (!row) return null; @@ -313,6 +336,33 @@ export async function getPatient( return demographicsOnly ? redactClinical(patient) : patient; } +// Reassign a patient to another clinician. Updates both the machine link +// (primaryProviderId — drives per-doctor visibility) and the display string +// (pcp). Org-scoped; returns null when the patient isn't in this clinic. +export async function transferPatient( + orgId: string, + fileNumber: string, + providerId: string, + providerName: string, + scopeProviderId?: string, +): Promise { + const scope = providerScopeFilter(scopeProviderId); + const [row] = await db + .update(patients) + .set({ primaryProviderId: providerId, pcp: providerName }) + .where( + and( + eq(patients.organizationId, orgId), + eq(patients.fileNumber, fileNumber), + ...(scope ? [scope] : []), + ), + ) + .returning(); + if (!row) return null; + const children = await loadChildren([row.id]); + return toPatient(row, children.get(row.id) ?? emptyChildren()); +} + export async function createPatient( orgId: string, userId: string, diff --git a/backend/src/types/patient.ts b/backend/src/types/patient.ts index 0f39618..3b8c971 100644 --- a/backend/src/types/patient.ts +++ b/backend/src/types/patient.ts @@ -57,7 +57,8 @@ export type Patient = { name: string; age: number; sex: Sex; - pcp: string; // primary care provider + pcp: string; // primary care provider (display name) + primaryProviderId?: string | null; // user id of the responsible clinician status: PatientStatus; initials: string; // for AvatarFallback allergies: Allergy[]; From 3eb5687f4da9f05c8ec7cc8aed756d8e2cd21b67 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Tue, 9 Jun 2026 19:51:38 +0300 Subject: [PATCH 2/2] frontend: messages search, care-team employee dialog, PCP picker + transfer, activity detail, analytics grid - Messages: search the inbox and the compose member picker. - Care Team: clickable member rows open an employee dialog showing role + permissions, with change-role (updateMemberRole) and remove. - Patients: Primary Care is now a provider dropdown (defaults to self for a doctor); add a Transfer action + dialog wired to the transfer API. - Activity: entries are clickable, opening a detail dialog. - Analytics: Section takes a columns prop so each row fills evenly (no orphan card in Appointments). - Add lib/staff.ts (listProviders), transferPatient client, rolePermissionSummary helper, and i18n keys for all new strings. Co-Authored-By: Claude Opus 4.8 --- .../components/activity/activity-view.tsx | 91 ++++++- .../components/analysis/analysis-view.tsx | 19 +- .../components/chat/patient-form-dialog.tsx | 60 ++++- .../components/messages/messages-view.tsx | 108 +++++--- .../patients/patient-detail-sheet.tsx | 18 ++ .../components/patients/patient-detail.tsx | 29 ++- .../patients/transfer-patient-dialog.tsx | 140 +++++++++++ .../settings/employee-detail-dialog.tsx | 230 ++++++++++++++++++ .../settings/settings-care-team.tsx | 68 ++++-- frontend/lib/i18n/locales/en/translation.json | 63 ++++- frontend/lib/patients.ts | 17 +- frontend/lib/roles.ts | 36 +++ frontend/lib/staff.ts | 14 ++ 13 files changed, 814 insertions(+), 79 deletions(-) create mode 100644 frontend/components/patients/transfer-patient-dialog.tsx create mode 100644 frontend/components/settings/employee-detail-dialog.tsx create mode 100644 frontend/lib/staff.ts diff --git a/frontend/components/activity/activity-view.tsx b/frontend/components/activity/activity-view.tsx index 0a160d1..aa7a422 100644 --- a/frontend/components/activity/activity-view.tsx +++ b/frontend/components/activity/activity-view.tsx @@ -17,6 +17,17 @@ import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { type ActivityEntityType, type ActivityEntry, @@ -56,6 +67,14 @@ function formatTime(iso: string): string { return `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${time}`; } +// Full, unambiguous timestamp for the detail dialog. +function formatFullTime(iso: string): string { + return new Date(iso).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); +} + function Kpi({ label, value, @@ -80,9 +99,19 @@ function Kpi({ ); } +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + export function ActivityView() { const { t } = useTranslation(); const [entries, setEntries] = useState([]); + const [selected, setSelected] = useState(null); useEffect(() => { let active = true; @@ -166,7 +195,14 @@ export function ActivityView() { {!isLast &&
}
-
+
+ ); })} )} + + !o && setSelected(null)} + open={selected !== null} + > + + + {t("activity.detail.title")} + {selected?.action} + + + + + {selected?.patientName && ( + + )} + {selected?.entityId && !selected?.patientFileNumber && ( + + )} + + + + }> + {t("activity.detail.close")} + + + + ); } diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx index a260133..8f7499e 100644 --- a/frontend/components/analysis/analysis-view.tsx +++ b/frontend/components/analysis/analysis-view.tsx @@ -24,13 +24,24 @@ function StatCard({ label, value }: Metric) { ); } +// Each section's grid fills its row evenly: the column count matches the number +// of cards so there's never an orphan card on its own row. Static class strings +// (no interpolation) so Tailwind can see them. +const GRID_BY_COLUMNS: Record<2 | 3 | 4, string> = { + 2: "grid grid-cols-1 gap-4 sm:grid-cols-2", + 3: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3", + 4: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4", +}; + function Section({ title, description, + columns, children, }: { title: string; description: string; + columns: 2 | 3 | 4; children: ReactNode; }) { return ( @@ -39,9 +50,7 @@ function Section({

{title}

{description}

-
- {children} -
+
{children}
); } @@ -77,6 +86,7 @@ export function AnalysisView() {
@@ -122,6 +132,7 @@ export function AnalysisView() {
@@ -144,6 +155,7 @@ export function AnalysisView() {
@@ -158,6 +170,7 @@ export function AnalysisView() {
diff --git a/frontend/components/chat/patient-form-dialog.tsx b/frontend/components/chat/patient-form-dialog.tsx index 6a3adcf..9b9d4c4 100644 --- a/frontend/components/chat/patient-form-dialog.tsx +++ b/frontend/components/chat/patient-form-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react"; -import { type FormEvent, type ReactNode, useState } from "react"; +import { type FormEvent, type ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -22,6 +22,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { ROLE_LABELS } from "@/lib/access"; +import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { type AllergySeverity, @@ -32,6 +34,7 @@ import { updatePatient, } from "@/lib/patients"; import { hasClinicalAccess, useActiveRole } from "@/lib/roles"; +import { listProviders, type Provider } from "@/lib/staff"; import { notify } from "@/lib/toast"; type PatientFormDialogProps = { @@ -209,6 +212,8 @@ export function PatientFormDialog({ // while the role is still loading to avoid a flash for clinical users. const role = useActiveRole(); const showClinical = role == null || hasClinicalAccess(role); + const { data: session } = authClient.useSession(); + const myId = session?.user?.id; const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -222,7 +227,10 @@ export function PatientFormDialog({ const [status, setStatus] = useState( patient?.status ?? "active" ); - const [pcp, setPcp] = useState(patient?.pcp ?? ""); + // Primary care provider is picked from the clinic's clinicians (drives + // per-doctor visibility), not free text. `providerId` is the selected user id. + const [providers, setProviders] = useState([]); + const [providerId, setProviderId] = useState(patient?.primaryProviderId ?? ""); const [bp, setBp] = useState(patient?.vitals.bp ?? ""); const [hr, setHr] = useState(patient?.vitals.hr ?? ""); const [temp, setTemp] = useState(patient?.vitals.temp ?? ""); @@ -249,18 +257,45 @@ export function PatientFormDialog({ })) ?? [] ); + // Load the clinic's clinicians for the PCP picker. When creating, default the + // PCP to the current user if they're a provider (a doctor registering their + // own patient). + useEffect(() => { + let active = true; + listProviders() + .then((list) => { + if (!active) return; + setProviders(list); + if (!isEdit && myId && list.some((p) => p.userId === myId)) { + setProviderId((cur) => cur || myId); + } + }) + .catch(() => { + /* leave the picker empty; PCP just stays unassigned */ + }); + return () => { + active = false; + }; + }, [isEdit, myId]); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (!name.trim() || submitting) { return; } + const selectedProvider = providers.find((p) => p.userId === providerId); + // Display name follows the selected provider; preserve any existing label + // when nothing is selected so legacy free-text PCPs aren't wiped on edit. + const pcpName = selectedProvider?.name ?? (patient?.pcp || "—"); + const built: Patient = { fileNumber, name: name.trim(), age: Number(age) || 0, sex, - pcp: pcp.trim() || "—", + pcp: pcpName, + primaryProviderId: providerId || null, status, initials: initialsFromName(name), allergies: allergies.filter((a) => a.substance.trim()), @@ -415,11 +450,20 @@ export function PatientFormDialog({ - setPcp(event.target.value)} - placeholder={t("patientForm.primaryCarePlaceholder")} - value={pcp} - /> + {showClinical && ( diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index 301e14a..4f74582 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { Mail, Plus, SendHorizonal } from "lucide-react"; +import { Mail, Plus, Search, SendHorizonal } from "lucide-react"; import { type FormEvent, useEffect, @@ -68,9 +68,11 @@ export function MessagesView() { const [selectedId, setSelectedId] = useState(null); const [messages, setMessages] = useState([]); const [showUnreadOnly, setShowUnreadOnly] = useState(false); + const [inboxQuery, setInboxQuery] = useState(""); const [draft, setDraft] = useState(""); const [composeOpen, setComposeOpen] = useState(false); const [members, setMembers] = useState([]); + const [memberQuery, setMemberQuery] = useState(""); // Refs so the socket handler (registered once) reads current values. const selectedIdRef = useRef(null); @@ -135,11 +137,22 @@ export function MessagesView() { const unreadCount = conversations.filter((c) => c.unread).length; const selected = conversations.find((c) => c.id === selectedId) ?? null; - const visible = useMemo( - () => - showUnreadOnly ? conversations.filter((c) => c.unread) : conversations, - [conversations, showUnreadOnly], - ); + const visible = useMemo(() => { + const q = inboxQuery.trim().toLowerCase(); + return conversations.filter((c) => { + if (showUnreadOnly && !c.unread) return false; + if (!q) return true; + return ( + c.name.toLowerCase().includes(q) || + (c.lastMessage?.body.toLowerCase().includes(q) ?? false) + ); + }); + }, [conversations, showUnreadOnly, inboxQuery]); + + const visibleMembers = useMemo(() => { + const q = memberQuery.trim().toLowerCase(); + return q ? members.filter((m) => m.name.toLowerCase().includes(q)) : members; + }, [members, memberQuery]); const open = (id: string) => { setSelectedId(id); @@ -170,6 +183,7 @@ export function MessagesView() { const openCompose = () => { setComposeOpen(true); + setMemberQuery(""); listClinicMembers() .then(setMembers) .catch(() => setMembers([])); @@ -220,12 +234,27 @@ export function MessagesView() { +
+
+ + setInboxQuery(e.target.value)} + placeholder={t("messages.searchPlaceholder")} + size="sm" + value={inboxQuery} + /> +
+
{visible.length === 0 ? (

- {showUnreadOnly - ? t("messages.noUnread") - : t("messages.noConversations")} + {inboxQuery.trim() + ? t("messages.noMatches") + : showUnreadOnly + ? t("messages.noUnread") + : t("messages.noConversations")}

) : ( visible.map((c) => { @@ -379,28 +408,45 @@ export function MessagesView() { {t("messages.compose.description")} - - {members.length === 0 ? ( -

- {t("messages.compose.noMembers")} -

- ) : ( - members.map((m) => ( - - )) - )} + +
+ + setMemberQuery(e.target.value)} + placeholder={t("messages.compose.searchPlaceholder")} + size="sm" + value={memberQuery} + /> +
+
+ {members.length === 0 ? ( +

+ {t("messages.compose.noMembers")} +

+ ) : visibleMembers.length === 0 ? ( +

+ {t("messages.compose.noMatches")} +

+ ) : ( + visibleMembers.map((m) => ( + + )) + )} +
diff --git a/frontend/components/patients/patient-detail-sheet.tsx b/frontend/components/patients/patient-detail-sheet.tsx index 457c623..15f5f5b 100644 --- a/frontend/components/patients/patient-detail-sheet.tsx +++ b/frontend/components/patients/patient-detail-sheet.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; import { PatientDetail } from "@/components/patients/patient-detail"; +import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog"; import { Sheet, SheetHeader, @@ -14,6 +15,7 @@ import { } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { getPatient, type Patient } from "@/lib/patients"; +import { hasClinicalAccess, useActiveRole } from "@/lib/roles"; type Status = "loading" | "ready" | "not-found"; @@ -54,9 +56,13 @@ export function PatientDetailSheet({ onOpenChange: (open: boolean) => void; }) { const { t } = useTranslation(); + const role = useActiveRole(); + // Clinical roles can reassign a chart; show optimistically while role loads. + const canTransfer = role == null || hasClinicalAccess(role); const [patient, setPatient] = useState(null); const [status, setStatus] = useState("loading"); const [editOpen, setEditOpen] = useState(false); + const [transferOpen, setTransferOpen] = useState(false); // Bumped on open so the editor remounts with the latest patient data. const [editKey, setEditKey] = useState(0); @@ -106,6 +112,9 @@ export function PatientDetailSheet({ setEditKey((k) => k + 1); setEditOpen(true); }} + onTransfer={ + canTransfer ? () => setTransferOpen(true) : undefined + } patient={patient} /> )} @@ -123,6 +132,15 @@ export function PatientDetailSheet({ patient={patient} /> )} + + {patient && ( + setPatient(updated)} + open={transferOpen} + patient={patient} + /> + )} ); } diff --git a/frontend/components/patients/patient-detail.tsx b/frontend/components/patients/patient-detail.tsx index b98b57b..6e10cc8 100644 --- a/frontend/components/patients/patient-detail.tsx +++ b/frontend/components/patients/patient-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { Pencil } from "lucide-react"; +import { ArrowLeftRight, Pencil } from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -81,9 +81,11 @@ function TrendBlock({ trend }: { trend: Trend }) { export function PatientDetail({ patient, onEdit, + onTransfer, }: { patient: Patient; onEdit?: () => void; + onTransfer?: () => void; }) { const { t } = useTranslation(); const sex = t(`patientCard.sex.${patient.sex}`); @@ -115,12 +117,25 @@ export function PatientDetail({
)} - {onEdit && ( - - )} +
+ {onTransfer && ( + + )} + {onEdit && ( + + )} +
diff --git a/frontend/components/patients/transfer-patient-dialog.tsx b/frontend/components/patients/transfer-patient-dialog.tsx new file mode 100644 index 0000000..d80bd78 --- /dev/null +++ b/frontend/components/patients/transfer-patient-dialog.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { ROLE_LABELS } from "@/lib/access"; +import { type Patient, transferPatient } from "@/lib/patients"; +import { listProviders, type Provider } from "@/lib/staff"; +import { notify } from "@/lib/toast"; + +const selectClass = + "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"; + +type Props = { + patient: Patient; + open: boolean; + onOpenChange: (open: boolean) => void; + onTransferred: (patient: Patient) => void; +}; + +// Reassign a patient to another clinician. The new provider becomes the +// patient's primary provider (and PCP label), which moves the chart into their +// panel under per-doctor visibility. +export function TransferPatientDialog({ + patient, + open, + onOpenChange, + onTransferred, +}: Props) { + const { t } = useTranslation(); + const [providers, setProviders] = useState([]); + const [providerId, setProviderId] = useState(patient.primaryProviderId ?? ""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setProviderId(patient.primaryProviderId ?? ""); + setError(null); + let active = true; + listProviders() + .then((list) => active && setProviders(list)) + .catch(() => active && setProviders([])); + return () => { + active = false; + }; + }, [open, patient.primaryProviderId]); + + const submit = async () => { + if (!providerId || submitting) return; + setSubmitting(true); + setError(null); + try { + const updated = await transferPatient(patient.fileNumber, providerId); + onTransferred(updated); + notify.success( + t("patients.transfer.successTitle"), + t("patients.transfer.successBody", { + name: updated.name, + provider: updated.pcp, + }), + ); + onOpenChange(false); + } catch (err) { + const message = + err instanceof Error ? err.message : t("patients.transfer.error"); + setError(message); + notify.error(t("patients.transfer.errorTitle"), message); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {t("patients.transfer.title")} + + {t("patients.transfer.description", { name: patient.name })} + + + + {error && ( +

+ {error} +

+ )} + +
+ + }> + {t("patients.transfer.cancel")} + + + +
+
+ ); +} diff --git a/frontend/components/settings/employee-detail-dialog.tsx b/frontend/components/settings/employee-detail-dialog.tsx new file mode 100644 index 0000000..8d7f1f4 --- /dev/null +++ b/frontend/components/settings/employee-detail-dialog.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { ROLE_LABELS } from "@/lib/access"; +import { authClient } from "@/lib/auth-client"; +import { PROVISIONABLE_ROLES, rolePermissionSummary } from "@/lib/roles"; +import { notify } from "@/lib/toast"; + +// One row of /api/staff — shared with the Care Team panel. +export type StaffMember = { + id: string; + userId: string; + role: string; + name: string | null; + email: string | null; + username: string | null; +}; + +const selectClass = + "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"; + +function roleLabel(role?: string | null): string { + if (!role) return ROLE_LABELS.member; + return (ROLE_LABELS as Record)[role] ?? role; +} + +function initials(name?: string | null, email?: string | null): string { + const source = name?.trim() || email?.trim() || "?"; + return ( + source + .split(/\s+/) + .map((w) => w[0]) + .filter(Boolean) + .join("") + .slice(0, 2) + .toUpperCase() || "?" + ); +} + +type Props = { + member: StaffMember | null; + open: boolean; + onOpenChange: (open: boolean) => void; + // True when the viewer may change/remove this member (admin, not self, not an + // owner). When false the dialog is read-only. + editable: boolean; + onChanged: () => void; + onRemove: (member: StaffMember) => void; +}; + +// Admin-facing detail view for a single clinic member: shows who they are, what +// their role lets them do, and (when editable) lets an admin change the role — +// which swaps the whole permission bundle — or remove them. +export function EmployeeDetailDialog({ + member, + open, + onOpenChange, + editable, + onChanged, + onRemove, +}: Props) { + const { t } = useTranslation(); + const [role, setRole] = useState(member?.role ?? ""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setRole(member?.role ?? ""); + }, [member?.id, member?.role]); + + const summary = rolePermissionSummary(member?.role); + const secondary = member?.username ? `@${member.username}` : member?.email; + // Keep the member's current role selectable even if it isn't admin-assignable + // (e.g. the "member"/Clinician role). + const roleOptions = Array.from( + new Set([member?.role, ...PROVISIONABLE_ROLES].filter(Boolean) as string[]), + ); + + const changeRole = async () => { + if (!member || saving || role === member.role) return; + setSaving(true); + const { error } = await authClient.organization.updateMemberRole({ + memberId: member.id, + role, + }); + setSaving(false); + if (error) { + notify.error( + t("settings.careTeam.employee.roleFailedTitle"), + error.message ?? t("settings.careTeam.employee.roleFailedBody"), + ); + return; + } + notify.success( + t("settings.careTeam.employee.roleUpdatedTitle"), + t("settings.careTeam.employee.roleUpdatedBody", { + name: member.name ?? member.email ?? "", + role: roleLabel(role), + }), + ); + onChanged(); + onOpenChange(false); + }; + + return ( + + + + {t("settings.careTeam.employee.title")} + + {t("settings.careTeam.employee.description")} + + + + +
+ + + {initials(member?.name, member?.email)} + + +
+

+ {member?.name || member?.email || member?.userId} +

+ {secondary && ( +

+ {secondary} +

+ )} +
+ + {roleLabel(member?.role)} + +
+ +
+ + {t("settings.careTeam.employee.permissions")} + +
+ {summary.map(({ resource, actions }) => ( +
+ + {t(`settings.careTeam.employee.resources.${resource}`)} + + + {actions.length === 0 + ? t("settings.careTeam.employee.noAccess") + : actions + .map((a) => + t(`settings.careTeam.employee.actions.${a}`), + ) + .join(" · ")} + +
+ ))} +
+
+ + {editable && ( +
+ + {t("settings.careTeam.employee.changeRole")} + +
+ + +
+
+ )} +
+ + + {editable && member && ( + + )} + }> + {t("settings.careTeam.employee.close")} + + +
+
+ ); +} diff --git a/frontend/components/settings/settings-care-team.tsx b/frontend/components/settings/settings-care-team.tsx index 5a68848..136a70b 100644 --- a/frontend/components/settings/settings-care-team.tsx +++ b/frontend/components/settings/settings-care-team.tsx @@ -1,10 +1,14 @@ "use client"; -import { UserPlus, X } from "lucide-react"; +import { UserPlus } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { AddStaffDialog } from "@/components/settings/add-staff-dialog"; +import { + EmployeeDetailDialog, + type StaffMember, +} from "@/components/settings/employee-detail-dialog"; import { SettingsCard, SettingsSection, @@ -26,17 +30,6 @@ import { apiFetch } from "@/lib/api-client"; import { authClient } from "@/lib/auth-client"; import { notify } from "@/lib/toast"; -// One row of /api/staff — clinic members joined to their user record (incl. the -// username admin-provisioned staff sign in with). -type StaffMember = { - id: string; - userId: string; - role: string; - name: string | null; - email: string | null; - username: string | null; -}; - function roleLabel(role?: string | null): string { if (!role) return ROLE_LABELS.member; return (ROLE_LABELS as Record)[role] ?? role; @@ -62,6 +55,7 @@ export function CareTeamPanel() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); + const [selected, setSelected] = useState(null); const [pendingRemove, setPendingRemove] = useState(null); const [removing, setRemoving] = useState(false); @@ -142,14 +136,14 @@ export function CareTeamPanel() { // Prefer the login username; fall back to email for owners who // signed up by email. const secondary = m.username ? `@${m.username}` : m.email; - return ( -
+ const body = ( + <> {initials(m.name, m.email)} -
+

{m.name || m.email || m.userId} {isSelf && ( @@ -167,17 +161,22 @@ export function CareTeamPanel() { {roleLabel(m.role)} - {canManage && !isSelf && m.role !== "owner" && ( - - )} + + ); + // Admins click a row to open the employee detail dialog (view + // permissions, change role, remove). Non-managers see a static row. + return canManage ? ( + + ) : ( +

+ {body}
); }) @@ -192,6 +191,23 @@ export function CareTeamPanel() { /> )} + {/* Click a member to view details, change role, or remove. */} + void load()} + onOpenChange={(o) => !o && setSelected(null)} + onRemove={(m) => { + setSelected(null); + setPendingRemove(m); + }} + open={selected !== null} + /> + {/* Confirm before removing a member — destructive and not reversible. */} !o && setPendingRemove(null)} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index c16b861..43c5884 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -187,6 +187,20 @@ "notFound": "Patient not found", "loading": "Loading patient…", "noPatientForFile": "No patient found for file #{{number}}." + }, + "transfer": { + "action": "Transfer", + "title": "Transfer patient", + "description": "Reassign {{name}} to another clinician. They become this patient's primary provider.", + "providerLabel": "New primary provider", + "choose": "Choose a provider…", + "cancel": "Cancel", + "confirm": "Transfer patient", + "transferring": "Transferring…", + "successTitle": "Patient transferred", + "successBody": "{{name}} is now with {{provider}}.", + "errorTitle": "Couldn't transfer patient", + "error": "Please try again." } }, "appointments": { @@ -366,6 +380,8 @@ "inbox": "Inbox", "unread": "Unread · {{count}}", "newMessage": "New message", + "searchPlaceholder": "Search conversations", + "noMatches": "No conversations match your search.", "noUnread": "No unread messages.", "noConversations": "No conversations yet.", "you": "You: ", @@ -378,6 +394,8 @@ "compose": { "title": "New message", "description": "Start a conversation with a member of your clinic.", + "searchPlaceholder": "Search people", + "noMatches": "No people match your search.", "noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team." }, "startFailedTitle": "Couldn't start conversation", @@ -430,7 +448,23 @@ "changesToday": "Changes today", "thisWeek": "This week", "totalRecorded": "Total recorded", - "empty": "No activity yet. Changes to patients, notes, appointments, prescriptions and tasks will appear here." + "empty": "No activity yet. Changes to patients, notes, appointments, prescriptions and tasks will appear here.", + "detail": { + "title": "Activity detail", + "person": "Performed by", + "record": "Record type", + "patient": "Patient", + "reference": "Reference", + "time": "Time", + "close": "Close", + "entityTypes": { + "patient": "Patient", + "note": "Note", + "appointment": "Appointment", + "prescription": "Prescription", + "task": "Task" + } + } }, "notes": { "title": "Notes", @@ -603,6 +637,7 @@ "status": "Status", "primaryCare": "Primary care", "primaryCarePlaceholder": "e.g. Dr. Lena Ortiz", + "primaryCareUnassigned": "Unassigned", "currentVitals": "Current vitals", "bp": "Blood pressure", "hr": "Heart rate", @@ -725,6 +760,32 @@ "you": "(you)", "addMember": "Add team member", "removeMember": "Remove member", + "employee": { + "title": "Team member", + "description": "View this member's access, change their role, or remove them.", + "permissions": "Permissions", + "noAccess": "No access", + "changeRole": "Change role", + "save": "Save", + "saving": "Saving…", + "remove": "Remove employee", + "close": "Close", + "roleUpdatedTitle": "Role updated", + "roleUpdatedBody": "{{name}} is now {{role}}.", + "roleFailedTitle": "Couldn't update role", + "roleFailedBody": "Please try again.", + "resources": { + "patient": "Patients", + "appointment": "Appointments", + "prescription": "Prescriptions", + "task": "Tasks" + }, + "actions": { + "read": "View", + "write": "Edit", + "delete": "Delete" + } + }, "remove": { "title": "Remove team member?", "description": "{{name}} will lose access to this clinic. This can't be undone.", diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts index 7960874..191fb9b 100644 --- a/frontend/lib/patients.ts +++ b/frontend/lib/patients.ts @@ -62,7 +62,8 @@ export type Patient = { name: string; age: number; sex: "M" | "F"; - pcp: string; // primary care provider + pcp: string; // primary care provider (display name) + primaryProviderId?: string | null; // user id of the responsible clinician status: "active" | "inpatient" | "discharged"; initials: string; // for AvatarFallback allergies: Allergy[]; @@ -112,6 +113,20 @@ export async function updatePatient(patient: Patient): Promise { ); } +// Reassign a patient to another clinician (sets their primary provider + PCP). +export async function transferPatient( + fileNumber: string, + providerId: string, +): Promise { + return apiFetch( + `/api/patients/${encodeURIComponent(fileNumber)}/transfer`, + { + method: "POST", + body: JSON.stringify({ providerId }), + }, + ); +} + // Suggest a unique-ish 5-digit file number for new charts. The server is the // source of truth and rejects collisions with a 409. export function generateFileNumber(): string { diff --git a/frontend/lib/roles.ts b/frontend/lib/roles.ts index d9e26bc..964439b 100644 --- a/frontend/lib/roles.ts +++ b/frontend/lib/roles.ts @@ -45,6 +45,42 @@ export function useActiveRole(): string | null { return role; } +// The clinical resources + actions we surface in the Care Team permissions +// summary. Mirrors the statements in lib/access.ts. +export const CLINICAL_RESOURCES = [ + "patient", + "appointment", + "prescription", + "task", +] as const; +const RESOURCE_ACTIONS = ["read", "write", "delete"] as const; + +type PermissionArg = Parameters< + typeof authClient.organization.checkRolePermission +>[0]["permissions"]; + +// For a given role, the allowed actions on each clinical resource — computed +// from Better Auth so it stays in lock-step with lib/access.ts. Used by the +// Care Team employee dialog to show what a role can do. +export function rolePermissionSummary( + role: string | null | undefined, +): { resource: (typeof CLINICAL_RESOURCES)[number]; actions: string[] }[] { + if (!role) return []; + return CLINICAL_RESOURCES.map((resource) => { + const actions = RESOURCE_ACTIONS.filter((action) => { + try { + return authClient.organization.checkRolePermission({ + role: role as RoleKey, + permissions: { [resource]: [action] } as PermissionArg, + }); + } catch { + return false; + } + }); + return { resource, actions }; + }); +} + // Whether a role may see clinical records (AI lookup, prescriptions, notes, // analysis). Driven by Better Auth permissions so it stays in lock-step with // lib/access.ts: the `reception` role has no `prescription` statement, so this diff --git a/frontend/lib/staff.ts b/frontend/lib/staff.ts new file mode 100644 index 0000000..5c61dff --- /dev/null +++ b/frontend/lib/staff.ts @@ -0,0 +1,14 @@ +import { apiFetch } from "@/lib/api-client"; + +// A clinician who can be assigned as a patient's primary provider. Returned by +// the backend's GET /api/staff/providers (clinical roles only — excludes +// reception/viewer). Readable by any clinic member. +export type Provider = { + userId: string; + name: string; + role: string; +}; + +export function listProviders(): Promise { + return apiFetch("/api/staff/providers"); +}