feat(meetings): Discord-style staff voice/video calls under Messages

Backend: meeting_rooms table + /api/meetings (list/create/delete, org-scoped),
and WebRTC mesh signaling over the existing authed Socket.io (call:join with a
≤4 cap and org authorization, call:signal relay, peer-joined/left, disconnect
cleanup). Migration 0024.

Frontend: Messages nav is now expandable (Inbox + Meetings); new
/messages/meetings page with a room list and a Discord-style call UI — a
useWebRtcMesh hook (camera/mic, screen share via replaceTrack, ≤4 mesh) and a
tile grid with a mic/camera/screen/leave control bar. New lib/meetings + i18n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-19 20:48:26 +03:00
parent 0fa2802723
commit c88b674196
16 changed files with 4854 additions and 1 deletions
+1
View File
@@ -17,3 +17,4 @@ export * from "./org-ai-policy.js";
export * from "./attachments.js";
export * from "./integrations.js";
export * from "./staff-profile.js";
export * from "./meetings.js";
+23
View File
@@ -0,0 +1,23 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// A persistent staff meeting room (Discord-style voice/video channel), scoped to
// a clinic. Rooms are long-lived "channels"; the live call (participants, media)
// is ephemeral and lives only in the realtime layer — nothing about an in-call
// session is persisted here.
export const meetingRooms = pgTable(
"meeting_rooms",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("meeting_rooms_org_idx").on(table.organizationId)],
);
+2
View File
@@ -19,6 +19,7 @@ import { dispensesRouter } from "./routes/dispenses.js";
import { integrationsRouter } from "./routes/integrations.js";
import { inventoryRouter } from "./routes/inventory.js";
import { invoicesRouter } from "./routes/invoices.js";
import { meetingsRouter } from "./routes/meetings.js";
import { notesRouter } from "./routes/notes.js";
import { notificationsRouter } from "./routes/notifications.js";
import { patientsRouter } from "./routes/patients.js";
@@ -77,6 +78,7 @@ app.use("/api/staff", staffRouter);
app.use("/api/activity", activityRouter);
app.use("/api/analytics", analyticsRouter);
app.use("/api/conversations", conversationsRouter);
app.use("/api/meetings", meetingsRouter);
app.use("/api/notifications", notificationsRouter);
app.use("/api/settings", settingsRouter);
app.use("/api/ai", aiRouter);
+86
View File
@@ -5,6 +5,7 @@ import { Server, type Socket } from "socket.io";
import { auth } from "./auth.js";
import { env } from "./env.js";
import * as meetings from "./services/meetings.js";
import * as messaging from "./services/messaging.js";
import { createNotification } from "./services/notifications.js";
import type { MessageAttachment } from "./types/messaging.js";
@@ -13,6 +14,16 @@ let io: Server | null = null;
const userRoom = (userId: string) => `user:${userId}`;
const convRoom = (conversationId: string) => `conv:${conversationId}`;
const callRoom = (roomId: string) => `call:${roomId}`;
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
// past that the room is closed to new joiners.
const MAX_CALL_PEERS = 4;
// Live call participants per room: roomId -> (socketId -> peer info). Ephemeral —
// nothing about an in-call session is persisted.
type CallPeer = { socketId: string; userId: string; userName: string };
const callParticipants = new Map<string, Map<string, CallPeer>>();
// Push helpers other modules can call without importing socket.io directly.
export function emitToUser(userId: string, event: string, data: unknown): void {
@@ -133,6 +144,81 @@ export function initRealtime(httpServer: HttpServer): Server {
await messaging.markRead(orgId, userId, conversationId).catch(() => {});
}
});
// --- Staff calls (WebRTC mesh signaling) -------------------------------
// The server only relays SDP/ICE between peers and tracks who's in a room;
// media flows peer-to-peer and never touches the server. Rooms are
// org-scoped: a join is authorized against the clinic's meeting_rooms.
const joinedCallRooms = new Set<string>();
const leaveCall = (roomId: string) => {
if (!joinedCallRooms.has(roomId)) return;
joinedCallRooms.delete(roomId);
socket.leave(callRoom(roomId));
callParticipants.get(roomId)?.delete(socket.id);
if (callParticipants.get(roomId)?.size === 0) {
callParticipants.delete(roomId);
}
socket.to(callRoom(roomId)).emit("call:peer-left", { socketId: socket.id });
};
socket.on("call:join", async (roomId: unknown, ack?: Ack) => {
try {
const id = String(roomId ?? "");
if (!(id && orgId && (await meetings.roomExists(orgId, id)))) {
ack?.({ ok: false, reason: "not_found" });
return;
}
const peers = callParticipants.get(id) ?? new Map<string, CallPeer>();
if (!peers.has(socket.id) && peers.size >= MAX_CALL_PEERS) {
ack?.({ ok: false, reason: "full" });
return;
}
socket.join(callRoom(id));
joinedCallRooms.add(id);
const me: CallPeer = { socketId: socket.id, userId, userName };
peers.set(socket.id, me);
callParticipants.set(id, peers);
// Tell existing peers a newcomer arrived; the newcomer initiates offers.
socket.to(callRoom(id)).emit("call:peer-joined", me);
// Reply with the peers already present (excluding self).
ack?.({
ok: true,
peers: [...peers.values()].filter((p) => p.socketId !== socket.id),
});
} catch {
ack?.({ ok: false, reason: "error" });
}
});
// Relay an SDP offer/answer or ICE candidate to a specific peer socket.
socket.on(
"call:signal",
(payload: { to?: string; signal?: unknown }) => {
const to = String(payload?.to ?? "");
if (!to) return;
io?.to(to).emit("call:signal", {
from: socket.id,
signal: payload.signal,
});
},
);
socket.on("call:leave", (roomId: unknown) => {
leaveCall(String(roomId ?? ""));
});
socket.on("disconnect", () => {
for (const roomId of joinedCallRooms) {
callParticipants.get(roomId)?.delete(socket.id);
if (callParticipants.get(roomId)?.size === 0) {
callParticipants.delete(roomId);
}
socket
.to(callRoom(roomId))
.emit("call:peer-left", { socketId: socket.id });
}
});
});
return io;
+53
View File
@@ -0,0 +1,53 @@
import { Router } from "express";
import { z } from "zod";
import { HttpError } from "../lib/http-error.js";
import { requireAuth, requireOrg } from "../middleware/auth.js";
import * as meetings from "../services/meetings.js";
export const meetingsRouter = Router();
// Staff meeting rooms (Discord-style voice/video channels), scoped to the active
// clinic. Any clinic member can list, create, and join rooms — calls are
// staff-to-staff. The live call (media + participants) is handled over Socket.io
// (see src/realtime.ts); these endpoints only manage the persistent room list.
meetingsRouter.use(requireAuth, requireOrg);
meetingsRouter.get("/", async (req, res, next) => {
try {
res.json(await meetings.listRooms(req.organizationId!));
} catch (err) {
next(err);
}
});
const createSchema = z.object({
name: z.string().trim().min(1).max(80),
});
meetingsRouter.post("/", async (req, res, next) => {
try {
const { name } = createSchema.parse(req.body);
const room = await meetings.createRoom(
req.organizationId!,
name,
req.user!.id,
);
res.status(201).json(room);
} catch (err) {
next(err);
}
});
meetingsRouter.delete("/:id", async (req, res, next) => {
try {
const ok = await meetings.deleteRoom(
req.organizationId!,
String(req.params.id ?? ""),
);
if (!ok) throw new HttpError(404, "Room not found.");
res.status(204).end();
} catch (err) {
next(err);
}
});
+69
View File
@@ -0,0 +1,69 @@
import { and, asc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { meetingRooms } from "../db/schema/meetings.js";
export type MeetingRoom = {
id: string;
name: string;
createdAt: string;
};
export async function listRooms(orgId: string): Promise<MeetingRoom[]> {
const rows = await db
.select({
id: meetingRooms.id,
name: meetingRooms.name,
createdAt: meetingRooms.createdAt,
})
.from(meetingRooms)
.where(eq(meetingRooms.organizationId, orgId))
.orderBy(asc(meetingRooms.createdAt));
return rows.map((r) => ({
id: r.id,
name: r.name,
createdAt: r.createdAt.toISOString(),
}));
}
export async function createRoom(
orgId: string,
name: string,
createdBy: string,
): Promise<MeetingRoom> {
const [row] = await db
.insert(meetingRooms)
.values({ organizationId: orgId, name, createdBy })
.returning({
id: meetingRooms.id,
name: meetingRooms.name,
createdAt: meetingRooms.createdAt,
});
return {
id: row!.id,
name: row!.name,
createdAt: row!.createdAt.toISOString(),
};
}
export async function deleteRoom(orgId: string, roomId: string): Promise<boolean> {
const deleted = await db
.delete(meetingRooms)
.where(
and(eq(meetingRooms.organizationId, orgId), eq(meetingRooms.id, roomId)),
)
.returning({ id: meetingRooms.id });
return deleted.length > 0;
}
// Whether a room exists within the given clinic — used by the realtime layer to
// authorize a call:join before relaying any signaling.
export async function roomExists(orgId: string, roomId: string): Promise<boolean> {
const [row] = await db
.select({ id: meetingRooms.id })
.from(meetingRooms)
.where(
and(eq(meetingRooms.organizationId, orgId), eq(meetingRooms.id, roomId)),
);
return Boolean(row);
}