mirror of
https://github.com/temetro/temetro.git
synced 2026-08-09 18:20:00 +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)],
|
||||
);
|
||||
+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;
|
||||
};
|
||||
Reference in New Issue
Block a user