mirror of
https://github.com/temetro/temetro.git
synced 2026-08-12 03:26:53 +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:
@@ -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)],
|
||||
);
|
||||
Reference in New Issue
Block a user