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/20260917004809_space_external_attendees.sql b/supabase/migrations/20260917004809_space_external_attendees.sql new file mode 100644 index 0000000..a5974de --- /dev/null +++ b/supabase/migrations/20260917004809_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/20260917_space_external_attendees_rollback.sql b/supabase/migrations/rollback/20260917_space_external_attendees_rollback.sql new file mode 100644 index 0000000..34e6efe --- /dev/null +++ b/supabase/migrations/rollback/20260917_space_external_attendees_rollback.sql @@ -0,0 +1,10 @@ +-- 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. + +alter table public.space_bookings + drop column if exists external_attendees; + +alter table public.space_booking_series + drop column if exists external_attendees;