mirror of
https://github.com/temetro/temetro.git
synced 2026-08-11 02:57:55 +00:00
feat(meetings): live invite, scheduling calendar, Discord-style redesign
- Live invite: ring a clinic member into a room (socket call:invite + a bell notification); the invitee gets a toast with a Join action. Notifications of type "meeting" deep-link to /messages/meetings?room=, which auto-joins. - Scheduling: new scheduled_meetings table + /api/meetings/events (list mine, create, delete); a Calendar tab on the Meetings page with a month picker (meeting-day dots), the day's agenda, and a Schedule-meeting dialog (title/date/time/participants). - Redesign: rounded control bar with tooltips (mic/cam/screen/invite + separated red Leave), speaking ring on tiles (Web Audio), and live room-occupancy counts via call:presence broadcasts. Migration 0025. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
@@ -21,3 +28,25 @@ export const meetingRooms = pgTable(
|
||||
},
|
||||
(table) => [index("meeting_rooms_org_idx").on(table.organizationId)],
|
||||
);
|
||||
|
||||
// A scheduled staff meeting (calendar event), scoped to a clinic. `participants`
|
||||
// holds the invited staff user ids; `date`/`time` are local strings (YYYY-MM-DD /
|
||||
// HH:mm) like appointments, to avoid timezone drift on the calendar.
|
||||
export const scheduledMeetings = pgTable(
|
||||
"scheduled_meetings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
date: text("date").notNull(),
|
||||
time: text("time").notNull(),
|
||||
participants: jsonb("participants").$type<string[]>().notNull().default([]),
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("scheduled_meetings_org_idx").on(table.organizationId)],
|
||||
);
|
||||
|
||||
+50
-1
@@ -15,6 +15,7 @@ let io: Server | null = null;
|
||||
const userRoom = (userId: string) => `user:${userId}`;
|
||||
const convRoom = (conversationId: string) => `conv:${conversationId}`;
|
||||
const callRoom = (roomId: string) => `call:${roomId}`;
|
||||
const orgRoom = (orgId: string) => `org:${orgId}`;
|
||||
|
||||
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
|
||||
// past that the room is closed to new joiners.
|
||||
@@ -69,8 +70,9 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
const userName: string = socket.data.userName;
|
||||
const orgId: string | null = socket.data.orgId;
|
||||
|
||||
// Personal room for notifications.
|
||||
// Personal room for notifications; clinic room for call presence broadcasts.
|
||||
socket.join(userRoom(userId));
|
||||
if (orgId) socket.join(orgRoom(orgId));
|
||||
|
||||
socket.on(
|
||||
"conversation:join",
|
||||
@@ -151,6 +153,14 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
// org-scoped: a join is authorized against the clinic's meeting_rooms.
|
||||
const joinedCallRooms = new Set<string>();
|
||||
|
||||
// Broadcast a room's live occupancy to the whole clinic so the meetings
|
||||
// room list can show "N in call".
|
||||
const emitPresence = (roomId: string) => {
|
||||
if (!orgId) return;
|
||||
const count = callParticipants.get(roomId)?.size ?? 0;
|
||||
io?.to(orgRoom(orgId)).emit("call:presence", { roomId, count });
|
||||
};
|
||||
|
||||
const leaveCall = (roomId: string) => {
|
||||
if (!joinedCallRooms.has(roomId)) return;
|
||||
joinedCallRooms.delete(roomId);
|
||||
@@ -160,6 +170,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
callParticipants.delete(roomId);
|
||||
}
|
||||
socket.to(callRoom(roomId)).emit("call:peer-left", { socketId: socket.id });
|
||||
emitPresence(roomId);
|
||||
};
|
||||
|
||||
socket.on("call:join", async (roomId: unknown, ack?: Ack) => {
|
||||
@@ -181,6 +192,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
callParticipants.set(id, peers);
|
||||
// Tell existing peers a newcomer arrived; the newcomer initiates offers.
|
||||
socket.to(callRoom(id)).emit("call:peer-joined", me);
|
||||
emitPresence(id);
|
||||
// Reply with the peers already present (excluding self).
|
||||
ack?.({
|
||||
ok: true,
|
||||
@@ -191,6 +203,42 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
}
|
||||
});
|
||||
|
||||
// Ring a clinic member into a room: push a live invite + a bell notification.
|
||||
socket.on(
|
||||
"call:invite",
|
||||
async (payload: { roomId?: string; toUserId?: string }) => {
|
||||
try {
|
||||
const roomId = String(payload?.roomId ?? "");
|
||||
const toUserId = String(payload?.toUserId ?? "");
|
||||
if (!(roomId && toUserId && orgId)) return;
|
||||
if (!(await meetings.roomExists(orgId, roomId))) return;
|
||||
const room = (await meetings.listRooms(orgId)).find(
|
||||
(r) => r.id === roomId,
|
||||
);
|
||||
const roomName = room?.name ?? "";
|
||||
emitToUser(toUserId, "call:invite", {
|
||||
roomId,
|
||||
roomName,
|
||||
fromName: userName,
|
||||
});
|
||||
const notification = await createNotification({
|
||||
orgId,
|
||||
userId: toUserId,
|
||||
type: "meeting",
|
||||
text: `${userName} invited you to a call`,
|
||||
entityType: "meeting",
|
||||
entityId: roomId,
|
||||
actorName: userName,
|
||||
});
|
||||
if (notification) {
|
||||
emitToUser(toUserId, "notification:new", notification);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Relay an SDP offer/answer or ICE candidate to a specific peer socket.
|
||||
socket.on(
|
||||
"call:signal",
|
||||
@@ -217,6 +265,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
socket
|
||||
.to(callRoom(roomId))
|
||||
.emit("call:peer-left", { socketId: socket.id });
|
||||
emitPresence(roomId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,51 @@ meetingsRouter.post("/", async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Scheduled meetings (calendar) -----------------------------------------
|
||||
|
||||
meetingsRouter.get("/events", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await meetings.listMeetingEvents(req.organizationId!, req.user!.id));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const eventSchema = z.object({
|
||||
title: z.string().trim().min(1).max(120),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
time: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
participants: z.array(z.string()).max(50).default([]),
|
||||
});
|
||||
|
||||
meetingsRouter.post("/events", async (req, res, next) => {
|
||||
try {
|
||||
const input = eventSchema.parse(req.body);
|
||||
const event = await meetings.createMeetingEvent(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
input,
|
||||
);
|
||||
res.status(201).json(event);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
meetingsRouter.delete("/events/:id", async (req, res, next) => {
|
||||
try {
|
||||
const ok = await meetings.deleteMeetingEvent(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
String(req.params.id ?? ""),
|
||||
);
|
||||
if (!ok) throw new HttpError(404, "Meeting not found.");
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
meetingsRouter.delete("/:id", async (req, res, next) => {
|
||||
try {
|
||||
const ok = await meetings.deleteRoom(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { meetingRooms } from "../db/schema/meetings.js";
|
||||
import { user } from "../db/schema/auth.js";
|
||||
import { meetingRooms, scheduledMeetings } from "../db/schema/meetings.js";
|
||||
|
||||
export type MeetingRoom = {
|
||||
id: string;
|
||||
@@ -67,3 +68,99 @@ export async function roomExists(orgId: string, roomId: string): Promise<boolean
|
||||
);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
// --- Scheduled meetings (calendar) -----------------------------------------
|
||||
|
||||
export type ScheduledMeeting = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
time: string;
|
||||
participants: string[];
|
||||
participantNames: string[];
|
||||
createdBy: string | null;
|
||||
};
|
||||
|
||||
// Meetings the user is part of (creator or invited participant), with the
|
||||
// participants' display names resolved for the calendar.
|
||||
export async function listMeetingEvents(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
): Promise<ScheduledMeeting[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(scheduledMeetings)
|
||||
.where(
|
||||
and(
|
||||
eq(scheduledMeetings.organizationId, orgId),
|
||||
or(
|
||||
eq(scheduledMeetings.createdBy, userId),
|
||||
// `participants` is a JSONB array of user ids.
|
||||
sql`${scheduledMeetings.participants} @> ${JSON.stringify([userId])}::jsonb`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(scheduledMeetings.date), asc(scheduledMeetings.time));
|
||||
|
||||
// Resolve participant names in one query.
|
||||
const ids = [...new Set(rows.flatMap((r) => r.participants))];
|
||||
const nameById = new Map<string, string>();
|
||||
if (ids.length > 0) {
|
||||
const users = await db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(inArray(user.id, ids));
|
||||
for (const u of users) nameById.set(u.id, u.name);
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
date: r.date,
|
||||
time: r.time,
|
||||
participants: r.participants,
|
||||
participantNames: r.participants.map((id) => nameById.get(id) ?? "—"),
|
||||
createdBy: r.createdBy,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createMeetingEvent(
|
||||
orgId: string,
|
||||
createdBy: string,
|
||||
input: { title: string; date: string; time: string; participants: string[] },
|
||||
): Promise<ScheduledMeeting> {
|
||||
// The creator is always a participant.
|
||||
const participants = [...new Set([createdBy, ...input.participants])];
|
||||
const [row] = await db
|
||||
.insert(scheduledMeetings)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
title: input.title,
|
||||
date: input.date,
|
||||
time: input.time,
|
||||
participants,
|
||||
createdBy,
|
||||
})
|
||||
.returning({ id: scheduledMeetings.id });
|
||||
const events = await listMeetingEvents(orgId, createdBy);
|
||||
return events.find((e) => e.id === row!.id)!;
|
||||
}
|
||||
|
||||
export async function deleteMeetingEvent(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
// Only the creator can delete.
|
||||
const deleted = await db
|
||||
.delete(scheduledMeetings)
|
||||
.where(
|
||||
and(
|
||||
eq(scheduledMeetings.organizationId, orgId),
|
||||
eq(scheduledMeetings.id, id),
|
||||
eq(scheduledMeetings.createdBy, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: scheduledMeetings.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user