From 90e6ec4cc05e7a5cb61ab06a4b587b23faec8f99 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sat, 20 Jun 2026 19:52:55 +0300 Subject: [PATCH] feat: email provider, admin password reset, portal new-patient, chat pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat: history pill now shows a History icon + a Start-new-chat (SquarePen) button; removed the duplicate chat-history list from the sidebar. Email: deployment-wide email provider config (Resend/Postmark/SendGrid/SMTP) in Settings → Developers, with encrypted API key and a Send-test action. sendEmail dispatches via the chosen provider (REST via fetch; SMTP via nodemailer). Forgot password with no provider: alert the clinic admin(s) via a "System" message card in Messages + a bell notification (seeded system user + per-clinic System conversation); clicking deep-links to /settings?tab=careTeam&member=. Admins can set a member's password directly from the employee dialog (PATCH /api/staff/:id/password via Better Auth's internal context — no admin plugin needed). Patient Portal: "New patient" booking path registers a demographics-only patient then books; bookings reject double-booked slots (409). Co-Authored-By: Claude Opus 4.8 --- backend/drizzle/0027_romantic_kylun.sql | 7 + backend/drizzle/meta/0027_snapshot.json | 4064 +++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/auth.ts | 23 +- backend/src/db/schema/email-settings.ts | 19 + backend/src/db/schema/index.ts | 1 + backend/src/lib/email.ts | 152 +- backend/src/routes/portal.ts | 54 +- backend/src/routes/settings.ts | 75 + backend/src/routes/staff.ts | 39 + backend/src/services/auth-fallback.ts | 68 + backend/src/services/email-config.ts | 96 + backend/src/services/messaging.ts | 105 + backend/src/types/activity.ts | 3 +- backend/src/types/messaging.ts | 11 +- .../components/chat/chat-history-panel.tsx | 10 +- .../components/messages/messages-view.tsx | 30 +- frontend/components/portal/portal-kiosk.tsx | 84 +- .../settings/employee-detail-dialog.tsx | 86 +- .../settings/settings-care-team.tsx | 20 +- .../settings/settings-developers.tsx | 162 + .../components/settings/settings-view.tsx | 11 +- .../components/sidebar-02/app-sidebar.tsx | 2 - .../sidebar-02/nav-chat-history.tsx | 82 - frontend/lib/email-settings.ts | 31 + frontend/lib/i18n/locales/en/translation.json | 64 +- frontend/lib/messages.ts | 8 +- frontend/lib/portal.ts | 16 + frontend/lib/staff.ts | 12 + 29 files changed, 5200 insertions(+), 142 deletions(-) create mode 100644 backend/drizzle/0027_romantic_kylun.sql create mode 100644 backend/drizzle/meta/0027_snapshot.json create mode 100644 backend/src/db/schema/email-settings.ts create mode 100644 backend/src/services/auth-fallback.ts create mode 100644 backend/src/services/email-config.ts delete mode 100644 frontend/components/sidebar-02/nav-chat-history.tsx create mode 100644 frontend/lib/email-settings.ts diff --git a/backend/drizzle/0027_romantic_kylun.sql b/backend/drizzle/0027_romantic_kylun.sql new file mode 100644 index 0000000..637eac8 --- /dev/null +++ b/backend/drizzle/0027_romantic_kylun.sql @@ -0,0 +1,7 @@ +CREATE TABLE "email_settings" ( + "id" text PRIMARY KEY DEFAULT 'default' NOT NULL, + "provider" text DEFAULT 'none' NOT NULL, + "from_address" text DEFAULT '' NOT NULL, + "credentials" text, + "updated_at" timestamp DEFAULT now() NOT NULL +); diff --git a/backend/drizzle/meta/0027_snapshot.json b/backend/drizzle/meta/0027_snapshot.json new file mode 100644 index 0000000..5d9118a --- /dev/null +++ b/backend/drizzle/meta/0027_snapshot.json @@ -0,0 +1,4064 @@ +{ + "id": "49718c46-d105-4a8c-8fbb-7e99b94bc070", + "prevId": "71748131-9899-4010-94c3-d290ff192e5e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_allergies": { + "name": "patient_allergies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "substance": { + "name": "substance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "allergies_patient_idx": { + "name": "allergies_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_allergies_patient_id_patients_id_fk": { + "name": "patient_allergies_patient_id_patients_id_fk", + "tableFrom": "patient_allergies", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_encounters": { + "name": "patient_encounters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "encounters_patient_idx": { + "name": "encounters_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_encounters_patient_id_patients_id_fk": { + "name": "patient_encounters_patient_id_patients_id_fk", + "tableFrom": "patient_encounters", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_labs": { + "name": "patient_labs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flag": { + "name": "flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "labs_patient_idx": { + "name": "labs_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_labs_patient_id_patients_id_fk": { + "name": "patient_labs_patient_id_patients_id_fk", + "tableFrom": "patient_labs", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_medications": { + "name": "patient_medications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "medications_patient_idx": { + "name": "medications_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_medications_patient_id_patients_id_fk": { + "name": "patient_medications_patient_id_patients_id_fk", + "tableFrom": "patient_medications", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patients": { + "name": "patients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_number": { + "name": "file_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sex": { + "name": "sex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pcp": { + "name": "pcp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initials": { + "name": "initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alerts": { + "name": "alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vitals_bp": { + "name": "vitals_bp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_hr": { + "name": "vitals_hr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_temp": { + "name": "vitals_temp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_spo2": { + "name": "vitals_spo2", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_taken_at": { + "name": "vitals_taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_trend": { + "name": "vitals_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lab_trend": { + "name": "lab_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "primary_provider_id": { + "name": "primary_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "patients_org_file_uidx": { + "name": "patients_org_file_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "patients_org_idx": { + "name": "patients_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patients_organization_id_organization_id_fk": { + "name": "patients_organization_id_organization_id_fk", + "tableFrom": "patients", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "patients_primary_provider_id_user_id_fk": { + "name": "patients_primary_provider_id_user_id_fk", + "tableFrom": "patients", + "tableTo": "user", + "columnsFrom": [ + "primary_provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "patients_created_by_user_id_fk": { + "name": "patients_created_by_user_id_fk", + "tableFrom": "patients", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_problems": { + "name": "patient_problems", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "since": { + "name": "since", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problems_patient_idx": { + "name": "problems_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_problems_patient_id_patients_id_fk": { + "name": "patient_problems_patient_id_patients_id_fk", + "tableFrom": "patient_problems", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notes_org_author_idx": { + "name": "notes_org_author_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_organization_id_organization_id_fk": { + "name": "notes_organization_id_organization_id_fk", + "tableFrom": "notes", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_author_id_user_id_fk": { + "name": "notes_author_id_user_id_fk", + "tableFrom": "notes", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "appointments_org_date_idx": { + "name": "appointments_org_date_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appointments_organization_id_organization_id_fk": { + "name": "appointments_organization_id_organization_id_fk", + "tableFrom": "appointments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "appointments_created_by_user_id_fk": { + "name": "appointments_created_by_user_id_fk", + "tableFrom": "appointments", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.prescriptions": { + "name": "prescriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medication": { + "name": "medication", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescriber": { + "name": "prescriber", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescribed_at": { + "name": "prescribed_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "prescriptions_org_idx": { + "name": "prescriptions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prescriptions_organization_id_organization_id_fk": { + "name": "prescriptions_organization_id_organization_id_fk", + "tableFrom": "prescriptions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prescriptions_created_by_user_id_fk": { + "name": "prescriptions_created_by_user_id_fk", + "tableFrom": "prescriptions", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "due_at": { + "name": "due_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "line_items": { + "name": "line_items", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installments": { + "name": "installments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_org_idx": { + "name": "invoices_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoices_org_file_idx": { + "name": "invoices_org_file_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "patient_file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_organization_id_organization_id_fk": { + "name": "invoices_organization_id_organization_id_fk", + "tableFrom": "invoices", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_created_by_user_id_fk": { + "name": "invoices_created_by_user_id_fk", + "tableFrom": "invoices", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory": { + "name": "inventory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "form": { + "name": "form", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "strength": { + "name": "strength", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "stock_quantity": { + "name": "stock_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reorder_threshold": { + "name": "reorder_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_org_idx": { + "name": "inventory_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_organization_id_organization_id_fk": { + "name": "inventory_organization_id_organization_id_fk", + "tableFrom": "inventory", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_created_by_user_id_fk": { + "name": "inventory_created_by_user_id_fk", + "tableFrom": "inventory", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dispenses": { + "name": "dispenses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "medication": { + "name": "medication", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "prescription_id": { + "name": "prescription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dispensed_by": { + "name": "dispensed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispensed_by_name": { + "name": "dispensed_by_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "dispensed_at": { + "name": "dispensed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dispenses_org_idx": { + "name": "dispenses_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dispenses_organization_id_organization_id_fk": { + "name": "dispenses_organization_id_organization_id_fk", + "tableFrom": "dispenses", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dispenses_dispensed_by_user_id_fk": { + "name": "dispenses_dispensed_by_user_id_fk", + "tableFrom": "dispenses", + "tableTo": "user", + "columnsFrom": [ + "dispensed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unassigned'" + }, + "assignee_role": { + "name": "assignee_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due": { + "name": "due", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'No due date'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'todo'" + }, + "patient": { + "name": "patient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_name": { + "name": "created_by_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_org_idx": { + "name": "tasks_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_organization_id_organization_id_fk": { + "name": "tasks_organization_id_organization_id_fk", + "tableFrom": "tasks", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_user_id_user_id_fk": { + "name": "tasks_assignee_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "assignee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_created_by_user_id_fk": { + "name": "tasks_created_by_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_org_created_idx": { + "name": "activity_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_organization_id_organization_id_fk": { + "name": "activity_log_organization_id_organization_id_fk", + "tableFrom": "activity_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_id_user_id_fk": { + "name": "activity_log_actor_id_user_id_fk", + "tableFrom": "activity_log", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_participants": { + "name": "conversation_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conv_participant_uidx": { + "name": "conv_participant_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conv_participant_user_idx": { + "name": "conv_participant_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_participants_conversation_id_conversations_id_fk": { + "name": "conversation_participants_conversation_id_conversations_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_participants_user_id_user_id_fk": { + "name": "conversation_participants_user_id_user_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_group": { + "name": "is_group", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_org_idx": { + "name": "conversations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_organization_id_organization_id_fk": { + "name": "conversations_organization_id_organization_id_fk", + "tableFrom": "conversations", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_created_by_user_id_fk": { + "name": "conversations_created_by_user_id_fk", + "tableFrom": "conversations", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_attachments": { + "name": "message_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploader_id": { + "name": "uploader_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "message_attachments_org_idx": { + "name": "message_attachments_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_attachments_organization_id_organization_id_fk": { + "name": "message_attachments_organization_id_organization_id_fk", + "tableFrom": "message_attachments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_attachments_uploader_id_user_id_fk": { + "name": "message_attachments_uploader_id_user_id_fk", + "tableFrom": "message_attachments", + "tableTo": "user", + "columnsFrom": [ + "uploader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conv_idx": { + "name": "messages_conv_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_user_id_fk": { + "name": "messages_sender_id_user_id_fk", + "tableFrom": "messages", + "tableTo": "user", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_initials": { + "name": "actor_initials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_org_user_read_idx": { + "name": "notifications_org_user_read_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_organization_id_organization_id_fk": { + "name": "notifications_organization_id_organization_id_fk", + "tableFrom": "notifications", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_user_id_fk": { + "name": "user_settings_user_id_user_id_fk", + "tableFrom": "user_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_settings": { + "name": "email_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "from_address": { + "name": "from_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_settings": { + "name": "user_ai_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anthropic'" + }, + "ollama_base_url": { + "name": "ollama_base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'http://localhost:11434'" + }, + "ollama_model": { + "name": "ollama_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'llama3.1'" + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-sonnet-4-6'" + }, + "default_effort": { + "name": "default_effort", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "veil_level": { + "name": "veil_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "api_keys_cipher": { + "name": "api_keys_cipher", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_settings_user_id_user_id_fk": { + "name": "user_ai_settings_user_id_user_id_fk", + "tableFrom": "user_ai_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_chat_messages": { + "name": "ai_chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_messages_thread_idx": { + "name": "ai_messages_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_chat_messages_thread_id_ai_chat_threads_id_fk": { + "name": "ai_chat_messages_thread_id_ai_chat_threads_id_fk", + "tableFrom": "ai_chat_messages", + "tableTo": "ai_chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_chat_threads": { + "name": "ai_chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_threads_org_user_idx": { + "name": "ai_threads_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_chat_threads_organization_id_organization_id_fk": { + "name": "ai_chat_threads_organization_id_organization_id_fk", + "tableFrom": "ai_chat_threads", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_chat_threads_user_id_user_id_fk": { + "name": "ai_chat_threads_user_id_user_id_fk", + "tableFrom": "ai_chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ai_policy": { + "name": "org_ai_policy", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ai_enabled": { + "name": "ai_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "disabled_for_employees": { + "name": "disabled_for_employees", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_ai_policy_organization_id_organization_id_fk": { + "name": "org_ai_policy_organization_id_organization_id_fk", + "tableFrom": "org_ai_policy", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_number": { + "name": "file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lab_key": { + "name": "lab_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_user_id": { + "name": "uploaded_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_org_file_idx": { + "name": "attachments_org_file_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_organization_id_organization_id_fk": { + "name": "attachments_organization_id_organization_id_fk", + "tableFrom": "attachments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_user_id_user_id_fk": { + "name": "attachments_uploaded_by_user_id_user_id_fk", + "tableFrom": "attachments", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unconfigured'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_organization_id_organization_id_fk": { + "name": "integrations_organization_id_organization_id_fk", + "tableFrom": "integrations", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrations_organization_id_type_pk": { + "name": "integrations_organization_id_type_pk", + "columns": [ + "organization_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_profile": { + "name": "staff_profile", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "specialty": { + "name": "specialty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "staff_profile_org_user_idx": { + "name": "staff_profile_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_profile_organization_id_organization_id_fk": { + "name": "staff_profile_organization_id_organization_id_fk", + "tableFrom": "staff_profile", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "staff_profile_user_id_user_id_fk": { + "name": "staff_profile_user_id_user_id_fk", + "tableFrom": "staff_profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_rooms": { + "name": "meeting_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_rooms_org_idx": { + "name": "meeting_rooms_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_rooms_organization_id_organization_id_fk": { + "name": "meeting_rooms_organization_id_organization_id_fk", + "tableFrom": "meeting_rooms", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_rooms_created_by_user_id_fk": { + "name": "meeting_rooms_created_by_user_id_fk", + "tableFrom": "meeting_rooms", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_meetings": { + "name": "scheduled_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participants": { + "name": "participants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_meetings_org_idx": { + "name": "scheduled_meetings_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_meetings_organization_id_organization_id_fk": { + "name": "scheduled_meetings_organization_id_organization_id_fk", + "tableFrom": "scheduled_meetings", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scheduled_meetings_created_by_user_id_fk": { + "name": "scheduled_meetings_created_by_user_id_fk", + "tableFrom": "scheduled_meetings", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "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 b4b0291..44ec41d 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1781969782874, "tag": "0026_many_wolfsbane", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1781973588708, + "tag": "0027_romantic_kylun", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/auth.ts b/backend/src/auth.ts index fd8bfa6..ad5d8c0 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -35,10 +35,25 @@ export const auth = betterAuth({ maxPasswordLength: 256, revokeSessionsOnPasswordReset: true, sendResetPassword: async ({ user, url }) => { - await sendEmail({ - to: user.email, - subject: "Reset your temetro password", - text: `Reset your password by opening this link:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`, + // With a provider configured, email the reset link. Otherwise fall back to + // alerting the clinic admin(s) so they can set a new password (dynamic + // imports keep the Better Auth CLI's static graph minimal at generate time). + const { isEmailConfigured } = await import("./services/email-config.js"); + if (await isEmailConfigured()) { + await sendEmail({ + to: user.email, + subject: "Reset your temetro password", + text: `Reset your password by opening this link:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`, + }); + return; + } + const { notifyAdminsPasswordReset } = await import( + "./services/auth-fallback.js" + ); + await notifyAdminsPasswordReset({ + id: user.id, + name: user.name, + email: user.email, }); }, }, diff --git a/backend/src/db/schema/email-settings.ts b/backend/src/db/schema/email-settings.ts new file mode 100644 index 0000000..ef8e38e --- /dev/null +++ b/backend/src/db/schema/email-settings.ts @@ -0,0 +1,19 @@ +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +// Deployment-wide email-provider configuration — a single row (id = "default"). +// Email (verification, password reset, invitations) is sent while the user is +// logged out / before any clinic exists, so this can't be per-clinic; it's one +// config for the whole deployment, set by any clinic admin from Settings → +// Developers. The provider's API key is stored encrypted (lib/crypto.ts). +export const emailSettings = pgTable("email_settings", { + id: text("id").primaryKey().default("default"), + // "none" | "smtp" | "resend" | "postmark" | "sendgrid" + provider: text("provider").notNull().default("none"), + fromAddress: text("from_address").notNull().default(""), + // Encrypted API key (Resend/Postmark/SendGrid). SMTP uses env credentials. + credentials: text("credentials"), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}); diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index e842acd..e959864 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -11,6 +11,7 @@ export * from "./activity.js"; export * from "./messaging.js"; export * from "./notifications.js"; export * from "./settings.js"; +export * from "./email-settings.js"; export * from "./ai.js"; export * from "./ai-chat.js"; export * from "./org-ai-policy.js"; diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 78c76e7..6805b15 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -1,6 +1,7 @@ import nodemailer from "nodemailer"; import { env } from "../env.js"; +import { getActiveConfig } from "../services/email-config.js"; type SendArgs = { to: string; @@ -9,10 +10,9 @@ type SendArgs = { html?: string; }; -// Lazily build a transport. With SMTP_HOST configured we send real mail; -// otherwise we fall back to logging the message (and any links) to the -// server console — zero setup for local / open-source development. -const transport = env.SMTP_HOST +// SMTP transport (built from env) — used when the active provider is "smtp" or, +// for backward compatibility, when SMTP_HOST is set and no provider is chosen. +const smtpTransport = env.SMTP_HOST ? nodemailer.createTransport({ host: env.SMTP_HOST, port: env.SMTP_PORT ?? 587, @@ -24,26 +24,126 @@ const transport = env.SMTP_HOST }) : null; -export async function sendEmail({ to, subject, text, html }: SendArgs): Promise { - if (!transport) { - console.info( - [ - "", - "✉️ [email:console] No SMTP configured — printing instead of sending.", - ` to: ${to}`, - ` subject: ${subject}`, - ` body: ${text}`, - "", - ].join("\n"), - ); - return; - } - - await transport.sendMail({ - from: env.SMTP_FROM, - to, - subject, - text, - html: html ?? text, - }); +function logToConsole({ to, subject, text }: SendArgs): void { + console.info( + [ + "", + "✉️ [email:console] No email provider configured — printing instead of sending.", + ` to: ${to}`, + ` subject: ${subject}`, + ` body: ${text}`, + "", + ].join("\n"), + ); +} + +async function sendViaResend( + apiKey: string, + from: string, + { to, subject, text, html }: SendArgs, +): Promise { + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, text, html: html ?? text }), + }); + if (!res.ok) throw new Error(`Resend failed: ${res.status} ${await res.text()}`); +} + +async function sendViaPostmark( + apiKey: string, + from: string, + { to, subject, text, html }: SendArgs, +): Promise { + const res = await fetch("https://api.postmarkapp.com/email", { + method: "POST", + headers: { + "X-Postmark-Server-Token": apiKey, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + From: from, + To: to, + Subject: subject, + TextBody: text, + HtmlBody: html ?? text, + MessageStream: "outbound", + }), + }); + if (!res.ok) + throw new Error(`Postmark failed: ${res.status} ${await res.text()}`); +} + +async function sendViaSendgrid( + apiKey: string, + from: string, + { to, subject, text, html }: SendArgs, +): Promise { + const res = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [ + { type: "text/plain", value: text }, + { type: "text/html", value: html ?? text }, + ], + }), + }); + if (!res.ok) + throw new Error(`SendGrid failed: ${res.status} ${await res.text()}`); +} + +// Send an email via the deployment's configured provider. Falls back to logging +// when nothing is configured so local/open-source dev needs zero setup. +export async function sendEmail(args: SendArgs): Promise { + const cfg = await getActiveConfig(); + // A real "from": the configured address, else the SMTP default. + const from = cfg.fromAddress || env.SMTP_FROM; + + switch (cfg.provider) { + case "resend": + if (!cfg.credentials) return logToConsole(args); + return sendViaResend(cfg.credentials, from, args); + case "postmark": + if (!cfg.credentials) return logToConsole(args); + return sendViaPostmark(cfg.credentials, from, args); + case "sendgrid": + if (!cfg.credentials) return logToConsole(args); + return sendViaSendgrid(cfg.credentials, from, args); + case "smtp": { + if (!smtpTransport) return logToConsole(args); + await smtpTransport.sendMail({ + from, + to: args.to, + subject: args.subject, + text: args.text, + html: args.html ?? args.text, + }); + return; + } + default: { + // No provider chosen — honour a pre-existing SMTP env setup if present. + if (smtpTransport) { + await smtpTransport.sendMail({ + from, + to: args.to, + subject: args.subject, + text: args.text, + html: args.html ?? args.text, + }); + return; + } + return logToConsole(args); + } + } } diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts index 99dffb1..453cd57 100644 --- a/backend/src/routes/portal.ts +++ b/backend/src/routes/portal.ts @@ -7,9 +7,10 @@ import { organization } from "../db/schema/auth.js"; import { appointmentInputSchema } from "../lib/appointment-validation.js"; import { HttpError } from "../lib/http-error.js"; import { initialsFromName } from "../lib/initials.js"; +import { patientInputSchema } from "../lib/patient-validation.js"; import { recordActivity } from "../services/activity.js"; import { createAppointment, listAppointments } from "../services/appointments.js"; -import { getPatient } from "../services/patients.js"; +import { createPatient, getPatient } from "../services/patients.js"; // Public, unauthenticated kiosk API for a clinic's Patient Portal (an iPad in the // waiting room). Scoped by the clinic slug in the URL — there is no session. @@ -52,6 +53,39 @@ const bookingSchema = z.object({ type: z.string().trim().max(120).optional(), }); +const newPatientSchema = z.object({ + name: z.string().trim().min(1, "Your name is required.").max(200), + sex: z.string().trim().optional(), + age: z.coerce.number().int().min(0).max(150).optional(), +}); + +// POST /api/portal/:clinic/patients — register a new (demographics-only) patient +// from the kiosk so a first-time visitor can get a file number and then book. +// Writes only demographics (no clinical PHI) from this unauthenticated surface. +portalRouter.post("/:clinic/patients", async (req, res, next) => { + try { + const clinic = await resolveClinic(req); + const body = newPatientSchema.parse(req.body); + const input = patientInputSchema.parse({ + name: body.name, + sex: body.sex ?? "M", + age: body.age ?? 0, + source: "manual", + }); + const created = await createPatient(clinic.id, "", input, true); + await recordActivity({ + orgId: clinic.id, + actor: { id: "", name: created.name }, + action: `Patient portal registration — ${created.name}`, + entityType: "patient", + entityId: created.fileNumber, + }); + res.status(201).json({ fileNumber: created.fileNumber, name: created.name }); + } catch (err) { + next(err); + } +}); + // POST /api/portal/:clinic/appointments — self-service booking for a registered // patient. Verifies the file number + name, then creates a confirmed appointment // that shows up on the clinic's Appointments page. @@ -84,6 +118,24 @@ portalRouter.post("/:clinic/appointments", async (req, res, next) => { status: "confirmed", source: "manual", }); + + // Prevent double-booking the same slot: a provider can't have two + // appointments at the same date+time (clinic-wide when the provider is + // unknown). Cancelled appointments don't count. + const taken = (await listAppointments(clinic.id)).some( + (a) => + a.status !== "cancelled" && + a.date === input.date && + a.time === input.time && + (!input.provider || !a.provider || a.provider === input.provider), + ); + if (taken) { + throw new HttpError( + 409, + "That time slot is already taken. Please choose another time.", + ); + } + const created = await createAppointment(clinic.id, "", input); await recordActivity({ orgId: clinic.id, diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 77066e2..237e2f5 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -11,7 +11,13 @@ import { requireOrg, requirePermission, } from "../middleware/auth.js"; +import { sendEmail } from "../lib/email.js"; import { recordActivity } from "../services/activity.js"; +import { + type EmailProvider, + getPublicConfig, + saveConfig, +} from "../services/email-config.js"; import { createPatient, listPatients } from "../services/patients.js"; export const settingsRouter = Router(); @@ -20,6 +26,75 @@ export const settingsRouter = Router(); // no active organization or RBAC permission. settingsRouter.use(requireAuth); +// --- Email provider (deployment-wide, admin-only) ------------------------ +// One config for the whole deployment (email is sent while logged out, so it +// can't be per-clinic). Gated by `member: ["create"]` — any clinic admin sets +// the deployment's provider. The API key is never returned. + +const emailConfigSchema = z.object({ + provider: z.enum(["none", "smtp", "resend", "postmark", "sendgrid"]), + fromAddress: z.string().trim().max(200).default(""), + // undefined = leave key as-is; "" = clear; string = set/replace. + credentials: z.string().trim().max(500).optional(), +}); + +settingsRouter.get( + "/email", + requireOrg, + requirePermission({ member: ["create"] }), + async (_req, res, next) => { + try { + res.json(await getPublicConfig()); + } catch (err) { + next(err); + } + }, +); + +settingsRouter.put( + "/email", + requireOrg, + requirePermission({ member: ["create"] }), + async (req, res, next) => { + try { + const input = emailConfigSchema.parse(req.body); + const saved = await saveConfig({ + provider: input.provider as EmailProvider, + fromAddress: input.fromAddress, + credentials: input.credentials, + }); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Updated email provider — ${saved.provider}`, + entityType: "settings", + entityId: "email", + }); + res.json(saved); + } catch (err) { + next(err); + } + }, +); + +settingsRouter.post( + "/email/test", + requireOrg, + requirePermission({ member: ["create"] }), + async (req, res, next) => { + try { + await sendEmail({ + to: req.user!.email, + subject: "temetro email test", + text: `This is a test email from temetro. If you received it, your email provider is configured correctly.`, + }); + res.json({ ok: true, to: req.user!.email }); + } catch (err) { + next(err); + } + }, +); + // --- Records import / export (clinic-wide, admin-only) ------------------- // Gated by `member: ["create"]` — the same admin/owner marker the staff route // uses — so only clinic admins can bulk-move records. diff --git a/backend/src/routes/staff.ts b/backend/src/routes/staff.ts index cad96e3..5ab6446 100644 --- a/backend/src/routes/staff.ts +++ b/backend/src/routes/staff.ts @@ -230,3 +230,42 @@ staffRouter.patch( } }, ); + +// Set a member's password directly (admin-driven reset — e.g. the employee +// forgot it and no email provider is configured). Owner/admin only, and the +// target must be a member of this clinic. Uses Better Auth's internal context to +// hash + store the password (the same calls its admin plugin makes), so no admin +// plugin is required. +const passwordInputSchema = z.object({ + newPassword: z.string().min(12).max(256), +}); + +staffRouter.patch( + "/:userId/password", + requirePermission({ member: ["update"] }), + async (req, res, next) => { + try { + const userId = String(req.params.userId ?? ""); + const { newPassword } = passwordInputSchema.parse(req.body); + + const [target] = await db + .select({ id: member.id }) + .from(member) + .where( + and( + eq(member.organizationId, req.organizationId!), + eq(member.userId, userId), + ), + ); + if (!target) throw new HttpError(404, "Member not found."); + + const ctx = await auth.$context; + const hashed = await ctx.password.hash(newPassword); + await ctx.internalAdapter.updatePassword(userId, hashed); + + res.json({ ok: true }); + } catch (err) { + next(err); + } + }, +); diff --git a/backend/src/services/auth-fallback.ts b/backend/src/services/auth-fallback.ts new file mode 100644 index 0000000..e690524 --- /dev/null +++ b/backend/src/services/auth-fallback.ts @@ -0,0 +1,68 @@ +import { and, eq, inArray } from "drizzle-orm"; + +import { db } from "../db/index.js"; +import { member } from "../db/schema/auth.js"; +import { emitToUser } from "../realtime.js"; +import { createSystemMessage } from "./messaging.js"; +import { createNotification } from "./notifications.js"; + +// When an employee asks for a password reset but no email provider is configured, +// alert the admin(s) of each clinic the user belongs to: a "System" message card +// in Messages + a bell notification, both deep-linking to that member's settings +// so an admin can set a new password. The reset URL is never exposed. +export async function notifyAdminsPasswordReset(u: { + id: string; + name: string; + email: string; +}): Promise { + const orgs = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, u.id)); + const orgIds = [...new Set(orgs.map((o) => o.organizationId))]; + + for (const orgId of orgIds) { + try { + const admins = await db + .select({ userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, orgId), + inArray(member.role, ["owner", "admin"]), + ), + ); + const adminIds = admins.map((a) => a.userId).filter((id) => id !== u.id); + if (adminIds.length === 0) continue; + + const body = `${u.name} requested a password reset, but no email provider is configured. Reset their password from their settings.`; + const { message, recipientIds } = await createSystemMessage( + orgId, + adminIds, + body, + { + kind: "passwordReset", + userId: u.id, + userName: u.name, + userEmail: u.email, + }, + ); + for (const rid of recipientIds) emitToUser(rid, "message:new", message); + + for (const rid of adminIds) { + const n = await createNotification({ + orgId, + userId: rid, + type: "password_reset", + text: `${u.name} needs a password reset`, + entityType: "user", + entityId: u.id, + actorName: u.name, + }); + if (n) emitToUser(rid, "notification:new", n); + } + } catch (err) { + console.error("password-reset fallback failed:", err); + } + } +} diff --git a/backend/src/services/email-config.ts b/backend/src/services/email-config.ts new file mode 100644 index 0000000..cf414a7 --- /dev/null +++ b/backend/src/services/email-config.ts @@ -0,0 +1,96 @@ +import { eq } from "drizzle-orm"; + +import { db } from "../db/index.js"; +import { emailSettings } from "../db/schema/email-settings.js"; +import { decryptSecret, encryptSecret } from "../lib/crypto.js"; + +export type EmailProvider = "none" | "smtp" | "resend" | "postmark" | "sendgrid"; + +const ROW_ID = "default"; + +// Providers that authenticate with an API key (so the UI knows to ask for one). +const API_KEY_PROVIDERS: EmailProvider[] = ["resend", "postmark", "sendgrid"]; + +export type PublicEmailConfig = { + provider: EmailProvider; + fromAddress: string; + hasCredentials: boolean; +}; + +export type ActiveEmailConfig = { + provider: EmailProvider; + fromAddress: string; + credentials: string | null; +}; + +async function getRow() { + const [row] = await db + .select() + .from(emailSettings) + .where(eq(emailSettings.id, ROW_ID)) + .limit(1); + return row ?? null; +} + +export async function getPublicConfig(): Promise { + const row = await getRow(); + return { + provider: (row?.provider as EmailProvider) ?? "none", + fromAddress: row?.fromAddress ?? "", + hasCredentials: Boolean(row?.credentials), + }; +} + +// Internal — includes the decrypted API key. Used by lib/email.ts at send time. +export async function getActiveConfig(): Promise { + const row = await getRow(); + return { + provider: (row?.provider as EmailProvider) ?? "none", + fromAddress: row?.fromAddress ?? "", + credentials: row?.credentials ? decryptSecret(row.credentials) : null, + }; +} + +// True when the deployment can actually deliver email (a real provider is set, +// and API-key providers have a key). SMTP relies on env, treated as configured. +export async function isEmailConfigured(): Promise { + const cfg = await getActiveConfig(); + if (cfg.provider === "none") return false; + if (API_KEY_PROVIDERS.includes(cfg.provider)) return Boolean(cfg.credentials); + return true; // smtp +} + +export async function saveConfig(input: { + provider: EmailProvider; + fromAddress: string; + // undefined = leave existing key untouched; "" = clear it. + credentials?: string; +}): Promise { + const existing = await getRow(); + const credentials = + input.credentials === undefined + ? (existing?.credentials ?? null) + : input.credentials + ? encryptSecret(input.credentials) + : null; + + await db + .insert(emailSettings) + .values({ + id: ROW_ID, + provider: input.provider, + fromAddress: input.fromAddress, + credentials, + }) + .onConflictDoUpdate({ + target: emailSettings.id, + set: { + provider: input.provider, + fromAddress: input.fromAddress, + credentials, + updatedAt: new Date(), + }, + }); + + return getPublicConfig(); +} diff --git a/backend/src/services/messaging.ts b/backend/src/services/messaging.ts index 313f172..3568f5e 100644 --- a/backend/src/services/messaging.ts +++ b/backend/src/services/messaging.ts @@ -482,6 +482,111 @@ export async function markRead( ); } +// --- System messages ------------------------------------------------------- + +// A reserved user that "sends" system messages. It has no account row, so it can +// never log in; the messages.senderId FK just needs a user to exist. +export const SYSTEM_USER_ID = "system"; +export const SYSTEM_USER_NAME = "temetro System"; + +async function ensureSystemUser(): Promise { + await db + .insert(user) + .values({ + id: SYSTEM_USER_ID, + name: SYSTEM_USER_NAME, + email: "system@temetro.local", + emailVerified: true, + }) + .onConflictDoNothing(); +} + +// Ensure the per-clinic "System" conversation exists and includes the system +// user plus the given recipients, returning its id. +async function ensureSystemConversation( + orgId: string, + recipientIds: string[], +): Promise { + await ensureSystemUser(); + const [existing] = await db + .select({ id: conversations.id }) + .from(conversations) + .where( + and( + eq(conversations.organizationId, orgId), + eq(conversations.name, "System"), + eq(conversations.createdBy, SYSTEM_USER_ID), + ), + ) + .limit(1); + let convId = existing?.id; + if (!convId) { + const [created] = await db + .insert(conversations) + .values({ + organizationId: orgId, + name: "System", + isGroup: true, + createdBy: SYSTEM_USER_ID, + }) + .returning(); + convId = created!.id; + } + const current = new Set(await participantIds(convId)); + const toAdd = [SYSTEM_USER_ID, ...recipientIds].filter( + (id) => !current.has(id), + ); + if (toAdd.length > 0) { + await db + .insert(conversationParticipants) + .values( + toAdd.map((uid) => ({ + conversationId: convId!, + userId: uid, + lastReadAt: uid === SYSTEM_USER_ID ? new Date() : null, + })), + ) + .onConflictDoNothing(); + } + return convId; +} + +// Post a message from the system user into the clinic's System conversation. +export async function createSystemMessage( + orgId: string, + recipientIds: string[], + body: string, + attachment: MessageAttachment, +): Promise<{ message: ConversationMessage; recipientIds: string[] }> { + const convId = await ensureSystemConversation(orgId, recipientIds); + const now = new Date(); + const [row] = await db + .insert(messages) + .values({ + conversationId: convId, + senderId: SYSTEM_USER_ID, + body, + attachments: [attachment], + }) + .returning(); + await db + .update(conversations) + .set({ updatedAt: now }) + .where(eq(conversations.id, convId)); + return { + message: { + id: row!.id, + conversationId: convId, + senderId: SYSTEM_USER_ID, + senderName: SYSTEM_USER_NAME, + body: row!.body, + attachments: row!.attachments, + createdAt: row!.createdAt.toISOString(), + }, + recipientIds, + }; +} + export async function listClinicMembers( orgId: string, excludeUserId: string, diff --git a/backend/src/types/activity.ts b/backend/src/types/activity.ts index e08d16d..2d1b85a 100644 --- a/backend/src/types/activity.ts +++ b/backend/src/types/activity.ts @@ -9,7 +9,8 @@ export type ActivityEntityType = | "invoice" | "inventory" | "dispense" - | "task"; + | "task" + | "settings"; export type ActivityEntry = { id: string; diff --git a/backend/src/types/messaging.ts b/backend/src/types/messaging.ts index 2c00727..8452dc6 100644 --- a/backend/src/types/messaging.ts +++ b/backend/src/types/messaging.ts @@ -25,7 +25,16 @@ export type MessageAttachment = mimeType: string; size: number; } - | { kind: "appointment"; appointment: AppointmentSnapshot }; + | { kind: "appointment"; appointment: AppointmentSnapshot } + // A system-generated alert (e.g. an employee asked for a password reset but no + // email provider is configured). Rendered as a distinct "System" card that + // deep-links an admin to the member's settings. + | { + kind: "passwordReset"; + userId: string; + userName: string; + userEmail: string; + }; export type ConversationMessage = { id: string; diff --git a/frontend/components/chat/chat-history-panel.tsx b/frontend/components/chat/chat-history-panel.tsx index 3890ebc..144eb8f 100644 --- a/frontend/components/chat/chat-history-panel.tsx +++ b/frontend/components/chat/chat-history-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { PanelLeft, Plus, Search, Trash2 } from "lucide-react"; +import { History, Plus, Search, SquarePen, Trash2 } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { type MouseEvent, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -78,15 +78,15 @@ export function ChatHistoryPanel() { onClick={() => setOpen(true)} type="button" > - + diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index 2cb188f..9701c9b 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -4,6 +4,7 @@ import { CalendarClock, Download, FileText, + KeyRound, Mail, Paperclip, Plus, @@ -93,7 +94,32 @@ const GROUP_WINDOW_MS = 5 * 60 * 1000; // shared-appointment card. Alignment (left/right) comes from the parent column. function SentAttachment({ att }: { att: MessageAttachment }) { const { t } = useTranslation(); + const router = useRouter(); const [apptOpen, setApptOpen] = useState(false); + if (att.kind === "passwordReset") { + return ( + + ); + } if (att.kind === "file") { return ( + ))} + + setName(e.target.value)} required value={name} /> - - setFileNumber(e.target.value)} - required - value={fileNumber} - /> - + + {mode === "returning" ? ( + + setFileNumber(e.target.value)} + required + value={fileNumber} + /> + + ) : ( +
+ + + + + setAge(e.target.value)} + type="number" + value={age} + /> + +
+ )} +
(member?.specialty ?? ""); const [savingSpecialty, setSavingSpecialty] = useState(false); + const [newPw, setNewPw] = useState(""); + const [confirmPw, setConfirmPw] = useState(""); + const [savingPw, setSavingPw] = useState(false); useEffect(() => { setRole(member?.role ?? ""); setSpecialty(member?.specialty ?? ""); + setNewPw(""); + setConfirmPw(""); }, [member?.id, member?.role, member?.specialty]); const summary = rolePermissionSummary(member?.role); @@ -169,6 +180,43 @@ export function EmployeeDetailDialog({ } }; + const resetPassword = async () => { + if (!member || savingPw) return; + if (newPw.length < 12) { + notify.error( + t("settings.careTeam.employee.pwTooShortTitle"), + t("settings.careTeam.employee.pwTooShortBody"), + ); + return; + } + if (newPw !== confirmPw) { + notify.error( + t("settings.careTeam.employee.pwMismatchTitle"), + t("settings.careTeam.employee.pwMismatchBody"), + ); + return; + } + setSavingPw(true); + try { + await setStaffPassword(member.userId, newPw); + notify.success( + t("settings.careTeam.employee.pwUpdatedTitle"), + t("settings.careTeam.employee.pwUpdatedBody", { + name: member.name ?? member.email ?? "", + }), + ); + setNewPw(""); + setConfirmPw(""); + } catch { + notify.error( + t("settings.careTeam.employee.pwFailedTitle"), + t("settings.careTeam.employee.pwFailedBody"), + ); + } finally { + setSavingPw(false); + } + }; + const showSpecialty = PROVIDER_ROLES.has(member?.role ?? ""); const specialtyOptions = [ { value: "", label: t("settings.careTeam.employee.noSpecialty") }, @@ -333,6 +381,42 @@ export function EmployeeDetailDialog({
)} + + {editable && ( +
+ + {t("settings.careTeam.employee.resetPassword")} + +

+ {t("settings.careTeam.employee.resetPasswordHint")} +

+ setNewPw(e.target.value)} + placeholder={t("settings.careTeam.employee.newPassword")} + type="password" + value={newPw} + /> +
+ setConfirmPw(e.target.value)} + placeholder={t("settings.careTeam.employee.confirmPassword")} + type="password" + value={confirmPw} + /> + +
+
+ )} diff --git a/frontend/components/settings/settings-care-team.tsx b/frontend/components/settings/settings-care-team.tsx index 0c4d43e..b1c1e13 100644 --- a/frontend/components/settings/settings-care-team.tsx +++ b/frontend/components/settings/settings-care-team.tsx @@ -1,7 +1,7 @@ "use client"; import { Info, UserPlus } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AddStaffDialog } from "@/components/settings/add-staff-dialog"; @@ -49,7 +49,11 @@ function initials(name?: string | null, email?: string | null): string { ); } -export function CareTeamPanel() { +export function CareTeamPanel({ + initialMemberId, +}: { + initialMemberId?: string; +}) { const { t } = useTranslation(); const { data: session } = authClient.useSession(); const [members, setMembers] = useState([]); @@ -79,6 +83,18 @@ export function CareTeamPanel() { void load(); }, [load]); + // Deep-link: open a specific member (e.g. from a password-reset system card). + const appliedDeepLink = useRef(false); + useEffect(() => { + if (appliedDeepLink.current || !initialMemberId || members.length === 0) + return; + const target = members.find((m) => m.userId === initialMemberId); + if (target) { + setSelected(target); + appliedDeepLink.current = true; + } + }, [initialMemberId, members]); + const myRole = members.find((m) => m.userId === session?.user?.id)?.role; const canManage = myRole === "owner" || myRole === "admin"; diff --git a/frontend/components/settings/settings-developers.tsx b/frontend/components/settings/settings-developers.tsx index 209c5be..1f0da5e 100644 --- a/frontend/components/settings/settings-developers.tsx +++ b/frontend/components/settings/settings-developers.tsx @@ -1,21 +1,183 @@ "use client"; import { KeyRound } from "lucide-react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { CopyField, + FieldLabel, SettingsCard, SettingsSection, } from "@/components/settings/settings-parts"; import { API_BASE_URL } from "@/lib/api-client"; +import { + type EmailConfig, + type EmailProvider, + getEmailConfig, + saveEmailConfig, + testEmailConfig, +} from "@/lib/email-settings"; +import { notify } from "@/lib/toast"; + +const PROVIDERS: EmailProvider[] = [ + "none", + "resend", + "postmark", + "sendgrid", + "smtp", +]; +// Providers that authenticate with an API key (so we show the key field). +const API_KEY_PROVIDERS: EmailProvider[] = ["resend", "postmark", "sendgrid"]; + +const controlClass = + "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"; + +function EmailProviderCard() { + const { t } = useTranslation(); + const [config, setConfig] = useState(null); + const [provider, setProvider] = useState("none"); + const [fromAddress, setFromAddress] = useState(""); + const [credentials, setCredentials] = useState(""); // empty = untouched + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + + useEffect(() => { + getEmailConfig() + .then((c) => { + setConfig(c); + setProvider(c.provider); + setFromAddress(c.fromAddress); + }) + .catch(() => { + /* non-admins won't reach this tab */ + }); + }, []); + + const save = async () => { + setSaving(true); + try { + const saved = await saveEmailConfig({ + provider, + fromAddress: fromAddress.trim(), + ...(credentials ? { credentials } : {}), + }); + setConfig(saved); + setCredentials(""); + notify.success(t("settings.developers.email.savedTitle")); + } catch { + notify.error(t("settings.developers.email.savedFailed")); + } finally { + setSaving(false); + } + }; + + const test = async () => { + setTesting(true); + try { + const r = await testEmailConfig(); + notify.success( + t("settings.developers.email.testSentTitle"), + t("settings.developers.email.testSentBody", { to: r.to }), + ); + } catch { + notify.error(t("settings.developers.email.testFailed")); + } finally { + setTesting(false); + } + }; + + const needsKey = API_KEY_PROVIDERS.includes(provider); + + return ( + + {t(`settings.developers.email.providers.${config.provider}`)} + + ) : null + } + description={t("settings.developers.email.description")} + title={t("settings.developers.email.title")} + > + +
+ {t("settings.developers.email.provider")} + +
+ +
+ {t("settings.developers.email.from")} + setFromAddress(e.target.value)} + placeholder="temetro " + value={fromAddress} + /> +
+ + {needsKey ? ( +
+ {t("settings.developers.email.apiKey")} + setCredentials(e.target.value)} + placeholder={ + config?.hasCredentials + ? t("settings.developers.email.apiKeySet") + : t("settings.developers.email.apiKeyPlaceholder") + } + type="password" + value={credentials} + /> +
+ ) : provider === "smtp" ? ( +

+ {t("settings.developers.email.smtpHint")} +

+ ) : null} + +
+ + +
+
+
+ ); +} export function DevelopersPanel() { const { t } = useTranslation(); return ( <> + + ("profile"); + const searchParams = useSearchParams(); + // Deep-link support: /settings?tab=careTeam&member=. + const urlTab = searchParams.get("tab") as Tab | null; + const urlMember = searchParams.get("member"); + const [tab, setTab] = useState(urlTab ?? "profile"); // Only clinic owners/admins manage clinic-wide settings (care team, records, // signing, developers). Everyone else gets their personal tabs (profile, AI). @@ -71,7 +76,9 @@ export function SettingsView() { {activeTab === "ai" && } {activeTab === "records" && } {activeTab === "signing" && } - {activeTab === "careTeam" && } + {activeTab === "careTeam" && ( + + )} {activeTab === "integrations" && } {activeTab === "developers" && } diff --git a/frontend/components/sidebar-02/app-sidebar.tsx b/frontend/components/sidebar-02/app-sidebar.tsx index 58340a5..723c98a 100644 --- a/frontend/components/sidebar-02/app-sidebar.tsx +++ b/frontend/components/sidebar-02/app-sidebar.tsx @@ -21,7 +21,6 @@ import Image from "next/image"; import { useTranslation } from "react-i18next"; import type { Route } from "./nav-main"; import DashboardNavigation from "@/components/sidebar-02/nav-main"; -import { NavChatHistory } from "@/components/sidebar-02/nav-chat-history"; import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications"; import { NavUser } from "@/components/sidebar-02/nav-user"; import { useCallInvites } from "@/components/meetings/use-call-invites"; @@ -103,7 +102,6 @@ export function DashboardSidebar() { - {aiAllowed && } diff --git a/frontend/components/sidebar-02/nav-chat-history.tsx b/frontend/components/sidebar-02/nav-chat-history.tsx deleted file mode 100644 index da2df9d..0000000 --- a/frontend/components/sidebar-02/nav-chat-history.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { Trash2 } from "lucide-react"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; -import { type MouseEvent, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { useSidebar } from "@/components/ui/sidebar"; -import { - deleteThread, - listThreads, - THREADS_CHANGED_EVENT, - type ThreadSummary, -} from "@/lib/ai-chat-history"; -import { cn } from "@/lib/utils"; - -// Claude-style list of saved AI chats in the sidebar. Refreshes when a chat is -// saved/deleted (via the THREADS_CHANGED_EVENT). Hidden when collapsed or empty. -export function NavChatHistory() { - const { t } = useTranslation(); - const { state } = useSidebar(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const activeThread = searchParams.get("thread"); - const [threads, setThreads] = useState([]); - - useEffect(() => { - const refresh = () => { - listThreads() - .then(setThreads) - .catch(() => { - /* not signed in / no clinic — just show nothing */ - }); - }; - refresh(); - window.addEventListener(THREADS_CHANGED_EVENT, refresh); - return () => window.removeEventListener(THREADS_CHANGED_EVENT, refresh); - }, []); - - if (state === "collapsed" || threads.length === 0) return null; - - const remove = async (event: MouseEvent, id: string) => { - event.preventDefault(); - event.stopPropagation(); - setThreads((prev) => prev.filter((x) => x.id !== id)); - await deleteThread(id).catch(() => { - /* ignore */ - }); - }; - - return ( -
- - {t("chat.history.title")} - - {threads.map((thread) => { - const active = pathname === "/" && activeThread === thread.id; - return ( - - {thread.title} - - - ); - })} -
- ); -} diff --git a/frontend/lib/email-settings.ts b/frontend/lib/email-settings.ts new file mode 100644 index 0000000..ae4da40 --- /dev/null +++ b/frontend/lib/email-settings.ts @@ -0,0 +1,31 @@ +import { apiFetch } from "@/lib/api-client"; + +// Deployment-wide email provider config (Settings → Developers). The API key is +// never returned — `hasCredentials` only signals whether one is stored. +export type EmailProvider = "none" | "smtp" | "resend" | "postmark" | "sendgrid"; + +export type EmailConfig = { + provider: EmailProvider; + fromAddress: string; + hasCredentials: boolean; +}; + +export function getEmailConfig(): Promise { + return apiFetch("/api/settings/email"); +} + +export function saveEmailConfig(input: { + provider: EmailProvider; + fromAddress: string; + // undefined = leave existing key; "" = clear; string = set/replace. + credentials?: string; +}): Promise { + return apiFetch("/api/settings/email", { + method: "PUT", + body: JSON.stringify(input), + }); +} + +export function testEmailConfig(): Promise<{ ok: boolean; to: string }> { + return apiFetch("/api/settings/email/test", { method: "POST" }); +} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 08d0ee5..5ce2fce 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -831,7 +831,12 @@ "apptPatient": "Patient", "apptStatus": "Status" }, - "startCall": "Start a call with {{name}}" + "startCall": "Start a call with {{name}}", + "system": { + "label": "System message", + "passwordResetTitle": "Password reset requested", + "passwordResetBody": "{{name}} forgot their password. Tap to open their settings and set a new one." + } }, "analysis": { "title": "Overview", @@ -1531,7 +1536,33 @@ "generateToken": "Generate token", "resourcesTitle": "Resources", "resourcesDescription": "Where to learn more", - "resourcesBody": "The API reference — covering patients, appointments, prescriptions, tasks, messaging, activity and analytics — lives in the project documentation under docs/api. temetro is open source, so the route handlers in backend/src/routes are the authoritative spec." + "resourcesBody": "The API reference — covering patients, appointments, prescriptions, tasks, messaging, activity and analytics — lives in the project documentation under docs/api. temetro is open source, so the route handlers in backend/src/routes are the authoritative spec.", + "email": { + "title": "Email provider", + "description": "How temetro sends verification, password-reset and invitation emails. This is a deployment-wide setting.", + "provider": "Provider", + "providers": { + "none": "Not configured", + "smtp": "SMTP", + "resend": "Resend", + "postmark": "Postmark", + "sendgrid": "SendGrid" + }, + "from": "From address", + "apiKey": "API key", + "apiKeySet": "•••••••• (saved — leave blank to keep)", + "apiKeyPlaceholder": "Paste your provider API key", + "smtpHint": "SMTP uses the SMTP_HOST/PORT/USER/PASS environment variables on the server.", + "save": "Save", + "saving": "Saving…", + "savedTitle": "Email settings saved", + "savedFailed": "Couldn't save email settings.", + "test": "Send test", + "testing": "Sending…", + "testSentTitle": "Test email sent", + "testSentBody": "Sent a test email to {{to}}.", + "testFailed": "Couldn't send the test email." + } }, "profile": { "sectionTitle": "Clinician profile", @@ -1657,7 +1688,20 @@ "read": "View", "write": "Edit", "delete": "Delete" - } + }, + "resetPassword": "Reset password", + "resetPasswordHint": "Set a new password for this employee (e.g. they forgot theirs and no email provider is configured).", + "newPassword": "New password (min 12 chars)", + "confirmPassword": "Confirm password", + "setPassword": "Set password", + "pwTooShortTitle": "Password too short", + "pwTooShortBody": "Use at least 12 characters.", + "pwMismatchTitle": "Passwords don't match", + "pwMismatchBody": "Re-enter the same password in both fields.", + "pwUpdatedTitle": "Password updated", + "pwUpdatedBody": "{{name}} can now sign in with the new password.", + "pwFailedTitle": "Couldn't set password", + "pwFailedBody": "Please try again." }, "remove": { "title": "Remove team member?", @@ -1810,14 +1854,24 @@ "date": "Date", "time": "Time", "reason": "Reason for visit (optional)", - "reasonPlaceholder": "e.g. Follow-up, check-up" + "reasonPlaceholder": "e.g. Follow-up, check-up", + "sex": "Sex", + "sexMale": "Male", + "sexFemale": "Female", + "age": "Age" }, "book": { "title": "Book an appointment", "submit": "Request appointment", "successTitle": "You're booked", "successBody": "Your appointment is set for {{date}} at {{time}}. Please check in at the front desk.", - "errorGeneric": "Couldn't book the appointment. Please try again or ask the front desk." + "errorGeneric": "Couldn't book the appointment. Please try again or ask the front desk.", + "mode": { + "returning": "Returning patient", + "new": "New patient" + }, + "newFileNote": "Your new file number is {{file}} — keep it for next time.", + "slotTaken": "That time is already taken. Please choose another." }, "results": { "title": "View my results", diff --git a/frontend/lib/messages.ts b/frontend/lib/messages.ts index 98f66e2..4d8d721 100644 --- a/frontend/lib/messages.ts +++ b/frontend/lib/messages.ts @@ -26,7 +26,13 @@ export type MessageAttachment = mimeType: string; size: number; } - | { kind: "appointment"; appointment: AppointmentSnapshot }; + | { kind: "appointment"; appointment: AppointmentSnapshot } + | { + kind: "passwordReset"; + userId: string; + userName: string; + userEmail: string; + }; export type ConversationMessage = { id: string; diff --git a/frontend/lib/portal.ts b/frontend/lib/portal.ts index 318ab2a..9fba313 100644 --- a/frontend/lib/portal.ts +++ b/frontend/lib/portal.ts @@ -65,6 +65,22 @@ export function getPortalClinic(clinic: string): Promise { return portalFetch(`/${encodeURIComponent(clinic)}`); } +export type PortalNewPatient = { + name: string; + sex?: string; + age?: number; +}; + +export function createPortalPatient( + clinic: string, + patient: PortalNewPatient, +): Promise<{ fileNumber: string; name: string }> { + return portalFetch(`/${encodeURIComponent(clinic)}/patients`, { + method: "POST", + body: JSON.stringify(patient), + }); +} + export function bookPortalAppointment( clinic: string, booking: PortalBooking, diff --git a/frontend/lib/staff.ts b/frontend/lib/staff.ts index 63cb687..fbf8bfb 100644 --- a/frontend/lib/staff.ts +++ b/frontend/lib/staff.ts @@ -15,6 +15,18 @@ export function listProviders(): Promise { return apiFetch("/api/staff/providers"); } +// Set a member's password directly (owner/admin only) — used when an employee +// forgot it and no email provider is configured. +export function setStaffPassword( + userId: string, + newPassword: string, +): Promise<{ ok: boolean }> { + return apiFetch(`/api/staff/${encodeURIComponent(userId)}/password`, { + method: "PATCH", + body: JSON.stringify({ newPassword }), + }); +} + // Update a member's clinical specialty (owner/admin only). Pass null to clear. export function updateStaffSpecialty( userId: string,