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
35 changes: 35 additions & 0 deletions apps/web/src/app/actions/meetings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
'use server';

import { createEmailer } from '@profullstack/emailer';
import { describeRecurrence, type RecurrenceRule } from '@/lib/recurrence';

/** The "Repeats every week on Tuesday, forever" row, or '' for a one-off meeting. */
function recurrenceRow(recurrence: RecurrenceRule | undefined, scheduledAt: string): string {
if (!recurrence?.freq) return '';
const label = describeRecurrence(recurrence, new Date(scheduledAt)).replace(/^Repeats /, '');
return `<tr>
<td style="padding:6px 0;color:#6b7280;font-size:13px;font-weight:600;text-transform:uppercase;">Repeats</td>
<td style="padding:6px 0;color:#111827;font-size:14px;font-weight:500;">${label}</td>
</tr>`;
}

interface InvitePayload {
scheduledSessionId: string;
Expand All @@ -11,6 +22,8 @@ interface InvitePayload {
durationMinutes: number;
joinCode: string;
hostName: string;
/** Set when the meeting repeats — the whole series shares this one invite. */
recurrence?: RecurrenceRule;
invitees: { email: string; name: string | null; token: string }[];
}

Expand All @@ -32,6 +45,9 @@ interface UpdatePayload {
// 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;
recurrence?: RecurrenceRule;
/** The host changed how (or whether) the meeting repeats. */
recurrenceChanged?: boolean;
}

interface RemovalPayload {
Expand Down Expand Up @@ -64,6 +80,7 @@ function inviteEmailHtml(opts: {
rsvpAcceptUrl: string;
rsvpDeclineUrl: string;
joinUrl: string;
recurrence?: RecurrenceRule;
}): string {
const formattedDate = formatDateTime(opts.scheduledAt);
const greeting = opts.inviteeName ? `Hi ${opts.inviteeName},` : 'Hi there,';
Expand Down Expand Up @@ -101,6 +118,7 @@ function inviteEmailHtml(opts: {
<td style="padding:6px 0;color:#6b7280;font-size:13px;font-weight:600;text-transform:uppercase;">Duration</td>
<td style="padding:6px 0;color:#111827;font-size:14px;font-weight:500;">${durationLabel}</td>
</tr>
${recurrenceRow(opts.recurrence, opts.scheduledAt)}
<tr>
<td style="padding:6px 0;color:#6b7280;font-size:13px;font-weight:600;text-transform:uppercase;">Host</td>
<td style="padding:6px 0;color:#111827;font-size:14px;font-weight:500;">${opts.hostName}</td>
Expand Down Expand Up @@ -174,6 +192,8 @@ function updateEmailHtml(opts: {
hostName: string;
joinUrl: string;
codeChanged?: boolean;
recurrence?: RecurrenceRule;
recurrenceChanged?: boolean;
}): string {
const timeChanged =
new Date(opts.scheduledAt).getTime() !== new Date(opts.previousScheduledAt).getTime();
Expand Down Expand Up @@ -218,6 +238,16 @@ function updateEmailHtml(opts: {
<td style="padding:6px 0;color:#6b7280;font-size:13px;font-weight:600;text-transform:uppercase;">Duration</td>
<td style="padding:6px 0;color:#111827;font-size:14px;font-weight:500;">${durationLabel}</td>
</tr>
${
opts.recurrence?.freq
? recurrenceRow(opts.recurrence, opts.scheduledAt)
: opts.recurrenceChanged
? `<tr>
<td style="padding:6px 0;color:#6b7280;font-size:13px;font-weight:600;text-transform:uppercase;">Repeats</td>
<td style="padding:6px 0;color:#111827;font-size:14px;font-weight:500;">No longer repeats</td>
</tr>`
: ''
}
${
opts.description
? `<tr>
Expand Down Expand Up @@ -301,6 +331,7 @@ export async function sendMeetingInvites(
joinUrl: `${appUrl}/join/${payload.joinCode}`,
rsvpAcceptUrl: `${rsvpBase}?rsvp=accepted`,
rsvpDeclineUrl: `${rsvpBase}?rsvp=declined`,
...(payload.recurrence !== undefined && { recurrence: payload.recurrence }),
});

try {
Expand Down Expand Up @@ -343,6 +374,10 @@ export async function sendMeetingUpdate(
hostName: payload.hostName,
joinUrl: `${appUrl}/join/${payload.joinCode}`,
...(payload.codeChanged !== undefined && { codeChanged: payload.codeChanged }),
...(payload.recurrence !== undefined && { recurrence: payload.recurrence }),
...(payload.recurrenceChanged !== undefined && {
recurrenceChanged: payload.recurrenceChanged,
}),
});

try {
Expand Down
56 changes: 55 additions & 1 deletion apps/web/src/app/api/scheduled-sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
sendInviteeRemoval,
} from '@/app/actions/meetings';
import { getUniqueJoinCode, liveSessionExistsForCode } from '@/lib/join-code';
import { ruleFromRow, type RecurrenceRow } from '@/lib/recurrence';
import { randomBytes } from 'crypto';

// GET /api/scheduled-sessions/[id]
Expand Down Expand Up @@ -48,12 +49,60 @@ function detailsChanged(before: any, after: any): boolean {
if ((before.title as string) !== (after.title as string)) return true;
if ((before.description ?? null) !== (after.description ?? null)) return true;
if ((before.duration_minutes as number) !== (after.duration_minutes as number)) return true;
if (recurrenceChanged(before, after)) return true;
return (
new Date(before.scheduled_at as string).getTime() !==
new Date(after.scheduled_at as string).getTime()
);
}

function recurrenceChanged(before: any, after: any): boolean {
const a = ruleFromRow(before as RecurrenceRow);
const b = ruleFromRow(after as RecurrenceRow);
return a.freq !== b.freq || a.interval !== b.interval || a.count !== b.count;
}

/**
* Bookkeeping the recurrence columns need alongside an edit.
*
* Moving the meeting, or changing how often it repeats, re-bases the series: the
* new time becomes the anchor and the occurrence count starts again, so "repeat
* 8 times" means eight more from here. Changing only the count leaves the tally
* alone — except when the new limit is already used up, where the occurrence now
* on the books becomes the last one rather than being cancelled out from under
* the invitees.
*/
function recurrenceUpdate(existing: any, fields: Record<string, unknown>): Record<string, unknown> {
const before = ruleFromRow(existing as RecurrenceRow);
const after = ruleFromRow({ ...existing, ...fields } as RecurrenceRow);

const movedTo =
typeof fields.scheduled_at === 'string' &&
new Date(fields.scheduled_at).getTime() !== new Date(existing.scheduled_at as string).getTime()
? fields.scheduled_at
: null;

const rebased =
movedTo !== null || before.freq !== after.freq || before.interval !== after.interval;

if (rebased) {
const anchor = movedTo ?? (existing.scheduled_at as string);
return {
recurrence_anchor_at: anchor,
occurrences_elapsed: 0,
// A finished series that is given a new time runs again.
...(existing.status === 'completed' ? { status: 'pending' } : {}),
};
}

const elapsed = (existing.occurrences_elapsed as number | null) ?? 0;
if (after.count > 0 && elapsed >= after.count) {
return { occurrences_elapsed: after.count - 1 };
}

return {};
}

// PATCH /api/scheduled-sessions/[id] — edit the meeting and/or its invitee list.
// `inviteeEmails` is the complete desired list: addresses not already invited get an
// invite email, addresses that disappear are removed and told so.
Expand Down Expand Up @@ -83,10 +132,12 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id

let updated = existing;

const recurrenceEdits = recurrenceUpdate(existing, fields);

if (Object.keys(fields).length > 0) {
const { data, error } = await (svc as any)
.from('scheduled_sessions')
.update({ ...fields, updated_at: new Date().toISOString() })
.update({ ...fields, ...recurrenceEdits, updated_at: new Date().toISOString() })
.eq('id', id)
.eq('host_user_id', user.id)
.select()
Expand Down Expand Up @@ -198,6 +249,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
durationMinutes: updated.duration_minutes as number,
joinCode,
hostName,
recurrence: ruleFromRow(updated as RecurrenceRow),
invitees: added.map((i) => ({ email: i.email, name: i.name, token: i.invite_token })),
});
if (!inviteResult.ok) console.error('Invite email error:', inviteResult.error);
Expand Down Expand Up @@ -225,6 +277,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
durationMinutes: updated.duration_minutes as number,
joinCode,
hostName,
recurrence: ruleFromRow(updated as RecurrenceRow),
recurrenceChanged: recurrenceChanged(existing, updated),
inviteeEmails: retained.map((i) => i.email),
codeChanged: codeRotated,
});
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/app/api/scheduled-sessions/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ vi.mock('@/app/actions/meetings', () => ({
sendMeetingInvites: vi.fn(),
}));

// Advancing recurring meetings has its own tests; here it only has to be called.
const mockRollForwardHostSeries = vi.fn();
vi.mock('@/lib/recurrence-rollforward', () => ({
rollForwardHostSeries: (...args: unknown[]) => mockRollForwardHostSeries(...args),
}));

interface ScheduledRow {
id: string;
scheduled_at: string;
Expand Down Expand Up @@ -112,6 +118,19 @@ describe('GET /api/scheduled-sessions', () => {
expect(lt).not.toHaveBeenCalled();
});

it('advances the host recurring meetings before listing them', async () => {
setupList([row('future', '2026-08-14T18:00:00.000Z')]);

const response = await GET(
new Request('http://localhost/api/scheduled-sessions?filter=upcoming')
);

expect(response.status).toBe(200);
// A lapsed occurrence sits in the past — exactly what "upcoming" filters out —
// so it has to be rolled forward before the query runs.
expect(mockRollForwardHostSeries).toHaveBeenCalledWith(expect.anything(), mockUser.id);
});

it('keeps currently running meetings out of the past filter', async () => {
const { gte, lt } = setupList([
row('expired', '2026-08-14T16:00:00.000Z'),
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/app/api/scheduled-sessions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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 { ruleFromRow, type RecurrenceRow } from '@/lib/recurrence';
import { rollForwardHostSeries } from '@/lib/recurrence-rollforward';
import { randomBytes } from 'crypto';
import {
earliestPossibleCurrentMeetingStart,
Expand Down Expand Up @@ -57,6 +59,13 @@ export async function POST(request: Request) {
scheduled_at: input.scheduledAt,
duration_minutes: input.durationMinutes,
join_code: joinCode,
// A recurring meeting is one row: scheduled_at tracks the next occurrence
// and the anchor keeps the day of the month stable for monthly series.
recurrence_freq: input.recurrenceFreq ?? null,
recurrence_interval: input.recurrenceFreq ? (input.recurrenceInterval ?? 1) : 1,
recurrence_count: input.recurrenceFreq ? (input.recurrenceCount ?? 0) : 0,
occurrences_elapsed: 0,
recurrence_anchor_at: input.scheduledAt,
})
.select()
.single();
Expand Down Expand Up @@ -100,6 +109,7 @@ export async function POST(request: Request) {
durationMinutes: input.durationMinutes,
joinCode,
hostName,
recurrence: ruleFromRow(scheduled as RecurrenceRow),
invitees: invitees.map((i) => ({ email: i.email, name: i.name, token: i.invite_token })),
});
if (!emailResult.ok) {
Expand All @@ -125,6 +135,12 @@ export async function GET(request: Request) {
if (authError || !user) return errorResponse('Authentication required', 401);

const svc = serviceClient();

// A recurring meeting whose occurrence has finished sits in the past until it
// is rolled forward — exactly what an "upcoming" query filters out — so
// advance this host's series before reading.
await rollForwardHostSeries(svc, user.id);

const nowMs = Date.now();
const now = new Date(nowMs).toISOString();

Expand Down
Loading
Loading