diff --git a/apps/web/src/app/actions/meetings.ts b/apps/web/src/app/actions/meetings.ts
index 94b4040..37381ef 100644
--- a/apps/web/src/app/actions/meetings.ts
+++ b/apps/web/src/app/actions/meetings.ts
@@ -29,6 +29,9 @@ interface UpdatePayload {
joinCode: string;
hostName: string;
inviteeEmails: string[];
+ // Set when someone was removed from the meeting: the join code was rotated, so the
+ // code in this email replaces the one the recipient was originally sent.
+ codeChanged?: boolean;
}
interface RemovalPayload {
@@ -170,6 +173,7 @@ function updateEmailHtml(opts: {
joinCode: string;
hostName: string;
joinUrl: string;
+ codeChanged?: boolean;
}): string {
const timeChanged =
new Date(opts.scheduledAt).getTime() !== new Date(opts.previousScheduledAt).getTime();
@@ -226,10 +230,15 @@ function updateEmailHtml(opts: {
-
Your join code (unchanged)
+
${opts.codeChanged ? 'Your new join code' : 'Your join code (unchanged)'}
${opts.joinCode}
+ ${
+ opts.codeChanged
+ ? `
The guest list changed, so the previous code no longer works. Use this one instead.
`
+ : ''
+ }
@@ -333,6 +342,7 @@ export async function sendMeetingUpdate(
joinCode: payload.joinCode,
hostName: payload.hostName,
joinUrl: `${appUrl}/join/${payload.joinCode}`,
+ ...(payload.codeChanged !== undefined && { codeChanged: payload.codeChanged }),
});
try {
diff --git a/apps/web/src/app/api/scheduled-sessions/[id]/route.test.ts b/apps/web/src/app/api/scheduled-sessions/[id]/route.test.ts
index d4ed5dd..0cdaecb 100644
--- a/apps/web/src/app/api/scheduled-sessions/[id]/route.test.ts
+++ b/apps/web/src/app/api/scheduled-sessions/[id]/route.test.ts
@@ -105,17 +105,35 @@ function invitee(email: string, id: string) {
function setupService(options: {
existingInvitees?: { id: string; email: string; name: string | null; invite_token: string }[];
meeting?: Partial;
+ /** Simulate the host having already started the meeting under its join code. */
+ liveSession?: boolean;
+ /** Make the join_code UPDATE fail, as a unique-constraint clash would. */
+ rotateFails?: boolean;
}) {
const existingInvitees = options.existingInvitees ?? [];
const meeting = { ...baseMeeting, ...options.meeting };
const mock = createServiceMock((chain) => {
+ // Probing whether a candidate join code is free — matched on the filter rather
+ // than the table, since both tables are checked with the same shape of query.
+ if (chain.op === 'select' && chain.filters.join_code !== undefined) {
+ const isLive = chain.table === 'sessions' && chain.filters.join_code === meeting.join_code;
+ return { data: isLive && options.liveSession === true ? { id: 'live-1' } : null, error: null };
+ }
if (chain.table === 'scheduled_sessions' && chain.op === 'select') {
return {
data: { ...meeting, scheduled_session_invitees: existingInvitees },
error: null,
};
}
+ if (
+ chain.table === 'scheduled_sessions' &&
+ chain.op === 'update' &&
+ (chain.payload as { join_code?: string }).join_code !== undefined &&
+ options.rotateFails === true
+ ) {
+ return { data: null, error: { message: 'duplicate key value' } };
+ }
if (chain.table === 'scheduled_sessions' && chain.op === 'update') {
return { data: { ...meeting, ...(chain.payload as object) }, error: null };
}
@@ -236,6 +254,127 @@ describe('PATCH /api/scheduled-sessions/[id]', () => {
expect(mockSendMeetingInvites).not.toHaveBeenCalled();
});
+ describe('join code rotation on removal', () => {
+ it('rotates the join code so the removed invitee is actually locked out', async () => {
+ const mock = setupService({
+ existingInvitees: [invitee('stay@example.com', 'i1'), invitee('drop@example.com', 'i2')],
+ });
+
+ const response = await PATCH(patchRequest({ inviteeEmails: ['stay@example.com'] }), {
+ params,
+ });
+ const body = (await response.json()) as { data: { join_code: string } };
+
+ const rotate = mock.calls.find(
+ (c) =>
+ c.table === 'scheduled_sessions' &&
+ c.op === 'update' &&
+ (c.payload as { join_code?: string }).join_code !== undefined
+ );
+ const newCode = (rotate?.payload as { join_code: string }).join_code;
+
+ expect(rotate).toBeDefined();
+ expect(newCode).not.toBe('ABC123');
+ expect(newCode).toMatch(/^[A-Z0-9]{6}$/);
+ expect(rotate?.filters).toMatchObject({ id: MEETING_ID, host_user_id: mockUser.id });
+
+ // The caller sees the new code, so the dashboard stops showing the dead one.
+ expect(body.data.join_code).toBe(newCode);
+ });
+
+ it('tells the invitees who remain what the new code is', async () => {
+ setupService({
+ existingInvitees: [invitee('stay@example.com', 'i1'), invitee('drop@example.com', 'i2')],
+ });
+
+ await PATCH(patchRequest({ inviteeEmails: ['stay@example.com'] }), { params });
+
+ expect(mockSendMeetingUpdate).toHaveBeenCalledTimes(1);
+ const payload = mockSendMeetingUpdate.mock.calls[0]?.[0] as {
+ inviteeEmails: string[];
+ joinCode: string;
+ codeChanged: boolean;
+ };
+
+ // Only the retained invitee, and the email must carry the rotated code — sending
+ // the old one would leave them holding a code that no longer works.
+ expect(payload.inviteeEmails).toEqual(['stay@example.com']);
+ expect(payload.codeChanged).toBe(true);
+ expect(payload.joinCode).not.toBe('ABC123');
+ });
+
+ it('leaves the code alone when nobody was removed', async () => {
+ const mock = setupService({ existingInvitees: [invitee('a@example.com', 'i1')] });
+
+ await PATCH(patchRequest({ inviteeEmails: ['a@example.com', 'b@example.com'] }), { params });
+
+ const rotate = mock.calls.find(
+ (c) =>
+ c.table === 'scheduled_sessions' &&
+ c.op === 'update' &&
+ (c.payload as { join_code?: string }).join_code !== undefined
+ );
+ expect(rotate).toBeUndefined();
+
+ // A newly added invitee gets the existing code, which still works.
+ const invitePayload = mockSendMeetingInvites.mock.calls[0]?.[0] as { joinCode: string };
+ expect(invitePayload.joinCode).toBe('ABC123');
+ });
+
+ it('does not rotate once the meeting has started', async () => {
+ const mock = setupService({
+ existingInvitees: [invitee('stay@example.com', 'i1'), invitee('drop@example.com', 'i2')],
+ liveSession: true,
+ });
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const response = await PATCH(patchRequest({ inviteeEmails: ['stay@example.com'] }), {
+ params,
+ });
+
+ // The live room holds its own copy of the code, so rotating the scheduled row
+ // would claim a lockout that did not happen.
+ const rotate = mock.calls.find(
+ (c) =>
+ c.table === 'scheduled_sessions' &&
+ c.op === 'update' &&
+ (c.payload as { join_code?: string }).join_code !== undefined
+ );
+ expect(rotate).toBeUndefined();
+ expect(response.status).toBe(200);
+ expect(warn).toHaveBeenCalled();
+
+ // Nothing changed for the people staying, so they are not emailed.
+ expect(mockSendMeetingUpdate).not.toHaveBeenCalled();
+ // The removal itself still went through.
+ expect(mockSendInviteeRemoval).toHaveBeenCalledTimes(1);
+ });
+
+ it('still applies the removal when the rotation write fails', async () => {
+ const mock = setupService({
+ existingInvitees: [invitee('stay@example.com', 'i1'), invitee('drop@example.com', 'i2')],
+ rotateFails: true,
+ });
+
+ const response = await PATCH(patchRequest({ inviteeEmails: ['stay@example.com'] }), {
+ params,
+ });
+
+ expect(response.status).toBe(200);
+
+ const del = mock.calls.find(
+ (c) => c.table === 'scheduled_session_invitees' && c.op === 'delete'
+ );
+ expect(del?.filters.id).toEqual(['i2']);
+
+ // The code did not change, so retained invitees must not be told that it did.
+ const updateCall = mockSendMeetingUpdate.mock.calls[0]?.[0] as
+ | { codeChanged: boolean }
+ | undefined;
+ expect(updateCall?.codeChanged ?? false).toBe(false);
+ });
+ });
+
it('removes every invitee when given an empty list', async () => {
const mock = setupService({ existingInvitees: [invitee('a@example.com', 'i1')] });
diff --git a/apps/web/src/app/api/scheduled-sessions/[id]/route.ts b/apps/web/src/app/api/scheduled-sessions/[id]/route.ts
index 122ce98..ac1555c 100644
--- a/apps/web/src/app/api/scheduled-sessions/[id]/route.ts
+++ b/apps/web/src/app/api/scheduled-sessions/[id]/route.ts
@@ -9,6 +9,7 @@ import {
sendMeetingUpdate,
sendInviteeRemoval,
} from '@/app/actions/meetings';
+import { getUniqueJoinCode, liveSessionExistsForCode } from '@/lib/join-code';
import { randomBytes } from 'crypto';
// GET /api/scheduled-sessions/[id]
@@ -140,6 +141,44 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
}
}
+ // Dropping someone from the list only revokes their access if the code they were
+ // already emailed stops working. The code is shared by the whole guest list, so
+ // rotating it means everyone still invited has to be told the new one — that is
+ // what `codeRotated` forces further down.
+ let joinCode = updated.join_code as string;
+ let codeRotated = false;
+
+ if (removed.length > 0) {
+ if (await liveSessionExistsForCode(svc, joinCode)) {
+ // The host already started this meeting, and the live room holds its own copy
+ // of the code. Rotating the scheduled row would not lock anyone out of it —
+ // /api/sessions/{id}/regenerate-code is the lever for a running session.
+ console.warn(`Not rotating join code for scheduled session ${id}: already started`);
+ } else {
+ try {
+ const nextCode = await getUniqueJoinCode(svc);
+
+ const { error: rotateErr } = await (svc as any)
+ .from('scheduled_sessions')
+ .update({ join_code: nextCode, updated_at: new Date().toISOString() })
+ .eq('id', id)
+ .eq('host_user_id', user.id);
+
+ if (rotateErr) {
+ // Keep the old code rather than failing the whole edit — the removal itself
+ // already succeeded, and a stale code is better than a half-applied PATCH.
+ console.error('Join code rotation error:', rotateErr);
+ } else {
+ joinCode = nextCode;
+ codeRotated = true;
+ updated = { ...updated, join_code: nextCode };
+ }
+ } catch (err) {
+ console.error('Join code rotation error:', err);
+ }
+ }
+ }
+
// Host display name for the emails below
const { data: profile } = await (svc as any)
.from('profiles')
@@ -148,7 +187,6 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
.maybeSingle();
const hostName = (profile?.display_name as string | undefined) ?? user.email ?? 'Someone';
- const joinCode = updated.join_code as string;
const description = (updated.description as string | null) ?? undefined;
if (added.length > 0) {
@@ -173,11 +211,12 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
});
}
- // Invitees who were already on the list only need a heads-up if something moved.
+ // Invitees who were already on the list only need a heads-up if something moved —
+ // or if the code they were given no longer opens the meeting.
const removedIds = new Set(removed.map((i) => i.id));
const retained = currentInvitees.filter((i) => !removedIds.has(i.id));
- if (retained.length > 0 && detailsChanged(existing, updated)) {
+ if (retained.length > 0 && (codeRotated || detailsChanged(existing, updated))) {
await sendMeetingUpdate({
title: updated.title as string,
description,
@@ -187,6 +226,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
joinCode,
hostName,
inviteeEmails: retained.map((i) => i.email),
+ codeChanged: codeRotated,
});
}
diff --git a/apps/web/src/app/api/scheduled-sessions/route.ts b/apps/web/src/app/api/scheduled-sessions/route.ts
index af83eb3..ac5f8c5 100644
--- a/apps/web/src/app/api/scheduled-sessions/route.ts
+++ b/apps/web/src/app/api/scheduled-sessions/route.ts
@@ -4,37 +4,9 @@ import { serviceClient } from '@/lib/supabase/service';
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
import { scheduleMeetingSchema } from '@/lib/validations';
import { sendMeetingInvites } from '@/app/actions/meetings';
+import { getUniqueJoinCode } from '@/lib/join-code';
import { randomBytes } from 'crypto';
-function generateJoinCode(): string {
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
- let code = '';
- for (let i = 0; i < 6; i++) {
- code += chars.charAt(Math.floor(Math.random() * chars.length));
- }
- return code;
-}
-
-async function getUniqueJoinCode(svc: ReturnType): Promise {
- for (let i = 0; i < 10; i++) {
- const code = generateJoinCode();
-
- const { data: inSessions } = await (svc as any)
- .from('sessions')
- .select('id')
- .eq('join_code', code)
- .maybeSingle();
-
- const { data: inScheduled } = await (svc as any)
- .from('scheduled_sessions')
- .select('id')
- .eq('join_code', code)
- .maybeSingle();
- if (!inSessions && !inScheduled) return code;
- }
- throw new Error('Failed to generate unique join code');
-}
-
// POST /api/scheduled-sessions — create a scheduled meeting + send invites
export async function POST(request: Request) {
try {
diff --git a/apps/web/src/lib/join-code.ts b/apps/web/src/lib/join-code.ts
new file mode 100644
index 0000000..c157133
--- /dev/null
+++ b/apps/web/src/lib/join-code.ts
@@ -0,0 +1,61 @@
+/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
+import type { serviceClient } from '@/lib/supabase/service';
+
+type ServiceClient = ReturnType;
+
+// Uppercase only — create_session() upper-cases whatever it is handed, so generating
+// anything else here would desync the scheduled code from the live room's code.
+const CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+
+export function generateJoinCode(): string {
+ let code = '';
+ for (let i = 0; i < 6; i++) {
+ code += CODE_CHARS.charAt(Math.floor(Math.random() * CODE_CHARS.length));
+ }
+ return code;
+}
+
+/**
+ * Finds a code that is not already taken by a live session or another scheduled one.
+ * Both tables have to be checked: a scheduled meeting's code is handed straight to
+ * create_session() when the host starts it, and that insert fails on a collision.
+ */
+export async function getUniqueJoinCode(svc: ServiceClient): Promise {
+ for (let i = 0; i < 10; i++) {
+ const code = generateJoinCode();
+
+ const { data: inSessions } = await (svc as any)
+ .from('sessions')
+ .select('id')
+ .eq('join_code', code)
+ .maybeSingle();
+
+ const { data: inScheduled } = await (svc as any)
+ .from('scheduled_sessions')
+ .select('id')
+ .eq('join_code', code)
+ .maybeSingle();
+
+ if (!inSessions && !inScheduled) return code;
+ }
+ throw new Error('Failed to generate unique join code');
+}
+
+/**
+ * Has the host already started this meeting? Once a live session exists under the
+ * scheduled code, rotating the scheduled row no longer revokes anything — the live
+ * room keeps its own copy of the code. Callers use this to avoid pretending a
+ * removal revoked access when it did not.
+ */
+export async function liveSessionExistsForCode(
+ svc: ServiceClient,
+ joinCode: string
+): Promise {
+ const { data } = await (svc as any)
+ .from('sessions')
+ .select('id')
+ .eq('join_code', joinCode)
+ .maybeSingle();
+
+ return Boolean(data);
+}
diff --git a/docs/API.md b/docs/API.md
index ace2ff0..83fb7f1 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1020,8 +1020,9 @@ Who gets email depends on what changed:
|---|---|
| Address added to `inviteeEmails` | Invitation, with the join code |
| Address dropped from `inviteeEmails` | Invitation withdrawn |
+| Anyone dropped (so the code rotated) | Update notice to everyone still invited, carrying the new code |
| `title`, `description`, `scheduled_at`, or `duration_minutes` changed | Update notice to everyone still invited |
-| Only the guest list changed | Nothing to retained invitees |
+| Invitees only added, nothing else changed | Nothing to existing invitees |
Editing a cancelled meeting returns `400`.
@@ -1065,14 +1066,33 @@ the only scheduled-session endpoints that do not require a logged-in user — th
An unrecognised token returns `404`. Both endpoints run through the service-role client,
which is why the table needs no anon-facing RLS policy.
-#### Known Limitation: Removal Does Not Revoke Access
+#### Join Code Rotation on Removal
-A scheduled meeting has a single shared `join_code` that is emailed to every invitee, and
-removing an invitee does **not** rotate it. A removed invitee who kept their original
-invitation email can still join using that code, and there is currently no endpoint to
-re-issue a scheduled meeting's code. (`/api/sessions/{id}/regenerate-code` applies to live
-sessions, not scheduled ones.) To genuinely lock someone out, cancel the meeting and
-create a new one.
+A scheduled meeting has a single shared `join_code` that is emailed to every invitee, so
+deleting an invitee row on its own would revoke nothing — the removed person still holds a
+working code. Whenever a `PATCH` drops at least one invitee, the meeting's `join_code` is
+therefore **rotated**, and the invitees who remain are emailed the replacement.
+
+```
+PATCH { inviteeEmails: [...] } removing someone
+ → new unique join_code written to scheduled_sessions
+ → removed invitees : "Invitation withdrawn"
+ → retained invitees : "Updated", carrying the new code
+ → response body : the rotated join_code
+```
+
+Because the code is shared, there is no way to revoke one person without reissuing it to
+everybody; that is inherent to a single shared code, not an implementation shortcut.
+Adding an invitee never rotates the code.
+
+**Once the meeting has started, rotation is skipped.** Starting a meeting creates a row in
+`sessions` carrying its own copy of the code, and rewriting the `scheduled_sessions` row
+would not evict anyone from the live room. The API detects this and leaves the code alone
+rather than reporting a lockout that did not happen — use
+`POST /api/sessions/{id}/regenerate-code` to rotate a running session's code instead.
+
+If the rotation write itself fails, the removal still stands: the invitee is deleted, the
+old code is kept, and retained invitees are not told the code changed.
### Profiles API