From bc69917a19d5f768c26cfb1d052f639ea7d15f26 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 16 Sep 2026 10:17:15 -0400 Subject: [PATCH 1/8] fix: give the all-spaces calendar a phone layout (#125) Seven days times one lane per space is 21 columns. On a phone that left each about 17px -- not a layout to tune but a layout with no phone form at all, since a lane that narrow cannot carry a title, a name, or a reliable tap target. Narrow screens now show one day at a time, with every space still side by side. That keeps what the view is for: "which room is free at 3pm" is the question it exists to answer, and it cannot be answered one room at a time. A day picker above the grid moves between the days of the week on screen, marking today distinctly from the day being shown, and the sticky day header is dropped there -- with a single day on screen it spent ~100px of a phone's height repeating what the picker already said. Where the switch happens depends on how many lanes a day carries, because the calendar never gets the whole viewport: the shell's sidebar takes 224px from md up and
adds 64px of padding. Two spaces hold up to lg, three to xl, and more than that to 2xl. A 1024px window with three spaces gives each lane 32px, which is the crushing this issue is about rather than a fix for it. Done entirely in CSS, with all seven days rendered and all but one hidden. Measuring the viewport in JS would mean either a server render that is wrong for half the visitors and corrects itself after hydration, or no server render at all; this way the markup is right at every width on the first paint. Single-space weeks are untouched at every width -- seven columns of one lane are what that view has always been, and they are fine. Verified at 375, 768, 1024 and 1280: the day picker switches days, tapping a free slot still selects the hour and reports the spaces free for it, the advance notice window still refuses a slot inside it, and the week returns unchanged above the breakpoint. Co-Authored-By: Claude Opus 5 --- app/(dashboard)/sga-spaces/space-calendar.tsx | 122 +++++++++++++++++- 1 file changed, 119 insertions(+), 3 deletions(-) diff --git a/app/(dashboard)/sga-spaces/space-calendar.tsx b/app/(dashboard)/sga-spaces/space-calendar.tsx index 058ae28..5b5eb97 100644 --- a/app/(dashboard)/sga-spaces/space-calendar.tsx +++ b/app/(dashboard)/sga-spaces/space-calendar.tsx @@ -151,6 +151,16 @@ export default function SpaceCalendar({ const [overlayCursor, setOverlayCursor] = useState('crosshair') const [hoveredBookingId, setHoveredBookingId] = useState(null) + /** + * Which day the narrow layout is showing, remembered against the week it was + * chosen in (issue #125). + * + * Paired with its week rather than reset by an effect: stepping to another + * week should go back to the default day, and deriving that during render + * avoids a setState-in-effect and the extra paint that comes with it. + */ + const [daySelection, setDaySelection] = useState<{ week: number; day: number } | null>(null) + // ── Lanes: one per space in the All spaces view, otherwise just one ───────── // A single space is the one-lane case of the same logic, so it behaves exactly // as it did: the server has already filtered bookings and blackouts to it. @@ -170,6 +180,63 @@ export default function SpaceCalendar({ }) }, [weekStart, isCurrentWeek, todayDay]) + /* + ── One day at a time on a narrow screen (issue #125) ────────────────────── + Seven days times one lane per space is 21 columns, and at phone width that + left each about 17px -- not a layout to tune, a layout with no phone form. + Narrow screens show a single day instead, keeping every space side by side, + because "which room is free at 3pm" is the question this view exists to + answer and it cannot be answered one room at a time. + + Done entirely in CSS, with all seven days still rendered and all but one + hidden. Measuring the viewport in JS would mean either a server render that + is wrong for half the visitors and corrects itself after hydration, or no + server render at all; this way the markup is right at every width on the + first paint. + + Single-space weeks are untouched at every width. Seven columns of one lane + are what that view has always been, and they are fine. + */ + const dayAtATime = laneSpaces !== null + + /** + * Where the week stops fitting, which depends on how many lanes a day carries. + * + * The calendar never gets the whole viewport: the shell's sidebar takes 224px + * from `md` up and
adds 64px of padding, so at a 1280px window the grid + * is about 934px -- roughly 133px per day, which three spaces divide into 44px + * each. The same window with five spaces gives 27px, and 1024px with three + * gives 32px, which is the crushing this issue is about rather than a fix for + * it. So the switch moves outward as lanes are added. + * + * Spelled as whole literal class names, never interpolated, because Tailwind + * finds classes by scanning the source for exactly these strings. + */ + const narrowVariant = + laneCount <= 2 + ? { hide: 'max-lg:hidden', edge: 'max-lg:border-r-0', picker: 'lg:hidden' } + : laneCount === 3 + ? { hide: 'max-xl:hidden', edge: 'max-xl:border-r-0', picker: 'xl:hidden' } + : { hide: 'max-2xl:hidden', edge: 'max-2xl:border-r-0', picker: '2xl:hidden' } + + const selectedDay = daySelection?.week === weekStart.getTime() + ? daySelection.day + // Opening on today is right far more often than opening on Sunday, and on + // any other week there is no better guess than the start of it. + : (isCurrentWeek ? todayDay : 0) + + /** + * Hides every day column but the selected one while the week does not fit; a + * no-op for a single space. Only the grid needs this -- the header row above + * it is dropped whole. + */ + const dayVisibilityCls = (dayIdx: number) => + !dayAtATime ? '' : dayIdx === selectedDay + // The one visible column sits against the container's own border here, so + // its divider would draw a line just inside the rounded edge. + ? narrowVariant.edge + : narrowVariant.hide + // ── Booking spans per day ──────────────────────────────────────────────────── interface BookingSpan { booking: Booking; startSlot: number; endSlot: number; lane: number } const bookingsByDay: BookingSpan[][] = useMemo(() => { @@ -419,9 +486,58 @@ export default function SpaceCalendar({ )} + {/* + The day picker for the narrow layout. Only rendered for the all-spaces + view, and only shown at the widths where the grid is down to one day. + + Seven buttons across a 375px phone is ~47px each, which clears the 44px + tap target the rest of the app is built to. + */} + {dayAtATime && ( +
+ {dayLabels.map((dl, i) => { + const isSelected = i === selectedDay + return ( + + ) + })} +
+ )} +
- {/* Sticky day header */} -
+ {/* + Sticky day header. Dropped in the narrow all-spaces layout: with one + day on screen it would spend ~100px of a phone's height repeating what + the picker above already says, and the picker sits outside the scroll + area so it stays visible without needing to be sticky at all. + + slotFromClientY reads this element's offsetHeight, which is 0 once it + is display:none, so the slot maths follows without being told. + */} +
{dayLabels.map((dl, i) => (
{/* Hour grid lines */} From f8052552c394357b782aa2c75447970f58a2b8c0 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 16 Sep 2026 10:20:12 -0400 Subject: [PATCH 2/8] feat: calendar invites for room bookings (#69) Only SGA Spaces bookings reached Outlook. Room bookings now do too, by the same means: the email a body already receives carries the invite, so no address is emailed that was not being emailed before. What goes on a calendar follows the allow-list Slack reminders use -- Reserved, the three Alternates and Virtual -- with Tentative sent as a tentative event. Waitlisted stays off until an administrator settles it, Pending Cancellation leaves the calendar untouched until the request is decided, and everything else comes off. An edit is a diff: weeks that are still meetings are re-sent, and ones that stopped being meetings, including weeks trimmed off a series, are cancelled. Past sessions are never touched, and a session nobody was invited to is never cancelled at them. Three paths changed a status and told the body nothing -- the admin Cancel button, approving a cancellation request, and Auto-Cancel, which emailed CSC alone. Each now sends a cancellation that also clears the event. A meeting moving online keeps its event, with Virtual as the location. One-time sessions are written in place, keyed on their id, the treatment issue #113 gave weekly occurrences: the delete-and-reinsert gave every session a new id on each save, so an invite had nothing stable to name and calendars would have collected an event per edit. Senate members who follow only some session types get only those sessions on their calendar, so one booking can send an email per audience. Every other body is a single email, as before. The VCALENDAR wrapper, escaping and SEQUENCE move to ics-core and are shared with the Spaces invites, whose output is unchanged byte for byte. Co-Authored-By: Claude Opus 5 --- .../bookings/edit-one-time-form.tsx | 7 + .../administrator/bookings/cancel/route.ts | 45 ++++ .../administrator/bookings/one-time/route.ts | 145 ++++++++--- .../administrator/bookings/weekly/route.ts | 118 +++++++-- .../cancellations/auto-cancel/route.ts | 16 ++ app/api/administrator/cancellations/route.ts | 14 + lib/booking-scope.ts | 6 + lib/emails/booking-cancelled.ts | 107 ++++++++ lib/emails/booking-created.ts | 14 +- lib/emails/booking-updated.ts | 15 +- lib/emails/ics-core.ts | 70 +++++ lib/emails/room-ics.ts | 98 +++++++ lib/emails/space-ics.ts | 59 +---- lib/room-calendar.ts | 203 +++++++++++++++ lib/room-invites.ts | 239 ++++++++++++++++++ 15 files changed, 1039 insertions(+), 117 deletions(-) create mode 100644 lib/emails/booking-cancelled.ts create mode 100644 lib/emails/ics-core.ts create mode 100644 lib/emails/room-ics.ts create mode 100644 lib/room-calendar.ts create mode 100644 lib/room-invites.ts diff --git a/app/(dashboard)/bookings/edit-one-time-form.tsx b/app/(dashboard)/bookings/edit-one-time-form.tsx index 431083f..6ee0083 100644 --- a/app/(dashboard)/bookings/edit-one-time-form.tsx +++ b/app/(dashboard)/bookings/edit-one-time-form.tsx @@ -28,6 +28,12 @@ interface Body { } interface OneTimeSession { + /** + * The stored row this edits, sent back so the save writes it in place rather + * than replacing it -- which is what keeps its calendar invite pointing at the + * same event (issue #69). Absent for a session added here. + */ + id?: string room_name: string booking_date: string start_time: string @@ -85,6 +91,7 @@ export default function EditOneTimeForm({ booking, bodies, onClose, onSuccess }: const [sessions, setSessions] = useState( booking.one_time_room_bookings?.map(d => ({ + id: d.id, room_name: d.room_name ?? '', booking_date: d.booking_date ?? '', start_time: d.start_time.slice(0, 5) ?? '', diff --git a/app/api/administrator/bookings/cancel/route.ts b/app/api/administrator/bookings/cancel/route.ts index 9140752..a410d0f 100644 --- a/app/api/administrator/bookings/cancel/route.ts +++ b/app/api/administrator/bookings/cancel/route.ts @@ -3,6 +3,8 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' +import { notifyCancelledReservations, type CancelledReservation } from '@/lib/room-invites' +import { waitUntil } from '@vercel/functions' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -22,6 +24,35 @@ export async function POST(request: Request) { const { booking_id, scope, occurrence_id } = await request.json() + /** + * The sessions this cancel is about, read before the write so the body can be + * told what was cancelled and their calendars can be corrected (issue #69). + * Only one-time sessions: tabling has no calendar invites, and the weekly + * editor cancels weeks through its own PATCH. + */ + let cancelled: CancelledReservation[] = [] + const readOneTimeSessions = async () => { + let query = adminSupabase + .from('one_time_room_bookings') + .select('id, booking_date, start_time, end_time, room_name, status') + .eq('booking_id', booking_id) + if (scope === 'occurrence' && occurrence_id) query = query.eq('id', occurrence_id) + const { data } = await query + return ((data ?? []) as { id: string; booking_date: string; start_time: string; end_time: string; room_name: string | null; status: string }[]) + // A session already cancelled needs no second notice. + .filter(r => r.status !== 'Cancelled') + .map(r => ({ + source: 'one_time' as const, + id: r.id, + bookingId: booking_id, + resultingStatus: 'Cancelled' as const, + date: r.booking_date, + startTime: r.start_time, + endTime: r.end_time, + roomOrTable: r.room_name ?? '', + })) + } + const { data: bookingRow } = await adminSupabase .from('bookings') .select('type') @@ -31,6 +62,7 @@ export async function POST(request: Request) { if (scope === 'occurrence' && occurrence_id) { if (bookingType === 'One-Time Room') { + cancelled = await readOneTimeSessions() const { error } = await adminSupabase .from('one_time_room_bookings') .update({ status: 'Cancelled' }) @@ -46,6 +78,7 @@ export async function POST(request: Request) { } else { // series scope — cancel all sessions if (bookingType === 'One-Time Room') { + cancelled = await readOneTimeSessions() const { error } = await adminSupabase .from('one_time_room_bookings') .update({ status: 'Cancelled' }) @@ -68,5 +101,17 @@ export async function POST(request: Request) { } } + if (cancelled.length) { + waitUntil( + (async () => { + try { + await notifyCancelledReservations(cancelled) + } catch (e) { + console.error('Cancellation notice failed:', e) + } + })() + ) + } + return NextResponse.json({ success: true }) } diff --git a/app/api/administrator/bookings/one-time/route.ts b/app/api/administrator/bookings/one-time/route.ts index 1f3413e..113f984 100644 --- a/app/api/administrator/bookings/one-time/route.ts +++ b/app/api/administrator/bookings/one-time/route.ts @@ -6,6 +6,8 @@ import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated' import { sendBookingCreatedEmail } from '@/lib/emails/booking-created' import { changed, collectChanges, formatDate, formatTime } from '@/lib/emails/changes' import { checkRateLimit } from '@/lib/check-rate-limit' +import { planInvites } from '@/lib/room-calendar' +import { appToday, oneTimeRoomSessions, sendPerAudience, type OneTimeRow } from '@/lib/room-invites' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' import { waitUntil } from '@vercel/functions' import { @@ -22,6 +24,8 @@ const adminSupabase = createAdminClient( ) interface OneTimeSession { + /** Present for a session that already exists; absent for one just added. */ + id?: string room_name: string booking_date: string start_time: string @@ -95,9 +99,12 @@ export async function POST(request: Request) { status: s.status, })) - const { error: detailError } = await adminSupabase + // Selected back for their ids, which the calendar invite uses as its event + // ids (issue #69). + const { data: createdSessions, error: detailError } = await adminSupabase .from('one_time_room_bookings') .insert(sessionRows) + .select('id, booking_date, start_time, end_time, status, room_name') if (detailError) return NextResponse.json({ error: detailError.message }, { status: 500 }) @@ -120,19 +127,29 @@ export async function POST(request: Request) { const { data: bodyData } = await adminSupabase .from('bodies').select('name').eq('id', selection.value.body_id).single() - await sendBookingCreatedEmail({ - bodyName: bodyData?.name ?? 'Unknown', - bookingType: 'One-Time Room', - purpose, - roomOrTable: sessionRows[0]?.room_name || 'N/A', - status: sessionRows[0]?.status ?? 'Reserved', - sessions: sessionRows.map((r: { booking_date: string; start_time: string; end_time: string; room_name: string | null }) => ({ - date: r.booking_date, - startTime: r.start_time, - endTime: r.end_time, - roomOrTable: r.room_name, - })), - recipients: recipients.map(r => r.email), + const bodyName = bodyData?.name ?? 'Unknown' + const plan = planInvites( + null, + oneTimeRoomSessions((createdSessions ?? []) as OneTimeRow[], purpose, bodyName), + appToday() + ) + + await sendPerAudience(recipients, plan, bodyName, async audience => { + await sendBookingCreatedEmail({ + bodyName, + bookingType: 'One-Time Room', + purpose, + roomOrTable: sessionRows[0]?.room_name || 'N/A', + status: sessionRows[0]?.status ?? 'Reserved', + sessions: sessionRows.map((r: { booking_date: string; start_time: string; end_time: string; room_name: string | null }) => ({ + date: r.booking_date, + startTime: r.start_time, + endTime: r.end_time, + roomOrTable: r.room_name, + })), + recipients: audience.recipients, + invite: audience.plan, + }) }) } catch (e) { console.error('Booking created email failed:', e) @@ -167,7 +184,7 @@ export async function PATCH(request: Request) { adminSupabase.from('bookings').select('purpose').eq('id', booking_id).single(), adminSupabase .from('one_time_room_bookings') - .select('room_name, booking_date, start_time, end_time, status, reservation_code') + .select('id, room_name, booking_date, start_time, end_time, status, reservation_code') .eq('booking_id', booking_id) .order('booking_date', { ascending: true }), ]) @@ -190,14 +207,6 @@ export async function PATCH(request: Request) { ) if (bodiesError) return NextResponse.json({ error: bodiesError }, { status: 500 }) - // Delete existing session rows and reinsert - const { error: deleteError } = await adminSupabase - .from('one_time_room_bookings') - .delete() - .eq('booking_id', booking_id) - - if (deleteError) return NextResponse.json({ error: deleteError.message }, { status: 500 }) - const sessionRows = sessions.map((s: OneTimeSession) => ({ booking_id, room_name: s.room_name || null, @@ -208,11 +217,46 @@ export async function PATCH(request: Request) { status: s.status, })) - const { error: insertError } = await adminSupabase + // Written in place, keyed on the session's own id -- the treatment issue #113 + // gave weekly occurrences, for the same reason (issue #69). Deleting every row + // and reinserting gave each session a new id on every save, so a calendar + // invite had nothing stable to name and would add an event per edit rather + // than moving the one it had. + // + // Updates and inserts run before the delete: if that last step fails, the + // booking is left with a session too many rather than with none at all. + const kept: string[] = [] + for (const [i, s] of (sessions as OneTimeSession[]).entries()) { + if (!s.id) continue + const { error } = await adminSupabase + .from('one_time_room_bookings') + .update(sessionRows[i]) + .eq('id', s.id) + .eq('booking_id', booking_id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + kept.push(s.id) + } + + const toInsert = sessionRows.filter((_: unknown, i: number) => !(sessions as OneTimeSession[])[i].id) + if (toInsert.length) { + const { data: inserted, error: insertError } = await adminSupabase + .from('one_time_room_bookings') + .insert(toInsert) + .select('id') + if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 }) + for (const row of (inserted ?? []) as { id: string }[]) kept.push(row.id) + } + + // Asked of the table rather than of the payload, so a session removed in the + // editor goes even if the read above failed. + let staleQuery = adminSupabase .from('one_time_room_bookings') - .insert(sessionRows) + .delete() + .eq('booking_id', booking_id) + if (kept.length) staleQuery = staleQuery.not('id', 'in', `(${kept.join(',')})`) - if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 }) + const { error: deleteError } = await staleQuery + if (deleteError) return NextResponse.json({ error: deleteError.message }, { status: 500 }) const firstSession = sessions[0] as OneTimeSession @@ -257,11 +301,9 @@ export async function PATCH(request: Request) { waitUntil( (async () => { try { - const emails = recipients.map(r => r.email) - // Sessions are replaced wholesale rather than edited in place, so they - // are compared position by position against the previous list, sorted - // the same way. A change in how many there are is reported on its own - // line, since pairing them up past that point would invent moves. + // Sessions are compared position by position against the previous list, + // sorted the same way. A change in how many there are is reported on its + // own line, since pairing them up past that point would invent moves. const prevFirst = (prevSessions ?? [])[0] const sessionCountChange = changed( 'Sessions', @@ -280,16 +322,37 @@ export async function PATCH(request: Request) { changed('Reservation code', prevFirst?.reservation_code, firstSession.reservation_code), ) - await sendBookingUpdatedEmail({ - bodyName, - purpose, - roomOrTable: firstSession.room_name || 'N/A', - date: firstSession.booking_date, - startTime: firstSession.start_time, - endTime: firstSession.end_time, - status: firstSession.status, - changes, - recipients: emails, + // What this edit does to calendars: sessions that are still meetings go + // on or move, and ones that stopped being meetings -- cancelled, or + // removed in the editor -- come off. + const { data: storedSessions } = await adminSupabase + .from('one_time_room_bookings') + .select('id, booking_date, start_time, end_time, status, room_name') + .eq('booking_id', booking_id) + + const plan = planInvites( + oneTimeRoomSessions( + ((prevSessions ?? []) as OneTimeRow[]), + prevBooking?.purpose ?? null, + bodyName + ), + oneTimeRoomSessions((storedSessions ?? []) as OneTimeRow[], purpose, bodyName), + appToday() + ) + + await sendPerAudience(recipients, plan, bodyName, async audience => { + await sendBookingUpdatedEmail({ + bodyName, + purpose, + roomOrTable: firstSession.room_name || 'N/A', + date: firstSession.booking_date, + startTime: firstSession.start_time, + endTime: firstSession.end_time, + status: firstSession.status, + changes, + recipients: audience.recipients, + invite: audience.plan, + }) }) } catch (e) { console.error('Booking updated email failed:', e) diff --git a/app/api/administrator/bookings/weekly/route.ts b/app/api/administrator/bookings/weekly/route.ts index 2446a94..d599813 100644 --- a/app/api/administrator/bookings/weekly/route.ts +++ b/app/api/administrator/bookings/weekly/route.ts @@ -7,6 +7,8 @@ import { sendBookingCreatedEmail } from '@/lib/emails/booking-created' import { changed, collectChanges, formatDate, formatTime } from '@/lib/emails/changes' import { occurrenceMoved } from '@/lib/weekly-occurrences' import { checkRateLimit } from '@/lib/check-rate-limit' +import { planInvites } from '@/lib/room-calendar' +import { appToday, sendPerAudience, weeklyRoomSessions, type OccurrenceRow } from '@/lib/room-invites' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' import { waitUntil } from '@vercel/functions' @@ -46,6 +48,8 @@ const adminSupabase = createAdminClient( /** One stored occurrence as read back before the write below. */ interface PrevOccurrenceRow { + /** Read for the calendar invite: the event id a recipient already holds (issue #69). */ + id: string occurrence_date: string room_name: string | null start_time: string | null @@ -141,9 +145,13 @@ export async function POST(request: Request) { occurrence_date: date, })) - const { error: occurrenceError } = await adminSupabase + // Selected back for their ids, which the calendar invite uses as its event + // ids (issue #69). They are stable across later edits, so an edit moves the + // event a recipient already has rather than adding a second one. + const { data: createdOccurrences, error: occurrenceError } = await adminSupabase .from('weekly_room_occurrences') .insert(occurrences) + .select('id, occurrence_date, room_name, start_time, end_time, status, senate_type, purpose') if (occurrenceError) return NextResponse.json({ error: occurrenceError.message }, { status: 500 }) @@ -168,17 +176,34 @@ export async function POST(request: Request) { const { data: bodyData } = await adminSupabase .from('bodies').select('name').eq('id', selection.value.body_id).single() - await sendBookingCreatedEmail({ - bodyName: bodyData?.name ?? 'Unknown', - bookingType: 'Weekly Room', - purpose, - roomOrTable: room_name || 'N/A', - status, - dateRange: { start: start_date, end: end_date }, - // Freshly generated, so every occurrence carries the series' room and - // time -- there are no per-week overrides to report yet. - sessions: dates.map(d => ({ date: d, startTime: start_time, endTime: end_time })), - recipients: recipients.map(r => r.email), + const bodyName = bodyData?.name ?? 'Unknown' + + // Everything upcoming goes on calendars; nothing comes off, since this + // booking has never been sent to anybody. + const plan = planInvites( + null, + weeklyRoomSessions( + (createdOccurrences ?? []) as OccurrenceRow[], + { room_name, start_time, end_time, status, purpose }, + bodyName + ), + appToday() + ) + + await sendPerAudience(recipients, plan, bodyName, async audience => { + await sendBookingCreatedEmail({ + bodyName, + bookingType: 'Weekly Room', + purpose, + roomOrTable: room_name || 'N/A', + status, + dateRange: { start: start_date, end: end_date }, + // Freshly generated, so every occurrence carries the series' room and + // time -- there are no per-week overrides to report yet. + sessions: dates.map(d => ({ date: d, startTime: start_time, endTime: end_time })), + recipients: audience.recipients, + invite: audience.plan, + }) }) } catch (e) { console.error('Booking created email failed:', e) @@ -220,7 +245,7 @@ export async function PATCH(request: Request) { adminSupabase .from('weekly_room_occurrences') .select( - 'occurrence_date, room_name, start_time, end_time, status, reservation_code, purpose, senate_type, hidden, is_event' + 'id, occurrence_date, room_name, start_time, end_time, status, reservation_code, purpose, senate_type, hidden, is_event' ) .eq('weekly_booking_id', weekly_id), ]) @@ -306,6 +331,14 @@ export async function PATCH(request: Request) { const { error: staleError } = await staleQuery if (staleError) return NextResponse.json({ error: staleError.message }, { status: 500 }) + // Read back after the write, for the ids the calendar invite needs (issue + // #69). A week the series still covers kept the id it was created with, so a + // recipient's event is moved rather than duplicated. + const { data: storedOccurrences } = await adminSupabase + .from('weekly_room_occurrences') + .select('id, occurrence_date, room_name, start_time, end_time, status, senate_type, purpose') + .eq('weekly_booking_id', weekly_id) + const { data: auditLog } = await adminSupabase .from('audit_logs') .insert({ booking_id, admin_id: user.id, new_status: status }) @@ -392,8 +425,6 @@ export async function PATCH(request: Request) { waitUntil( (async () => { try { - const emails = recipients.map(r => r.email) - // When the series itself did not move, the edit was to the weeks that // moved -- so the email describes those weeks. If the series moved too, // the series is the story and a per-week heading would understate it. @@ -427,17 +458,52 @@ export async function PATCH(request: Request) { } }) - await sendBookingUpdatedEmail({ - bodyName, - purpose, - roomOrTable: room_name || 'N/A', - date: start_date, - startTime: start_time, - endTime: end_time, - status, - changes: seriesChanges, - sessions, - recipients: emails, + // What this edit does to calendars: every upcoming week that is still a + // meeting is (re)sent, and one that stopped being a meeting -- cancelled, + // waitlisted, or trimmed off the end of the series -- is taken off. + const plan = planInvites( + weeklyRoomSessions( + ((prevOccurrences ?? []) as PrevOccurrenceRow[]).map(o => ({ + id: o.id, + occurrence_date: o.occurrence_date, + room_name: o.room_name, + start_time: o.start_time, + end_time: o.end_time, + status: o.status, + senate_type: o.senate_type, + purpose: o.purpose, + })), + { + room_name: prevWeekly?.room_name ?? null, + start_time: prevWeekly?.start_time ?? start_time, + end_time: prevWeekly?.end_time ?? end_time, + status: prevWeekly?.status ?? status, + purpose: prevBooking?.purpose ?? null, + }, + bodyName + ), + weeklyRoomSessions( + (storedOccurrences ?? []) as OccurrenceRow[], + { room_name, start_time, end_time, status, purpose }, + bodyName + ), + appToday() + ) + + await sendPerAudience(recipients, plan, bodyName, async audience => { + await sendBookingUpdatedEmail({ + bodyName, + purpose, + roomOrTable: room_name || 'N/A', + date: start_date, + startTime: start_time, + endTime: end_time, + status, + changes: seriesChanges, + sessions, + recipients: audience.recipients, + invite: audience.plan, + }) }) } catch (e) { console.error('Booking updated email failed:', e) diff --git a/app/api/administrator/cancellations/auto-cancel/route.ts b/app/api/administrator/cancellations/auto-cancel/route.ts index d51330e..229c816 100644 --- a/app/api/administrator/cancellations/auto-cancel/route.ts +++ b/app/api/administrator/cancellations/auto-cancel/route.ts @@ -3,6 +3,8 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' +import { notifyCancelledReservations } from '@/lib/room-invites' +import { waitUntil } from '@vercel/functions' import { sendCscCancellationRequest } from '@/lib/emails/csc-cancellation-request' import { applyCancellationOutcomes, @@ -193,6 +195,20 @@ export async function POST(request: Request) { // the same state. const failures = await applyCancellationOutcomes(selected) + // The body is told too (issue #69). Auto-Cancel emailed CSC to release the + // room and nobody else, so the meeting stayed on every calendar it had + // reached. waitUntil: the statuses are written, and the admin should not wait + // on a Resend round trip. + waitUntil( + (async () => { + try { + await notifyCancelledReservations(selected) + } catch (e) { + console.error('Cancellation notice failed:', e) + } + })() + ) + // Close the cancellation requests this send acted on, so nobody has to go and // press "Mark as Done" for work Auto-Cancel already did. // diff --git a/app/api/administrator/cancellations/route.ts b/app/api/administrator/cancellations/route.ts index 0d1f176..5b1336b 100644 --- a/app/api/administrator/cancellations/route.ts +++ b/app/api/administrator/cancellations/route.ts @@ -3,6 +3,8 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' +import { notifyCancelledReservations } from '@/lib/room-invites' +import { waitUntil } from '@vercel/functions' import { applyCancellationOutcomes, cancellationAuditRows, @@ -147,6 +149,18 @@ export async function PATCH(request: Request) { const failures = await applyCancellationOutcomes(covered) + // Same notice Auto-Cancel sends, for the same reason: approving a request + // changed a status and told the body nothing (issue #69). + waitUntil( + (async () => { + try { + await notifyCancelledReservations(covered) + } catch (e) { + console.error('Cancellation notice failed:', e) + } + })() + ) + // One entry per booking touched, so the change shows up in the Audit tab // beside every other status change rather than appearing to have happened by // itself. Best effort, as in Auto-Cancel. diff --git a/lib/booking-scope.ts b/lib/booking-scope.ts index a409f95..dcee207 100644 --- a/lib/booking-scope.ts +++ b/lib/booking-scope.ts @@ -338,6 +338,11 @@ export interface Recipient { userId: string email: string fullName: string + /** + * What they follow, for callers that decide session by session rather than + * per email -- calendar invites list one event per session (issue #69). + */ + senatePreferences: Record | null } interface RecipientUser { @@ -442,6 +447,7 @@ export async function resolveBookingRecipients( userId: m.user_id, email: user.email, fullName: user.full_name, + senatePreferences: user.senate_type_preferences ?? null, }) } } diff --git a/lib/emails/booking-cancelled.ts b/lib/emails/booking-cancelled.ts new file mode 100644 index 0000000..c4d5945 --- /dev/null +++ b/lib/emails/booking-cancelled.ts @@ -0,0 +1,107 @@ +import { emailFrom, resend } from '@/lib/resend' +import { sanitize, buildEmailHtml } from './utils' +import { formatDate, formatTime } from './changes' +import { icsSequenceNow } from './ics-core' +import { roomIcsAttachments } from './room-ics' +import type { InvitePlan } from '@/lib/room-calendar' + +/** + * Tells a body that sessions of its booking are off, and takes them off the + * calendars they are on (issue #69). + * + * Three paths cancelled reservations without telling anybody: the admin Cancel + * button, approving a cancellation request, and Auto-Cancel. Auto-Cancel emailed + * CSC to release the room and nobody else, so the body heard nothing and the + * meeting stayed in My Rooms' past and on every calendar that had it. + * + * A meeting moving online is a cancellation of the room, not of the meeting, so + * it says so and keeps the session on the calendar with Virtual as its location. + */ +interface BookingCancelledEmailParams { + bodyName: string + purpose?: string | null + /** Sessions no longer happening in a room, for the wording. */ + cancelled: { date: string; startTime: string; endTime: string; roomOrTable?: string | null }[] + /** Sessions that moved online rather than being called off. */ + virtual?: { date: string; startTime: string; endTime: string }[] + recipients: string[] + /** What to change on calendars: Virtual sessions are re-sent, cancelled ones removed. */ + invite?: InvitePlan | null +} + +const MAX_LISTED = 8 + +function lines(sessions: { date: string; startTime: string; endTime: string; roomOrTable?: string | null }[]): string[] { + return sessions.slice(0, MAX_LISTED).map(s => + `${formatDate(s.date)} · ${formatTime(s.startTime)} to ${formatTime(s.endTime)}${ + s.roomOrTable ? ` · ${sanitize(s.roomOrTable)}` : '' + }` + ) +} + +export async function sendBookingCancelledEmail(params: BookingCancelledEmailParams) { + const { bodyName, purpose, cancelled, virtual = [], recipients, invite } = params + if (!recipients.length || (!cancelled.length && !virtual.length)) return + + const sBodyName = sanitize(bodyName) + const sPurpose = purpose ? sanitize(purpose) : null + + const cancelledLines = lines(cancelled) + const virtualLines = lines(virtual) + const moreCancelled = cancelled.length - cancelledLines.length + const moreVirtual = virtual.length - virtualLines.length + + const lead = cancelled.length && virtual.length + ? `Some sessions of your ${sBodyName} booking have been cancelled, and others are moving online.` + : cancelled.length + ? `${cancelled.length === 1 ? 'A session' : `${cancelled.length} sessions`} of your ${sBodyName} booking ${cancelled.length === 1 ? 'has' : 'have'} been cancelled.` + : `${virtual.length === 1 ? 'A session' : `${virtual.length} sessions`} of your ${sBodyName} booking ${virtual.length === 1 ? 'is' : 'are'} moving online. The room has been released.` + + const groups = [ + { heading: 'Cancelled', items: cancelledLines, more: moreCancelled }, + { heading: 'Moving online', items: virtualLines, more: moreVirtual }, + ].filter(g => g.items.length) + + const text = groups + .map(g => `${g.heading}:\n${g.items.map(l => ` ${l}`).join('\n')}${g.more > 0 ? `\n …and ${g.more} more` : ''}`) + .join('\n\n') + + const html = groups + .map(g => ` +

${g.heading}

+
    + ${g.items.map(l => `
  • ${l}
  • `).join('')} + ${g.more > 0 ? `
  • …and ${g.more} more
  • ` : ''} +
`) + .join('') + + await resend.emails.send({ + from: emailFrom(), + // BCC, matching the other booking emails: recipients are a whole body's + // membership and should not see each other's addresses. + to: process.env.RESEND_FROM_EMAIL!, + bcc: recipients, + subject: `Chambers — Booking Cancelled for ${sBodyName}`, + text: `${lead} + +${sPurpose ? `Purpose: ${sPurpose}\nBody: ${sBodyName}` : `Body: ${sBodyName}`} + +${text} + +You can see this booking in Chambers under My Rooms. + +If you have questions, please reach out to sgaOperations@northeastern.edu.`, + html: buildEmailHtml(` +

${lead}

+

+ ${sPurpose ? `Purpose: ${sPurpose}
` : ''} + Body: ${sBodyName} +

+ ${html} +

You can see this booking in Chambers under My Rooms.

+ `), + ...(invite && (invite.request.length || invite.cancel.length) + ? { attachments: roomIcsAttachments(invite, icsSequenceNow()) } + : {}), + }) +} diff --git a/lib/emails/booking-created.ts b/lib/emails/booking-created.ts index e30c2e6..f91258c 100644 --- a/lib/emails/booking-created.ts +++ b/lib/emails/booking-created.ts @@ -1,6 +1,9 @@ import { emailFrom, resend } from '@/lib/resend' import { sanitize, buildEmailHtml } from './utils' import { formatDate, formatTime } from './changes' +import { icsSequenceNow } from './ics-core' +import { roomIcsAttachments } from './room-ics' +import type { InvitePlan } from '@/lib/room-calendar' /** * One dated slot on the booking. A one-time booking can carry several, a weekly @@ -23,6 +26,12 @@ interface BookingCreatedEmailParams { /** Shown only when set; a weekly series has one, a one-time booking does not. */ dateRange?: { start: string; end: string } | null recipients: string[] + /** + * The sessions to put on the recipients' calendars (issue #69). Attached to + * this email rather than sent as one of its own, because every address counts + * against the Resend quota and these people are being emailed anyway. + */ + invite?: InvitePlan | null } /** @@ -36,7 +45,7 @@ interface BookingCreatedEmailParams { const MAX_LISTED_SESSIONS = 8 export async function sendBookingCreatedEmail(params: BookingCreatedEmailParams) { - const { bodyName, bookingType, purpose, roomOrTable, status, sessions, dateRange, recipients } = params + const { bodyName, bookingType, purpose, roomOrTable, status, sessions, dateRange, recipients, invite } = params if (!recipients.length) return const sBodyName = sanitize(bodyName) @@ -107,5 +116,8 @@ If you have questions, please reach out to sgaOperations@northeastern.edu.`,

You can see this booking, and request a change to it, in Chambers under My Rooms.

`), + ...(invite && (invite.request.length || invite.cancel.length) + ? { attachments: roomIcsAttachments(invite, icsSequenceNow()) } + : {}), }) } diff --git a/lib/emails/booking-updated.ts b/lib/emails/booking-updated.ts index 74d65d0..063471e 100644 --- a/lib/emails/booking-updated.ts +++ b/lib/emails/booking-updated.ts @@ -1,6 +1,9 @@ import { emailFrom, resend } from '@/lib/resend' import { sanitize, buildEmailHtml } from './utils' import { formatDate, formatTime, renderChanges, type BookingChange } from './changes' +import { icsSequenceNow } from './ics-core' +import { roomIcsAttachments } from './room-ics' +import type { InvitePlan } from '@/lib/room-calendar' /** * One session of a repeating booking that an edit actually moved. @@ -48,6 +51,13 @@ interface BookingUpdatedEmailParams { sessions?: UpdatedSession[] | null /** Shown above the details when set, e.g. the booking's purpose. */ purpose?: string | null + /** + * How this edit changes the recipients' calendars (issue #69): the sessions to + * put on or move, and the ones to take off. Attached here rather than sent + * separately, since these people are being emailed anyway and every address + * counts against the Resend quota. + */ + invite?: InvitePlan | null } /** The "Body / Room / Date / Time / Status" block, in both bodies of the email. */ @@ -95,7 +105,7 @@ ${details.text.split('\n').map(l => ` ${l}`).join('\n')}`, export async function sendBookingUpdatedEmail(params: BookingUpdatedEmailParams) { const { bodyName, roomOrTable, date, startTime, endTime, status, recipients, - changes = [], sessions = null, purpose = null, + changes = [], sessions = null, purpose = null, invite = null, } = params if (!recipients.length) return @@ -126,6 +136,9 @@ If you have questions, please reach out to sgaOperations@northeastern.edu.`,

${lead}

${html} `), + ...(invite && (invite.request.length || invite.cancel.length) + ? { attachments: roomIcsAttachments(invite, icsSequenceNow()) } + : {}), }) } diff --git a/lib/emails/ics-core.ts b/lib/emails/ics-core.ts new file mode 100644 index 0000000..27c3b5f --- /dev/null +++ b/lib/emails/ics-core.ts @@ -0,0 +1,70 @@ +/** + * The parts of an iCalendar file every Chambers invite shares (issue #69). + * + * SGA Spaces has sent invites since its confirmation email was written, and room + * bookings now send them too. Both need the same VTIMEZONE block, the same + * escaping and the same monotonic SEQUENCE, and two copies of that would drift. + * What differs is where the times come from -- a Space stores an instant, a room + * booking stores a date and a clock time -- so each builds its own VEVENT lines + * and hands them here to be wrapped. + */ + +function pad(n: number): string { + return String(n).padStart(2, '0') +} + +/** DTSTAMP: when the file was generated, in real UTC. */ +export function icsUtcStamp(date: Date = new Date()): string { + return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z` +} + +export function escapeIcs(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n') +} + +/** + * A SEQUENCE that is always higher than any this app issued before. + * + * Calendars ignore an update or cancellation whose SEQUENCE is not above the one + * they hold, and an event may be updated any number of times, so a fixed value + * cannot work. Whole seconds since the epoch only ever increase and fit the + * 32-bit integer calendars expect. + */ +export function icsSequenceNow(): number { + return Math.floor(Date.now() / 1000) +} + +/** Lets Outlook resolve the TZID on DTSTART/DTEND correctly. */ +export const VTIMEZONE_LINES = [ + 'BEGIN:VTIMEZONE', + 'TZID:America/New_York', + 'BEGIN:DAYLIGHT', + 'TZOFFSETFROM:-0500', + 'TZOFFSETTO:-0400', + 'TZNAME:EDT', + 'DTSTART:19700308T020000', + 'RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3', + 'END:DAYLIGHT', + 'BEGIN:STANDARD', + 'TZOFFSETFROM:-0400', + 'TZOFFSETTO:-0500', + 'TZNAME:EST', + 'DTSTART:19701101T020000', + 'RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11', + 'END:STANDARD', + 'END:VTIMEZONE', +] + +/** One VCALENDAR holding every event, as the file a mail client attaches. */ +export function buildCalendar(method: 'REQUEST' | 'CANCEL', events: string[][]): Buffer { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Chambers//SGA Room Manager//EN', + `METHOD:${method}`, + ...VTIMEZONE_LINES, + ...events.flat(), + 'END:VCALENDAR', + ] + return Buffer.from(lines.join('\r\n')) +} diff --git a/lib/emails/room-ics.ts b/lib/emails/room-ics.ts new file mode 100644 index 0000000..8657dc4 --- /dev/null +++ b/lib/emails/room-ics.ts @@ -0,0 +1,98 @@ +import { buildCalendar, escapeIcs, icsUtcStamp } from './ics-core' +import { calendarStateOf, type RoomSession } from '../room-calendar' + +/** + * Calendar invites for room bookings (issue #69). + * + * Unlike a Space booking, which stores an instant, a room session stores a date + * and a clock time in Boston local terms -- '2026-09-17' and '18:00:00'. Those + * digits pair directly with TZID=America/New_York, so no conversion is wanted + * here; doing one is how a booking ends up an hour out across a DST change. + */ + +/** '2026-09-17' + '18:00:00' -> '20260917T180000'. */ +function toIcsLocal(date: string, time: string): string { + const [h, m] = time.split(':') + return `${date.replace(/-/g, '')}T${h}${m}00` +} + +/** 'YYYY-MM-DD' plus one day, on the calendar. */ +function nextDay(date: string): string { + const [y, m, d] = date.split('-').map(Number) + return new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10) +} + +/** + * One VCALENDAR holding every session, so one email puts a whole series on a + * calendar rather than sending a message per week. + * + * `sequence` is omitted for a first invite, which calendars read as 0. + */ +export function buildRoomIcs( + method: 'REQUEST' | 'CANCEL', + sessions: RoomSession[], + sequence?: number +): Buffer { + const stamp = icsUtcStamp() + + const blocks = sessions.map(s => { + // A session ending at or before it starts runs past midnight -- 11:00 PM to + // 12:00 AM is the common one -- so its end belongs to the next day. + const endDate = s.endTime.slice(0, 5) <= s.startTime.slice(0, 5) ? nextDay(s.date) : s.date + + const lines = [ + 'BEGIN:VEVENT', + `UID:${s.uid}`, + `DTSTAMP:${stamp}`, + `DTSTART;TZID=America/New_York:${toIcsLocal(s.date, s.startTime)}`, + `DTEND;TZID=America/New_York:${toIcsLocal(endDate, s.endTime)}`, + `SUMMARY:${escapeIcs(s.summary)}`, + `LOCATION:${escapeIcs(s.location)}`, + ] + + if (method === 'CANCEL') { + lines.push('STATUS:CANCELLED') + } else { + // Tentative rides on the event itself rather than on the wording, so + // Outlook hatches it and it counts as busy-tentative. + lines.push(calendarStateOf(s.status) === 'tentative' ? 'STATUS:TENTATIVE' : 'STATUS:CONFIRMED') + lines.push(`DESCRIPTION:${escapeIcs(`${s.status} in Chambers. See My Rooms for the booking.`)}`) + } + + if (sequence !== undefined) lines.push(`SEQUENCE:${sequence}`) + lines.push('END:VEVENT') + return lines + }) + + return buildCalendar(method, blocks) +} + +/** + * The attachments an email carries for one audience's invite, ready to spread + * into a Resend send. Both files share a sequence, so a calendar sees the + * additions and the removals as one revision. + * + * REQUEST and CANCEL cannot share a file: METHOD is a property of the calendar, + * not of the event. + */ +export function roomIcsAttachments( + plan: { request: RoomSession[]; cancel: RoomSession[] }, + sequence: number +): { filename: string; content: Buffer; contentType: string }[] { + const attachments = [] + if (plan.request.length) { + attachments.push({ + filename: 'booking.ics', + content: buildRoomIcs('REQUEST', plan.request, sequence), + contentType: 'text/calendar; method=REQUEST', + }) + } + if (plan.cancel.length) { + attachments.push({ + filename: 'cancel.ics', + content: buildRoomIcs('CANCEL', plan.cancel, sequence), + contentType: 'text/calendar; method=CANCEL', + }) + } + return attachments +} diff --git a/lib/emails/space-ics.ts b/lib/emails/space-ics.ts index 274b24d..3c1f9c5 100644 --- a/lib/emails/space-ics.ts +++ b/lib/emails/space-ics.ts @@ -6,7 +6,13 @@ * cancelling a single week of a series -- which goes through the one-off * cancellation email -- remove exactly that week from a calendar that received * the whole series in one invite. + * + * The VCALENDAR around the events, the escaping and the SEQUENCE are shared with + * room booking invites in ics-core (issue #69). */ +import { buildCalendar, escapeIcs, icsUtcStamp } from './ics-core' + +export { icsSequenceNow } from './ics-core' export interface SpaceIcsEvent { bookingId: string @@ -27,31 +33,10 @@ function toIcsLocal(iso: string): string { return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}` } -// DTSTAMP records when the ICS was generated — must be real UTC with Z. -function toIcsUtc(date: Date): string { - return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z` -} - -function escapeIcs(s: string): string { - return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n') -} - export function spaceIcsUid(bookingId: string): string { return `${bookingId}@chambers.northeasternsga.com` } -/** - * A SEQUENCE that is always higher than any this app issued before. - * - * Calendars ignore an update or cancellation whose SEQUENCE is not above the - * one they hold, and an event may be updated any number of times, so a fixed - * value cannot work once series edits send updated invites. Whole seconds since - * the epoch only ever increase and fit the 32-bit integer calendars expect. - */ -export function icsSequenceNow(): number { - return Math.floor(Date.now() / 1000) -} - /** * One VCALENDAR holding every event. A series goes out as a single file with a * VEVENT per week, so one email puts the whole series on a calendar. @@ -63,34 +48,12 @@ export function buildSpaceIcs( events: SpaceIcsEvent[], sequence?: number ): Buffer { - const stamp = toIcsUtc(new Date()) + const stamp = icsUtcStamp() - const lines = [ - 'BEGIN:VCALENDAR', - 'VERSION:2.0', - 'PRODID:-//Chambers//SGA Room Manager//EN', - `METHOD:${method}`, - // VTIMEZONE lets Outlook resolve the TZID on DTSTART/DTEND correctly. - 'BEGIN:VTIMEZONE', - 'TZID:America/New_York', - 'BEGIN:DAYLIGHT', - 'TZOFFSETFROM:-0500', - 'TZOFFSETTO:-0400', - 'TZNAME:EDT', - 'DTSTART:19700308T020000', - 'RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3', - 'END:DAYLIGHT', - 'BEGIN:STANDARD', - 'TZOFFSETFROM:-0400', - 'TZOFFSETTO:-0500', - 'TZNAME:EST', - 'DTSTART:19701101T020000', - 'RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11', - 'END:STANDARD', - 'END:VTIMEZONE', - ] + const blocks: string[][] = [] for (const e of events) { + const lines: string[] = [] lines.push( 'BEGIN:VEVENT', `UID:${spaceIcsUid(e.bookingId)}`, @@ -107,10 +70,10 @@ export function buildSpaceIcs( } if (sequence !== undefined) lines.push(`SEQUENCE:${sequence}`) lines.push('END:VEVENT') + blocks.push(lines) } - lines.push('END:VCALENDAR') - return Buffer.from(lines.join('\r\n')) + return buildCalendar(method, blocks) } /** "Tuesday, September 16, 2026, 6:00 PM" from a stored space time. */ diff --git a/lib/room-calendar.ts b/lib/room-calendar.ts new file mode 100644 index 0000000..b99cb49 --- /dev/null +++ b/lib/room-calendar.ts @@ -0,0 +1,203 @@ +import { wantsSenateSession } from './senate-types' + +/** + * Which room sessions belong on a calendar, and what an edit has to send to keep + * one honest (issue #69). + * + * Room bookings reach Outlook the way SGA Spaces bookings do: the email carries + * an invite. The hard part is not building the file, it is deciding what the + * file should say after an edit -- a session that stopped being a meeting has to + * be taken off the calendar, or someone turns up to a room that was released. + * A stale calendar entry is worse than none, because people act on it. + * + * Everything here is pure, so the rules can be read and exercised without a + * database. The routes gather the rows; this decides what they mean. + */ + +/** What a status says about whether a session is on a calendar. */ +export type CalendarState = + /** On the calendar, as a meeting that is happening. */ + | 'confirmed' + /** On the calendar, marked tentative -- Outlook hatches it. */ + | 'tentative' + /** Not on the calendar; taken off if it was. */ + | 'off' + /** Left exactly as it is, neither added nor removed. */ + | 'leave' + +/** + * The statuses that put a session on a calendar, matching the allow-list Slack + * reminders use (lib/meeting-reminders.ts): a status is only on the calendar + * when it says plainly that the body is meeting. + * + * Virtual is included. The meeting still happens, so it stays on the calendar -- + * its location says so rather than a room. + */ +const CONFIRMED_STATUSES = new Set([ + 'Reserved', + 'Alternate Room', + 'Alternate Time', + 'Alternate Room and Time', + 'Virtual', +]) + +/** + * What each status does to a calendar. + * + * Tentative goes on hatched: it is a real plan, not yet settled. + * Waitlisted stays off until an administrator settles it, since + * there is no room to go to yet. + * Pending Cancellation leaves the calendar alone. The request has not been + * decided, and removing the event now would mean putting + * it back if the request is denied. + * Cancelled, Unavailable, Missed, Repurposed, and anything added later + * come off: none of them is a meeting in that room. + */ +export function calendarStateOf(status: string | null | undefined): CalendarState { + if (!status) return 'off' + if (CONFIRMED_STATUSES.has(status)) return 'confirmed' + if (status === 'Tentative') return 'tentative' + if (status === 'Pending Cancellation') return 'leave' + return 'off' +} + +/** One dated session of a room booking, as a calendar sees it. */ +export interface RoomSession { + /** Stable across edits: the id of the occurrence or session row. */ + uid: string + /** 'YYYY-MM-DD', in Boston local time, as the booking tables store it. */ + date: string + /** 'HH:MM' or 'HH:MM:SS', Boston local time. */ + startTime: string + endTime: string + summary: string + /** The room, or 'Virtual' for a meeting that moved online. */ + location: string + status: string + /** Only ever set on Senate bookings; decides who the session is sent to. */ + senateType?: string | null +} + +export const ROOM_UID_DOMAIN = 'chambers.northeasternsga.com' + +/** + * UIDs are built from the row id, which is stable across edits: weekly + * occurrences are written in place (issue #113) and one-time sessions likewise + * (issue #69). A UID that changed on every save would leave a calendar holding + * one event per edit rather than replacing the one it had. + * + * The prefix names the table, so the two id spaces can never be confused if a + * calendar receives both. + */ +export function occurrenceUid(id: string): string { + return `weekly-${id}@${ROOM_UID_DOMAIN}` +} + +export function sessionUid(id: string): string { + return `one-time-${id}@${ROOM_UID_DOMAIN}` +} + +export interface InvitePlan { + /** Sessions to put on, or move on, a calendar. */ + request: RoomSession[] + /** Sessions to take off it. */ + cancel: RoomSession[] +} + +/** + * What to send so that calendars match `next`. + * + * `previous` is the booking as it stood before this save, and null for one being + * created. A session only gets a cancellation when it was actually on a calendar + * before: sending one for an event nobody was ever sent is noise, and some + * clients show it as a phantom cancelled meeting. + * + * Past sessions are left alone in both directions. Nobody needs a meeting added + * to last Tuesday, and removing one rewrites a record of what happened. + * `today` is the Boston date, since that is the day the booking tables count in. + */ +export function planInvites( + previous: RoomSession[] | null, + next: RoomSession[], + today: string +): InvitePlan { + const wasOn = new Map() + for (const s of previous ?? []) { + const state = calendarStateOf(s.status) + if (state === 'confirmed' || state === 'tentative') wasOn.set(s.uid, s) + } + + const request: RoomSession[] = [] + const cancel: RoomSession[] = [] + const seen = new Set() + + for (const session of next) { + seen.add(session.uid) + if (session.date < today) continue + + const state = calendarStateOf(session.status) + if (state === 'leave') continue + if (state === 'off') { + if (wasOn.has(session.uid)) cancel.push(session) + continue + } + request.push(session) + } + + // A session that is gone from the booking altogether -- a week an edit trimmed + // off the end of a series, or a one-time session an editor removed -- has no + // row left to carry a status, so the previous values are what its cancellation + // describes. + for (const [uid, session] of wasOn) { + if (seen.has(uid) || session.date < today) continue + cancel.push(session) + } + + return { request, cancel } +} + +/** Someone the booking notifies, with what they have said they want to hear about. */ +export interface CalendarRecipient { + email: string + senatePreferences?: Record | null +} + +/** One email to send: the people who share an invite, and the invite they get. */ +export interface InviteAudience { + recipients: string[] + plan: InvitePlan +} + +/** + * Splits an invite by what each person follows. + * + * A Senate member who has deselected Office Hours should not get those sessions + * on their calendar -- the same rule that decides whether they are emailed at + * all (issues #92, #93), applied session by session rather than to the email as + * a whole. Everyone who ends up with the same set of sessions shares one email, + * so a booking whose sessions are all one type still sends exactly one. + * + * Bodies other than the Senate, and sessions with no type, are wanted by + * everyone, so this collapses to a single group for almost every booking. + */ +export function splitByAudience( + recipients: CalendarRecipient[], + plan: InvitePlan, + ownerBodyName: string | null | undefined +): InviteAudience[] { + const groups = new Map() + + for (const person of recipients) { + const wanted = (s: RoomSession) => wantsSenateSession(person.senatePreferences, ownerBodyName, s.senateType) + const request = plan.request.filter(wanted) + const cancel = plan.cancel.filter(wanted) + if (!request.length && !cancel.length) continue + + const key = [...request.map(s => `r${s.uid}`), ...cancel.map(s => `c${s.uid}`)].join('|') + const group = groups.get(key) ?? { recipients: [], plan: { request, cancel } } + group.recipients.push(person.email) + groups.set(key, group) + } + + return [...groups.values()] +} diff --git a/lib/room-invites.ts b/lib/room-invites.ts new file mode 100644 index 0000000..4989bdd --- /dev/null +++ b/lib/room-invites.ts @@ -0,0 +1,239 @@ +import { createClient as createAdminClient } from '@supabase/supabase-js' +import { appZoneParts } from './meeting-reminders' +import { resolveBookingRecipients, type Recipient, type ScopedRow } from './booking-scope' +import { occurrenceUid, sessionUid, splitByAudience, type RoomSession } from './room-calendar' +import { sendBookingCancelledEmail } from './emails/booking-cancelled' + +/** + * Turning room bookings into the calendar sessions an invite is built from, and + * the one cancellation path that three routes share (issue #69). + * + * The shapes here are the booking tables': a date and two clock times, with an + * occurrence's null columns meaning "inherit from the series". Resolving that + * inheritance is what makes a calendar event say the room a week is actually in + * rather than the one its series started in. + */ + +const adminSupabase = createAdminClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! +) + +/** Today's date in the zone the booking tables count in. */ +export function appToday(): string { + return appZoneParts().date +} + +/** A meeting that moved online keeps its place on the calendar, without a room. */ +export function locationOf(status: string | null | undefined, roomOrTable: string | null | undefined): string { + if (status === 'Virtual') return 'Virtual' + return roomOrTable || 'Room to be confirmed' +} + +/** What a calendar event is called: the purpose, falling back to whose booking it is. */ +export function summaryOf(purpose: string | null | undefined, bodyName: string): string { + const trimmed = purpose?.trim() + return trimmed ? `${trimmed} (${bodyName})` : `${bodyName} booking` +} + +/** The series' values, for resolving an occurrence's null columns. */ +export interface WeeklyDefaults { + room_name: string | null + start_time: string + end_time: string + status: string + purpose: string | null +} + +export interface OccurrenceRow { + id: string + occurrence_date: string + room_name: string | null + start_time: string | null + end_time: string | null + status: string | null + senate_type: string | null + purpose: string | null +} + +/** + * One calendar session per week, with each override resolved against the series. + * A null column means inherit, so these are the values the week actually has. + */ +export function weeklyRoomSessions( + occurrences: OccurrenceRow[], + series: WeeklyDefaults, + bodyName: string +): RoomSession[] { + return occurrences.map(o => { + const status = o.status ?? series.status + return { + uid: occurrenceUid(o.id), + date: o.occurrence_date, + startTime: o.start_time ?? series.start_time, + endTime: o.end_time ?? series.end_time, + summary: summaryOf(o.purpose ?? series.purpose, bodyName), + location: locationOf(status, o.room_name ?? series.room_name), + status, + senateType: o.senate_type, + } + }) +} + +export interface OneTimeRow { + id: string + booking_date: string + start_time: string + end_time: string + status: string + room_name: string | null +} + +/** One calendar session per dated session of a one-time booking. */ +export function oneTimeRoomSessions( + rows: OneTimeRow[], + purpose: string | null, + bodyName: string +): RoomSession[] { + return rows.map(r => ({ + uid: sessionUid(r.id), + date: r.booking_date, + startTime: r.start_time, + endTime: r.end_time, + summary: summaryOf(purpose, bodyName), + location: locationOf(r.status, r.room_name), + status: r.status, + })) +} + +/** + * Sends one email per audience, so a Senate member who follows only Full Body + * gets only those sessions on their calendar. For every other body this is a + * single email, since everyone wants every session. + */ +export async function sendPerAudience( + recipients: Recipient[], + plan: { request: RoomSession[]; cancel: RoomSession[] }, + ownerBodyName: string | null | undefined, + send: (audience: { recipients: string[]; plan: { request: RoomSession[]; cancel: RoomSession[] } }) => Promise +): Promise { + const audiences = splitByAudience( + recipients.map(r => ({ email: r.email, senatePreferences: r.senatePreferences })), + plan, + ownerBodyName + ) + for (const audience of audiences) await send(audience) +} + +/** One reservation that a cancellation acted on, as both callers already have it. */ +export interface CancelledReservation { + source: 'one_time' | 'occurrence' | 'tabling_session' + id: string + bookingId: string + resultingStatus: 'Cancelled' | 'Virtual' + date: string + startTime: string + endTime: string + roomOrTable: string +} + +interface BookingRow { + id: string + body_id: string + scope: ScopedRow['scope'] + division: string | null + purpose: string | null + bodies: { name: string } | { name: string }[] | null +} + +/** + * Tells a body that reservations were cancelled, and takes them off calendars. + * + * Shared by Auto-Cancel, by marking a cancellation request Done, and by the + * admin Cancel button -- the three paths that changed a status and told the body + * nothing, so a cancelled meeting sat on every calendar it had reached. + * + * Tabling has no calendar invites, so its rows are ignored here rather than + * sending a body an email about an event they were never sent. + */ +export async function notifyCancelledReservations(rows: CancelledReservation[]): Promise { + const roomRows = rows.filter(r => r.source !== 'tabling_session') + if (!roomRows.length) return + + const today = appToday() + + // Session types decide who each cancelled week is sent to, and they live on + // the occurrence rather than on the line the caller holds. + const occurrenceIds = roomRows.filter(r => r.source === 'occurrence').map(r => r.id) + const senateTypes = new Map() + if (occurrenceIds.length) { + const { data } = await adminSupabase + .from('weekly_room_occurrences') + .select('id, senate_type') + .in('id', occurrenceIds) + for (const o of (data ?? []) as { id: string; senate_type: string | null }[]) { + senateTypes.set(o.id, o.senate_type) + } + } + + const byBooking = new Map() + for (const r of roomRows) { + byBooking.set(r.bookingId, [...(byBooking.get(r.bookingId) ?? []), r]) + } + + const { data: bookingRows } = await adminSupabase + .from('bookings') + .select('id, body_id, scope, division, purpose, bodies(name)') + .in('id', [...byBooking.keys()]) + + for (const booking of (bookingRows ?? []) as BookingRow[]) { + const reservations = byBooking.get(booking.id) ?? [] + const body = Array.isArray(booking.bodies) ? booking.bodies[0] : booking.bodies + const bodyName = body?.name ?? 'Unknown' + + const scopedRow: ScopedRow = { + id: booking.id, + body_id: booking.body_id, + scope: booking.scope, + division: booking.division as ScopedRow['division'], + } + const recipients = await resolveBookingRecipients(adminSupabase, scopedRow) + if (!recipients.length) continue + + const sessions: RoomSession[] = reservations.map(r => ({ + uid: r.source === 'occurrence' ? occurrenceUid(r.id) : sessionUid(r.id), + date: r.date, + startTime: r.startTime, + endTime: r.endTime, + summary: summaryOf(booking.purpose, bodyName), + location: locationOf(r.resultingStatus, r.roomOrTable), + status: r.resultingStatus, + senateType: r.source === 'occurrence' ? senateTypes.get(r.id) ?? null : null, + })) + + // Grouped over every session first, so each audience's email lists the + // sessions that audience actually follows -- wording and invite alike. + await sendPerAudience(recipients, { request: sessions, cancel: [] }, bodyName, async audience => { + const theirs = audience.plan.request + const cancelled = theirs.filter(s => s.status === 'Cancelled') + const virtual = theirs.filter(s => s.status === 'Virtual') + + // A meeting going virtual is still a meeting: its event is re-sent with + // Virtual as the location rather than removed. Past sessions are left on + // calendars either way -- they are a record of what happened. + const upcoming = (s: RoomSession) => s.date >= today + + await sendBookingCancelledEmail({ + bodyName, + purpose: booking.purpose, + cancelled: cancelled.map(s => ({ date: s.date, startTime: s.startTime, endTime: s.endTime, roomOrTable: s.location })), + virtual: virtual.map(s => ({ date: s.date, startTime: s.startTime, endTime: s.endTime })), + recipients: audience.recipients, + invite: { + request: virtual.filter(upcoming), + cancel: cancelled.filter(upcoming), + }, + }) + }) + } +} From 48f58ef2f9b11c9e5f4ca09866b36332fd585486 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 16 Sep 2026 20:12:42 -0400 Subject: [PATCH 3/8] feat: Ops Review and Awaiting CSC request statuses (#128) Pending hid where a request was waiting. Room and revision requests now start in Ops Review, and an admin can mark one Sent to CSC (Awaiting CSC) when Operational Affairs passes it on. The Requests tab leads with the suggested next step from each status but lets admins move freely: back to Ops Review, Fulfill or Deny from either open status, and Reopen a denial. A fulfilled request stays final, since a booking is linked to it. Only Ops Review counts as an admin Pending Action; Awaiting CSC is waiting on CSC. Requesters get a bell notification when their request goes to CSC, see what each open status means on the Request page, and a booking's leaders see its open revision request, and its status, in the booking detail. The migration renames 'Pending' to 'Ops Review' on both tables and must ship with this code: the old code inserts 'Pending'. Co-Authored-By: Claude Opus 5 --- app/(dashboard)/bookings/deny-modal.tsx | 6 +- app/(dashboard)/bookings/one-time-form.tsx | 3 +- app/(dashboard)/bookings/requests-tab.tsx | 119 ++++++++++++++++-- app/(dashboard)/bookings/tabling-form.tsx | 3 +- app/(dashboard)/bookings/weekly-form.tsx | 3 +- .../my-rooms/booking-detail-modal.tsx | 33 ++++- .../my-rooms/notification-bell.tsx | 12 ++ app/(dashboard)/request/page.tsx | 22 +++- .../administrator/bookings/one-time/route.ts | 3 +- .../administrator/bookings/tabling/route.ts | 3 +- .../administrator/bookings/weekly/route.ts | 3 +- app/api/administrator/requests/route.ts | 70 +++++++++-- app/api/administrator/revisions/route.ts | 76 ++++++----- app/api/request/route.ts | 3 +- app/api/revision-requests/route.ts | 39 +++++- app/api/slack/interaction/route.ts | 3 +- lib/pending-actions.ts | 7 +- lib/request-status.ts | 42 +++++++ ...20260916000000_request_review_statuses.sql | 34 +++++ ...60916_request_review_statuses_rollback.sql | 26 ++++ 20 files changed, 434 insertions(+), 76 deletions(-) create mode 100644 lib/request-status.ts create mode 100644 supabase/migrations/20260916000000_request_review_statuses.sql create mode 100644 supabase/migrations/rollback/20260916_request_review_statuses_rollback.sql diff --git a/app/(dashboard)/bookings/deny-modal.tsx b/app/(dashboard)/bookings/deny-modal.tsx index 937ee28..b459b17 100644 --- a/app/(dashboard)/bookings/deny-modal.tsx +++ b/app/(dashboard)/bookings/deny-modal.tsx @@ -36,11 +36,7 @@ export default function DenyModal({ requestId, onClose, onDenied, kind = 'reques method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( - isRevision - // The revisions route only ever denies, so it takes no status -- - // there is no other transition to ask it for. - ? { id: requestId, denial_reason: reason.trim() || null } - : { id: requestId, status: 'Denied', denial_reason: reason.trim() || null } + { id: requestId, status: 'Denied', denial_reason: reason.trim() || null } ), } ) diff --git a/app/(dashboard)/bookings/one-time-form.tsx b/app/(dashboard)/bookings/one-time-form.tsx index 36528a9..bf0a37b 100644 --- a/app/(dashboard)/bookings/one-time-form.tsx +++ b/app/(dashboard)/bookings/one-time-form.tsx @@ -7,6 +7,7 @@ import DateField from '@/app/_components/date-field' import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector' import ScopeLabel from '@/app/_components/scope-label' import { DIVISIONS, type Division, type BookingScope } from '@/lib/booking-scope' +import { isOpenRequestStatus } from '@/lib/request-status' const STATUSES = [ 'Reserved', @@ -105,7 +106,7 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O getJson<{ requests?: PendingRequest[] }>('/api/administrator/requests', {}) .then(({ requests }) => { setPendingRequests( - (requests ?? []).filter((r: PendingRequest) => r.status === 'Pending' && r.type === 'One-Time Room') + (requests ?? []).filter((r: PendingRequest) => isOpenRequestStatus(r.status) && r.type === 'One-Time Room') ) }) }, []) diff --git a/app/(dashboard)/bookings/requests-tab.tsx b/app/(dashboard)/bookings/requests-tab.tsx index 84623ba..9083229 100644 --- a/app/(dashboard)/bookings/requests-tab.tsx +++ b/app/(dashboard)/bookings/requests-tab.tsx @@ -7,6 +7,14 @@ import { Skeleton } from '@/app/_components/skeleton' import ScopeLabel from '@/app/_components/scope-label' import type { BookingScope, Division } from '@/lib/booking-scope' import { usePendingActionsWatch } from '../pending-actions-watch' +import { + AWAITING_CSC, + OPS_REVIEW, + isOpenRequestStatus, + type OpenRequestStatus, + type RevisionRequestStatus, + type RoomRequestStatus, +} from '@/lib/request-status' function RequestsTabSkeleton() { const card = (wide: boolean) => ( @@ -38,8 +46,6 @@ function RequestsTabSkeleton() { ) } -type RequestStatus = 'Pending' | 'Fulfilled' | 'Denied' - interface RevisionRequest { id: string change_type: 'Time' | 'Room' | 'Both' @@ -47,6 +53,7 @@ interface RevisionRequest { new_end_time: string | null new_room: string | null more_info: string + status: RevisionRequestStatus created_at: string bookings: { id: string @@ -64,7 +71,7 @@ interface RoomRequest { purpose: string /** Null on tabling, and on room requests made before issue #76. */ capacity: number | null - status: RequestStatus + status: RoomRequestStatus notes: string | null created_at: string scope: BookingScope @@ -100,12 +107,32 @@ function formatDate(date: string) { }) } -const statusColors: Record = { - Pending: 'bg-[#3d2200] text-[#fb923c]', +const statusColors: Record = { + [OPS_REVIEW]: 'bg-[#3d2200] text-[#fb923c]', + [AWAITING_CSC]: 'bg-[#2a1f4d] text-[#a78bfa]', Fulfilled: 'bg-[#0f3d20] text-[#4ade80]', + Done: 'bg-[#0f3d20] text-[#4ade80]', Denied: 'bg-[#3d0f0f] text-[#f87171]', } +const primaryBtn = 'px-3 py-1 text-sm bg-[#7c3aed] text-white rounded-lg hover:bg-[#6d28d9] disabled:opacity-60' +const secondaryBtn = 'px-3 py-1 text-sm border border-[#1e5080] text-[#93b8d8] rounded-lg hover:text-[#f0f6ff] hover:border-[#93b8d8] disabled:opacity-60' + +/** + * The open status an admin can move a request to from the other one (issue + * #128). From Ops Review it is the suggested next step and is drawn as the + * primary action; from Awaiting CSC it is the way back, and is secondary to + * Fulfill and Deny. The order is a suggestion, not a rule. + */ +const OTHER_OPEN_STATUS: Record = { + [OPS_REVIEW]: AWAITING_CSC, + [AWAITING_CSC]: OPS_REVIEW, +} +const MOVE_LABELS: Record = { + [OPS_REVIEW]: 'Back to Ops Review', + [AWAITING_CSC]: 'Mark Sent to CSC', +} + interface RequestsTabProps { onCountChange: () => void } @@ -129,6 +156,8 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) { linkedBodies: { id: string; name: string }[] } | null>(null) const [typeFilter, setTypeFilter] = useState<'all' | BookingScope>('all') + const [moving, setMoving] = useState(null) + const [moveError, setMoveError] = useState<{ id: string; message: string } | null>(null) const fetchRequests = async () => { const [reqRes, revRes] = await Promise.all([ @@ -146,6 +175,29 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) { fetchRequests() }, []) + /** Moves a room or revision request to an open status, e.g. Ops Review -> Awaiting CSC. */ + const moveTo = async (kind: 'request' | 'revision', id: string, status: OpenRequestStatus) => { + setMoving(id) + setMoveError(null) + try { + const res = await fetch(kind === 'revision' ? '/api/administrator/revisions' : '/api/administrator/requests', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, status }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setMoveError({ id, message: data.error || 'Something went wrong.' }) + } + // Refetched either way: a 409 means the row moved underneath this admin, + // and the list should show where it went. + await fetchRequests() + onCountChange() + } finally { + setMoving(null) + } + } + if (loading) return if (requests.length === 0 && revisions.length === 0) return
No requests found.
@@ -176,9 +228,14 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) { · {rv.bookings?.type === 'One-Time Room' ? 'One-Time/Multiple Room' : rv.bookings?.type}
- - Revision Request - +
+ + Revision Request + + + {rv.status} + +

Requested by: {rv.users?.full_name || 'Unknown'}

@@ -207,7 +264,16 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) { is no "Approve" here to pair with this: it would have nothing to do that opening the booking does not already do. */} -
+
+ {isOpenRequestStatus(rv.status) && ( + + )}
+ {rv.status === AWAITING_CSC && ( +

Once CSC responds, edit the booking to grant this, or deny it.

+ )} + {moveError?.id === rv.id &&

{moveError.message}

}
))}
@@ -316,7 +386,7 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
{/* Actions */} - {r.status === 'Pending' && ( + {isOpenRequestStatus(r.status) && ( confirmingDenial === r.id ? (
Are you sure? @@ -334,7 +404,18 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) {
) : ( -
+
+ {/* + The suggested next step leads (issue #128): a request in Ops + Review goes to CSC first, and one back from CSC is fulfilled or + denied. Fulfill and Deny stay available from either, since not + every request needs CSC. + */} + {r.status === OPS_REVIEW && ( + + )} + {r.status === AWAITING_CSC && ( + + )}
) )} + {/* A denial can be reversed; a fulfilment cannot, since a booking is linked to it. */} + {r.status === 'Denied' && ( +
+ +
+ )} + {moveError?.id === r.id &&

{moveError.message}

}
))}
@@ -382,7 +477,7 @@ export default function RequestsTab({ onCountChange }: RequestsTabProps) { kind="revision" requestId={denyingRevision} onClose={() => setDenyingRevision(null)} - // fetchRequests drops the row (the GET only returns Pending) and + // fetchRequests drops the row (the GET only returns open ones) and // onCountChange clears the pending action it was driving. onDenied={() => { setDenyingRevision(null); fetchRequests(); onCountChange() }} /> diff --git a/app/(dashboard)/bookings/tabling-form.tsx b/app/(dashboard)/bookings/tabling-form.tsx index 508db02..ec52862 100644 --- a/app/(dashboard)/bookings/tabling-form.tsx +++ b/app/(dashboard)/bookings/tabling-form.tsx @@ -7,6 +7,7 @@ import DateField from '@/app/_components/date-field' import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector' import ScopeLabel from '@/app/_components/scope-label' import { DIVISIONS, type Division, type BookingScope } from '@/lib/booking-scope' +import { isOpenRequestStatus } from '@/lib/request-status' const STATUSES = [ 'Reserved', @@ -109,7 +110,7 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T getJson<{ requests?: PendingRequest[] }>('/api/administrator/requests', {}) .then(({ requests }) => { setPendingRequests( - (requests ?? []).filter((r: PendingRequest) => r.status === 'Pending' && r.type === 'Tabling') + (requests ?? []).filter((r: PendingRequest) => isOpenRequestStatus(r.status) && r.type === 'Tabling') ) }) }, []) diff --git a/app/(dashboard)/bookings/weekly-form.tsx b/app/(dashboard)/bookings/weekly-form.tsx index 51e2455..2885959 100644 --- a/app/(dashboard)/bookings/weekly-form.tsx +++ b/app/(dashboard)/bookings/weekly-form.tsx @@ -7,6 +7,7 @@ import DateField from '@/app/_components/date-field' import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector' import ScopeLabel from '@/app/_components/scope-label' import { DIVISIONS, type Division, type BookingScope } from '@/lib/booking-scope' +import { isOpenRequestStatus } from '@/lib/request-status' const STATUSES = [ 'Reserved', @@ -98,7 +99,7 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We getJson<{ requests?: PendingRequest[] }>('/api/administrator/requests', {}) .then(({ requests }) => { setPendingRequests( - (requests ?? []).filter((r: PendingRequest) => r.status === 'Pending' && r.type === 'Weekly Room') + (requests ?? []).filter((r: PendingRequest) => isOpenRequestStatus(r.status) && r.type === 'Weekly Room') ) }) }, []) diff --git a/app/(dashboard)/my-rooms/booking-detail-modal.tsx b/app/(dashboard)/my-rooms/booking-detail-modal.tsx index c48d4c2..69a8a3e 100644 --- a/app/(dashboard)/my-rooms/booking-detail-modal.tsx +++ b/app/(dashboard)/my-rooms/booking-detail-modal.tsx @@ -1,8 +1,9 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import BookingModal from '../bookings/booking-modal' import { type FlatBooking, statusTextColors, senateTypeBadgeColors, DEFAULT_SENATE_BADGE } from './shared' +import { AWAITING_CSC, OPEN_STATUS_DESCRIPTIONS, type OpenRequestStatus } from '@/lib/request-status' interface BookingDetailModalProps { booking: FlatBooking @@ -36,6 +37,20 @@ export default function BookingDetailModal({ booking, isLeadership, onClose, onC const scopeFull = booking.scopeFull ?? [booking.scopeLabel] const hasPeerBodies = scopeFull.length > 1 + // The booking's open revision request, if any, so its leaders can see where it + // stands -- with Operational Affairs or with CSC (issue #128). Only leaders may + // request a revision, so only they are asked about one. + const [openRevision, setOpenRevision] = useState<{ status: OpenRequestStatus; created_at: string } | null>(null) + useEffect(() => { + if (!isLeadership) return + let cancelled = false + fetch(`/api/revision-requests?booking_id=${encodeURIComponent(booking.bookingId)}`) + .then(res => (res.ok ? res.json() : null)) + .then(data => { if (!cancelled) setOpenRevision(data?.revision ?? null) }) + .catch(() => {}) + return () => { cancelled = true } + }, [isLeadership, booking.bookingId]) + return (
@@ -91,9 +106,23 @@ export default function BookingDetailModal({ booking, isLeadership, onClose, onC )}
+ {openRevision && ( +
+

Revision request: {openRevision.status}

+

+ {OPEN_STATUS_DESCRIPTIONS[openRevision.status]} Submitted{' '} + {new Date(openRevision.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}. +

+
+ )} + {(canCancel || canRevise) && (
- {canRevise && ( + {canRevise && !openRevision && ( ))} + {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 464875c773b0a544bf5da085c164a8bdc19f8d7e Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 16 Sep 2026 20:46:13 -0400 Subject: [PATCH 5/8] fix: name the scoped entity in the admin weekly bookings grid (#133) The weekly grid labelled each row with the owning body's name, so a division-wide booking read as a booking for its primary body. It now uses formatScopeLabel, like the rest of the admin UI: a divisional booking shows its division, and a multi-body booking shows its owner plus the count of the others. Co-Authored-By: Claude Opus 5 --- app/(dashboard)/bookings/weekly-booking-grid.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/(dashboard)/bookings/weekly-booking-grid.tsx b/app/(dashboard)/bookings/weekly-booking-grid.tsx index b4ea02b..3bc0eb5 100644 --- a/app/(dashboard)/bookings/weekly-booking-grid.tsx +++ b/app/(dashboard)/bookings/weekly-booking-grid.tsx @@ -1,7 +1,7 @@ 'use client' import { Fragment } from 'react' -import type { BookingScope, Division } from '@/lib/booking-scope' +import { formatScopeLabel, type BookingScope, type Division } from '@/lib/booking-scope' interface WeeklyOccurrence { id: string @@ -159,8 +159,14 @@ export default function WeeklyBookingGrid({ bookings, onBookingClick }: WeeklyBo )} + {/* + Named for who the booking is for, not the body that owns the + row: a divisional booking shows its division, and a multi-body + one its owner plus the others (issue #133). + */} - {b.bodies?.name} — {formatTime(w.start_time)} + {formatScopeLabel(b, (b.booking_bodies ?? []).map(x => ({ id: x.body_id, name: x.bodies?.name ?? '' }))).short} + {' — '}{formatTime(w.start_time)} {weeks.map(wk => { const occ = occMap.get(wk) From 5df7fdd845f3a7de5b4ca63571ef6b7e925c4527 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 16 Sep 2026 20:48:32 -0400 Subject: [PATCH 6/8] 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. From d5d04474a93eceee37d26be0e08030fc741b29f9 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Thu, 17 Sep 2026 11:09:49 -0400 Subject: [PATCH 7/8] chore: rename the #128 migration to the version production recorded Applied to production as 20260917150907_request_review_statuses; contents unchanged. The rollback's header now names the renamed file. Co-Authored-By: Claude Opus 5 --- ...ew_statuses.sql => 20260917150907_request_review_statuses.sql} | 0 ...rollback.sql => 20260917_request_review_statuses_rollback.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260916000000_request_review_statuses.sql => 20260917150907_request_review_statuses.sql} (100%) rename supabase/migrations/rollback/{20260916_request_review_statuses_rollback.sql => 20260917_request_review_statuses_rollback.sql} (100%) diff --git a/supabase/migrations/20260916000000_request_review_statuses.sql b/supabase/migrations/20260917150907_request_review_statuses.sql similarity index 100% rename from supabase/migrations/20260916000000_request_review_statuses.sql rename to supabase/migrations/20260917150907_request_review_statuses.sql diff --git a/supabase/migrations/rollback/20260916_request_review_statuses_rollback.sql b/supabase/migrations/rollback/20260917_request_review_statuses_rollback.sql similarity index 100% rename from supabase/migrations/rollback/20260916_request_review_statuses_rollback.sql rename to supabase/migrations/rollback/20260917_request_review_statuses_rollback.sql From 54d93e19ac46268602b50191f778e4311d089d3b Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Thu, 17 Sep 2026 11:10:04 -0400 Subject: [PATCH 8/8] chore: name the renamed migration in the #128 rollback header Co-Authored-By: Claude Opus 5 --- .../rollback/20260917_request_review_statuses_rollback.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/migrations/rollback/20260917_request_review_statuses_rollback.sql b/supabase/migrations/rollback/20260917_request_review_statuses_rollback.sql index 6f8958b..f981fa6 100644 --- a/supabase/migrations/rollback/20260917_request_review_statuses_rollback.sql +++ b/supabase/migrations/rollback/20260917_request_review_statuses_rollback.sql @@ -1,4 +1,4 @@ --- Rollback for 20260916000000_request_review_statuses.sql. +-- Rollback for 20260917150907_request_review_statuses.sql. -- -- Both open statuses fold back into 'Pending'. Whether a request had been passed -- to CSC is lost. 'Awaiting CSC' notifications already sent stay in user_alerts;