mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 14:55:28 +00:00
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:
@@ -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";
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user