diff --git a/backend/drizzle/0016_past_maestro.sql b/backend/drizzle/0016_past_maestro.sql new file mode 100644 index 0000000..26b57b5 --- /dev/null +++ b/backend/drizzle/0016_past_maestro.sql @@ -0,0 +1,23 @@ +CREATE TABLE "ai_chat_messages" ( + "id" text PRIMARY KEY NOT NULL, + "thread_id" text NOT NULL, + "position" integer NOT NULL, + "role" text NOT NULL, + "parts" jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ai_chat_threads" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "title" text DEFAULT 'New chat' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "ai_chat_messages" ADD CONSTRAINT "ai_chat_messages_thread_id_ai_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."ai_chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ai_chat_threads" ADD CONSTRAINT "ai_chat_threads_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ai_chat_threads" ADD CONSTRAINT "ai_chat_threads_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "ai_messages_thread_idx" ON "ai_chat_messages" USING btree ("thread_id","position");--> statement-breakpoint +CREATE INDEX "ai_threads_org_user_idx" ON "ai_chat_threads" USING btree ("organization_id","user_id"); \ No newline at end of file diff --git a/backend/drizzle/meta/0016_snapshot.json b/backend/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..d9914bc --- /dev/null +++ b/backend/drizzle/meta/0016_snapshot.json @@ -0,0 +1,3164 @@ +{ + "id": "edd85e3c-84a6-437b-be14-8460a26c409b", + "prevId": "c2d24a1e-f929-4ee6-a252-a0cbba19e961", + "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()" + }, + "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.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unassigned'" + }, + "assignee_role": { + "name": "assignee_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due": { + "name": "due", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'No due date'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient": { + "name": "patient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_name": { + "name": "created_by_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_org_idx": { + "name": "tasks_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_organization_id_organization_id_fk": { + "name": "tasks_organization_id_organization_id_fk", + "tableFrom": "tasks", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_created_by_user_id_fk": { + "name": "tasks_created_by_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_org_created_idx": { + "name": "activity_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_organization_id_organization_id_fk": { + "name": "activity_log_organization_id_organization_id_fk", + "tableFrom": "activity_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_id_user_id_fk": { + "name": "activity_log_actor_id_user_id_fk", + "tableFrom": "activity_log", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_participants": { + "name": "conversation_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conv_participant_uidx": { + "name": "conv_participant_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conv_participant_user_idx": { + "name": "conv_participant_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_participants_conversation_id_conversations_id_fk": { + "name": "conversation_participants_conversation_id_conversations_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_participants_user_id_user_id_fk": { + "name": "conversation_participants_user_id_user_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_group": { + "name": "is_group", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_org_idx": { + "name": "conversations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_organization_id_organization_id_fk": { + "name": "conversations_organization_id_organization_id_fk", + "tableFrom": "conversations", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_created_by_user_id_fk": { + "name": "conversations_created_by_user_id_fk", + "tableFrom": "conversations", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conv_idx": { + "name": "messages_conv_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_user_id_fk": { + "name": "messages_sender_id_user_id_fk", + "tableFrom": "messages", + "tableTo": "user", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_initials": { + "name": "actor_initials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_org_user_read_idx": { + "name": "notifications_org_user_read_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_organization_id_organization_id_fk": { + "name": "notifications_organization_id_organization_id_fk", + "tableFrom": "notifications", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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.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 + } + }, + "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 2a6249a..b84f5ee 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1781455848772, "tag": "0015_polite_captain_marvel", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1781464772532, + "tag": "0016_past_maestro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema/ai-chat.ts b/backend/src/db/schema/ai-chat.ts new file mode 100644 index 0000000..77cbd85 --- /dev/null +++ b/backend/src/db/schema/ai-chat.ts @@ -0,0 +1,50 @@ +import { + index, + integer, + jsonb, + pgTable, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +import { organization, user } from "./auth.js"; + +// Persisted AI-chat threads (Claude-style history), per user within a clinic. +// The thread id is client-generated (nanoid) so the frontend can start saving a +// fresh chat without a round-trip. Messages store the full UIMessage `parts` +// (text + custom record cards) as JSONB so a reopened thread renders exactly as +// it did live. +export const aiChatThreads = pgTable( + "ai_chat_threads", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + title: text("title").notNull().default("New chat"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [index("ai_threads_org_user_idx").on(t.organizationId, t.userId)], +); + +export const aiChatMessages = pgTable( + "ai_chat_messages", + { + id: text("id").primaryKey(), + threadId: text("thread_id") + .notNull() + .references(() => aiChatThreads.id, { onDelete: "cascade" }), + position: integer("position").notNull(), + role: text("role").notNull(), + parts: jsonb("parts").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [index("ai_messages_thread_idx").on(t.threadId, t.position)], +); diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index c059844..70e7553 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -11,3 +11,4 @@ export * from "./messaging.js"; export * from "./notifications.js"; export * from "./settings.js"; export * from "./ai.js"; +export * from "./ai-chat.js"; diff --git a/backend/src/routes/analytics.ts b/backend/src/routes/analytics.ts index 77b5b18..ecce357 100644 --- a/backend/src/routes/analytics.ts +++ b/backend/src/routes/analytics.ts @@ -15,3 +15,12 @@ analyticsRouter.get("/", async (req, res, next) => { next(err); } }); + +// Lightweight real-time metric polled by the Live card: patients checked in today. +analyticsRouter.get("/live", async (req, res, next) => { + try { + res.json({ value: await service.getLiveMetric(req.organizationId!) }); + } catch (err) { + next(err); + } +}); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 38087d1..1966b08 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -10,13 +10,16 @@ import { type UIMessage, } from "ai"; import { Router } from "express"; +import { z } from "zod"; +import { HttpError } from "../lib/http-error.js"; import { requireAuth, requireOrg, requirePermission, } from "../middleware/auth.js"; import { recordActivity } from "../services/activity.js"; +import * as aiChat from "../services/ai-chat.js"; import { getAiSettings } from "../services/ai/config.js"; import { resolveModel } from "../services/ai/provider.js"; import { createChatTools } from "../services/ai/tools.js"; @@ -43,12 +46,19 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string { "- listAppointments: when asked to see the schedule / upcoming visits.", "- listTasks: when asked to see open tasks / to-dos.", "- listPrescriptions: when asked to see prescriptions.", + "- getClinicInfo: the clinic's name / basic info (e.g. 'what's my clinic called?').", + "- getAnalytics: clinic KPIs AND earnings (money billed / paid / outstanding, by month). Use for analytics, earnings, revenue, or performance questions.", + "- listInventory: stock levels / low-stock / reorder questions.", "", "Add tools (propose only — these NEVER write):", "- proposeAppointment / proposeTask / proposePrescription: when the clinician", " asks to add/book/create one. They show an approval card; the record is only", " written after the clinician clicks Add. NEVER say you added/booked/created", " something — say you've drafted it for their approval.", + "- proposeInvoice: when the clinician wants to bill someone — e.g. they upload", + " a list of purchased medications/items. Parse it into line items", + " {description, quantity, unitPrice} (use the prices in the document) and call", + " proposeInvoice with the patient/client name.", "- previewImport: when the clinician wants to import/migrate an existing", " patient database file, or add a single patient. Parse the uploaded content", " into our patient shape and call previewImport.", @@ -167,3 +177,66 @@ chatRouter.post("/", async (req, res, next) => { next(err); } }); + +// --- Persisted conversation history (Claude-style) -------------------------- +// Threads are per-user within the clinic. The client owns the thread id (nanoid) +// and saves a snapshot of the conversation after each exchange. + +chatRouter.get("/threads", async (req, res, next) => { + try { + res.json(await aiChat.listThreads(req.organizationId!, req.user!.id)); + } catch (err) { + next(err); + } +}); + +chatRouter.get("/threads/:id", async (req, res, next) => { + try { + const thread = await aiChat.getThread( + req.organizationId!, + req.user!.id, + req.params.id as string, + ); + if (!thread) throw new HttpError(404, "Conversation not found."); + res.json(thread); + } catch (err) { + next(err); + } +}); + +const saveThreadSchema = z.object({ + messages: z + .array(z.object({ role: z.string(), parts: z.unknown() })) + .max(500), + title: z.string().trim().max(120).default("New chat"), +}); + +chatRouter.put("/threads/:id", async (req, res, next) => { + try { + const { messages, title } = saveThreadSchema.parse(req.body); + await aiChat.saveThread( + req.organizationId!, + req.user!.id, + req.params.id as string, + messages, + title || "New chat", + ); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +chatRouter.delete("/threads/:id", async (req, res, next) => { + try { + const ok = await aiChat.deleteThread( + req.organizationId!, + req.user!.id, + req.params.id as string, + ); + if (!ok) throw new HttpError(404, "Conversation not found."); + res.status(204).end(); + } catch (err) { + next(err); + } +}); diff --git a/backend/src/services/ai-chat.ts b/backend/src/services/ai-chat.ts new file mode 100644 index 0000000..a708677 --- /dev/null +++ b/backend/src/services/ai-chat.ts @@ -0,0 +1,132 @@ +import { randomUUID } from "node:crypto"; + +import { and, asc, desc, eq } from "drizzle-orm"; + +import { db } from "../db/index.js"; +import { aiChatMessages, aiChatThreads } from "../db/schema/ai-chat.js"; +import { HttpError } from "../lib/http-error.js"; + +export type StoredMessage = { role: string; parts: unknown }; + +export type ThreadSummary = { + id: string; + title: string; + updatedAt: string; +}; + +export async function listThreads( + orgId: string, + userId: string, +): Promise { + const rows = await db + .select({ + id: aiChatThreads.id, + title: aiChatThreads.title, + updatedAt: aiChatThreads.updatedAt, + }) + .from(aiChatThreads) + .where( + and( + eq(aiChatThreads.organizationId, orgId), + eq(aiChatThreads.userId, userId), + ), + ) + .orderBy(desc(aiChatThreads.updatedAt)) + .limit(50); + return rows.map((r) => ({ + id: r.id, + title: r.title, + updatedAt: r.updatedAt.toISOString(), + })); +} + +export async function getThread( + orgId: string, + userId: string, + threadId: string, +): Promise<{ id: string; title: string; messages: StoredMessage[] } | null> { + const [thread] = await db + .select() + .from(aiChatThreads) + .where( + and( + eq(aiChatThreads.id, threadId), + eq(aiChatThreads.organizationId, orgId), + eq(aiChatThreads.userId, userId), + ), + ); + if (!thread) return null; + const rows = await db + .select({ role: aiChatMessages.role, parts: aiChatMessages.parts }) + .from(aiChatMessages) + .where(eq(aiChatMessages.threadId, threadId)) + .orderBy(asc(aiChatMessages.position)); + return { + id: thread.id, + title: thread.title, + messages: rows.map((r) => ({ role: r.role, parts: r.parts })), + }; +} + +// Upsert a thread and replace its messages with the supplied snapshot. The +// thread id is client-generated; ownership is enforced (you can only write your +// own threads within your clinic). +export async function saveThread( + orgId: string, + userId: string, + threadId: string, + messages: StoredMessage[], + title: string, +): Promise { + await db.transaction(async (tx) => { + const [existing] = await tx + .select({ userId: aiChatThreads.userId }) + .from(aiChatThreads) + .where(eq(aiChatThreads.id, threadId)); + if (existing && existing.userId !== userId) { + throw new HttpError(403, "Not your conversation."); + } + if (existing) { + await tx + .update(aiChatThreads) + .set({ title, updatedAt: new Date() }) + .where(eq(aiChatThreads.id, threadId)); + await tx + .delete(aiChatMessages) + .where(eq(aiChatMessages.threadId, threadId)); + } else { + await tx + .insert(aiChatThreads) + .values({ id: threadId, organizationId: orgId, userId, title }); + } + if (messages.length > 0) { + await tx.insert(aiChatMessages).values( + messages.map((m, i) => ({ + id: randomUUID(), + threadId, + position: i, + role: m.role, + parts: m.parts, + })), + ); + } + }); +} + +export async function deleteThread( + orgId: string, + userId: string, + threadId: string, +): Promise { + const deleted = await db + .delete(aiChatThreads) + .where( + and( + eq(aiChatThreads.id, threadId), + eq(aiChatThreads.organizationId, orgId), + eq(aiChatThreads.userId, userId), + ), + ) + .returning({ id: aiChatThreads.id }); + return deleted.length > 0; +} diff --git a/backend/src/services/ai/tools.ts b/backend/src/services/ai/tools.ts index a2173a7..8029f85 100644 --- a/backend/src/services/ai/tools.ts +++ b/backend/src/services/ai/tools.ts @@ -2,11 +2,19 @@ import { tool } from "ai"; import type { UIMessageStreamWriter } from "ai"; import { z } from "zod"; +import { eq } from "drizzle-orm"; + +import { db } from "../../db/index.js"; +import { organization } from "../../db/schema/auth.js"; import { appointmentInputSchema } from "../../lib/appointment-validation.js"; +import { initialsFromName } from "../../lib/initials.js"; +import { invoiceInputSchema } from "../../lib/invoice-validation.js"; import { patientInputSchema } from "../../lib/patient-validation.js"; import { prescriptionInputSchema } from "../../lib/prescription-validation.js"; import { taskInputSchema } from "../../lib/task-validation.js"; +import * as analytics from "../analytics.js"; import * as appointments from "../appointments.js"; +import * as inventory from "../inventory.js"; import * as patients from "../patients.js"; import * as prescriptions from "../prescriptions.js"; import * as tasks from "../tasks.js"; @@ -407,6 +415,123 @@ export function createChatTools(ctx: ToolContext) { }, }), + // --- Clinic-wide reads (aggregates / non-PHI — safe to return to model) --- + + getClinicInfo: tool({ + description: + "Get the clinic's name and basic info. Use when the clinician asks about their clinic/organization (e.g. 'what's my clinic called?').", + inputSchema: z.object({}), + execute: async () => { + step("Loading clinic info"); + const [org] = await db + .select({ + name: organization.name, + slug: organization.slug, + createdAt: organization.createdAt, + }) + .from(organization) + .where(eq(organization.id, orgId)); + const info = { + name: org?.name ?? "", + slug: org?.slug ?? null, + createdAt: org?.createdAt ? org.createdAt.toISOString() : null, + }; + writer.write({ type: "data-clinicCard", data: info }); + return info; + }, + }), + + getAnalytics: tool({ + description: + "Retrieve the clinic's analytics AND earnings — patient/appointment/prescription/task counts plus money billed, paid, and outstanding (from invoices), with a by-month earnings trend. Use for KPIs, earnings, revenue, or performance questions.", + inputSchema: z.object({}), + execute: async () => { + step("Loading clinic analytics"); + const data = await analytics.getAnalytics(orgId); + writer.write({ type: "data-analyticsCard", data }); + return data; // aggregates only, no PHI + }, + }), + + listInventory: tool({ + description: + "List the clinic's inventory (medications/supplies, stock levels, reorder thresholds). Use for stock, low-stock, or reorder questions.", + inputSchema: z.object({}), + execute: async () => { + step("Loading inventory"); + const items = await inventory.listInventory(orgId); + writer.write({ type: "data-inventoryList", data: { items } }); + return { + count: items.length, + items: items.map((i) => ({ + name: i.name, + form: i.form, + strength: i.strength, + stock: i.stockQuantity, + reorderThreshold: i.reorderThreshold, + })), + }; + }, + }), + + proposeInvoice: tool({ + description: + "Propose a new invoice for the clinician to approve — e.g. parse an uploaded list of purchased medications into billable line items. Does NOT save; it shows an approval card the clinician confirms. Provide the patient/client name (a file number if known) and line items {description, quantity, unitPrice}; prices come from the uploaded document.", + inputSchema: z.object({ + name: z.string().describe("Patient/client name (may be a token)"), + fileNumber: z + .string() + .optional() + .describe("Patient file number / MRN if known"), + lineItems: z + .array( + z.object({ + description: z.string(), + quantity: z.number(), + unitPrice: z.number(), + }), + ) + .describe("Billed items, e.g. each purchased medication"), + notes: z.string().nullish(), + }), + execute: async ({ name, fileNumber, lineItems, notes }) => { + step(`Drafting invoice for ${fileNumber ?? name}`); + const patient = fileNumber ? await resolvePatient(fileNumber) : null; + const resolvedName = patient?.name ?? (name ? veil.rehydrate(name) : ""); + if (!resolvedName) { + return { ok: false as const, reason: "patient_not_found" as const }; + } + const candidate = { + fileNumber: patient?.fileNumber ?? "", + name: resolvedName, + initials: patient?.initials ?? initialsFromName(resolvedName), + lineItems, + notes: notes ?? null, + source: "ai" as const, + }; + const parsed = invoiceInputSchema.safeParse(candidate); + const issues = parsed.success + ? [] + : parsed.error.issues.map( + (i) => `${i.path.join(".") || "(root)"}: ${i.message}`, + ); + writer.write({ + type: "data-actionPreview", + data: { + token: `invoice-${stepSeq}`, + kind: "invoice" as const, + record: parsed.success ? parsed.data : candidate, + issues, + }, + }); + return { + ok: parsed.success, + issues, + note: "Preview only — awaiting clinician approval before any write.", + }; + }, + }), + // Migration: validate parsed records WITHOUT writing. The model parses an // uploaded export into our patient shape and calls this; the result drives // an approval card. Nothing is inserted until the clinician approves and the diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts index 3a1c8cc..2679f7e 100644 --- a/backend/src/services/analytics.ts +++ b/backend/src/services/analytics.ts @@ -3,10 +3,12 @@ import type { PgTable } from "drizzle-orm/pg-core"; import { db } from "../db/index.js"; import { appointments } from "../db/schema/appointments.js"; +import { invoices } from "../db/schema/invoices.js"; import { patients } from "../db/schema/patients.js"; import { prescriptions } from "../db/schema/prescriptions.js"; import { tasks } from "../db/schema/tasks.js"; import type { Analytics } from "../types/analytics.js"; +import { invoiceTotal } from "./invoices.js"; const pad = (n: number) => String(n).padStart(2, "0"); const keyOf = (d: Date) => @@ -178,6 +180,41 @@ export async function getAnalytics(orgId: string): Promise { count: dayCounts.get(d.key) ?? 0, })); + // Earnings from invoices (real money). `void` invoices are excluded entirely. + const invoiceRows = await db + .select({ + lineItems: invoices.lineItems, + status: invoices.status, + issuedAt: invoices.issuedAt, + }) + .from(invoices) + .where(eq(invoices.organizationId, orgId)); + let totalBilled = 0; + let totalPaid = 0; + let totalOutstanding = 0; + const billedByMonth = new Map(months.map((m) => [m.key, 0])); + const paidByMonth = new Map(months.map((m) => [m.key, 0])); + for (const inv of invoiceRows) { + if (inv.status === "void") continue; + const amount = invoiceTotal({ lineItems: inv.lineItems }); + totalBilled += amount; + if (inv.status === "paid") totalPaid += amount; + else totalOutstanding += amount; // draft + sent + // issuedAt is a YYYY-MM-DD string; its YYYY-MM prefix is the month key. + const monthKey = inv.issuedAt.slice(0, 7); + if (billedByMonth.has(monthKey)) { + billedByMonth.set(monthKey, billedByMonth.get(monthKey)! + amount); + if (inv.status === "paid") { + paidByMonth.set(monthKey, paidByMonth.get(monthKey)! + amount); + } + } + } + const earningsByMonth = months.map((m) => ({ + label: m.label, + billed: billedByMonth.get(m.key) ?? 0, + paid: paidByMonth.get(m.key) ?? 0, + })); + return { patients: { total: patientsTotal, @@ -192,6 +229,26 @@ export async function getAnalytics(orgId: string): Promise { }, prescriptions: { total: rxTotal, active: rxActive }, tasks: { open: tasksOpen, done: tasksDone }, + earnings: { + totalBilled, + totalPaid, + totalOutstanding, + byMonth: earningsByMonth, + }, trends: { patientsByMonth, appointmentsByWeekday }, }; } + +// "In the building now" — today's appointments that are checked in. Cheap query +// for the Analysis Live card to poll. +export async function getLiveMetric(orgId: string): Promise { + const todayKey = keyOf(new Date()); + return countWhere( + appointments, + and( + eq(appointments.organizationId, orgId), + eq(appointments.date, todayKey), + eq(appointments.status, "checked-in"), + )!, + ); +} diff --git a/backend/src/services/invoices.ts b/backend/src/services/invoices.ts index 2fc668a..bf5f59b 100644 --- a/backend/src/services/invoices.ts +++ b/backend/src/services/invoices.ts @@ -4,6 +4,7 @@ import { db } from "../db/index.js"; import { invoices } from "../db/schema/invoices.js"; import type { InvoiceInput } from "../lib/invoice-validation.js"; import type { Invoice, InvoiceInstallment } from "../types/invoice.js"; +import * as patients from "./patients.js"; type InvoiceRow = typeof invoices.$inferSelect; @@ -104,9 +105,17 @@ export async function createInvoice( input: InvoiceInput, ): Promise { const number = input.number || (await generateInvoiceNumber(orgId)); + // Link to a patient — creating one when the invoice has no file number (e.g. + // an AI invoice built from an uploaded purchase list), so the client shows up + // on the Patients page. + const fileNumber = await patients.ensurePatient(orgId, userId, { + fileNumber: input.fileNumber, + name: input.name, + initials: input.initials, + }); const [row] = await db .insert(invoices) - .values(columns(orgId, { ...input, number }, userId)) + .values(columns(orgId, { ...input, number, fileNumber }, userId)) .returning(); return toInvoice(row!); } diff --git a/backend/src/types/analytics.ts b/backend/src/types/analytics.ts index 10358f0..f3cbffd 100644 --- a/backend/src/types/analytics.ts +++ b/backend/src/types/analytics.ts @@ -1,6 +1,9 @@ // A single bar/point in a time-series chart (e.g. one month or one weekday). export type TrendPoint = { label: string; count: number }; +// One month of billing, in currency units (computed from invoices). +export type EarningsPoint = { label: string; billed: number; paid: number }; + // Server-computed clinic analytics returned by GET /api/analytics. All figures // are aggregates over the active clinic's real data (no fabricated financials). export type Analytics = { @@ -23,6 +26,14 @@ export type Analytics = { open: number; done: number; }; + // Real money, computed from invoices: billed = sum of line items; paid = + // invoices marked paid; outstanding = draft + sent. `void` is excluded. + earnings: { + totalBilled: number; + totalPaid: number; + totalOutstanding: number; + byMonth: EarningsPoint[]; + }; // Time-series for charts: new patients over the last 6 months, and // appointments per day across the current week. trends: { diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx index f0325f6..79fb982 100644 --- a/frontend/components/analysis/analysis-view.tsx +++ b/frontend/components/analysis/analysis-view.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { EarningsChart } from "@/components/analysis/earnings-chart"; import { LiveHospitalChart } from "@/components/analysis/live-hospital-chart"; import { Area, AreaChart } from "@/components/charts/area-chart"; import { Bar } from "@/components/charts/bar"; @@ -13,6 +14,7 @@ import { ChartTooltip } from "@/components/charts/tooltip"; import { XAxis } from "@/components/charts/x-axis"; import { Card } from "@/components/ui/card"; import { type Analytics, getAnalytics } from "@/lib/analytics"; +import { formatMoney } from "@/lib/invoices"; // Clinic analytics computed on the server from real data (patients, // appointments, prescriptions, tasks). No fabricated financials — temetro has no @@ -216,6 +218,37 @@ export function AnalysisView() { +
+
+

+ {t("analysis.earnings.title")} +

+

+ {t("analysis.earnings.description")} +

+
+
+ + + +
+ + + {t("analysis.earnings.byMonth")} + + + +
+
({ + name: d.label, + billed: d.billed, + paid: d.paid, + })); + return ( + + + + + + + + ); +} diff --git a/frontend/components/analysis/live-hospital-chart.tsx b/frontend/components/analysis/live-hospital-chart.tsx index a7b3959..0a63b29 100644 --- a/frontend/components/analysis/live-hospital-chart.tsx +++ b/frontend/components/analysis/live-hospital-chart.tsx @@ -12,51 +12,63 @@ import { LiveLine } from "@/components/charts/live-line"; import { LiveYAxis } from "@/components/charts/live-y-axis"; import { ChartTooltip } from "@/components/charts/tooltip"; import { Button } from "@/components/ui/button"; +import { getLiveMetric } from "@/lib/analytics"; -// temetro has no real-time telemetry feed yet, so the "Live" panel simulates a -// hospital signal (patients currently in the building) as a bounded random walk -// that updates once a second. Swap `tick()` for a WebSocket/poll when a real -// feed exists — the chart contract (append { time, value }) stays the same. -// -// The simulation (a 1s interval + an rAF animation loop) only runs while the -// clinician has explicitly toggled it on, so the Analysis page stays idle by -// default instead of animating in the background. -const BASELINE = 48; -const MIN = 24; -const MAX = 90; +// The "Live" panel plots a REAL clinic signal — patients checked in today — by +// polling GET /api/analytics/live. It only runs while the clinician toggles it +// on, so the Analysis page stays idle by default. The metric changes slowly +// (only as people check in), so the line scrolls smoothly using the last value +// and refreshes from the server every few seconds. const WINDOW_SECONDS = 30; +const REFETCH_EVERY_TICKS = 5; // poll the server every 5s (1s render ticks) export function LiveHospitalChart() { const { t } = useTranslation(); const [live, setLive] = useState(false); const [data, setData] = useState([]); - const [value, setValue] = useState(BASELINE); - const valueRef = useRef(BASELINE); + const [value, setValue] = useState(0); + const valueRef = useRef(0); useEffect(() => { if (!live) return; - // Seed a short history so the line is drawn immediately on start. - const now = Date.now() / 1000; - valueRef.current = BASELINE; - setValue(BASELINE); - setData( - Array.from({ length: WINDOW_SECONDS }, (_, i) => ({ - time: now - (WINDOW_SECONDS - i), - value: BASELINE, - })), - ); + let active = true; + const refetch = () => + getLiveMetric() + .then((r) => { + if (!active) return; + valueRef.current = r.value; + setValue(r.value); + }) + .catch(() => { + /* keep the last value on a transient error */ + }); + + // Seed a short flat history from the first reading so the line draws at once. + refetch().finally(() => { + if (!active) return; + const now = Date.now() / 1000; + setData( + Array.from({ length: WINDOW_SECONDS }, (_, i) => ({ + time: now - (WINDOW_SECONDS - i), + value: valueRef.current, + })), + ); + }); + + let tick = 0; const id = setInterval(() => { - const drift = (Math.random() - 0.5) * 5; - const next = Math.max(MIN, Math.min(MAX, valueRef.current + drift)); - valueRef.current = next; - setValue(next); + tick += 1; + if (tick % REFETCH_EVERY_TICKS === 0) void refetch(); setData((prev) => [ ...prev.slice(-500), - { time: Date.now() / 1000, value: next }, + { time: Date.now() / 1000, value: valueRef.current }, ]); }, 1000); - return () => clearInterval(id); + return () => { + active = false; + clearInterval(id); + }; }, [live]); return ( @@ -86,7 +98,7 @@ export function LiveHospitalChart() {
diff --git a/frontend/components/charts/live-line.tsx b/frontend/components/charts/live-line.tsx index 35e3160..e1f71a4 100644 --- a/frontend/components/charts/live-line.tsx +++ b/frontend/components/charts/live-line.tsx @@ -290,30 +290,38 @@ export function LiveLine({ /> - {/* Badge — use popover vars so text is never white-on-white */} - {badge && ( - - - - {formatValue(liveValue)} - - - )} + {/* Badge — use popover vars so text is never white-on-white. Flip it to + the left of the dot when it would overflow the right edge, so the + value never spills outside the card. */} + {badge && + (() => { + const badgeWidth = formatValue(liveValue).length * 7.5 + 16; + const flip = liveDotX + 12 + badgeWidth > innerWidth; + const badgeX = flip ? liveDotX - 12 - badgeWidth : liveDotX + 12; + return ( + + + + {formatValue(liveValue)} + + + ); + })()} ); diff --git a/frontend/components/chat/action-preview-card.tsx b/frontend/components/chat/action-preview-card.tsx index 8a2ae3c..7d8d656 100644 --- a/frontend/components/chat/action-preview-card.tsx +++ b/frontend/components/chat/action-preview-card.tsx @@ -1,6 +1,14 @@ "use client"; -import { AlertTriangle, CalendarPlus, Check, ClipboardList, Pill, X } from "lucide-react"; +import { + AlertTriangle, + CalendarPlus, + Check, + ClipboardList, + Pill, + Receipt, + X, +} from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -8,6 +16,12 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import type { ActionPreviewData } from "@/lib/ai-chat"; import { type AppointmentInput, createAppointment } from "@/lib/appointments"; +import { + createInvoice, + formatMoney, + type InvoiceInput, + type InvoiceLineItem, +} from "@/lib/invoices"; import { type PrescriptionInput, createPrescription } from "@/lib/prescriptions"; import { type TaskInput, createTask } from "@/lib/tasks"; import { notify } from "@/lib/toast"; @@ -18,6 +32,7 @@ export const ACTION_ICONS = { appointment: CalendarPlus, task: ClipboardList, prescription: Pill, + invoice: Receipt, } as const; const ICONS = ACTION_ICONS; @@ -38,6 +53,14 @@ export function summarize(data: ActionPreviewData): string[] { [r.assignee, r.due, r.priority].filter(Boolean).join(" · "), ].filter(Boolean); } + if (data.kind === "invoice") { + const items = (r.lineItems as InvoiceLineItem[] | undefined) ?? []; + const total = items.reduce((s, li) => s + li.quantity * li.unitPrice, 0); + return [ + String(r.name ?? ""), + `${items.length} item${items.length === 1 ? "" : "s"} · ${formatMoney(total)}`, + ].filter(Boolean); + } // prescription return [ [r.medication, r.dose].filter(Boolean).join(" "), @@ -56,6 +79,8 @@ export async function commitAction(data: ActionPreviewData): Promise { }); } else if (data.kind === "task") { await createTask(data.record as TaskInput); + } else if (data.kind === "invoice") { + await createInvoice({ ...(data.record as InvoiceInput), source: "ai" }); } else { await createPrescription({ ...(data.record as PrescriptionInput), diff --git a/frontend/components/chat/analytics-card.tsx b/frontend/components/chat/analytics-card.tsx new file mode 100644 index 0000000..688aefd --- /dev/null +++ b/frontend/components/chat/analytics-card.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { BarChart3 } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { EarningsChart } from "@/components/analysis/earnings-chart"; +import { Card } from "@/components/ui/card"; +import type { Analytics } from "@/lib/analytics"; +import { formatMoney } from "@/lib/invoices"; + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +// The agent's analytics card: clinic KPIs + earnings, with a Bklit earnings +// chart (reused from the Analysis page). +export function AnalyticsCard({ data }: { data: Analytics }) { + const { t } = useTranslation(); + return ( + +
+ + + {t("chat.analyticsCard.title")} + +
+
+ + + + + + + +
+
+ + {t("chat.analyticsCard.byMonth")} + + +
+
+ ); +} diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index 2b1631f..6e0393e 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -43,8 +43,11 @@ import { ToolOutput, } from "@/components/ai-elements/tool"; import { ActionPreviewCard } from "@/components/chat/action-preview-card"; +import { AnalyticsCard } from "@/components/chat/analytics-card"; import { BatchActionPreviewCard } from "@/components/chat/batch-action-preview-card"; import { ChatInput } from "@/components/chat/chat-input"; +import { ClinicCard } from "@/components/chat/clinic-card"; +import { InventoryListCard } from "@/components/chat/inventory-list-card"; import { ImportPreviewCard } from "@/components/chat/import-preview-card"; import { LabChartCard } from "@/components/chat/lab-chart-card"; import { PatientResult } from "@/components/chat/patient-cards"; @@ -67,6 +70,11 @@ import { getModel, } from "@/lib/ai-models"; import type { ActionPreviewData, TemetroUIMessage } from "@/lib/ai-chat"; +import { + getThread, + notifyThreadsChanged, + saveThread, +} from "@/lib/ai-chat-history"; import { getAiConfig } from "@/lib/ai-settings"; import { API_BASE_URL } from "@/lib/api-client"; import { getPatient } from "@/lib/patients"; @@ -90,6 +98,15 @@ export function ChatPanel() { // (or waiting on the Veil gate) wait here and auto-send when it goes idle. const [queued, setQueued] = useState([]); + // Persisted conversation: a client-owned thread id (a fresh one per new chat), + // saved to the server after each exchange so history survives reloads. + const [threadId, setThreadId] = useState(() => nanoid()); + const threadIdRef = useRef(threadId); + threadIdRef.current = threadId; + // Skip the auto-save that would otherwise fire right after loading a thread + // (which would needlessly bump it to the top of the history). + const justLoadedRef = useRef(false); + const transport = useMemo( () => new DefaultChatTransport({ @@ -135,7 +152,10 @@ export function ChatPanel() { // Run the LLM agent for a message (after any Veil gate) on a given model. const runAgentWith = useCallback( (text: string, modelId: string) => { - sendMessage({ text }, { body: { model: modelId, effort } }); + sendMessage( + { text }, + { body: { model: modelId, effort, threadId: threadIdRef.current } }, + ); }, [sendMessage, effort], ); @@ -239,6 +259,67 @@ export function ChatPanel() { } }, [requestedPatient, send]); + // Open a saved thread from `/?thread=` (sidebar history); a bare `/` starts + // a fresh chat. Driven by the URL so the sidebar links and "New chat" work. + const requestedThread = searchParams.get("thread"); + useEffect(() => { + if (requestedThread) { + if (requestedThread === threadIdRef.current) return; // already open + let active = true; + getThread(requestedThread) + .then((thread) => { + if (!active) return; + justLoadedRef.current = true; + setThreadId(thread.id); + setMessages( + thread.messages.map( + (m) => + ({ + id: nanoid(), + role: m.role, + parts: m.parts, + }) as TemetroUIMessage, + ), + ); + }) + .catch(() => { + /* missing/forbidden thread → leave the current chat as-is */ + }); + return () => { + active = false; + }; + } + // No ?thread → fresh chat (e.g. after "New chat"). + setThreadId(nanoid()); + setMessages([]); + }, [requestedThread, setMessages]); + + // Auto-save the conversation a moment after it settles (covers both LLM and + // the `/patient` fast path). Skips the redundant save right after a load. + useEffect(() => { + if (messages.length === 0) return; + if (status === "submitted" || status === "streaming") return; + if (justLoadedRef.current) { + justLoadedRef.current = false; + return; + } + const id = setTimeout(() => { + const firstUser = messages.find((m) => m.role === "user"); + const textPart = firstUser?.parts.find((p) => p.type === "text") as + | { text?: string } + | undefined; + const title = + (textPart?.text ?? "").trim().slice(0, 60) || + t("chat.history.untitled"); + saveThread(threadIdRef.current, messages, title) + .then(notifyThreadsChanged) + .catch(() => { + /* a failed save shouldn't disrupt the chat */ + }); + }, 800); + return () => clearTimeout(id); + }, [messages, status, t]); + const promptInput = ( ); } + if (part.type === "data-inventoryList") { + return ; + } + if (part.type === "data-clinicCard") { + return ; + } + if (part.type === "data-analyticsCard") { + return ; + } if (part.type === "data-veilNotice") { return ( diff --git a/frontend/components/chat/clinic-card.tsx b/frontend/components/chat/clinic-card.tsx new file mode 100644 index 0000000..4080c13 --- /dev/null +++ b/frontend/components/chat/clinic-card.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Building2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Card } from "@/components/ui/card"; +import type { ClinicCardData } from "@/lib/ai-chat"; + +// Small card the agent shows for getClinicInfo. +export function ClinicCard({ data }: { data: ClinicCardData }) { + const { t } = useTranslation(); + const since = data.createdAt + ? new Date(data.createdAt).toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }) + : null; + return ( + +
+ + + {t("chat.clinicCard.title")} + +
+ + {data.name} + + {since ? ( + + {t("chat.clinicCard.since", { date: since })} + + ) : null} +
+ ); +} diff --git a/frontend/components/chat/inventory-list-card.tsx b/frontend/components/chat/inventory-list-card.tsx new file mode 100644 index 0000000..db9fa86 --- /dev/null +++ b/frontend/components/chat/inventory-list-card.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Boxes } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import type { InventoryItem } from "@/lib/inventory"; + +// Read-only inventory card the agent shows for listInventory; low-stock items +// (at or below their reorder threshold) get a destructive badge. +export function InventoryListCard({ items }: { items: InventoryItem[] }) { + const { t } = useTranslation(); + return ( + +
+ + {t("chat.lists.inventory")} + + {items.length} + +
+ {items.length === 0 ? ( +

+ {t("chat.lists.noInventory")} +

+ ) : ( +
+ {items.map((it) => { + const low = it.stockQuantity <= it.reorderThreshold; + return ( +
+
+ + {it.name} + + + {[it.strength, it.form].filter(Boolean).join(" · ")} + +
+ + {it.stockQuantity} {it.unit} + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/components/sidebar-02/app-sidebar.tsx b/frontend/components/sidebar-02/app-sidebar.tsx index 3d16648..777db16 100644 --- a/frontend/components/sidebar-02/app-sidebar.tsx +++ b/frontend/components/sidebar-02/app-sidebar.tsx @@ -20,6 +20,7 @@ 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"; @@ -91,6 +92,7 @@ export function DashboardSidebar() { + diff --git a/frontend/components/sidebar-02/nav-chat-history.tsx b/frontend/components/sidebar-02/nav-chat-history.tsx new file mode 100644 index 0000000..7f3f375 --- /dev/null +++ b/frontend/components/sidebar-02/nav-chat-history.tsx @@ -0,0 +1,82 @@ +"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/ai-chat-history.ts b/frontend/lib/ai-chat-history.ts new file mode 100644 index 0000000..7768c27 --- /dev/null +++ b/frontend/lib/ai-chat-history.ts @@ -0,0 +1,44 @@ +import type { TemetroUIMessage } from "@/lib/ai-chat"; +import { apiFetch } from "@/lib/api-client"; + +// Persisted AI-chat threads (Claude-style history). Threads are per-user within +// the active clinic; the thread id is generated on the client. +export type ThreadSummary = { id: string; title: string; updatedAt: string }; + +type StoredMessage = { role: string; parts: unknown }; + +export function listThreads(): Promise { + return apiFetch("/api/chat/threads"); +} + +export function getThread( + id: string, +): Promise<{ id: string; title: string; messages: StoredMessage[] }> { + return apiFetch(`/api/chat/threads/${id}`); +} + +export function saveThread( + id: string, + messages: TemetroUIMessage[], + title: string, +): Promise<{ ok: boolean }> { + return apiFetch(`/api/chat/threads/${id}`, { + method: "PUT", + body: JSON.stringify({ + messages: messages.map((m) => ({ role: m.role, parts: m.parts })), + title, + }), + }); +} + +export function deleteThread(id: string): Promise { + return apiFetch(`/api/chat/threads/${id}`, { method: "DELETE" }); +} + +// Fired after a thread is saved/deleted so the sidebar history can refresh. +export const THREADS_CHANGED_EVENT = "temetro:threads-changed"; +export function notifyThreadsChanged() { + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(THREADS_CHANGED_EVENT)); + } +} diff --git a/frontend/lib/ai-chat.ts b/frontend/lib/ai-chat.ts index bd15426..fe4df79 100644 --- a/frontend/lib/ai-chat.ts +++ b/frontend/lib/ai-chat.ts @@ -1,6 +1,8 @@ import type { UIMessage } from "ai"; +import type { Analytics } from "@/lib/analytics"; import type { Appointment } from "@/lib/appointments"; +import type { InventoryItem } from "@/lib/inventory"; import type { Lab, Patient, Trend } from "@/lib/patients"; import type { Prescription } from "@/lib/prescriptions"; import type { Task } from "@/lib/tasks"; @@ -39,10 +41,22 @@ export type StepData = { export type AppointmentListData = { appointments: Appointment[] }; export type TaskListData = { tasks: Task[] }; export type PrescriptionListData = { prescriptions: Prescription[] }; +export type InventoryListData = { items: InventoryItem[] }; + +// Clinic-wide read cards. +export type ClinicCardData = { + name: string; + slug: string | null; + createdAt: string | null; +}; // An add proposed by the agent, awaiting one-click clinician approval. `record` // is the validated, ready-to-commit input for the matching create endpoint. -export type ActionPreviewKind = "appointment" | "task" | "prescription"; +export type ActionPreviewKind = + | "appointment" + | "task" + | "prescription" + | "invoice"; export type ActionPreviewData = { token: string; kind: ActionPreviewKind; @@ -61,6 +75,9 @@ export type TemetroDataParts = { appointmentList: AppointmentListData; taskList: TaskListData; prescriptionList: PrescriptionListData; + inventoryList: InventoryListData; + clinicCard: ClinicCardData; + analyticsCard: Analytics; actionPreview: ActionPreviewData; }; diff --git a/frontend/lib/analytics.ts b/frontend/lib/analytics.ts index acc8f95..79c5c9d 100644 --- a/frontend/lib/analytics.ts +++ b/frontend/lib/analytics.ts @@ -3,6 +3,7 @@ import { apiFetch } from "@/lib/api-client"; // Server-computed clinic analytics. Mirrors the backend `src/types/analytics.ts`. // All figures are aggregates over the active clinic's real data. export type TrendPoint = { label: string; count: number }; +export type EarningsPoint = { label: string; billed: number; paid: number }; export type Analytics = { patients: { @@ -24,6 +25,12 @@ export type Analytics = { open: number; done: number; }; + earnings: { + totalBilled: number; + totalPaid: number; + totalOutstanding: number; + byMonth: EarningsPoint[]; + }; trends: { patientsByMonth: TrendPoint[]; appointmentsByWeekday: TrendPoint[]; @@ -33,3 +40,9 @@ export type Analytics = { export function getAnalytics(): Promise { return apiFetch("/api/analytics"); } + +// Current "in the building now" count — checked-in appointments today. Polled by +// the Analysis Live card. +export function getLiveMetric(): Promise<{ value: number }> { + return apiFetch<{ value: number }>("/api/analytics/live"); +} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 24f4326..b80b6de 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -646,6 +646,14 @@ "newThisMonth": "New this month", "active": "Active patients" }, + "earnings": { + "title": "Earnings", + "description": "Billed, paid and outstanding — from invoices", + "billed": "Total billed", + "paid": "Total paid", + "outstanding": "Outstanding", + "byMonth": "Billed vs paid, by month" + }, "appointments": { "title": "Appointments & schedule", "description": "Bookings, attendance and what's coming up", @@ -773,6 +781,12 @@ "thinking": "Thinking…", "steps": "Steps", "reasoning": "Reasoning", + "history": { + "title": "Chats", + "untitled": "New chat", + "empty": "No saved chats yet.", + "delete": "Delete chat" + }, "suggestions": { "schedule": "Show today's schedule", "tasks": "List open tasks", @@ -795,12 +809,14 @@ "title": { "appointment": "Proposed appointment", "task": "Proposed task", - "prescription": "Proposed prescription" + "prescription": "Proposed prescription", + "invoice": "Proposed invoice" }, "kind": { "appointment": "Appointment added.", "task": "Task added.", - "prescription": "Prescription added." + "prescription": "Prescription added.", + "invoice": "Invoice added." }, "approve": "Add", "adding": "Adding…", @@ -832,7 +848,24 @@ "done": "Done", "noAppointments": "No appointments.", "noTasks": "No tasks.", - "noPrescriptions": "No prescriptions." + "noPrescriptions": "No prescriptions.", + "inventory": "Inventory", + "noInventory": "No inventory items." + }, + "clinicCard": { + "title": "Clinic", + "since": "Since {{date}}" + }, + "analyticsCard": { + "title": "Clinic analytics", + "patients": "Patients", + "appointmentsThisWeek": "Appts this week", + "activePrescriptions": "Active Rx", + "openTasks": "Open tasks", + "billed": "Billed", + "paid": "Paid", + "outstanding": "Outstanding", + "byMonth": "Billed vs paid, by month" }, "labCard": { "flags": {