Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/web/src/app/actions/meetings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -226,10 +230,15 @@ function updateEmailHtml(opts: {
</div>

<div style="text-align:center;margin-bottom:28px;">
<p style="color:#6b7280;font-size:13px;margin:0 0 10px;">Your join code (unchanged)</p>
<p style="color:#6b7280;font-size:13px;margin:0 0 10px;">${opts.codeChanged ? 'Your new join code' : 'Your join code (unchanged)'}</p>
<div style="display:inline-block;background:#ede9fe;border-radius:10px;padding:14px 28px;">
<span style="font-family:monospace;font-size:34px;font-weight:800;color:#4f46e5;letter-spacing:8px;">${opts.joinCode}</span>
</div>
${
opts.codeChanged
? `<p style="color:#9ca3af;font-size:13px;margin:12px 0 0;">The guest list changed, so the previous code no longer works. Use this one instead.</p>`
: ''
}
</div>

<div style="text-align:center;">
Expand Down Expand Up @@ -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 {
Expand Down
139 changes: 139 additions & 0 deletions apps/web/src/app/api/scheduled-sessions/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof baseMeeting>;
/** 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 };
}
Expand Down Expand Up @@ -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')] });

Expand Down
46 changes: 43 additions & 3 deletions apps/web/src/app/api/scheduled-sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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')
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -187,6 +226,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
joinCode,
hostName,
inviteeEmails: retained.map((i) => i.email),
codeChanged: codeRotated,
});
}

Expand Down
30 changes: 1 addition & 29 deletions apps/web/src/app/api/scheduled-sessions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof serviceClient>): Promise<string> {
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 {
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/lib/join-code.ts
Original file line number Diff line number Diff line change
@@ -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<typeof serviceClient>;

// 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<string> {
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<boolean> {
const { data } = await (svc as any)
.from('sessions')
.select('id')
.eq('join_code', joinCode)
.maybeSingle();

return Boolean(data);
}
Loading
Loading