diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index b524524..baabdfd 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -27,10 +27,21 @@ No test runner is configured. Verify by running the stack (`docker compose up`) ## Architecture -- **`src/auth.ts`** — the Better Auth config (the CLI auto-discovers it). Email/password, - organization plugin (clinics) with custom RBAC from **`src/lib/access.ts`** - (`owner`/`admin`/`member`/`viewer` + a `patient` resource). Mounted in `src/index.ts` via - `toNodeHandler(auth)` at `/api/auth/*`. +- **`src/auth.ts`** — the Better Auth config (the CLI auto-discovers it). Email/password + + **username** plugin (staff sign in by username) and the organization plugin (clinics) with custom + RBAC from **`src/lib/access.ts`** (`owner`/`admin`/`doctor`/`reception`/`member`/`viewer` over + `patient`/`appointment`/`prescription`/`task` resources). `reception` has no `prescription` + statement — it's scoped to scheduling + registration, and `src/services/patients.ts` redacts + clinical fields for it. Mounted in `src/index.ts` via `toNodeHandler(auth)` at `/api/auth/*`. +- **`src/routes/staff.ts`** — admin-provisioned staff: `POST /api/staff` creates a user + (`auth.api.signUpEmail`) + attaches them to the clinic (`auth.api.addMember`); `GET /api/staff` + lists members with usernames. Replaces the old email-invitation flow. Gated by + `requirePermission({ member: ["create"] })`. +- **Better Auth = the single source of RBAC.** Manage all permissions through Better Auth, not a + custom layer. Authoritative references live as repo skills under + `.claude/skills/{better-auth-best-practices,organization-best-practices, + email-and-password-best-practices,better-auth-security-best-practices}` — consult them before + changing `auth.ts`, `src/lib/access.ts`, or the auth schema. - **`src/db/`** — `index.ts` is the Drizzle client (no schema passed; we use the core query builder). `schema/auth.ts` is **generated** by the Better Auth CLI; `schema/patients.ts` is hand-written and references the generated `organization`/`user` tables. diff --git a/backend/drizzle/0007_skinny_bloodstrike.sql b/backend/drizzle/0007_skinny_bloodstrike.sql new file mode 100644 index 0000000..122ae10 --- /dev/null +++ b/backend/drizzle/0007_skinny_bloodstrike.sql @@ -0,0 +1,3 @@ +ALTER TABLE "user" ADD COLUMN "username" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "display_username" text;--> statement-breakpoint +ALTER TABLE "user" ADD CONSTRAINT "user_username_unique" UNIQUE("username"); \ No newline at end of file diff --git a/backend/drizzle/meta/0007_snapshot.json b/backend/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..91f783f --- /dev/null +++ b/backend/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2454 @@ +{ + "id": "1c9076a8-0da3-4ba1-aeb9-1fb14bb18b31", + "prevId": "fb89e8c6-f2b3-4e5d-b33d-cea858047715", + "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 + }, + "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_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'" + }, + "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_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 d7a0557..ee36ab3 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1780856647280, "tag": "0006_square_wither", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1780933857005, + "tag": "0007_skinny_bloodstrike", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/auth.ts b/backend/src/auth.ts index aa11eaa..9756ebc 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -1,6 +1,7 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { organization } from "better-auth/plugins"; +import { username } from "better-auth/plugins/username"; import { eq } from "drizzle-orm"; import { db } from "./db/index.js"; @@ -55,6 +56,13 @@ export const auth = betterAuth({ }, plugins: [ + // Lets staff sign in with a username (in addition to email). Admin-created + // staff accounts (see src/routes/staff.ts) set a username + password the + // employee uses to log in. Adds `username` + `displayUsername` to `user`. + username({ + minUsernameLength: 3, + maxUsernameLength: 32, + }), organization({ ac, roles, diff --git a/backend/src/db/schema/auth.ts b/backend/src/db/schema/auth.ts index e418491..8f60dd8 100644 --- a/backend/src/db/schema/auth.ts +++ b/backend/src/db/schema/auth.ts @@ -21,6 +21,8 @@ export const user = pgTable("user", { .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), + username: text("username").unique(), + displayUsername: text("display_username"), }); export const session = pgTable( diff --git a/backend/src/index.ts b/backend/src/index.ts index 7eef6f1..dbe7c2f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -16,6 +16,7 @@ import { notesRouter } from "./routes/notes.js"; import { notificationsRouter } from "./routes/notifications.js"; import { patientsRouter } from "./routes/patients.js"; import { prescriptionsRouter } from "./routes/prescriptions.js"; +import { staffRouter } from "./routes/staff.js"; import { tasksRouter } from "./routes/tasks.js"; const app = express(); @@ -58,6 +59,7 @@ app.use("/api/notes", notesRouter); app.use("/api/appointments", appointmentsRouter); app.use("/api/prescriptions", prescriptionsRouter); app.use("/api/tasks", tasksRouter); +app.use("/api/staff", staffRouter); app.use("/api/activity", activityRouter); app.use("/api/analytics", analyticsRouter); app.use("/api/conversations", conversationsRouter); @@ -78,6 +80,7 @@ server.listen(env.PORT, () => { console.log(` • appts: /api/appointments`); console.log(` • rx: /api/prescriptions`); console.log(` • tasks: /api/tasks`); + console.log(` • staff: /api/staff`); console.log(` • activity: /api/activity`); console.log(` • stats: /api/analytics`); console.log(` • messages: /api/conversations (+ Socket.io)`); diff --git a/backend/src/lib/access.ts b/backend/src/lib/access.ts index d8aa50a..7eec9ad 100644 --- a/backend/src/lib/access.ts +++ b/backend/src/lib/access.ts @@ -51,6 +51,29 @@ export const member = ac.newRole({ task: ["read", "write", "delete"], }); +// doctor (clinician): same clinical access as `member` — the role we provision +// for physicians. Kept distinct from `member` so the UI can label/treat it as +// "Doctor" and so reception can be a sibling role with narrower access. +export const doctor = ac.newRole({ + ...memberAc.statements, + patient: ["read", "write"], + appointment: ["read", "write", "delete"], + prescription: ["read", "write", "delete"], + task: ["read", "write", "delete"], +}); + +// reception (front desk): scheduling + patient registration only. Can manage +// appointments and register/edit patient demographics, but has NO access to +// clinical records (no prescription statement at all) — least-privilege per +// EHR RBAC guidance. The patients service additionally redacts clinical fields +// for this role so demographics-only is enforced server-side, not just in UI. +export const reception = ac.newRole({ + ...memberAc.statements, + patient: ["read", "write"], + appointment: ["read", "write", "delete"], + task: ["read", "write"], +}); + // viewer: read-only access to clinical records. export const viewer = ac.newRole({ patient: ["read"], @@ -59,4 +82,4 @@ export const viewer = ac.newRole({ task: ["read"], }); -export const roles = { owner, admin, member, viewer }; +export const roles = { owner, admin, doctor, reception, member, viewer }; diff --git a/backend/src/routes/patients.ts b/backend/src/routes/patients.ts index a437d6b..f1112b1 100644 --- a/backend/src/routes/patients.ts +++ b/backend/src/routes/patients.ts @@ -15,6 +15,18 @@ import * as service from "../services/patients.js"; export const patientsRouter = Router(); +// 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. +function isReceptionOnly(memberRole?: string): boolean { + const names = String(memberRole ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (!names.includes("reception")) return false; + return !names.some((r) => ["owner", "admin", "doctor", "member"].includes(r)); +} + // Notify the rest of the clinic about a patient record change (best-effort, // pushed live over the socket). async function notifyClinic( @@ -50,7 +62,12 @@ patientsRouter.get( requirePermission({ patient: ["read"] }), async (req, res, next) => { try { - res.json(await service.listPatients(req.organizationId!)); + res.json( + await service.listPatients( + req.organizationId!, + isReceptionOnly(req.memberRole), + ), + ); } catch (err) { next(err); } @@ -65,6 +82,7 @@ patientsRouter.get( const patient = await service.getPatient( req.organizationId!, req.params.fileNumber as string, + isReceptionOnly(req.memberRole), ); if (!patient) throw new HttpError(404, "Patient not found."); res.json(patient); @@ -84,6 +102,7 @@ patientsRouter.post( req.organizationId!, req.user!.id, input, + isReceptionOnly(req.memberRole), ); await recordActivity({ orgId: req.organizationId!, @@ -117,6 +136,7 @@ patientsRouter.put( req.organizationId!, req.params.fileNumber as string, input, + isReceptionOnly(req.memberRole), ); if (!updated) throw new HttpError(404, "Patient not found."); await recordActivity({ diff --git a/backend/src/routes/staff.ts b/backend/src/routes/staff.ts new file mode 100644 index 0000000..74edbd0 --- /dev/null +++ b/backend/src/routes/staff.ts @@ -0,0 +1,134 @@ +import { asc, eq } from "drizzle-orm"; +import { Router } from "express"; +import { z } from "zod"; + +import { auth } from "../auth.js"; +import { db } from "../db/index.js"; +import { member, organization, user } from "../db/schema/auth.js"; +import { HttpError } from "../lib/http-error.js"; +import { requireAuth, requireOrg, requirePermission } from "../middleware/auth.js"; + +export const staffRouter = Router(); + +// Admin-provisioned staff accounts. Instead of emailing an invitation link, an +// owner/admin creates the employee's account directly — name, role and a +// username + password the employee uses to sign in. Everything is gated by the +// Better Auth `member` permission so RBAC stays in one place. The account is +// created via `auth.api.signUpEmail` and attached to the active clinic via +// `auth.api.addMember`; the new user then shows up everywhere org members do +// (e.g. the Messages compose picker) with no extra wiring. + +// Roles an admin may assign — `owner` is intentionally excluded (the clinic +// creator is the sole owner; transfer ownership via member-role updates). +const PROVISIONABLE_ROLES = ["admin", "doctor", "reception", "viewer"] as const; + +const staffInputSchema = z.object({ + name: z.string().trim().min(1).max(120), + username: z + .string() + .trim() + .min(3) + .max(32) + .regex( + /^[a-zA-Z0-9_.]+$/, + "Username may only contain letters, numbers, dots and underscores.", + ), + password: z.string().min(12).max(256), + role: z.enum(PROVISIONABLE_ROLES), + // Optional real email; staff sign in by username, so when omitted we mint a + // placeholder (the email column is required + unique). + email: z.preprocess( + (v) => (v === "" ? undefined : v), + z.string().trim().email().optional(), + ), +}); + +staffRouter.use(requireAuth, requireOrg); + +// List the clinic's members with their usernames (the org client's +// getFullOrganization doesn't expose username). Owner/admin only. +staffRouter.get( + "/", + requirePermission({ member: ["create"] }), + async (req, res, next) => { + try { + const rows = await db + .select({ + id: member.id, + userId: member.userId, + role: member.role, + name: user.name, + email: user.email, + username: user.username, + }) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .where(eq(member.organizationId, req.organizationId!)) + .orderBy(asc(user.name)); + res.json(rows); + } catch (err) { + next(err); + } + }, +); + +// Provision a new staff account and add them to the active clinic. +staffRouter.post( + "/", + requirePermission({ member: ["create"] }), + async (req, res, next) => { + try { + const input = staffInputSchema.parse(req.body); + + const [org] = await db + .select({ slug: organization.slug }) + .from(organization) + .where(eq(organization.id, req.organizationId!)); + if (!org) throw new HttpError(404, "Clinic not found."); + + const email = + input.email ?? `${input.username.toLowerCase()}@${org.slug}.temetro.local`; + + // Create the credential account (user + hashed password + username). + let newUserId: string; + try { + const result = await auth.api.signUpEmail({ + body: { + name: input.name, + email, + password: input.password, + username: input.username, + }, + }); + newUserId = result.user.id; + } catch (err) { + // Surface Better Auth's reason (e.g. username/email already taken). + const message = + (err as { body?: { message?: string } })?.body?.message ?? + (err as Error)?.message ?? + "Could not create the account."; + throw new HttpError(400, message); + } + + // Attach the new user to the clinic with the chosen role (server-side, + // no invitation step). + await auth.api.addMember({ + body: { + userId: newUserId, + organizationId: req.organizationId!, + role: input.role, + }, + }); + + res.status(201).json({ + userId: newUserId, + name: input.name, + email, + username: input.username.toLowerCase(), + role: input.role, + }); + } catch (err) { + next(err); + } + }, +); diff --git a/backend/src/services/patients.ts b/backend/src/services/patients.ts index 27ea2ad..46c8ee7 100644 --- a/backend/src/services/patients.ts +++ b/backend/src/services/patients.ts @@ -18,6 +18,7 @@ import type { Medication, Patient, Problem, + Trend, } from "../types/patient.js"; type PatientRow = typeof patients.$inferSelect; @@ -65,6 +66,27 @@ function toPatient(row: PatientRow, children: Children): Patient { }; } +const EMPTY_TREND: Trend = { label: "", unit: "", points: [] }; + +// Strip every clinical section, leaving only registration/demographic fields. +// Used for the `reception` role, which is scoped to scheduling + registration +// and must never receive PHI (labs, meds, problems, vitals, encounters). This +// enforces least-privilege server-side rather than relying on the UI to hide it. +function redactClinical(patient: Patient): Patient { + return { + ...patient, + allergies: [], + alerts: [], + medications: [], + problems: [], + vitals: { bp: "", hr: "", temp: "", spo2: "", takenAt: "" }, + vitalsTrend: EMPTY_TREND, + labs: [], + labTrend: EMPTY_TREND, + encounters: [], + }; +} + // Input children are already in the canonical Patient sub-shapes. function childrenFromInput(input: PatientInput): Children { return { @@ -98,6 +120,49 @@ function patientColumns(orgId: string, input: PatientInput, createdBy?: string) }; } +// Registration columns only — clinical columns get empty values (they are +// NOT NULL). Used when the `reception` role creates a patient so they can never +// write PHI even if the request body contains clinical fields. +function demographicColumns( + orgId: string, + input: PatientInput, + createdBy?: string, +) { + return { + organizationId: orgId, + fileNumber: input.fileNumber, + name: input.name, + age: input.age, + sex: input.sex, + pcp: input.pcp, + status: input.status, + initials: input.initials, + alerts: [] as string[], + vitalsBp: "", + vitalsHr: "", + vitalsTemp: "", + vitalsSpo2: "", + vitalsTakenAt: "", + vitalsTrend: EMPTY_TREND, + labTrend: EMPTY_TREND, + ...(createdBy ? { createdBy } : {}), + }; +} + +// The demographic subset for a `reception` update — never touches clinical +// columns or child tables, so an existing record's PHI is preserved. +function demographicUpdateColumns(input: PatientInput) { + return { + fileNumber: input.fileNumber, + name: input.name, + age: input.age, + sex: input.sex, + pcp: input.pcp, + status: input.status, + initials: input.initials, + }; +} + // Loads and groups child rows for a set of patients in one round-trip each. async function loadChildren( patientIds: string[], @@ -212,19 +277,26 @@ function isUniqueViolation(err: unknown): boolean { ); } -export async function listPatients(orgId: string): Promise { +export async function listPatients( + orgId: string, + demographicsOnly = false, +): Promise { const rows = await db .select() .from(patients) .where(eq(patients.organizationId, orgId)) .orderBy(asc(patients.name)); const children = await loadChildren(rows.map((r) => r.id)); - return rows.map((r) => toPatient(r, children.get(r.id) ?? emptyChildren())); + return rows.map((r) => { + const patient = toPatient(r, children.get(r.id) ?? emptyChildren()); + return demographicsOnly ? redactClinical(patient) : patient; + }); } export async function getPatient( orgId: string, fileNumber: string, + demographicsOnly = false, ): Promise { const [row] = await db .select() @@ -237,16 +309,27 @@ export async function getPatient( ); if (!row) return null; const children = await loadChildren([row.id]); - return toPatient(row, children.get(row.id) ?? emptyChildren()); + const patient = toPatient(row, children.get(row.id) ?? emptyChildren()); + return demographicsOnly ? redactClinical(patient) : patient; } export async function createPatient( orgId: string, userId: string, input: PatientInput, + demographicsOnly = false, ): Promise { try { return await db.transaction(async (tx) => { + // Reception registers demographics only — clinical input is ignored and + // no child (clinical) rows are written. + if (demographicsOnly) { + const [row] = await tx + .insert(patients) + .values(demographicColumns(orgId, input, userId)) + .returning(); + return toPatient(row!, emptyChildren()); + } const [row] = await tx .insert(patients) .values(patientColumns(orgId, input, userId)) @@ -269,6 +352,7 @@ export async function updatePatient( orgId: string, fileNumber: string, input: PatientInput, + demographicsOnly = false, ): Promise { try { return await db.transaction(async (tx) => { @@ -283,6 +367,21 @@ export async function updatePatient( ); if (!existing) return null; + // Reception edits demographics only: update the registration columns and + // leave clinical columns + child tables (existing PHI) untouched, then + // return a redacted record. + if (demographicsOnly) { + const [row] = await tx + .update(patients) + .set(demographicUpdateColumns(input)) + .where(eq(patients.id, existing.id)) + .returning(); + const children = await loadChildren([existing.id]); + return redactClinical( + toPatient(row!, children.get(existing.id) ?? emptyChildren()), + ); + } + const [row] = await tx .update(patients) .set(patientColumns(orgId, input)) diff --git a/frontend/components/auth/app-auth-guard.tsx b/frontend/components/auth/app-auth-guard.tsx index 382b9ed..a9b9cf0 100644 --- a/frontend/components/auth/app-auth-guard.tsx +++ b/frontend/components/auth/app-auth-guard.tsx @@ -1,9 +1,10 @@ "use client"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { type ReactNode, useEffect, useRef } from "react"; import { authClient } from "@/lib/auth-client"; +import { canAccessRoute, defaultLandingFor, useActiveRole } from "@/lib/roles"; // Authoritative client-side gate for the app shell. Requires a session and an // active clinic. If the user is signed in without an active clinic but already @@ -11,6 +12,8 @@ import { authClient } from "@/lib/auth-client"; // with no clinics at all. The API enforces the same access rules server-side. export function AppAuthGuard({ children }: { children: ReactNode }) { const router = useRouter(); + const pathname = usePathname(); + const role = useActiveRole(); const { data: session, isPending } = authClient.useSession(); const { data: orgs, isPending: orgsPending } = authClient.useListOrganizations(); @@ -41,6 +44,17 @@ export function AppAuthGuard({ children }: { children: ReactNode }) { }, [isPending, hasUser, activeOrgId, orgsPending, orgs, router]); const ready = hasUser && Boolean(activeOrgId); + + // Role-based route guard: keep non-clinical roles (reception) out of clinical + // pages — bounce them to their default landing. The backend enforces the same + // via per-route RBAC (403); this just avoids showing an empty/erroring page. + useEffect(() => { + if (!ready || role == null) return; + if (!canAccessRoute(pathname, role)) { + router.replace(defaultLandingFor(role)); + } + }, [ready, role, pathname, router]); + if (!ready) { return (
diff --git a/frontend/components/chat/patient-form-dialog.tsx b/frontend/components/chat/patient-form-dialog.tsx index 4df5732..6a3adcf 100644 --- a/frontend/components/chat/patient-form-dialog.tsx +++ b/frontend/components/chat/patient-form-dialog.tsx @@ -31,6 +31,7 @@ import { type Patient, updatePatient, } from "@/lib/patients"; +import { hasClinicalAccess, useActiveRole } from "@/lib/roles"; import { notify } from "@/lib/toast"; type PatientFormDialogProps = { @@ -203,6 +204,11 @@ export function PatientFormDialog({ }: PatientFormDialogProps) { const { t } = useTranslation(); const isEdit = mode === "edit"; + // Reception registers demographics only — clinical sections are hidden (the + // backend also redacts/ignores clinical data for this role). Show everything + // while the role is still loading to avoid a flash for clinical users. + const role = useActiveRole(); + const showClinical = role == null || hasClinicalAccess(role); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -416,6 +422,8 @@ export function PatientFormDialog({ /> + {showClinical && ( + <>
{t("patientForm.currentVitals")} @@ -615,6 +623,8 @@ export function PatientFormDialog({ )} rows={visits} /> + + )} diff --git a/frontend/components/command-palette.tsx b/frontend/components/command-palette.tsx index 70cc90f..2506bd6 100644 --- a/frontend/components/command-palette.tsx +++ b/frontend/components/command-palette.tsx @@ -27,7 +27,7 @@ import { CommandPanel, } from "@/components/ui/command"; import { Kbd, KbdGroup } from "@/components/ui/kbd"; -import { navItems } from "@/lib/nav"; +import { useActiveRole, visibleNavItems } from "@/lib/roles"; type CommandPaletteContextValue = { open: () => void }; @@ -50,6 +50,7 @@ export function useCommandPalette(): CommandPaletteContextValue { export function CommandPaletteProvider({ children }: { children: ReactNode }) { const router = useRouter(); const { t } = useTranslation(); + const role = useActiveRole(); const [open, setOpen] = useState(false); useEffect(() => { @@ -70,7 +71,8 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) { value: "pages", label: t("nav.commandGroup"), // Flatten sub-pages so e.g. "Appointments & Schedule" is reachable. - items: navItems.flatMap((item) => + // Filtered by role so reception can't jump to clinical pages. + items: visibleNavItems(role).flatMap((item) => item.subs?.length ? item.subs.map((sub) => ({ id: sub.id, @@ -89,7 +91,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) { ), }, ], - [t], + [t, role], ); type Group = (typeof groups)[number]; diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx index 9244510..3b6322c 100644 --- a/frontend/components/login-form.tsx +++ b/frontend/components/login-form.tsx @@ -15,17 +15,23 @@ import { } from "@/components/ui/card"; import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; import { authClient } from "@/lib/auth-client"; import { notify } from "@/lib/toast"; import { cn } from "@/lib/utils"; +type Mode = "email" | "username"; + export function LoginForm({ className, ...props }: React.ComponentProps<"div">) { const { t } = useTranslation(); const router = useRouter(); - const [email, setEmail] = useState(""); + // Staff provisioned by an admin sign in with a username; clinic owners sign in + // with the email they signed up with. The tab picks which credential to use. + const [mode, setMode] = useState("email"); + const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -36,11 +42,19 @@ export function LoginForm({ setSubmitting(true); setError(null); - const { error: err } = await authClient.signIn.email({ - email: email.trim(), - password, - callbackURL: `${window.location.origin}/`, - }); + const callbackURL = `${window.location.origin}/`; + const { error: err } = + mode === "email" + ? await authClient.signIn.email({ + email: identifier.trim(), + password, + callbackURL, + }) + : await authClient.signIn.username({ + username: identifier.trim(), + password, + callbackURL, + }); if (err) { const message = err.message ?? t("auth.login.error"); @@ -68,18 +82,40 @@ export function LoginForm({ {error}

)} + { + setMode(value as Mode); + setError(null); + }} + value={mode} + > + + + {t("auth.login.tabEmail")} + + + {t("auth.login.tabUsername")} + + + - - {t("auth.login.emailLabel")} + + {mode === "email" + ? t("auth.login.emailLabel") + : t("auth.login.usernameLabel")} setEmail(e.target.value)} - placeholder={t("auth.login.emailPlaceholder")} + autoComplete={mode === "email" ? "email" : "username"} + id="identifier" + onChange={(e) => setIdentifier(e.target.value)} + placeholder={ + mode === "email" + ? t("auth.login.emailPlaceholder") + : t("auth.login.usernamePlaceholder") + } required - type="email" - value={email} + type={mode === "email" ? "email" : "text"} + value={identifier} /> diff --git a/frontend/components/settings/add-staff-dialog.tsx b/frontend/components/settings/add-staff-dialog.tsx new file mode 100644 index 0000000..cc095b8 --- /dev/null +++ b/frontend/components/settings/add-staff-dialog.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { type FormEvent, 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 { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { ROLE_LABELS } from "@/lib/access"; +import { apiFetch } from "@/lib/api-client"; +import { PROVISIONABLE_ROLES } from "@/lib/roles"; +import { notify } from "@/lib/toast"; + +const MIN_PASSWORD = 12; +const MIN_USERNAME = 3; + +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 = { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated?: () => void; +}; + +// Admin provisions a staff account in two steps: first the "invitation" (who + +// what role), then the credentials (username + password) the employee uses to +// sign in. Posts to /api/staff, which creates the account and adds them to the +// active clinic via Better Auth. +export function AddStaffDialog({ open, onOpenChange, onCreated }: Props) { + const { t } = useTranslation(); + const [step, setStep] = useState<1 | 2>(1); + const [name, setName] = useState(""); + const [role, setRole] = useState("reception"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const reset = () => { + setStep(1); + setName(""); + setRole("reception"); + setUsername(""); + setPassword(""); + setError(null); + setSubmitting(false); + }; + + const handleOpenChange = (next: boolean) => { + if (!next) reset(); + onOpenChange(next); + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + // Step 1 → advance to credentials. + if (step === 1) { + if (!name.trim()) { + setError(t("settings.careTeam.add.nameRequired")); + return; + } + setStep(2); + return; + } + + // Step 2 → create the account. + if (username.trim().length < MIN_USERNAME) { + setError(t("settings.careTeam.add.usernameTooShort", { count: MIN_USERNAME })); + return; + } + if (password.length < MIN_PASSWORD) { + setError(t("settings.careTeam.add.passwordTooShort", { count: MIN_PASSWORD })); + return; + } + + setSubmitting(true); + try { + await apiFetch("/api/staff", { + method: "POST", + body: JSON.stringify({ + name: name.trim(), + role, + username: username.trim(), + password, + }), + }); + notify.success( + t("settings.careTeam.add.createdTitle"), + t("settings.careTeam.add.createdBody", { + name: name.trim(), + username: username.trim().toLowerCase(), + }), + ); + onCreated?.(); + handleOpenChange(false); + } catch (err) { + const message = + err instanceof Error ? err.message : t("settings.careTeam.add.error"); + setError(message); + notify.error(t("settings.careTeam.add.errorTitle"), message); + setSubmitting(false); + } + }; + + return ( + + + + {t("settings.careTeam.add.title")} + + {step === 1 + ? t("settings.careTeam.add.step1Description") + : t("settings.careTeam.add.step2Description")} + + + +
+ + {error && ( +

+ {error} +

+ )} + + {step === 1 ? ( + <> + + + {t("settings.careTeam.add.nameLabel")} + + setName(e.target.value)} + placeholder={t("settings.careTeam.add.namePlaceholder")} + required + value={name} + /> + + + + {t("settings.careTeam.add.roleLabel")} + + + + + ) : ( + <> + + + {t("settings.careTeam.add.usernameLabel")} + + setUsername(e.target.value)} + placeholder={t("settings.careTeam.add.usernamePlaceholder")} + required + value={username} + /> + + + + {t("settings.careTeam.add.passwordLabel")} + + setPassword(e.target.value)} + required + type="password" + value={password} + /> + + {t("settings.careTeam.add.passwordHint", { count: MIN_PASSWORD })} + + + + )} +
+ + + {step === 2 && ( + + )} + }> + {t("settings.careTeam.add.cancel")} + + + +
+
+
+ ); +} diff --git a/frontend/components/settings/settings-care-team.tsx b/frontend/components/settings/settings-care-team.tsx index fa5546c..f38b376 100644 --- a/frontend/components/settings/settings-care-team.tsx +++ b/frontend/components/settings/settings-care-team.tsx @@ -1,9 +1,10 @@ "use client"; -import { X } from "lucide-react"; -import { type FormEvent, useCallback, useEffect, useState } from "react"; +import { UserPlus, X } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { AddStaffDialog } from "@/components/settings/add-staff-dialog"; import { SettingsCard, SettingsSection, @@ -11,27 +12,23 @@ import { import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { ROLE_LABELS } from "@/lib/access"; +import { apiFetch } from "@/lib/api-client"; import { authClient } from "@/lib/auth-client"; -type Member = { +// 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; - role: string; userId: string; - user?: { name?: string | null; email?: string | null }; + role: string; + name: string | null; + email: string | null; + username: string | null; }; -type Invite = { - id: string; - email: string; - role?: string | null; - status: string; -}; - -const INVITE_ROLES = ["member", "admin", "viewer"] as const; function roleLabel(role?: string | null): string { - if (!role) return "Member"; + if (!role) return ROLE_LABELS.member; return (ROLE_LABELS as Record)[role] ?? role; } @@ -51,32 +48,24 @@ function initials(name?: string | null, email?: string | null): string { export function CareTeamPanel() { const { t } = useTranslation(); const { data: session } = authClient.useSession(); - const [members, setMembers] = useState([]); - const [invites, setInvites] = useState([]); + const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - - const [email, setEmail] = useState(""); - const [role, setRole] = useState<(typeof INVITE_ROLES)[number]>("member"); - const [inviting, setInviting] = useState(false); + const [adding, setAdding] = useState(false); const load = useCallback(async () => { - const { data, error: err } = - await authClient.organization.getFullOrganization(); - if (err || !data) { - setError(err?.message ?? t("settings.careTeam.loadError")); + try { + const data = await apiFetch("/api/staff"); + setMembers(data); + setError(null); + } catch (err) { + setError( + err instanceof Error ? err.message : t("settings.careTeam.loadError"), + ); + } finally { setLoading(false); - return; } - setMembers((data.members ?? []) as Member[]); - setInvites( - ((data.invitations ?? []) as Invite[]).filter( - (i) => i.status === "pending" - ) - ); - setError(null); - setLoading(false); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -86,36 +75,11 @@ export function CareTeamPanel() { const myRole = members.find((m) => m.userId === session?.user?.id)?.role; const canManage = myRole === "owner" || myRole === "admin"; - const invite = async (event: FormEvent) => { - event.preventDefault(); - if (!email.trim() || inviting) return; - setInviting(true); - setNotice(null); - setError(null); - const { error: err } = await authClient.organization.inviteMember({ - email: email.trim(), - role, - }); - setInviting(false); - if (err) { - setError(err.message ?? t("settings.careTeam.inviteError")); - return; - } - setEmail(""); - setNotice(t("settings.careTeam.inviteSent", { email: email.trim() })); - void load(); - }; - const removeMember = async (memberId: string) => { await authClient.organization.removeMember({ memberIdOrEmail: memberId }); void load(); }; - const cancelInvite = async (invitationId: string) => { - await authClient.organization.cancelInvitation({ invitationId }); - void load(); - }; - return ( )} - {notice && ( -

- {notice} -

- )} {canManage && ( - -
- setEmail(e.target.value)} - placeholder={t("settings.careTeam.invitePlaceholder")} - required - type="email" - value={email} - /> - - -
-
+
+ +
)} @@ -176,25 +108,28 @@ export function CareTeamPanel() { ) : ( members.map((m) => { const isSelf = m.userId === session?.user?.id; + // Prefer the login username; fall back to email for owners who + // signed up by email. + const secondary = m.username ? `@${m.username}` : m.email; return (
- {initials(m.user?.name, m.user?.email)} + {initials(m.name, m.email)}

- {m.user?.name || m.user?.email || m.userId} + {m.name || m.email || m.userId} {isSelf && ( {t("settings.careTeam.you")} )}

- {m.user?.email && ( + {secondary && (

- {m.user.email} + {secondary}

)}
@@ -218,33 +153,12 @@ export function CareTeamPanel() { )} - {invites.length > 0 && ( - -

- {t("settings.careTeam.pendingInvitations")} -

- {invites.map((inv) => ( -
-
-

{inv.email}

-
- - {roleLabel(inv.role)} - - {canManage && ( - - )} -
- ))} -
+ {canManage && ( + void load()} + onOpenChange={setAdding} + open={adding} + /> )} ); diff --git a/frontend/components/settings/settings-view.tsx b/frontend/components/settings/settings-view.tsx index 808d539..71f0892 100644 --- a/frontend/components/settings/settings-view.tsx +++ b/frontend/components/settings/settings-view.tsx @@ -11,6 +11,7 @@ import { import { SigningPanel } from "@/components/settings/settings-billing"; import { CareTeamPanel } from "@/components/settings/settings-care-team"; import { ProfilePanel } from "@/components/settings/settings-preferences"; +import { useActiveRole } from "@/lib/roles"; const TABS = [ { id: "profile", labelKey: "settings.tabs.profile" }, @@ -41,20 +42,27 @@ function PlaceholderPanel({ export function SettingsView() { const { t } = useTranslation(); + const role = useActiveRole(); const [tab, setTab] = useState("profile"); + // Only clinic owners/admins manage clinic-wide settings (care team, records, + // signing, developers). Everyone else gets their own profile only. + const isAdmin = role === "owner" || role === "admin"; + const visibleTabs = isAdmin ? TABS : TABS.filter((item) => item.id === "profile"); + const activeTab = visibleTabs.some((item) => item.id === tab) ? tab : "profile"; + return (

- {t(`settings.tabs.${tab}`)} + {t(`settings.tabs.${activeTab}`)}