mirror of
https://github.com/temetro/temetro.git
synced 2026-07-27 12:18:56 +00:00
feat: real-time staff messaging over Socket.io
Add conversations/participants/messages tables, a participant-scoped REST API (/api/conversations) and a Socket.io server (session-authenticated handshake; per-user + per-conversation rooms) sharing the HTTP port. New messages broadcast live and create per-recipient notifications. Also lands the notifications table + service + routes (used by the message flow). The Messages page is rewritten: live threads, unread state, and a compose dialog to start a conversation with a clinic member. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+203
-3
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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)],
|
||||
);
|
||||
+14
-1
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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<ConversationSummary[]> {
|
||||
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<string, Participant[]>();
|
||||
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<string, ConversationMessage | null>();
|
||||
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<ConversationSummary[]> {
|
||||
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<ConversationSummary | null> {
|
||||
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<ConversationMessage[]> {
|
||||
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<string | null> {
|
||||
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<string, Set<string>>();
|
||||
for (const p of parts) {
|
||||
const set = byConv.get(p.convId) ?? new Set<string>();
|
||||
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<ConversationSummary> {
|
||||
// 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<void> {
|
||||
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<Participant[]> {
|
||||
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);
|
||||
}
|
||||
@@ -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<Notification | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await db
|
||||
.update(notifications)
|
||||
.set({ read: true })
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.organizationId, orgId),
|
||||
eq(notifications.userId, userId),
|
||||
eq(notifications.read, false),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<Conversation[]>(seed);
|
||||
const { data: session } = authClient.useSession();
|
||||
const myId = session?.user?.id ?? "";
|
||||
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ConversationMessage[]>([]);
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [members, setMembers] = useState<Participant[]>([]);
|
||||
|
||||
// Refs so the socket handler (registered once) reads current values.
|
||||
const selectedIdRef = useRef<string | null>(null);
|
||||
const myIdRef = useRef<string>("");
|
||||
myIdRef.current = myId;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex h-full w-full gap-4 p-4">
|
||||
{/* Left: conversation list */}
|
||||
<aside className="flex w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-card/30">
|
||||
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
|
||||
<h1 className="font-semibold text-base tracking-tight">Inbox</h1>
|
||||
<Button
|
||||
aria-pressed={showUnreadOnly}
|
||||
onClick={() => setShowUnreadOnly((v) => !v)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={showUnreadOnly ? "secondary" : "ghost"}
|
||||
>
|
||||
Unread · {unreadCount}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-pressed={showUnreadOnly}
|
||||
onClick={() => setShowUnreadOnly((v) => !v)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={showUnreadOnly ? "secondary" : "ghost"}
|
||||
>
|
||||
Unread · {unreadCount}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="New message"
|
||||
onClick={openCompose}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
{visible.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-muted-foreground text-sm">
|
||||
No unread messages.
|
||||
{showUnreadOnly ? "No unread messages." : "No conversations yet."}
|
||||
</p>
|
||||
) : (
|
||||
visible.map((c) => {
|
||||
const last = c.messages.at(-1);
|
||||
const last = c.lastMessage;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
@@ -213,12 +246,12 @@ export function MessagesView() {
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{last?.time}
|
||||
{last ? formatTime(last.createdAt) : ""}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-full truncate text-muted-foreground text-xs">
|
||||
{last?.direction === "out" && "You: "}
|
||||
{last?.text}
|
||||
{last?.senderId === myId && "You: "}
|
||||
{last?.body}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -233,43 +266,56 @@ export function MessagesView() {
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border bg-card/30 p-4">
|
||||
<Avatar className="size-9">
|
||||
<AvatarFallback>{selected.initials}</AvatarFallback>
|
||||
<AvatarFallback>{initials(selected.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{selected.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{selected.role}
|
||||
{selected.isGroup
|
||||
? `${selected.participants.length} people`
|
||||
: "Direct message"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4">
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{selected.messages.map((m) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
m.direction === "out" ? "items-end" : "items-start",
|
||||
)}
|
||||
key={m.id}
|
||||
>
|
||||
{messages.map((m) => {
|
||||
const out = m.senderId === myId;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||
m.direction === "out"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
"flex flex-col gap-1",
|
||||
out ? "items-end" : "items-start",
|
||||
)}
|
||||
key={m.id}
|
||||
>
|
||||
{m.text}
|
||||
{selected.isGroup && !out && (
|
||||
<span className="px-1 text-muted-foreground text-[11px]">
|
||||
{m.senderName}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||
out
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
{m.body}
|
||||
</div>
|
||||
<span className="px-1 text-muted-foreground text-[11px]">
|
||||
{formatTime(m.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="px-1 text-muted-foreground text-[11px]">
|
||||
{m.time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -303,13 +349,49 @@ export function MessagesView() {
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No conversation selected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Choose a conversation from the inbox to read and reply.
|
||||
Choose a conversation from the inbox, or start a new one.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose: pick a clinic member to message */}
|
||||
<Dialog onOpenChange={setComposeOpen} open={composeOpen}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New message</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a conversation with a member of your clinic.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-1">
|
||||
{members.length === 0 ? (
|
||||
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
|
||||
No other clinic members yet. Invite colleagues from Settings →
|
||||
Care team.
|
||||
</p>
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
key={m.id}
|
||||
onClick={() => startConversation(m.id)}
|
||||
type="button"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{initials(m.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-foreground text-sm">
|
||||
{m.name}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ConversationSummary[]> {
|
||||
return apiFetch<ConversationSummary[]>("/api/conversations");
|
||||
}
|
||||
|
||||
export function listClinicMembers(): Promise<Participant[]> {
|
||||
return apiFetch<Participant[]>("/api/conversations/members");
|
||||
}
|
||||
|
||||
export function createConversation(input: {
|
||||
participantIds: string[];
|
||||
name?: string | null;
|
||||
}): Promise<ConversationSummary> {
|
||||
return apiFetch<ConversationSummary>("/api/conversations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function getMessages(
|
||||
conversationId: string,
|
||||
): Promise<ConversationMessage[]> {
|
||||
return apiFetch<ConversationMessage[]>(
|
||||
`/api/conversations/${conversationId}/messages`,
|
||||
);
|
||||
}
|
||||
|
||||
// REST fallback for sending (the realtime path is the socket "message:send").
|
||||
export function sendMessageRest(
|
||||
conversationId: string,
|
||||
body: string,
|
||||
): Promise<ConversationMessage> {
|
||||
return apiFetch<ConversationMessage>(
|
||||
`/api/conversations/${conversationId}/messages`,
|
||||
{ method: "POST", body: JSON.stringify({ body }) },
|
||||
);
|
||||
}
|
||||
|
||||
export function markConversationRead(conversationId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/conversations/${conversationId}/read`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+86
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user