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