From 93a76784c9e81b42ee153d9e343839ad7fdd6500 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 16 Sep 2026 20:45:17 -0400 Subject: [PATCH 1/2] feat: external attendees on SGA Space bookings (#132) An SGA Space booking could only list Chambers users, so an interview candidate or a guest from another office couldn't be put on it or sent its invite. Typing an @northeastern.edu address in the attendee search now offers to add it when no Chambers user has that address, and it shows as a dashed chip. Addresses are stored in a new external_attendees column on bookings and series, validated to @northeastern.edu and capped at 25. Every place that works out who to email -- create, edit (added/removed diff), cancel, blackout cascades, and all three series routes -- resolves attendees through attendeeKeys, where an external address is an `email:` key that resolveSpacesAddresses maps to itself. So external attendees get the same invites, updates and cancellations as everyone else. The room display counts them as "Guest" instead of showing their address. Co-Authored-By: Claude Opus 5 --- app/(dashboard)/bookings/sga-spaces-tab.tsx | 3 +- app/(dashboard)/sga-spaces/page.tsx | 4 + .../sga-spaces/space-booking-modal.tsx | 87 +++++++++++++++++-- app/(dashboard)/sga-spaces/space-calendar.tsx | 1 + app/api/display/[spaceId]/route.ts | 14 ++- app/api/spaces/blackouts/[id]/route.ts | 12 +-- app/api/spaces/blackouts/route.ts | 12 +-- app/api/spaces/bookings/[id]/route.ts | 28 ++++-- app/api/spaces/bookings/route.ts | 16 +++- app/api/spaces/series/[id]/route.ts | 39 ++++++--- app/api/spaces/series/route.ts | 16 +++- lib/spaces-email.ts | 58 ++++++++++++- ...0260916010000_space_external_attendees.sql | 30 +++++++ ...0916_space_external_attendees_rollback.sql | 10 +++ 14 files changed, 282 insertions(+), 48 deletions(-) create mode 100644 supabase/migrations/20260916010000_space_external_attendees.sql create mode 100644 supabase/migrations/rollback/20260916_space_external_attendees_rollback.sql diff --git a/app/(dashboard)/bookings/sga-spaces-tab.tsx b/app/(dashboard)/bookings/sga-spaces-tab.tsx index bf85b66..168bf28 100644 --- a/app/(dashboard)/bookings/sga-spaces-tab.tsx +++ b/app/(dashboard)/bookings/sga-spaces-tab.tsx @@ -41,6 +41,7 @@ interface SpaceBooking { start_time: string end_time: string attendee_ids: string[] + external_attendees?: string[] | null creator_name: string | null /** The weekly series this booking is one week of (issue #112). */ series_id: string | null @@ -308,7 +309,7 @@ function AdminBookingsPanel({ spaces }: { spaces: Space[] }) { {b.creator_name ?? '—'} {formatDateTime(b.start_time)} {formatDateTime(b.end_time)} - {(b.attendee_ids ?? []).length + 1} + {(b.attendee_ids ?? []).length + (b.external_attendees ?? []).length + 1} ))} + {canAddExternal && ( + + )} )} +

+ Not on Chambers? Type their @northeastern.edu email. +

+ {/* Attendee chips */} - {attendees.length > 0 && ( + {(attendees.length > 0 || externalAttendees.length > 0) && (
{attendees.map(a => (
))} + {externalAttendees.map(email => ( +
+ {email} + +
+ ))}
)} diff --git a/app/(dashboard)/sga-spaces/space-calendar.tsx b/app/(dashboard)/sga-spaces/space-calendar.tsx index 31629dd..68f4cec 100644 --- a/app/(dashboard)/sga-spaces/space-calendar.tsx +++ b/app/(dashboard)/sga-spaces/space-calendar.tsx @@ -10,6 +10,7 @@ interface Booking { start_time: string end_time: string attendee_ids: string[] + external_attendees?: string[] | null creator_name: string | null series_id: string | null } diff --git a/app/api/display/[spaceId]/route.ts b/app/api/display/[spaceId]/route.ts index bf675f6..0e28968 100644 --- a/app/api/display/[spaceId]/route.ts +++ b/app/api/display/[spaceId]/route.ts @@ -7,6 +7,11 @@ const adminSupabase = createAdminClient( process.env.SUPABASE_SERVICE_ROLE_KEY! ) +function guestLabel(count: number): string[] { + if (count === 0) return [] + return [count === 1 ? 'Guest' : `${count} guests`] +} + export async function GET( request: Request, { params }: { params: Promise<{ spaceId: string }> } @@ -44,7 +49,7 @@ export async function GET( .single(), adminSupabase .from('space_bookings') - .select('id, title, start_time, end_time, creator_id, attendee_ids') + .select('id, title, start_time, end_time, creator_id, attendee_ids, external_attendees') .eq('space_id', spaceId) .gte('start_time', todayStart.toISOString()) .lt('start_time', todayEnd.toISOString()) @@ -93,7 +98,12 @@ export async function GET( start_time: b.start_time, end_time: b.end_time, creator_name: userMap[b.creator_id] ?? null, - attendee_names: (b.attendee_ids ?? []).map((id: string) => userMap[id]).filter(Boolean), + // External attendees (issue #132) are counted, not named: this screen hangs + // outside the room, and their addresses are not for passers-by. + attendee_names: [ + ...(b.attendee_ids ?? []).map((id: string) => userMap[id]).filter(Boolean), + ...guestLabel((b.external_attendees ?? []).length), + ], })), }) } diff --git a/app/api/spaces/blackouts/[id]/route.ts b/app/api/spaces/blackouts/[id]/route.ts index ef3d5f2..70df244 100644 --- a/app/api/spaces/blackouts/[id]/route.ts +++ b/app/api/spaces/blackouts/[id]/route.ts @@ -3,7 +3,7 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cancelled' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' -import { cancellationAddressing, resolveSpacesAddresses } from '@/lib/spaces-email' +import { attendeeKeys, cancellationAddressing, resolveSpacesAddresses } from '@/lib/spaces-email' import { waitUntil } from '@vercel/functions' const adminSupabase = createAdminClient( @@ -14,7 +14,7 @@ const adminSupabase = createAdminClient( async function cascadeCancelBookings(spaceId: string | null, startTime: string, endTime: string) { let q = adminSupabase .from('space_bookings') - .select('id, title, start_time, end_time, creator_id, attendee_ids, spaces(name)') + .select('id, title, start_time, end_time, creator_id, attendee_ids, external_attendees, spaces(name)') .lt('start_time', endTime) .gt('end_time', startTime) if (spaceId) q = q.eq('space_id', spaceId) @@ -25,8 +25,8 @@ async function cascadeCancelBookings(spaceId: string | null, startTime: string, // Wherever each affected person chose to receive SGA Spaces emails (issue #109). const addresses = await resolveSpacesAddresses( adminSupabase, - affected.flatMap((b: { creator_id: string; attendee_ids: string[] }) => - [b.creator_id, ...(b.attendee_ids ?? [])] + affected.flatMap((b: { creator_id: string; attendee_ids: string[]; external_attendees: string[] | null }) => + [b.creator_id, ...attendeeKeys(b)] ) ) @@ -34,8 +34,8 @@ async function cascadeCancelBookings(spaceId: string | null, startTime: string, // Bookings are already deleted; notifying is a post-commit side effect. waitUntil( - Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; spaces: { name: string }[] | null }) => { - const { to, bcc } = cancellationAddressing(addresses, b.creator_id, b.attendee_ids) + Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; external_attendees: string[] | null; spaces: { name: string }[] | null }) => { + const { to, bcc } = cancellationAddressing(addresses, b.creator_id, attendeeKeys(b)) await sendSpaceBookingCancelledEmail({ bookingId: b.id, title: b.title, diff --git a/app/api/spaces/blackouts/route.ts b/app/api/spaces/blackouts/route.ts index 857f969..3650523 100644 --- a/app/api/spaces/blackouts/route.ts +++ b/app/api/spaces/blackouts/route.ts @@ -4,7 +4,7 @@ import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cancelled' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' -import { cancellationAddressing, resolveSpacesAddresses } from '@/lib/spaces-email' +import { attendeeKeys, cancellationAddressing, resolveSpacesAddresses } from '@/lib/spaces-email' import { waitUntil } from '@vercel/functions' const adminSupabase = createAdminClient( @@ -71,7 +71,7 @@ export async function POST(request: Request) { try { let bookingsQuery = adminSupabase .from('space_bookings') - .select('id, title, start_time, end_time, creator_id, attendee_ids, spaces(name)') + .select('id, title, start_time, end_time, creator_id, attendee_ids, external_attendees, spaces(name)') .lt('start_time', end_time) .gt('end_time', start_time) @@ -85,8 +85,8 @@ export async function POST(request: Request) { // to receive SGA Spaces emails (issue #109). const addresses = await resolveSpacesAddresses( adminSupabase, - affected.flatMap((b: { creator_id: string; attendee_ids: string[] }) => - [b.creator_id, ...(b.attendee_ids ?? [])] + affected.flatMap((b: { creator_id: string; attendee_ids: string[]; external_attendees: string[] | null }) => + [b.creator_id, ...attendeeKeys(b)] ) ) @@ -100,8 +100,8 @@ export async function POST(request: Request) { // post-commit side effect. Previously the admin's request blocked on one // Resend call per affected booking, which could run into seconds. waitUntil( - Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; spaces: { name: string }[] | null }) => { - const { to, bcc } = cancellationAddressing(addresses, b.creator_id, b.attendee_ids) + Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; external_attendees: string[] | null; spaces: { name: string }[] | null }) => { + const { to, bcc } = cancellationAddressing(addresses, b.creator_id, attendeeKeys(b)) await sendSpaceBookingCancelledEmail({ bookingId: b.id, title: b.title, diff --git a/app/api/spaces/bookings/[id]/route.ts b/app/api/spaces/bookings/[id]/route.ts index 62c1faa..9f95f65 100644 --- a/app/api/spaces/bookings/[id]/route.ts +++ b/app/api/spaces/bookings/[id]/route.ts @@ -6,7 +6,14 @@ import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cance import { sendSpaceBookingUpdatedEmail, type SpaceBookingDetails } from '@/lib/emails/space-booking-updated' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' import { advanceNoticeError } from '@/lib/spaces-advance-notice' -import { cancellationAddressing, dedupeEmails, resolveSpacesAddresses } from '@/lib/spaces-email' +import { + EXTERNAL_ATTENDEES_ERROR, + attendeeKeys, + cancellationAddressing, + dedupeEmails, + parseExternalAttendees, + resolveSpacesAddresses, +} from '@/lib/spaces-email' import { waitUntil } from '@vercel/functions' import { DEFAULT_WEEKLY_HOURS, minutesOf, touchesDeadZone, weekBoundsOf as getWeekBounds } from '@/lib/space-series' @@ -38,12 +45,15 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const { title, start_time, end_time, attendee_ids, space_id } = await request.json() + const { title, start_time, end_time, attendee_ids, external_attendees, space_id } = await request.json() if (!title || !start_time || !end_time) { return NextResponse.json({ error: 'title, start_time, and end_time are required' }, { status: 400 }) } + const nextExternals = parseExternalAttendees(external_attendees) + if (!nextExternals) return NextResponse.json({ error: EXTERNAL_ATTENDEES_ERROR }, { status: 400 }) + if (minutesOf(start_time) % 15 !== 0 || minutesOf(end_time) % 15 !== 0) { return NextResponse.json({ error: 'Bookings must start and end on 15-minute intervals.' }, { status: 400 }) } @@ -123,11 +133,13 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id } const cleanTitle = title.trim() - const nextAttendees: string[] = Array.isArray(attendee_ids) ? attendee_ids : [] + const nextAttendeeIds: string[] = Array.isArray(attendee_ids) ? attendee_ids : [] const { data: updated, error: updateError } = await adminSupabase .from('space_bookings') - .update({ title: cleanTitle, start_time, end_time, attendee_ids: nextAttendees, space_id: spaceId }) + .update({ + title: cleanTitle, start_time, end_time, attendee_ids: nextAttendeeIds, external_attendees: nextExternals, space_id: spaceId, + }) .eq('id', id) .select() .single() @@ -156,7 +168,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id Date.parse(booking.startTime) !== Date.parse(previous.startTime) || Date.parse(booking.endTime) !== Date.parse(previous.endTime) - const previousAttendees: string[] = existing.attendee_ids ?? [] + // Chambers users and external addresses alike, as keys (see attendeeKeys). + const nextAttendees = attendeeKeys({ attendee_ids: nextAttendeeIds, external_attendees: nextExternals }) + const previousAttendees = attendeeKeys(existing) const addedAttendees = nextAttendees.filter(a => !previousAttendees.includes(a)) const removedAttendees = previousAttendees.filter(a => !nextAttendees.includes(a) && a !== existing.creator_id) @@ -244,9 +258,9 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ // Sent wherever each person chose to receive SGA Spaces emails (issue #109), // so the cancellation reaches the same inbox the invite did. const addresses = await resolveSpacesAddresses( - adminSupabase, [booking.creator_id, ...(booking.attendee_ids ?? [])] + adminSupabase, [booking.creator_id, ...attendeeKeys(booking)] ) - const { to, bcc } = cancellationAddressing(addresses, booking.creator_id, booking.attendee_ids) + const { to, bcc } = cancellationAddressing(addresses, booking.creator_id, attendeeKeys(booking)) const spaceName = (booking.spaces as { name: string } | null)?.name ?? 'SGA Space' await sendSpaceBookingCancelledEmail({ bookingId: id, diff --git a/app/api/spaces/bookings/route.ts b/app/api/spaces/bookings/route.ts index a586a4e..4b709e2 100644 --- a/app/api/spaces/bookings/route.ts +++ b/app/api/spaces/bookings/route.ts @@ -5,7 +5,13 @@ import { checkRateLimit } from '@/lib/check-rate-limit' import { advanceNoticeError } from '@/lib/spaces-advance-notice' import { sendSpaceBookingConfirmedEmail } from '@/lib/emails/space-booking-confirmed' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' -import { dedupeEmails, resolveSpacesAddresses } from '@/lib/spaces-email' +import { + EXTERNAL_ATTENDEES_ERROR, + attendeeKeys, + dedupeEmails, + parseExternalAttendees, + resolveSpacesAddresses, +} from '@/lib/spaces-email' import { waitUntil } from '@vercel/functions' import { DEFAULT_WEEKLY_HOURS, minutesOf, touchesDeadZone, weekBoundsOf as getWeekBounds } from '@/lib/space-series' @@ -95,12 +101,15 @@ export async function POST(request: Request) { } } - const { space_id, title, start_time, end_time, attendee_ids } = await request.json() + const { space_id, title, start_time, end_time, attendee_ids, external_attendees } = await request.json() if (!space_id || !title || !start_time || !end_time) { return NextResponse.json({ error: 'space_id, title, start_time, and end_time are required' }, { status: 400 }) } + const externals = parseExternalAttendees(external_attendees) + if (!externals) return NextResponse.json({ error: EXTERNAL_ATTENDEES_ERROR }, { status: 400 }) + // 15-minute interval check if (minutesOf(start_time) % 15 !== 0 || minutesOf(end_time) % 15 !== 0) { return NextResponse.json({ error: 'Bookings must start and end on 15-minute intervals.' }, { status: 400 }) @@ -174,6 +183,7 @@ export async function POST(request: Request) { start_time, end_time, attendee_ids: attendee_ids ?? [], + external_attendees: externals, }) .select() .single() @@ -186,7 +196,7 @@ export async function POST(request: Request) { waitUntil( (async () => { try { - const allUserIds: string[] = [user.id, ...(attendee_ids ?? [])] + const allUserIds: string[] = [user.id, ...attendeeKeys({ attendee_ids, external_attendees: externals })] // Each person's own choice of inbox, creator and attendees alike (issue #109). const [{ data: space }, addresses] = await Promise.all([ adminSupabase.from('spaces').select('name').eq('id', space_id).single(), diff --git a/app/api/spaces/series/[id]/route.ts b/app/api/spaces/series/[id]/route.ts index b4c3e70..971b716 100644 --- a/app/api/spaces/series/[id]/route.ts +++ b/app/api/spaces/series/[id]/route.ts @@ -6,7 +6,14 @@ import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' import { hasLiveAdmin, type AuthedUser } from '@/lib/auth' import { bostonWallClockNow } from '@/lib/boston-time' -import { cancellationAddressing, dedupeEmails, resolveSpacesAddresses } from '@/lib/spaces-email' +import { + EXTERNAL_ATTENDEES_ERROR, + attendeeKeys, + cancellationAddressing, + dedupeEmails, + parseExternalAttendees, + resolveSpacesAddresses, +} from '@/lib/spaces-email' import { sendSpaceSeriesCancelledEmail, sendSpaceSeriesUpdatedEmail, @@ -50,6 +57,7 @@ interface SeriesRow { creator_id: string title: string attendee_ids: string[] + external_attendees: string[] start_time: string end_time: string starts_on: string @@ -62,6 +70,7 @@ interface WeekRow { start_time: string end_time: string attendee_ids: string[] | null + external_attendees: string[] | null /** Usually the series' space; a week can be moved to another on its own. */ space_id: string } @@ -69,7 +78,7 @@ interface WeekRow { async function loadSeries(id: string): Promise { const { data } = await adminSupabase .from('space_booking_series') - .select('id, space_id, creator_id, title, attendee_ids, start_time, end_time, starts_on, ends_on, cancelled_at') + .select('id, space_id, creator_id, title, attendee_ids, external_attendees, start_time, end_time, starts_on, ends_on, cancelled_at') .eq('id', id) .maybeSingle() return (data as SeriesRow | null) ?? null @@ -79,7 +88,7 @@ async function loadSeries(id: string): Promise { async function loadUpcoming(seriesId: string): Promise { const { data } = await adminSupabase .from('space_bookings') - .select('id, start_time, end_time, attendee_ids, space_id') + .select('id, start_time, end_time, attendee_ids, external_attendees, space_id') .eq('series_id', seriesId) .gte('start_time', bostonWallClockNow().toISOString()) .order('start_time') @@ -153,7 +162,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id return NextResponse.json({ error: 'This weekly booking has been cancelled.' }, { status: 400 }) } - const { title, start_time, end_time, until, attendee_ids, skip_conflicts } = await request.json() + const { title, start_time, end_time, until, attendee_ids, external_attendees, skip_conflicts } = await request.json() if (typeof title !== 'string' || !title.trim()) { return NextResponse.json({ error: 'Title is required.' }, { status: 400 }) @@ -162,6 +171,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id return NextResponse.json({ error: 'until, start_time and end_time are required.' }, { status: 400 }) } const attendees: string[] = Array.isArray(attendee_ids) ? attendee_ids.filter((a: unknown) => typeof a === 'string') : [] + const externals = parseExternalAttendees(external_attendees) + if (!externals) return NextResponse.json({ error: EXTERNAL_ATTENDEES_ERROR }, { status: 400 }) // The time pattern is validated once on the first date; every week shares it. const sample = intervalFor(series.starts_on, start_time, end_time) @@ -249,6 +260,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .update({ title: cleanTitle, attendee_ids: attendees, + external_attendees: externals, ...(planned ? { start_time: planned.interval.start, end_time: planned.interval.end, space_id: series.space_id } : {}), }) .eq('id', r.id) @@ -270,9 +282,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id start_time: w.interval.start, end_time: w.interval.end, attendee_ids: attendees, + external_attendees: externals, series_id: id, }))) - .select('id, start_time, end_time, attendee_ids, space_id') + .select('id, start_time, end_time, attendee_ids, external_attendees, space_id') if (error) return NextResponse.json({ error: error.message }, { status: 500 }) inserted = (data as WeekRow[] | null) ?? [] } @@ -284,7 +297,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const { error: seriesError } = await adminSupabase .from('space_booking_series') - .update({ title: cleanTitle, attendee_ids: attendees, start_time, end_time, ends_on: until }) + .update({ title: cleanTitle, attendee_ids: attendees, external_attendees: externals, start_time, end_time, ends_on: until }) .eq('id', id) if (seriesError) return NextResponse.json({ error: seriesError.message }, { status: 500 }) @@ -303,13 +316,15 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id nameOtherSpaces(removed.map(withSpace), series.space_id), ]) + // Chambers users and external addresses alike, as keys (see attendeeKeys). + const currentAttendees = attendeeKeys({ attendee_ids: attendees, external_attendees: externals }) const previousAttendees = new Set([ - ...series.attendee_ids, - ...upcoming.flatMap(r => r.attendee_ids ?? []), + ...attendeeKeys(series), + ...upcoming.flatMap(r => attendeeKeys(r)), ]) - const droppedAttendees = [...previousAttendees].filter(a => !attendees.includes(a) && a !== series.creator_id) + const droppedAttendees = [...previousAttendees].filter(a => !currentAttendees.includes(a) && a !== series.creator_id) - const currentIds = [series.creator_id, ...attendees] + const currentIds = [series.creator_id, ...currentAttendees] const [{ data: space }, addresses] = await Promise.all([ adminSupabase.from('spaces').select('name').eq('id', series.space_id).single(), resolveSpacesAddresses(adminSupabase, [...currentIds, ...droppedAttendees]), @@ -392,8 +407,8 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ try { // Anyone on any upcoming week, including one added to a single week. const attendees = [...new Set([ - ...series.attendee_ids, - ...upcoming.flatMap(r => r.attendee_ids ?? []), + ...attendeeKeys(series), + ...upcoming.flatMap(r => attendeeKeys(r)), ])].filter(a => a !== series.creator_id) const [{ data: space }, addresses] = await Promise.all([ diff --git a/app/api/spaces/series/route.ts b/app/api/spaces/series/route.ts index 2e1fa34..38a1077 100644 --- a/app/api/spaces/series/route.ts +++ b/app/api/spaces/series/route.ts @@ -4,7 +4,13 @@ import { NextResponse } from 'next/server' import { waitUntil } from '@vercel/functions' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' -import { dedupeEmails, resolveSpacesAddresses } from '@/lib/spaces-email' +import { + EXTERNAL_ATTENDEES_ERROR, + attendeeKeys, + dedupeEmails, + parseExternalAttendees, + resolveSpacesAddresses, +} from '@/lib/spaces-email' import { sendSpaceSeriesConfirmedEmail } from '@/lib/emails/space-series' import { addDays, @@ -45,7 +51,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Only Leadership members and administrators may create space bookings.' }, { status: 403 }) } - const { space_id, title, date, start_time, end_time, until, attendee_ids, skip_conflicts } = await request.json() + const { space_id, title, date, start_time, end_time, until, attendee_ids, external_attendees, skip_conflicts } = await request.json() if (!space_id || typeof title !== 'string' || !title.trim()) { return NextResponse.json({ error: 'space_id and title are required.' }, { status: 400 }) @@ -54,6 +60,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'date, until, start_time and end_time are required.' }, { status: 400 }) } const attendees: string[] = Array.isArray(attendee_ids) ? attendee_ids.filter((a: unknown) => typeof a === 'string') : [] + const externals = parseExternalAttendees(external_attendees) + if (!externals) return NextResponse.json({ error: EXTERNAL_ATTENDEES_ERROR }, { status: 400 }) const first = intervalFor(date, start_time, end_time) if (new Date(first.start).getUTCMinutes() % 15 !== 0 || new Date(first.end).getUTCMinutes() % 15 !== 0) { @@ -107,6 +115,7 @@ export async function POST(request: Request) { creator_id: user.id, title: title.trim(), attendee_ids: attendees, + external_attendees: externals, start_time, end_time, starts_on: date, @@ -128,6 +137,7 @@ export async function POST(request: Request) { start_time: w.interval.start, end_time: w.interval.end, attendee_ids: attendees, + external_attendees: externals, series_id: series.id, }))) .select('id, start_time, end_time') @@ -142,7 +152,7 @@ export async function POST(request: Request) { waitUntil( (async () => { try { - const userIds = [user.id, ...attendees] + const userIds = [user.id, ...attendeeKeys({ attendee_ids: attendees, external_attendees: externals })] const [{ data: space }, addresses] = await Promise.all([ adminSupabase.from('spaces').select('name').eq('id', space_id).single(), resolveSpacesAddresses(adminSupabase, userIds), diff --git a/lib/spaces-email.ts b/lib/spaces-email.ts index 1b609c9..0f49722 100644 --- a/lib/spaces-email.ts +++ b/lib/spaces-email.ts @@ -33,6 +33,55 @@ export function isSgaEmail(v: unknown): v is string { return typeof v === 'string' && SGA_EMAIL_PATTERN.test(v) } +/** + * Attendees without a Chambers account (issue #132) -- an interview candidate, + * say -- are stored on a booking by address, beside the user ids in + * attendee_ids. They are held to the same university domain as SGA inboxes: + * every one of them is sent invites from Chambers, and the booking form should + * not be a way to email any address at all. + */ +export const MAX_EXTERNAL_ATTENDEES = 25 + +/** + * The external attendees in a request body, trimmed, lowercased and + * deduplicated -- or null when any entry is not a university address, so the + * route can refuse the whole request rather than quietly drop someone. + */ +export function parseExternalAttendees(v: unknown): string[] | null { + if (v === undefined || v === null) return [] + if (!Array.isArray(v)) return null + const emails: string[] = [] + for (const raw of v) { + if (typeof raw !== 'string') return null + const email = raw.trim().toLowerCase() + if (!isSgaEmail(email)) return null + if (!emails.includes(email)) emails.push(email) + } + return emails.length > MAX_EXTERNAL_ATTENDEES ? null : emails +} + +export const EXTERNAL_ATTENDEES_ERROR = + `External attendees must be @northeastern.edu addresses, up to ${MAX_EXTERNAL_ATTENDEES} per booking.` + +const EXTERNAL_KEY_PREFIX = 'email:' + +/** + * Every attendee of a booking or series as one list of keys: user ids as they + * are, external addresses as `email:
`. resolveSpacesAddresses accepts + * both, so the code that works out who to email -- who was added, who was + * dropped, who gets a cancellation -- handles both kinds of attendee without + * knowing there are two. + */ +export function attendeeKeys(row: { + attendee_ids?: string[] | null + external_attendees?: string[] | null +}): string[] { + return [ + ...(row.attendee_ids ?? []), + ...(row.external_attendees ?? []).map(e => `${EXTERNAL_KEY_PREFIX}${e.toLowerCase()}`), + ] +} + /** One inbox a person may choose, with the bodies that make it available to them. */ export interface SgaEmailOption { email: string @@ -126,14 +175,19 @@ export function spacesAddressesFor( /** * Resolves each of `userIds` to the addresses their SGA Spaces emails go to. - * A user with no row or no address maps to an empty list. + * A user with no row or no address maps to an empty list. An external + * attendee's key (see attendeeKeys) maps to its own address. */ export async function resolveSpacesAddresses( adminSupabase: SupabaseClient, userIds: string[] ): Promise> { - const ids = [...new Set(userIds.filter(Boolean))] const result = new Map() + const ids: string[] = [] + for (const key of new Set(userIds.filter(Boolean))) { + if (key.startsWith(EXTERNAL_KEY_PREFIX)) result.set(key, [key.slice(EXTERNAL_KEY_PREFIX.length)]) + else ids.push(key) + } if (ids.length === 0) return result const [{ data: users }, options] = await Promise.all([ diff --git a/supabase/migrations/20260916010000_space_external_attendees.sql b/supabase/migrations/20260916010000_space_external_attendees.sql new file mode 100644 index 0000000..a5974de --- /dev/null +++ b/supabase/migrations/20260916010000_space_external_attendees.sql @@ -0,0 +1,30 @@ +-- Attendees without a Chambers account on SGA Space bookings (issue #132). +-- +-- attendee_ids can only name Chambers users, so someone from outside -- an +-- interview candidate, a guest from another office -- could not be put on a +-- booking or sent its invite. Their addresses are stored beside the ids, on each +-- booking and on the series that creates weekly ones. +-- +-- The API holds every address to @northeastern.edu and caps the list; the +-- constraint here only bounds its size. +-- +-- Additive: code from before this migration never reads or writes the column. + +alter table public.space_bookings + add column if not exists external_attendees text[] not null default '{}'; + +alter table public.space_booking_series + add column if not exists external_attendees text[] not null default '{}'; + +alter table public.space_bookings + add constraint space_bookings_external_attendees_size + check (cardinality(external_attendees) <= 25); + +alter table public.space_booking_series + add constraint space_booking_series_external_attendees_size + check (cardinality(external_attendees) <= 25); + +comment on column public.space_bookings.external_attendees is + 'Attendees without a Chambers account, by @northeastern.edu address (issue #132). Sent the same invites as attendee_ids.'; +comment on column public.space_booking_series.external_attendees is + 'External attendees every week of the series is created with (issue #132).'; diff --git a/supabase/migrations/rollback/20260916_space_external_attendees_rollback.sql b/supabase/migrations/rollback/20260916_space_external_attendees_rollback.sql new file mode 100644 index 0000000..40482f9 --- /dev/null +++ b/supabase/migrations/rollback/20260916_space_external_attendees_rollback.sql @@ -0,0 +1,10 @@ +-- Rollback for 20260916010000_space_external_attendees.sql. +-- +-- External attendees are dropped from every booking and series. They keep any +-- invite already sent, and are not told the booking has changed afterwards. + +alter table public.space_bookings + drop column if exists external_attendees; + +alter table public.space_booking_series + drop column if exists external_attendees; From 5df7fdd845f3a7de5b4ca63571ef6b7e925c4527 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 16 Sep 2026 20:48:32 -0400 Subject: [PATCH 2/2] chore: rename the #132 migration to the version production recorded Applied to production as 20260917004809_space_external_attendees; contents unchanged. The rollback's header now names the renamed file. Co-Authored-By: Claude Opus 5 --- ...ttendees.sql => 20260917004809_space_external_attendees.sql} | 0 ...lback.sql => 20260917_space_external_attendees_rollback.sql} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename supabase/migrations/{20260916010000_space_external_attendees.sql => 20260917004809_space_external_attendees.sql} (100%) rename supabase/migrations/rollback/{20260916_space_external_attendees_rollback.sql => 20260917_space_external_attendees_rollback.sql} (84%) diff --git a/supabase/migrations/20260916010000_space_external_attendees.sql b/supabase/migrations/20260917004809_space_external_attendees.sql similarity index 100% rename from supabase/migrations/20260916010000_space_external_attendees.sql rename to supabase/migrations/20260917004809_space_external_attendees.sql diff --git a/supabase/migrations/rollback/20260916_space_external_attendees_rollback.sql b/supabase/migrations/rollback/20260917_space_external_attendees_rollback.sql similarity index 84% rename from supabase/migrations/rollback/20260916_space_external_attendees_rollback.sql rename to supabase/migrations/rollback/20260917_space_external_attendees_rollback.sql index 40482f9..34e6efe 100644 --- a/supabase/migrations/rollback/20260916_space_external_attendees_rollback.sql +++ b/supabase/migrations/rollback/20260917_space_external_attendees_rollback.sql @@ -1,4 +1,4 @@ --- Rollback for 20260916010000_space_external_attendees.sql. +-- Rollback for 20260917004809_space_external_attendees.sql. -- -- External attendees are dropped from every booking and series. They keep any -- invite already sent, and are not told the booking has changed afterwards.