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:
Khalid Abdi
2026-06-20 02:07:41 +03:00
parent e841cb56e1
commit 913a217e1d
16 changed files with 5046 additions and 133 deletions
+99 -2
View File
@@ -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;
}