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
+118
View File
@@ -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);
}
});
+46
View File
@@ -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);
}
});