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:
Khalid Abdi
2026-06-07 21:26:28 +03:00
parent 48378ebc5e
commit 730e07fcfc
21 changed files with 4086 additions and 158 deletions
+71
View File
@@ -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)],
);