diff --git a/backend/drizzle/0006_square_wither.sql b/backend/drizzle/0006_square_wither.sql new file mode 100644 index 0000000..3ebc433 --- /dev/null +++ b/backend/drizzle/0006_square_wither.sql @@ -0,0 +1,52 @@ +CREATE TABLE "conversation_participants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "user_id" text NOT NULL, + "last_read_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "conversations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" text NOT NULL, + "name" text, + "is_group" boolean DEFAULT false NOT NULL, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "conversation_id" uuid NOT NULL, + "sender_id" text NOT NULL, + "body" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "type" text NOT NULL, + "text" text NOT NULL, + "read" boolean DEFAULT false NOT NULL, + "entity_type" text, + "entity_id" text, + "actor_name" text, + "actor_initials" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversations" ADD CONSTRAINT "conversations_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "conversations" ADD CONSTRAINT "conversations_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_user_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "conv_participant_uidx" ON "conversation_participants" USING btree ("conversation_id","user_id");--> statement-breakpoint +CREATE INDEX "conv_participant_user_idx" ON "conversation_participants" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "conversations_org_idx" ON "conversations" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "messages_conv_idx" ON "messages" USING btree ("conversation_id","created_at");--> statement-breakpoint +CREATE INDEX "notifications_org_user_read_idx" ON "notifications" USING btree ("organization_id","user_id","read"); \ No newline at end of file diff --git a/backend/drizzle/meta/0006_snapshot.json b/backend/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..226d8ff --- /dev/null +++ b/backend/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2435 @@ +{ + "id": "fb89e8c6-f2b3-4e5d-b33d-cea858047715", + "prevId": "1b314b44-b15d-4260-8e23-ae049f317ec9", + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_allergies": { + "name": "patient_allergies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "substance": { + "name": "substance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "allergies_patient_idx": { + "name": "allergies_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_allergies_patient_id_patients_id_fk": { + "name": "patient_allergies_patient_id_patients_id_fk", + "tableFrom": "patient_allergies", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_encounters": { + "name": "patient_encounters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "encounters_patient_idx": { + "name": "encounters_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_encounters_patient_id_patients_id_fk": { + "name": "patient_encounters_patient_id_patients_id_fk", + "tableFrom": "patient_encounters", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_labs": { + "name": "patient_labs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flag": { + "name": "flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "labs_patient_idx": { + "name": "labs_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_labs_patient_id_patients_id_fk": { + "name": "patient_labs_patient_id_patients_id_fk", + "tableFrom": "patient_labs", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_medications": { + "name": "patient_medications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "medications_patient_idx": { + "name": "medications_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_medications_patient_id_patients_id_fk": { + "name": "patient_medications_patient_id_patients_id_fk", + "tableFrom": "patient_medications", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patients": { + "name": "patients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_number": { + "name": "file_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sex": { + "name": "sex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pcp": { + "name": "pcp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initials": { + "name": "initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alerts": { + "name": "alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vitals_bp": { + "name": "vitals_bp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_hr": { + "name": "vitals_hr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_temp": { + "name": "vitals_temp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_spo2": { + "name": "vitals_spo2", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_taken_at": { + "name": "vitals_taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_trend": { + "name": "vitals_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lab_trend": { + "name": "lab_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "patients_org_file_uidx": { + "name": "patients_org_file_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "patients_org_idx": { + "name": "patients_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patients_organization_id_organization_id_fk": { + "name": "patients_organization_id_organization_id_fk", + "tableFrom": "patients", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "patients_created_by_user_id_fk": { + "name": "patients_created_by_user_id_fk", + "tableFrom": "patients", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_problems": { + "name": "patient_problems", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "since": { + "name": "since", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problems_patient_idx": { + "name": "problems_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_problems_patient_id_patients_id_fk": { + "name": "patient_problems_patient_id_patients_id_fk", + "tableFrom": "patient_problems", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notes_org_author_idx": { + "name": "notes_org_author_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_organization_id_organization_id_fk": { + "name": "notes_organization_id_organization_id_fk", + "tableFrom": "notes", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_author_id_user_id_fk": { + "name": "notes_author_id_user_id_fk", + "tableFrom": "notes", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "appointments_org_date_idx": { + "name": "appointments_org_date_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appointments_organization_id_organization_id_fk": { + "name": "appointments_organization_id_organization_id_fk", + "tableFrom": "appointments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "appointments_created_by_user_id_fk": { + "name": "appointments_created_by_user_id_fk", + "tableFrom": "appointments", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.prescriptions": { + "name": "prescriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medication": { + "name": "medication", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescriber": { + "name": "prescriber", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescribed_at": { + "name": "prescribed_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "prescriptions_org_idx": { + "name": "prescriptions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prescriptions_organization_id_organization_id_fk": { + "name": "prescriptions_organization_id_organization_id_fk", + "tableFrom": "prescriptions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prescriptions_created_by_user_id_fk": { + "name": "prescriptions_created_by_user_id_fk", + "tableFrom": "prescriptions", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unassigned'" + }, + "due": { + "name": "due", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'No due date'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient": { + "name": "patient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_org_idx": { + "name": "tasks_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_organization_id_organization_id_fk": { + "name": "tasks_organization_id_organization_id_fk", + "tableFrom": "tasks", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_created_by_user_id_fk": { + "name": "tasks_created_by_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_org_created_idx": { + "name": "activity_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_organization_id_organization_id_fk": { + "name": "activity_log_organization_id_organization_id_fk", + "tableFrom": "activity_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_id_user_id_fk": { + "name": "activity_log_actor_id_user_id_fk", + "tableFrom": "activity_log", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_participants": { + "name": "conversation_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conv_participant_uidx": { + "name": "conv_participant_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conv_participant_user_idx": { + "name": "conv_participant_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_participants_conversation_id_conversations_id_fk": { + "name": "conversation_participants_conversation_id_conversations_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_participants_user_id_user_id_fk": { + "name": "conversation_participants_user_id_user_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_group": { + "name": "is_group", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_org_idx": { + "name": "conversations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_organization_id_organization_id_fk": { + "name": "conversations_organization_id_organization_id_fk", + "tableFrom": "conversations", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_created_by_user_id_fk": { + "name": "conversations_created_by_user_id_fk", + "tableFrom": "conversations", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conv_idx": { + "name": "messages_conv_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_user_id_fk": { + "name": "messages_sender_id_user_id_fk", + "tableFrom": "messages", + "tableTo": "user", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_initials": { + "name": "actor_initials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_org_user_read_idx": { + "name": "notifications_org_user_read_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_organization_id_organization_id_fk": { + "name": "notifications_organization_id_organization_id_fk", + "tableFrom": "notifications", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 384acb2..d7a0557 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1780850717058, "tag": "0005_true_gwen_stacy", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1780856647280, + "tag": "0006_square_wither", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index c256d80..3d8702d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,7 @@ "nanoid": "^5.1.11", "nodemailer": "^8.0.10", "pg": "^8.21.0", + "socket.io": "^4.8.3", "zod": "^4.4.3" }, "devDependencies": { @@ -2105,6 +2106,12 @@ } } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2136,7 +2143,6 @@ "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2178,7 +2184,6 @@ "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -2241,6 +2246,15 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2275,6 +2289,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.33", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", @@ -3093,6 +3116,79 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.20.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4546,6 +4642,90 @@ "dev": true, "license": "MIT" }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.20.1" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5209,7 +5389,6 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -5274,6 +5453,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index b8abd1b..a903200 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "nanoid": "^5.1.11", "nodemailer": "^8.0.10", "pg": "^8.21.0", + "socket.io": "^4.8.3", "zod": "^4.4.3" }, "devDependencies": { diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 940c7ff..e02001e 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -5,3 +5,5 @@ export * from "./appointments.js"; export * from "./prescriptions.js"; export * from "./tasks.js"; export * from "./activity.js"; +export * from "./messaging.js"; +export * from "./notifications.js"; diff --git a/backend/src/db/schema/messaging.ts b/backend/src/db/schema/messaging.ts new file mode 100644 index 0000000..6ede51e --- /dev/null +++ b/backend/src/db/schema/messaging.ts @@ -0,0 +1,71 @@ +import { + boolean, + index, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +import { organization, user } from "./auth.js"; + +// A conversation between clinic staff, scoped to a clinic (organization). `name` +// is set for named/group conversations; a 1:1 DM leaves it null and the display +// name is derived from the other participant. +export const conversations = pgTable( + "conversations", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + name: text("name"), + isGroup: boolean("is_group").notNull().default(false), + createdBy: text("created_by").references(() => user.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + // Bumped on each new message so the inbox can sort by recency. + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [index("conversations_org_idx").on(t.organizationId)], +); + +export const conversationParticipants = pgTable( + "conversation_participants", + { + id: uuid("id").primaryKey().defaultRandom(), + conversationId: uuid("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + // Last time this participant read the conversation; drives unread state. + lastReadAt: timestamp("last_read_at"), + }, + (t) => [ + uniqueIndex("conv_participant_uidx").on(t.conversationId, t.userId), + index("conv_participant_user_idx").on(t.userId), + ], +); + +export const messages = pgTable( + "messages", + { + id: uuid("id").primaryKey().defaultRandom(), + conversationId: uuid("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + senderId: text("sender_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + body: text("body").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [index("messages_conv_idx").on(t.conversationId, t.createdAt)], +); diff --git a/backend/src/db/schema/notifications.ts b/backend/src/db/schema/notifications.ts new file mode 100644 index 0000000..0c1c0d4 --- /dev/null +++ b/backend/src/db/schema/notifications.ts @@ -0,0 +1,34 @@ +import { + boolean, + index, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { organization, user } from "./auth.js"; + +// One row per notification for a recipient, scoped to a clinic (organization). +// Written best-effort from events (a new message, a patient record change). +export const notifications = pgTable( + "notifications", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + type: text("type").notNull(), + text: text("text").notNull(), + read: boolean("read").notNull().default(false), + entityType: text("entity_type"), + entityId: text("entity_id"), + actorName: text("actor_name"), + actorInitials: text("actor_initials"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [index("notifications_org_user_read_idx").on(t.organizationId, t.userId, t.read)], +); diff --git a/backend/src/index.ts b/backend/src/index.ts index e75e123..7eef6f1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,3 +1,5 @@ +import { createServer } from "node:http"; + import { toNodeHandler } from "better-auth/node"; import cors from "cors"; import express from "express"; @@ -5,10 +7,13 @@ import express from "express"; import { auth } from "./auth.js"; import { env } from "./env.js"; import { errorHandler, notFound } from "./middleware/error.js"; +import { initRealtime } from "./realtime.js"; import { activityRouter } from "./routes/activity.js"; import { analyticsRouter } from "./routes/analytics.js"; import { appointmentsRouter } from "./routes/appointments.js"; +import { conversationsRouter } from "./routes/conversations.js"; import { notesRouter } from "./routes/notes.js"; +import { notificationsRouter } from "./routes/notifications.js"; import { patientsRouter } from "./routes/patients.js"; import { prescriptionsRouter } from "./routes/prescriptions.js"; import { tasksRouter } from "./routes/tasks.js"; @@ -55,11 +60,17 @@ app.use("/api/prescriptions", prescriptionsRouter); app.use("/api/tasks", tasksRouter); app.use("/api/activity", activityRouter); app.use("/api/analytics", analyticsRouter); +app.use("/api/conversations", conversationsRouter); +app.use("/api/notifications", notificationsRouter); app.use(notFound); app.use(errorHandler); -app.listen(env.PORT, () => { +// Wrap the Express app in an HTTP server so Socket.io can share the port. +const server = createServer(app); +initRealtime(server); + +server.listen(env.PORT, () => { console.log(`temetro backend listening on ${env.BETTER_AUTH_URL}`); console.log(` • auth: /api/auth/* (frontend origin: ${env.FRONTEND_URL})`); console.log(` • patients: /api/patients`); @@ -69,4 +80,6 @@ app.listen(env.PORT, () => { console.log(` • tasks: /api/tasks`); console.log(` • activity: /api/activity`); console.log(` • stats: /api/analytics`); + console.log(` • messages: /api/conversations (+ Socket.io)`); + console.log(` • notifs: /api/notifications`); }); diff --git a/backend/src/realtime.ts b/backend/src/realtime.ts new file mode 100644 index 0000000..c9055dd --- /dev/null +++ b/backend/src/realtime.ts @@ -0,0 +1,129 @@ +import type { Server as HttpServer } from "node:http"; + +import { fromNodeHeaders } from "better-auth/node"; +import { Server, type Socket } from "socket.io"; + +import { auth } from "./auth.js"; +import { env } from "./env.js"; +import * as messaging from "./services/messaging.js"; +import { createNotification } from "./services/notifications.js"; + +let io: Server | null = null; + +const userRoom = (userId: string) => `user:${userId}`; +const convRoom = (conversationId: string) => `conv:${conversationId}`; + +// Push helpers other modules can call without importing socket.io directly. +export function emitToUser(userId: string, event: string, data: unknown): void { + io?.to(userRoom(userId)).emit(event, data); +} + +export function emitToConversation( + conversationId: string, + event: string, + data: unknown, +): void { + io?.to(convRoom(conversationId)).emit(event, data); +} + +type Ack = (response: { ok: boolean; [key: string]: unknown }) => void; + +export function initRealtime(httpServer: HttpServer): Server { + io = new Server(httpServer, { + cors: { origin: env.FRONTEND_URL, credentials: true }, + }); + + // Authenticate the handshake with the Better Auth session cookie. + io.use(async (socket, next) => { + try { + const session = await auth.api.getSession({ + headers: fromNodeHeaders(socket.request.headers), + }); + if (!session?.session) { + next(new Error("unauthorized")); + return; + } + socket.data.userId = session.user.id; + socket.data.userName = session.user.name; + socket.data.orgId = session.session.activeOrganizationId ?? null; + next(); + } catch { + next(new Error("unauthorized")); + } + }); + + io.on("connection", (socket: Socket) => { + const userId: string = socket.data.userId; + const userName: string = socket.data.userName; + const orgId: string | null = socket.data.orgId; + + // Personal room for notifications. + socket.join(userRoom(userId)); + + socket.on( + "conversation:join", + async (conversationId: string, ack?: Ack) => { + try { + if (await messaging.isParticipant(conversationId, userId)) { + socket.join(convRoom(conversationId)); + ack?.({ ok: true }); + } else { + ack?.({ ok: false }); + } + } catch { + ack?.({ ok: false }); + } + }, + ); + + socket.on( + "message:send", + async ( + payload: { conversationId?: string; body?: string }, + ack?: Ack, + ) => { + try { + const conversationId = String(payload?.conversationId ?? ""); + const body = String(payload?.body ?? "").trim(); + if (!(conversationId && body && orgId)) { + ack?.({ ok: false }); + return; + } + const { message, recipientIds } = await messaging.createMessage( + orgId, + userId, + userName, + conversationId, + body, + ); + emitToConversation(conversationId, "message:new", message); + + // Notify the other participants (best-effort) and push live. + for (const recipientId of recipientIds) { + const notification = await createNotification({ + orgId, + userId: recipientId, + type: "message", + text: `New message from ${userName}`, + entityType: "conversation", + entityId: conversationId, + actorName: userName, + }); + if (notification) emitToUser(recipientId, "notification:new", notification); + } + ack?.({ ok: true, message }); + } catch { + ack?.({ ok: false }); + } + }, + ); + + socket.on("message:read", async (conversationId: string) => { + if (orgId) { + await messaging.markRead(orgId, userId, conversationId).catch(() => {}); + } + }); + }); + + return io; +} diff --git a/backend/src/routes/conversations.ts b/backend/src/routes/conversations.ts new file mode 100644 index 0000000..61a21f9 --- /dev/null +++ b/backend/src/routes/conversations.ts @@ -0,0 +1,118 @@ +import { Router } from "express"; +import { z } from "zod"; + +import { requireAuth, requireOrg } from "../middleware/auth.js"; +import { emitToConversation, emitToUser } from "../realtime.js"; +import * as service from "../services/messaging.js"; +import { createNotification } from "../services/notifications.js"; + +export const conversationsRouter = Router(); + +// Conversations are participant-scoped within the active clinic (no extra RBAC). +conversationsRouter.use(requireAuth, requireOrg); + +const createSchema = z.object({ + participantIds: z.array(z.string().min(1)).min(1), + name: z.string().trim().max(120).nullish(), +}); + +const messageSchema = z.object({ + body: z.string().trim().min(1, "Message can't be empty.").max(5000), +}); + +// GET /api/conversations — the caller's conversations (with last message + unread) +conversationsRouter.get("/", async (req, res, next) => { + try { + res.json( + await service.listConversations(req.organizationId!, req.user!.id), + ); + } catch (err) { + next(err); + } +}); + +// GET /api/conversations/members — clinic members to start a conversation with +conversationsRouter.get("/members", async (req, res, next) => { + try { + res.json( + await service.listClinicMembers(req.organizationId!, req.user!.id), + ); + } catch (err) { + next(err); + } +}); + +// POST /api/conversations — create (or reuse an existing DM) +conversationsRouter.post("/", async (req, res, next) => { + try { + const input = createSchema.parse(req.body); + const conversation = await service.createConversation( + req.organizationId!, + req.user!.id, + input, + ); + res.status(201).json(conversation); + } catch (err) { + next(err); + } +}); + +// GET /api/conversations/:id/messages — message history +conversationsRouter.get("/:id/messages", async (req, res, next) => { + try { + res.json( + await service.getMessages( + req.organizationId!, + req.user!.id, + req.params.id as string, + ), + ); + } catch (err) { + next(err); + } +}); + +// POST /api/conversations/:id/messages — send (REST fallback; also broadcasts) +conversationsRouter.post("/:id/messages", async (req, res, next) => { + try { + const { body } = messageSchema.parse(req.body); + const conversationId = req.params.id as string; + const { message, recipientIds } = await service.createMessage( + req.organizationId!, + req.user!.id, + req.user!.name, + conversationId, + body, + ); + emitToConversation(conversationId, "message:new", message); + for (const recipientId of recipientIds) { + const notification = await createNotification({ + orgId: req.organizationId!, + userId: recipientId, + type: "message", + text: `New message from ${req.user!.name}`, + entityType: "conversation", + entityId: conversationId, + actorName: req.user!.name, + }); + if (notification) emitToUser(recipientId, "notification:new", notification); + } + res.status(201).json(message); + } catch (err) { + next(err); + } +}); + +// POST /api/conversations/:id/read — clear unread for the caller +conversationsRouter.post("/:id/read", async (req, res, next) => { + try { + await service.markRead( + req.organizationId!, + req.user!.id, + req.params.id as string, + ); + res.status(204).end(); + } catch (err) { + next(err); + } +}); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 0000000..7061f6a --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,46 @@ +import { Router } from "express"; + +import { HttpError } from "../lib/http-error.js"; +import { requireAuth, requireOrg } from "../middleware/auth.js"; +import * as service from "../services/notifications.js"; + +export const notificationsRouter = Router(); + +// Notifications are per-recipient within the active clinic (no extra RBAC). +notificationsRouter.use(requireAuth, requireOrg); + +// GET /api/notifications — recent notifications + unread count for the caller +notificationsRouter.get("/", async (req, res, next) => { + try { + res.json( + await service.listNotifications(req.organizationId!, req.user!.id), + ); + } catch (err) { + next(err); + } +}); + +// PATCH /api/notifications/:id/read — mark one read +notificationsRouter.patch("/:id/read", async (req, res, next) => { + try { + const ok = await service.markRead( + req.organizationId!, + req.user!.id, + req.params.id as string, + ); + if (!ok) throw new HttpError(404, "Notification not found."); + res.status(204).end(); + } catch (err) { + next(err); + } +}); + +// POST /api/notifications/read-all — mark all read +notificationsRouter.post("/read-all", async (req, res, next) => { + try { + await service.markAllRead(req.organizationId!, req.user!.id); + res.status(204).end(); + } catch (err) { + next(err); + } +}); diff --git a/backend/src/services/messaging.ts b/backend/src/services/messaging.ts new file mode 100644 index 0000000..b29028d --- /dev/null +++ b/backend/src/services/messaging.ts @@ -0,0 +1,413 @@ +import { and, asc, desc, eq, inArray } from "drizzle-orm"; + +import { db } from "../db/index.js"; +import { member, user } from "../db/schema/auth.js"; +import { + conversationParticipants, + conversations, + messages, +} from "../db/schema/messaging.js"; +import { HttpError } from "../lib/http-error.js"; +import type { + ConversationMessage, + ConversationSummary, + Participant, +} from "../types/messaging.js"; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// --- helpers --------------------------------------------------------------- + +async function conversationInOrg( + orgId: string, + conversationId: string, +): Promise { + if (!UUID_RE.test(conversationId)) return false; + const [row] = await db + .select({ id: conversations.id }) + .from(conversations) + .where( + and( + eq(conversations.id, conversationId), + eq(conversations.organizationId, orgId), + ), + ); + return !!row; +} + +export async function isParticipant( + conversationId: string, + userId: string, +): Promise { + if (!UUID_RE.test(conversationId)) return false; + const [row] = await db + .select({ id: conversationParticipants.id }) + .from(conversationParticipants) + .where( + and( + eq(conversationParticipants.conversationId, conversationId), + eq(conversationParticipants.userId, userId), + ), + ); + return !!row; +} + +async function participantIds(conversationId: string): Promise { + const rows = await db + .select({ userId: conversationParticipants.userId }) + .from(conversationParticipants) + .where(eq(conversationParticipants.conversationId, conversationId)); + return rows.map((r) => r.userId); +} + +type BaseRow = { + convId: string; + name: string | null; + isGroup: boolean; + updatedAt: Date; + lastReadAt: Date | null; +}; + +// Turns the caller's conversation rows into full summaries (participants, last +// message, unread, display name) in a few batched queries. +async function buildSummaries( + userId: string, + base: BaseRow[], +): Promise { + const convIds = base.map((b) => b.convId); + if (convIds.length === 0) return []; + + const partRows = await db + .select({ + convId: conversationParticipants.conversationId, + userId: user.id, + name: user.name, + }) + .from(conversationParticipants) + .innerJoin(user, eq(user.id, conversationParticipants.userId)) + .where(inArray(conversationParticipants.conversationId, convIds)); + + const partsByConv = new Map(); + for (const p of partRows) { + const list = partsByConv.get(p.convId) ?? []; + list.push({ id: p.userId, name: p.name }); + partsByConv.set(p.convId, list); + } + + const lastByConv = new Map(); + await Promise.all( + convIds.map(async (convId) => { + const [row] = await db + .select({ + id: messages.id, + conversationId: messages.conversationId, + senderId: messages.senderId, + senderName: user.name, + body: messages.body, + createdAt: messages.createdAt, + }) + .from(messages) + .innerJoin(user, eq(user.id, messages.senderId)) + .where(eq(messages.conversationId, convId)) + .orderBy(desc(messages.createdAt)) + .limit(1); + lastByConv.set( + convId, + row ? { ...row, createdAt: row.createdAt.toISOString() } : null, + ); + }), + ); + + return base.map((b) => { + const participants = partsByConv.get(b.convId) ?? []; + const others = participants.filter((p) => p.id !== userId); + const displayName = + b.name?.trim() || + (b.isGroup + ? others.map((p) => p.name).join(", ") || "Group" + : (others[0]?.name ?? "Conversation")); + const lastMessage = lastByConv.get(b.convId) ?? null; + const unread = + !!lastMessage && + lastMessage.senderId !== userId && + (!b.lastReadAt || new Date(lastMessage.createdAt) > b.lastReadAt); + return { + id: b.convId, + name: displayName, + isGroup: b.isGroup, + participants, + lastMessage, + unread, + updatedAt: b.updatedAt.toISOString(), + }; + }); +} + +// --- queries --------------------------------------------------------------- + +export async function listConversations( + orgId: string, + userId: string, +): Promise { + const base = await db + .select({ + convId: conversations.id, + name: conversations.name, + isGroup: conversations.isGroup, + updatedAt: conversations.updatedAt, + lastReadAt: conversationParticipants.lastReadAt, + }) + .from(conversationParticipants) + .innerJoin( + conversations, + eq(conversations.id, conversationParticipants.conversationId), + ) + .where( + and( + eq(conversationParticipants.userId, userId), + eq(conversations.organizationId, orgId), + ), + ) + .orderBy(desc(conversations.updatedAt)); + return buildSummaries(userId, base); +} + +async function getSummary( + orgId: string, + userId: string, + conversationId: string, +): Promise { + const base = await db + .select({ + convId: conversations.id, + name: conversations.name, + isGroup: conversations.isGroup, + updatedAt: conversations.updatedAt, + lastReadAt: conversationParticipants.lastReadAt, + }) + .from(conversationParticipants) + .innerJoin( + conversations, + eq(conversations.id, conversationParticipants.conversationId), + ) + .where( + and( + eq(conversationParticipants.userId, userId), + eq(conversationParticipants.conversationId, conversationId), + eq(conversations.organizationId, orgId), + ), + ); + const [summary] = await buildSummaries(userId, base); + return summary ?? null; +} + +export async function getMessages( + orgId: string, + userId: string, + conversationId: string, +): Promise { + if (!(await conversationInOrg(orgId, conversationId))) { + throw new HttpError(404, "Conversation not found."); + } + if (!(await isParticipant(conversationId, userId))) { + throw new HttpError(403, "You are not part of this conversation."); + } + const rows = await db + .select({ + id: messages.id, + conversationId: messages.conversationId, + senderId: messages.senderId, + senderName: user.name, + body: messages.body, + createdAt: messages.createdAt, + }) + .from(messages) + .innerJoin(user, eq(user.id, messages.senderId)) + .where(eq(messages.conversationId, conversationId)) + .orderBy(asc(messages.createdAt)); + return rows.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })); +} + +// Finds an existing 1:1 DM between two users in the clinic, if any. +async function findDirectConversation( + orgId: string, + userId: string, + otherId: string, +): Promise { + const mine = await db + .select({ convId: conversations.id }) + .from(conversationParticipants) + .innerJoin( + conversations, + and( + eq(conversations.id, conversationParticipants.conversationId), + eq(conversations.organizationId, orgId), + eq(conversations.isGroup, false), + ), + ) + .where(eq(conversationParticipants.userId, userId)); + const ids = mine.map((m) => m.convId); + if (ids.length === 0) return null; + const parts = await db + .select({ + convId: conversationParticipants.conversationId, + userId: conversationParticipants.userId, + }) + .from(conversationParticipants) + .where(inArray(conversationParticipants.conversationId, ids)); + const byConv = new Map>(); + for (const p of parts) { + const set = byConv.get(p.convId) ?? new Set(); + set.add(p.userId); + byConv.set(p.convId, set); + } + for (const [convId, set] of byConv) { + if (set.size === 2 && set.has(userId) && set.has(otherId)) return convId; + } + return null; +} + +export async function createConversation( + orgId: string, + userId: string, + input: { participantIds: string[]; name?: string | null }, +): Promise { + // Keep only valid clinic members other than the caller. + const requested = [...new Set(input.participantIds)].filter( + (id) => id !== userId, + ); + const others = + requested.length === 0 + ? [] + : ( + await db + .select({ id: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, orgId), + inArray(member.userId, requested), + ), + ) + ).map((m) => m.id); + + if (others.length === 0) { + throw new HttpError(400, "Pick at least one clinic member to message."); + } + + const isGroup = others.length > 1 || !!input.name?.trim(); + + if (!isGroup) { + const existing = await findDirectConversation(orgId, userId, others[0]!); + if (existing) { + const summary = await getSummary(orgId, userId, existing); + if (summary) return summary; + } + } + + const allIds = [...new Set([userId, ...others])]; + const now = new Date(); + const summary = await db.transaction(async (tx) => { + const [conv] = await tx + .insert(conversations) + .values({ + organizationId: orgId, + name: input.name?.trim() || null, + isGroup, + createdBy: userId, + }) + .returning(); + await tx.insert(conversationParticipants).values( + allIds.map((uid) => ({ + conversationId: conv!.id, + userId: uid, + // The creator has "read" the empty conversation. + lastReadAt: uid === userId ? now : null, + })), + ); + return conv!.id; + }); + + const result = await getSummary(orgId, userId, summary); + if (!result) throw new HttpError(500, "Failed to create conversation."); + return result; +} + +export async function createMessage( + orgId: string, + userId: string, + senderName: string, + conversationId: string, + body: string, +): Promise<{ message: ConversationMessage; recipientIds: string[] }> { + if (!(await conversationInOrg(orgId, conversationId))) { + throw new HttpError(404, "Conversation not found."); + } + if (!(await isParticipant(conversationId, userId))) { + throw new HttpError(403, "You are not part of this conversation."); + } + const now = new Date(); + const [row] = await db + .insert(messages) + .values({ conversationId, senderId: userId, body }) + .returning(); + // Bump conversation recency and mark the sender's own read pointer. + await Promise.all([ + db + .update(conversations) + .set({ updatedAt: now }) + .where(eq(conversations.id, conversationId)), + db + .update(conversationParticipants) + .set({ lastReadAt: now }) + .where( + and( + eq(conversationParticipants.conversationId, conversationId), + eq(conversationParticipants.userId, userId), + ), + ), + ]); + + const ids = await participantIds(conversationId); + return { + message: { + id: row!.id, + conversationId, + senderId: userId, + senderName, + body: row!.body, + createdAt: row!.createdAt.toISOString(), + }, + recipientIds: ids.filter((id) => id !== userId), + }; +} + +export async function markRead( + orgId: string, + userId: string, + conversationId: string, +): Promise { + if (!UUID_RE.test(conversationId)) return; + await db + .update(conversationParticipants) + .set({ lastReadAt: new Date() }) + .where( + and( + eq(conversationParticipants.conversationId, conversationId), + eq(conversationParticipants.userId, userId), + ), + ); +} + +export async function listClinicMembers( + orgId: string, + excludeUserId: string, +): Promise { + const rows = await db + .select({ id: user.id, name: user.name }) + .from(member) + .innerJoin(user, eq(user.id, member.userId)) + .where(eq(member.organizationId, orgId)); + return rows.filter((r) => r.id !== excludeUserId); +} diff --git a/backend/src/services/notifications.ts b/backend/src/services/notifications.ts new file mode 100644 index 0000000..c475add --- /dev/null +++ b/backend/src/services/notifications.ts @@ -0,0 +1,113 @@ +import { and, desc, eq } from "drizzle-orm"; + +import { db } from "../db/index.js"; +import { notifications } from "../db/schema/notifications.js"; +import type { Notification } from "../types/notification.js"; +import { initialsOf } from "./activity.js"; + +type NotificationRow = typeof notifications.$inferSelect; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function toNotification(row: NotificationRow): Notification { + return { + id: row.id, + type: row.type, + text: row.text, + read: row.read, + entityType: row.entityType, + entityId: row.entityId, + actorName: row.actorName, + actorInitials: row.actorInitials, + createdAt: row.createdAt.toISOString(), + }; +} + +// Best-effort: a notification must never fail the originating action. Returns the +// created notification (so the caller can push it over the socket) or null. +export async function createNotification(params: { + orgId: string; + userId: string; + type: string; + text: string; + entityType?: string | null; + entityId?: string | null; + actorName?: string | null; +}): Promise { + try { + const [row] = await db + .insert(notifications) + .values({ + organizationId: params.orgId, + userId: params.userId, + type: params.type, + text: params.text, + entityType: params.entityType ?? null, + entityId: params.entityId ?? null, + actorName: params.actorName ?? null, + actorInitials: params.actorName ? initialsOf(params.actorName) : null, + }) + .returning(); + return row ? toNotification(row) : null; + } catch (err) { + console.error("Failed to create notification:", err); + return null; + } +} + +export async function listNotifications( + orgId: string, + userId: string, + limit = 30, +): Promise<{ notifications: Notification[]; unread: number }> { + const rows = await db + .select() + .from(notifications) + .where( + and( + eq(notifications.organizationId, orgId), + eq(notifications.userId, userId), + ), + ) + .orderBy(desc(notifications.createdAt)) + .limit(limit); + const unread = rows.filter((r) => !r.read).length; + return { notifications: rows.map(toNotification), unread }; +} + +export async function markRead( + orgId: string, + userId: string, + id: string, +): Promise { + if (!UUID_RE.test(id)) return false; + const updated = await db + .update(notifications) + .set({ read: true }) + .where( + and( + eq(notifications.id, id), + eq(notifications.organizationId, orgId), + eq(notifications.userId, userId), + ), + ) + .returning({ id: notifications.id }); + return updated.length > 0; +} + +export async function markAllRead( + orgId: string, + userId: string, +): Promise { + await db + .update(notifications) + .set({ read: true }) + .where( + and( + eq(notifications.organizationId, orgId), + eq(notifications.userId, userId), + eq(notifications.read, false), + ), + ); +} diff --git a/backend/src/types/messaging.ts b/backend/src/types/messaging.ts new file mode 100644 index 0000000..0c1b763 --- /dev/null +++ b/backend/src/types/messaging.ts @@ -0,0 +1,25 @@ +// Canonical messaging shapes returned by the API / emitted over Socket.io. +// Mirrors the frontend `lib/messages.ts`. +export type Participant = { + id: string; + name: string; +}; + +export type ConversationMessage = { + id: string; + conversationId: string; + senderId: string; + senderName: string; + body: string; + createdAt: string; +}; + +export type ConversationSummary = { + id: string; + name: string; // display name: group name, or the other participant for a DM + isGroup: boolean; + participants: Participant[]; + lastMessage: ConversationMessage | null; + unread: boolean; + updatedAt: string; +}; diff --git a/backend/src/types/notification.ts b/backend/src/types/notification.ts new file mode 100644 index 0000000..21875ab --- /dev/null +++ b/backend/src/types/notification.ts @@ -0,0 +1,13 @@ +// The canonical Notification shape returned by the API. Per-recipient, scoped to +// a clinic. Auto-created from events (new message, patient record change). +export type Notification = { + id: string; + type: string; + text: string; + read: boolean; + entityType: string | null; + entityId: string | null; + actorName: string | null; + actorInitials: string | null; + createdAt: string; +}; diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index 6884d10..bdf6994 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -1,10 +1,24 @@ "use client"; -import { Mail, SendHorizonal } from "lucide-react"; -import { type FormEvent, useMemo, useState } from "react"; +import { Mail, Plus, SendHorizonal } from "lucide-react"; +import { + type FormEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; import { Empty, EmptyDescription, @@ -13,132 +27,129 @@ import { EmptyTitle, } from "@/components/ui/empty"; import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/auth-client"; +import { + type ConversationMessage, + type ConversationSummary, + type Participant, + createConversation, + getMessages, + listClinicMembers, + listConversations, +} from "@/lib/messages"; +import { getSocket } from "@/lib/socket"; +import { notify } from "@/lib/toast"; import { cn } from "@/lib/utils"; -// All conversations here are mock/placeholder data — there is no messaging -// backend. They illustrate the inbox + chat-thread timeline layout. +// Up to two-letter initials from a display name. +function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase(); +} -type ChatMessage = { - id: string; - direction: "in" | "out"; - text: string; - time: string; -}; - -type Conversation = { - id: string; - name: string; - initials: string; - role: string; - unread: boolean; - messages: ChatMessage[]; -}; - -const seed: Conversation[] = [ - { - id: "1", - name: "Dr. Stein", - initials: "DS", - role: "Endocrinology", - unread: true, - messages: [ - { - id: "1a", - direction: "in", - text: "The lab results for Amina Yusuf are back — lipid panel and HbA1c are both in range.", - time: "10:24", - }, - { - id: "1b", - direction: "in", - text: "Want me to adjust her plan before the follow-up?", - time: "10:25", - }, - ], - }, - { - id: "2", - name: "Dr. Okafor", - initials: "DO", - role: "Family medicine", - unread: true, - messages: [ - { - id: "2a", - direction: "out", - text: "Sent over Daniel Mensah's intake notes — can you take a look?", - time: "08:50", - }, - { - id: "2b", - direction: "in", - text: "Thanks! Added him to tomorrow at 10:00. Were his prior records imported?", - time: "09:12", - }, - ], - }, - { - id: "3", - name: "Care team", - initials: "CT", - role: "Clinic-wide", - unread: false, - messages: [ - { - id: "3a", - direction: "in", - text: "Seasonal vaccine stock has been replenished — slots are open all week.", - time: "Yesterday", - }, - { - id: "3b", - direction: "out", - text: "Great, I'll direct eligible patients to the front desk.", - time: "Yesterday", - }, - ], - }, - { - id: "4", - name: "Reception", - initials: "RC", - role: "Front desk", - unread: false, - messages: [ - { - id: "4a", - direction: "in", - text: "Two Friday afternoon appointments were moved to next Monday at the patients' request.", - time: "Mon", - }, - ], - }, -]; - -const now = () => - new Date().toLocaleTimeString("en-US", { +// ISO timestamp -> "10:24" (24h). +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); +} export function MessagesView() { - const [conversations, setConversations] = useState(seed); + const { data: session } = authClient.useSession(); + const myId = session?.user?.id ?? ""; + + const [conversations, setConversations] = useState([]); const [selectedId, setSelectedId] = useState(null); + const [messages, setMessages] = useState([]); const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [draft, setDraft] = useState(""); + const [composeOpen, setComposeOpen] = useState(false); + const [members, setMembers] = useState([]); + + // Refs so the socket handler (registered once) reads current values. + const selectedIdRef = useRef(null); + const myIdRef = useRef(""); + myIdRef.current = myId; + + const scrollRef = useRef(null); + + // Initial conversation load. + useEffect(() => { + let active = true; + listConversations() + .then((data) => { + if (active) setConversations(data); + }) + .catch(() => { + /* api-client redirects on 401 */ + }); + return () => { + active = false; + }; + }, []); + + // Realtime: append to the open thread and keep the inbox fresh. + useEffect(() => { + const socket = getSocket(); + const onMessageNew = (msg: ConversationMessage) => { + setConversations((prev) => { + const existing = prev.find((c) => c.id === msg.conversationId); + if (!existing) { + listConversations().then(setConversations).catch(() => {}); + return prev; + } + const isSelected = selectedIdRef.current === msg.conversationId; + const updated: ConversationSummary = { + ...existing, + lastMessage: msg, + updatedAt: msg.createdAt, + unread: msg.senderId !== myIdRef.current && !isSelected, + }; + return [updated, ...prev.filter((c) => c.id !== msg.conversationId)]; + }); + + if (selectedIdRef.current === msg.conversationId) { + setMessages((prev) => + prev.some((m) => m.id === msg.id) ? prev : [...prev, msg], + ); + socket.emit("message:read", msg.conversationId); + } + }; + socket.on("message:new", onMessageNew); + return () => { + socket.off("message:new", onMessageNew); + }; + }, []); + + // Auto-scroll the open thread to the latest message. + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); + }, [messages]); const unreadCount = conversations.filter((c) => c.unread).length; const selected = conversations.find((c) => c.id === selectedId) ?? null; const visible = useMemo( - () => (showUnreadOnly ? conversations.filter((c) => c.unread) : conversations), + () => + showUnreadOnly ? conversations.filter((c) => c.unread) : conversations, [conversations, showUnreadOnly], ); const open = (id: string) => { setSelectedId(id); + selectedIdRef.current = id; setDraft(""); + setMessages([]); + getMessages(id) + .then(setMessages) + .catch(() => {}); + const socket = getSocket(); + socket.emit("conversation:join", id); + socket.emit("message:read", id); setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, unread: false } : c)), ); @@ -148,46 +159,68 @@ export function MessagesView() { event.preventDefault(); const text = draft.trim(); if (!(text && selected)) return; - const message: ChatMessage = { - id: `${selected.id}-${Date.now()}`, - direction: "out", - text, - time: now(), - }; - setConversations((prev) => - prev.map((c) => - c.id === selected.id - ? { ...c, messages: [...c.messages, message] } - : c, - ), - ); + getSocket().emit("message:send", { + conversationId: selected.id, + body: text, + }); setDraft(""); }; + const openCompose = () => { + setComposeOpen(true); + listClinicMembers() + .then(setMembers) + .catch(() => setMembers([])); + }; + + const startConversation = async (memberId: string) => { + try { + const conv = await createConversation({ participantIds: [memberId] }); + setConversations((prev) => + prev.some((c) => c.id === conv.id) ? prev : [conv, ...prev], + ); + setComposeOpen(false); + open(conv.id); + } catch { + notify.error("Couldn't start conversation", "Please try again."); + } + }; + return (
{/* Left: conversation list */}
); } diff --git a/frontend/lib/messages.ts b/frontend/lib/messages.ts new file mode 100644 index 0000000..e9c770a --- /dev/null +++ b/frontend/lib/messages.ts @@ -0,0 +1,69 @@ +import { apiFetch } from "@/lib/api-client"; + +// Messaging shapes. Mirror the backend `src/types/messaging.ts`. +export type Participant = { + id: string; + name: string; +}; + +export type ConversationMessage = { + id: string; + conversationId: string; + senderId: string; + senderName: string; + body: string; + createdAt: string; +}; + +export type ConversationSummary = { + id: string; + name: string; + isGroup: boolean; + participants: Participant[]; + lastMessage: ConversationMessage | null; + unread: boolean; + updatedAt: string; +}; + +export function listConversations(): Promise { + return apiFetch("/api/conversations"); +} + +export function listClinicMembers(): Promise { + return apiFetch("/api/conversations/members"); +} + +export function createConversation(input: { + participantIds: string[]; + name?: string | null; +}): Promise { + return apiFetch("/api/conversations", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function getMessages( + conversationId: string, +): Promise { + return apiFetch( + `/api/conversations/${conversationId}/messages`, + ); +} + +// REST fallback for sending (the realtime path is the socket "message:send"). +export function sendMessageRest( + conversationId: string, + body: string, +): Promise { + return apiFetch( + `/api/conversations/${conversationId}/messages`, + { method: "POST", body: JSON.stringify({ body }) }, + ); +} + +export function markConversationRead(conversationId: string): Promise { + return apiFetch(`/api/conversations/${conversationId}/read`, { + method: "POST", + }); +} diff --git a/frontend/lib/socket.ts b/frontend/lib/socket.ts new file mode 100644 index 0000000..9aea06c --- /dev/null +++ b/frontend/lib/socket.ts @@ -0,0 +1,18 @@ +import { io, type Socket } from "socket.io-client"; + +import { API_BASE_URL } from "@/lib/api-client"; + +// A single shared Socket.io connection to the backend, authenticated by the +// Better Auth session cookie (withCredentials). Used by messaging and +// notifications. Created lazily on first use in the browser. +let socket: Socket | null = null; + +export function getSocket(): Socket { + if (!socket) { + socket = io(API_BASE_URL, { + withCredentials: true, + transports: ["websocket", "polling"], + }); + } + return socket; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b17f45d..76c80a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,6 +44,7 @@ "react-jsx-parser": "^2.4.1", "shadcn": "^4.8.3", "shiki": "^4.1.0", + "socket.io-client": "^4.8.3", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tokenlens": "^1.3.1", @@ -2836,6 +2837,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6971,6 +6978,28 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz", + "integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.20.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.22.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", @@ -13800,6 +13829,34 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -15260,6 +15317,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -15276,6 +15354,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index af9d251..fcbc8b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,6 +45,7 @@ "react-jsx-parser": "^2.4.1", "shadcn": "^4.8.3", "shiki": "^4.1.0", + "socket.io-client": "^4.8.3", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tokenlens": "^1.3.1",