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/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/(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/sga-spaces-tab.tsx b/app/(dashboard)/bookings/sga-spaces-tab.tsx index bf85b66..168bf28 100644 --- a/app/(dashboard)/bookings/sga-spaces-tab.tsx +++ b/app/(dashboard)/bookings/sga-spaces-tab.tsx @@ -41,6 +41,7 @@ interface SpaceBooking { start_time: string end_time: string attendee_ids: string[] + external_attendees?: string[] | null creator_name: string | null /** The weekly series this booking is one week of (issue #112). */ series_id: string | null @@ -308,7 +309,7 @@ function AdminBookingsPanel({ spaces }: { spaces: Space[] }) { {b.creator_name ?? '—'} {formatDateTime(b.start_time)} {formatDateTime(b.end_time)} - {(b.attendee_ids ?? []).length + 1} + {(b.attendee_ids ?? []).length + (b.external_attendees ?? []).length + 1} ))} + {canAddExternal && ( + + )} )} +

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

+ {/* Attendee chips */} - {attendees.length > 0 && ( + {(attendees.length > 0 || externalAttendees.length > 0) && (
{attendees.map(a => (
))} + {externalAttendees.map(email => ( +
+ {email} + +
+ ))}
)} diff --git a/app/(dashboard)/sga-spaces/space-calendar.tsx b/app/(dashboard)/sga-spaces/space-calendar.tsx index 310e528..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 } @@ -152,6 +153,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. @@ -171,6 +182,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(() => { @@ -420,9 +488,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 */} 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..b7a2a26 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 { @@ -15,6 +17,7 @@ import { syncBookingBodies, type ScopedRow, } from '@/lib/booking-scope' +import { OPEN_REQUEST_STATUSES } from '@/lib/request-status' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -22,6 +25,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 +100,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 +128,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 +185,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 +208,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 +218,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 +302,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 +323,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) @@ -302,7 +366,7 @@ export async function PATCH(request: Request) { .from('revision_requests') .update({ status: 'Done' }) .eq('booking_id', booking_id) - .eq('status', 'Pending') + .in('status', OPEN_REQUEST_STATUSES) if (firstSession.status === 'Missed') { waitUntil( diff --git a/app/api/administrator/bookings/tabling/route.ts b/app/api/administrator/bookings/tabling/route.ts index 6b18f36..7f0dd8d 100644 --- a/app/api/administrator/bookings/tabling/route.ts +++ b/app/api/administrator/bookings/tabling/route.ts @@ -15,6 +15,7 @@ import { syncBookingBodies, type ScopedRow, } from '@/lib/booking-scope' +import { OPEN_REQUEST_STATUSES } from '@/lib/request-status' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -312,7 +313,7 @@ export async function PATCH(request: Request) { .from('revision_requests') .update({ status: 'Done' }) .eq('booking_id', booking_id) - .eq('status', 'Pending') + .in('status', OPEN_REQUEST_STATUSES) if (sessions.some((s: Session) => s.status === 'Missed')) { waitUntil( diff --git a/app/api/administrator/bookings/weekly/route.ts b/app/api/administrator/bookings/weekly/route.ts index 2446a94..f1fc9bb 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' @@ -17,6 +19,7 @@ import { syncBookingBodies, type ScopedRow, } from '@/lib/booking-scope' +import { OPEN_REQUEST_STATUSES } from '@/lib/request-status' /** * One occurrence as the editor submits it (issue #55). @@ -46,6 +49,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 +146,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 +177,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 +246,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 +332,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 +426,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 +459,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) @@ -450,7 +517,7 @@ export async function PATCH(request: Request) { .from('revision_requests') .update({ status: 'Done' }) .eq('booking_id', booking_id) - .eq('status', 'Pending') + .in('status', OPEN_REQUEST_STATUSES) // Which weeks this save actually marked Missed. // 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/app/api/administrator/requests/route.ts b/app/api/administrator/requests/route.ts index 3c75745..53fab16 100644 --- a/app/api/administrator/requests/route.ts +++ b/app/api/administrator/requests/route.ts @@ -3,6 +3,15 @@ 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 { + AWAITING_CSC, + AWAITING_CSC_ALERT, + OPEN_REQUEST_STATUSES, + isOpenRequestStatus, + type RoomRequestStatus, +} from '@/lib/request-status' + +const ROOM_REQUEST_STATUSES: RoomRequestStatus[] = [...OPEN_REQUEST_STATUSES, 'Fulfilled', 'Denied'] const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -48,13 +57,58 @@ export async function PATCH(request: Request) { const { id, status, booking_id, notes, denial_reason, is_event } = await request.json() - // Update request status and notes - const { error: requestError } = await adminSupabase + if (!id) return NextResponse.json({ error: 'Missing request id' }, { status: 400 }) + if (!ROOM_REQUEST_STATUSES.includes(status)) { + return NextResponse.json({ error: 'Invalid status' }, { status: 400 }) + } + + const { data: current, error: readError } = await adminSupabase .from('room_requests') - .update({ status, notes: notes || null }) + .select('status, requested_by') .eq('id', id) + .single() + + if (readError || !current) return NextResponse.json({ error: 'Request not found' }, { status: 404 }) + + // Admins move requests between statuses freely (issue #128), with one + // exception: a fulfilled request has a booking linked to it, and reopening it + // would leave that booking pointing at a request that says it is still open. + if (current.status === 'Fulfilled') { + return NextResponse.json({ error: 'This request has already been fulfilled.' }, { status: 409 }) + } + if (current.status === status) { + return NextResponse.json({ error: `This request is already ${status}.` }, { status: 409 }) + } + + // Only an open-status move leaves the notes alone; fulfilling and denying are + // where notes are written. + const update: Record = { status } + if (!isOpenRequestStatus(status)) update.notes = notes || null + + // Guarded on the status just read, so two admins acting on the same request + // cannot both win -- the second is told to refresh instead. + const { data: updated, error: requestError } = await adminSupabase + .from('room_requests') + .update(update) + .eq('id', id) + .eq('status', current.status) + .select('id') if (requestError) return NextResponse.json({ error: requestError.message }, { status: 500 }) + if (!updated?.length) { + return NextResponse.json({ error: 'This request was changed by someone else. Refresh and try again.' }, { status: 409 }) + } + + // Tell the requester their request has gone to CSC. Not fatal: the status has + // moved, and failing here would invite the admin to move it again. + if (status === AWAITING_CSC && current.requested_by) { + const { error: alertError } = await adminSupabase.from('user_alerts').insert({ + user_id: current.requested_by, + request_id: id, + booking_type: AWAITING_CSC_ALERT, + }) + if (alertError) console.error('Awaiting CSC alert failed:', alertError) + } // Link booking if fulfilling if (status === 'Fulfilled' && booking_id) { @@ -71,15 +125,9 @@ export async function PATCH(request: Request) { // Create denial notification for the requester if (status === 'Denied') { - const { data: roomRequest } = await adminSupabase - .from('room_requests') - .select('requested_by') - .eq('id', id) - .single() - - if (roomRequest?.requested_by) { + if (current.requested_by) { const { error: alertError } = await adminSupabase.from('user_alerts').insert({ - user_id: roomRequest.requested_by, + user_id: current.requested_by, request_id: id, booking_type: 'Denied', denial_reason: denial_reason ?? null, diff --git a/app/api/administrator/revisions/route.ts b/app/api/administrator/revisions/route.ts index 1ee5888..cbd6011 100644 --- a/app/api/administrator/revisions/route.ts +++ b/app/api/administrator/revisions/route.ts @@ -3,6 +3,12 @@ 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 { + OPEN_REQUEST_STATUSES, + OPS_REVIEW, + REVISION_AWAITING_CSC_ALERT, + isOpenRequestStatus, +} from '@/lib/request-status' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -27,27 +33,27 @@ export async function GET() { bookings(id, type, purpose, bodies(name)), users(full_name) `) - .eq('status', 'Pending') + .in('status', OPEN_REQUEST_STATUSES) .order('created_at', { ascending: false }) return NextResponse.json({ revisions: revisions || [] }) } /** - * Denies a revision request. + * Moves an open revision request to another status: between Ops Review and + * Awaiting CSC (issue #128), or to Denied. * - * Until now the only way a revision request could leave the Administrator's list - * was for an admin to edit the booking, which the three booking routes treat as - * granting it ("Resolve any pending revision request" -> status 'Done'). A - * request for something that cannot be done -- a room already taken, a time - * outside CSC hours, a series that has since ended -- had no exit, and - * lib/pending-actions.ts kept surfacing it as a danger row for as long as it - * stayed Pending (issue #77). + * Granting is not here. It happens by editing the booking, which the three + * booking routes treat as granting the request ("Resolve any pending revision + * request" -> status 'Done'). * - * Denying is deliberately not a delete. The row stays, with its reason, so the - * decision is auditable and the requester's notification has something behind - * it. The pending action clears on its own: every query that builds one filters - * on `status = 'Pending'`. + * Denying was added for a request that cannot be made -- a room already taken, a + * time outside CSC hours, a series that has since ended -- which otherwise had + * no exit and stayed a danger pending action forever (issue #77). It is + * deliberately not a delete. The row stays, with its reason, so the decision is + * auditable and the requester's notification has something behind it. + * + * `status` defaults to 'Denied', which is all this route used to do. */ export async function PATCH(request: Request) { const supabase = await createClient() @@ -60,11 +66,14 @@ export async function PATCH(request: Request) { const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { id, denial_reason } = await request.json() + const { id, denial_reason, status = 'Denied' } = await request.json() if (!id) return NextResponse.json({ error: 'Missing revision request id' }, { status: 400 }) + if (status !== 'Denied' && !isOpenRequestStatus(status)) { + return NextResponse.json({ error: 'Invalid status' }, { status: 400 }) + } // Read before writing, for two reasons: the requester has to be known in order - // to notify them, and the Pending check below needs the current status. + // to notify them, and the open check below needs the current status. const { data: revision, error: readError } = await adminSupabase .from('revision_requests') .select('id, status, booking_id, requested_by') @@ -79,45 +88,54 @@ export async function PATCH(request: Request) { // booking was edited elsewhere. Denying something already granted would // silently overwrite that, and tell the requester their revision was refused // when it had in fact been made. - if (revision.status !== 'Pending') { + if (!isOpenRequestStatus(revision.status)) { return NextResponse.json( { error: `This revision request has already been resolved (${revision.status}).` }, { status: 409 } ) } + if (revision.status === status) { + return NextResponse.json({ error: `This revision request is already ${status}.` }, { status: 409 }) + } - const reason = typeof denial_reason === 'string' && denial_reason.trim() + const reason = status === 'Denied' && typeof denial_reason === 'string' && denial_reason.trim() ? denial_reason.trim() : null - const { error: updateError } = await adminSupabase + const { data: updated, error: updateError } = await adminSupabase .from('revision_requests') - .update({ status: 'Denied', denial_reason: reason }) + .update(status === 'Denied' ? { status, denial_reason: reason } : { status }) .eq('id', id) // Re-checked in the write itself: the read above can go stale between the two - // statements, and this makes the transition Pending -> Denied rather than - // *-> Denied. - .eq('status', 'Pending') + // statements, and this makes the transition from the status just read rather + // than from whatever the row holds by now. + .eq('status', revision.status) + .select('id') if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + if (!updated?.length) { + return NextResponse.json( + { error: 'This revision request was changed by someone else. Refresh and try again.' }, + { status: 409 } + ) + } // The requester asked for this change and would otherwise never hear back. // // Linked by booking_id, not request_id: user_alerts.request_id is a foreign key // to room_requests, so a revision request's id does not belong in it. The // booking is the thing the requester recognises anyway. - if (revision.requested_by) { + if (revision.requested_by && status !== OPS_REVIEW) { const { error: alertError } = await adminSupabase.from('user_alerts').insert({ user_id: revision.requested_by, booking_id: revision.booking_id, - booking_type: 'Revision Denied', + booking_type: status === 'Denied' ? 'Revision Denied' : REVISION_AWAITING_CSC_ALERT, denial_reason: reason, }) - // Deliberately not fatal. The denial is already recorded, and failing the - // request here would invite the admin to deny it again -- which the Pending - // guard above would then refuse, leaving them stuck on a row that is in fact - // resolved. - if (alertError) console.error('Revision denial alert failed:', alertError) + // Deliberately not fatal. The status is already recorded, and failing the + // request here would invite the admin to change it again -- which the guard + // above would then refuse, leaving them stuck on a row that has in fact moved. + if (alertError) console.error('Revision status alert failed:', alertError) } return NextResponse.json({ success: true }) 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/request/route.ts b/app/api/request/route.ts index 7e9b337..be6af15 100644 --- a/app/api/request/route.ts +++ b/app/api/request/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' import { DIVISIONS, loadScopeContext, validateScopeSelection } from '@/lib/booking-scope' +import { OPS_REVIEW } from '@/lib/request-status' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -184,7 +185,7 @@ export async function POST(request: Request) { capacity: capacityValue, notes: notes || null, requested_by: user.id, - status: 'Pending', + status: OPS_REVIEW, }) .select() .single() diff --git a/app/api/revision-requests/route.ts b/app/api/revision-requests/route.ts index 02af041..9a3f040 100644 --- a/app/api/revision-requests/route.ts +++ b/app/api/revision-requests/route.ts @@ -4,12 +4,43 @@ import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' import { requireBookingManager } from '@/lib/booking-scope' +import { OPEN_REQUEST_STATUSES, OPS_REVIEW } from '@/lib/request-status' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ) +/** + * The open revision request on a booking, if there is one, so the booking's + * detail view can say where it stands (issue #128). Any leader who may request + * a revision may see it -- only one can be open per booking, and the leader who + * did not file it is the one most likely to try filing it again. + */ +export async function GET(request: Request) { + const supabase = await createClient() + + const user = await getAuthedUserWithLiveRoles(supabase) + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const bookingId = new URL(request.url).searchParams.get('booking_id') + if (!bookingId) return NextResponse.json({ error: 'Missing booking_id' }, { status: 400 }) + + const guard = await requireBookingManager(supabase, adminSupabase, user, bookingId) + if (guard.error) return guard.error + + const { data, error } = await adminSupabase + .from('revision_requests') + .select('id, status, change_type, created_at') + .eq('booking_id', bookingId) + .in('status', OPEN_REQUEST_STATUSES) + .maybeSingle() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ revision: data }) +} + export async function POST(request: Request) { const supabase = await createClient() @@ -26,17 +57,17 @@ export async function POST(request: Request) { const guard = await requireBookingManager(supabase, adminSupabase, user, booking_id) if (guard.error) return guard.error - // Block if a pending revision request already exists for this booking + // Block if an open revision request already exists for this booking const { data: existing } = await adminSupabase .from('revision_requests') .select('id') .eq('booking_id', booking_id) - .eq('status', 'Pending') + .in('status', OPEN_REQUEST_STATUSES) .maybeSingle() if (existing) { return NextResponse.json( - { error: 'A revision request for this booking is already pending.' }, + { error: 'A revision request for this booking is already open.' }, { status: 409 } ) } @@ -51,7 +82,7 @@ export async function POST(request: Request) { new_end_time: new_end_time || null, new_room: new_room || null, more_info, - status: 'Pending', + status: OPS_REVIEW, }) if (error) return NextResponse.json({ error: error.message }, { status: 500 }) diff --git a/app/api/slack/interaction/route.ts b/app/api/slack/interaction/route.ts index 18311b5..48252e0 100644 --- a/app/api/slack/interaction/route.ts +++ b/app/api/slack/interaction/route.ts @@ -2,6 +2,7 @@ import { waitUntil } from '@vercel/functions' import { createClient as createAdminClient } from '@supabase/supabase-js' import { verifySlackRequest } from '@/lib/slack-verify' import { checkRateLimit } from '@/lib/check-rate-limit' +import { OPS_REVIEW } from '@/lib/request-status' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -123,7 +124,7 @@ export async function POST(request: Request) { purpose, notes: notes || null, requested_by: connection.chambers_user_id, - status: 'Pending', + status: OPS_REVIEW, }) .select() .single() 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/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/pending-actions.ts b/lib/pending-actions.ts index abbd587..6077211 100644 --- a/lib/pending-actions.ts +++ b/lib/pending-actions.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { isManagementRole } from './admin-roles' +import { OPS_REVIEW } from './request-status' /** * The admin "Pending Actions" model (issue #38). @@ -297,11 +298,13 @@ export async function fetchPendingActions( adminSupabase .from('room_requests') .select('id, type, purpose, bodies(name), room_request_details(start_date), tabling_request_sessions(session_date)') - .eq('status', 'Pending'), + // Only Ops Review is waiting on an admin. Awaiting CSC is waiting on CSC + // Operations, so it is not a task (issue #128). + .eq('status', OPS_REVIEW), adminSupabase .from('revision_requests') .select(`id, booking_id, bookings(${BOOKING_CHILD_SELECT})`) - .eq('status', 'Pending'), + .eq('status', OPS_REVIEW), adminSupabase .from('cancellation_requests') .select(`id, booking_id, occurrence_id, bookings(${BOOKING_CHILD_SELECT})`) diff --git a/lib/request-status.ts b/lib/request-status.ts new file mode 100644 index 0000000..f3f3af2 --- /dev/null +++ b/lib/request-status.ts @@ -0,0 +1,42 @@ +/** + * The statuses a room request or a revision request moves through (issue #128). + * + * "Pending" hid the wait that matters most: much of the time a request sits with + * CSC Operations, not with SGA. So an open request is in one of two states -- + * + * Ops Review Operational Affairs has it (every new request starts here) + * Awaiting CSC Operational Affairs has passed it to CSC Operations + * + * -- and then closes. A room request closes as Fulfilled or Denied; a revision + * request as Done (granted by editing the booking) or Denied. + * + * Admins may move an open request between the two open states in either + * direction. The UI suggests the usual order, and nothing enforces it. + */ + +export const OPS_REVIEW = 'Ops Review' +export const AWAITING_CSC = 'Awaiting CSC' + +export const OPEN_REQUEST_STATUSES = [OPS_REVIEW, AWAITING_CSC] as const +export type OpenRequestStatus = typeof OPEN_REQUEST_STATUSES[number] + +export type RoomRequestStatus = OpenRequestStatus | 'Fulfilled' | 'Denied' +export type RevisionRequestStatus = OpenRequestStatus | 'Done' | 'Denied' + +export function isOpenRequestStatus(status: unknown): status is OpenRequestStatus { + return (OPEN_REQUEST_STATUSES as readonly unknown[]).includes(status) +} + +/** What a requester is told each open status means. */ +export const OPEN_STATUS_DESCRIPTIONS: Record = { + [OPS_REVIEW]: 'Operational Affairs is reviewing this request.', + [AWAITING_CSC]: 'Operational Affairs has sent this request to CSC Operations and is waiting on their response.', +} + +/** + * The user_alerts.booking_type written when a request moves to Awaiting CSC. + * A room request's alert carries request_id; a revision's carries booking_id, + * for the reason given in app/api/administrator/revisions/route.ts. + */ +export const AWAITING_CSC_ALERT = 'Awaiting CSC' +export const REVISION_AWAITING_CSC_ALERT = 'Revision Awaiting CSC' 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), + }, + }) + }) + } +} diff --git a/lib/spaces-email.ts b/lib/spaces-email.ts index 1b609c9..0f49722 100644 --- a/lib/spaces-email.ts +++ b/lib/spaces-email.ts @@ -33,6 +33,55 @@ export function isSgaEmail(v: unknown): v is string { return typeof v === 'string' && SGA_EMAIL_PATTERN.test(v) } +/** + * Attendees without a Chambers account (issue #132) -- an interview candidate, + * say -- are stored on a booking by address, beside the user ids in + * attendee_ids. They are held to the same university domain as SGA inboxes: + * every one of them is sent invites from Chambers, and the booking form should + * not be a way to email any address at all. + */ +export const MAX_EXTERNAL_ATTENDEES = 25 + +/** + * The external attendees in a request body, trimmed, lowercased and + * deduplicated -- or null when any entry is not a university address, so the + * route can refuse the whole request rather than quietly drop someone. + */ +export function parseExternalAttendees(v: unknown): string[] | null { + if (v === undefined || v === null) return [] + if (!Array.isArray(v)) return null + const emails: string[] = [] + for (const raw of v) { + if (typeof raw !== 'string') return null + const email = raw.trim().toLowerCase() + if (!isSgaEmail(email)) return null + if (!emails.includes(email)) emails.push(email) + } + return emails.length > MAX_EXTERNAL_ATTENDEES ? null : emails +} + +export const EXTERNAL_ATTENDEES_ERROR = + `External attendees must be @northeastern.edu addresses, up to ${MAX_EXTERNAL_ATTENDEES} per booking.` + +const EXTERNAL_KEY_PREFIX = 'email:' + +/** + * Every attendee of a booking or series as one list of keys: user ids as they + * are, external addresses as `email:
`. resolveSpacesAddresses accepts + * both, so the code that works out who to email -- who was added, who was + * dropped, who gets a cancellation -- handles both kinds of attendee without + * knowing there are two. + */ +export function attendeeKeys(row: { + attendee_ids?: string[] | null + external_attendees?: string[] | null +}): string[] { + return [ + ...(row.attendee_ids ?? []), + ...(row.external_attendees ?? []).map(e => `${EXTERNAL_KEY_PREFIX}${e.toLowerCase()}`), + ] +} + /** One inbox a person may choose, with the bodies that make it available to them. */ export interface SgaEmailOption { email: string @@ -126,14 +175,19 @@ export function spacesAddressesFor( /** * Resolves each of `userIds` to the addresses their SGA Spaces emails go to. - * A user with no row or no address maps to an empty list. + * A user with no row or no address maps to an empty list. An external + * attendee's key (see attendeeKeys) maps to its own address. */ export async function resolveSpacesAddresses( adminSupabase: SupabaseClient, userIds: string[] ): Promise> { - const ids = [...new Set(userIds.filter(Boolean))] const result = new Map() + const ids: string[] = [] + for (const key of new Set(userIds.filter(Boolean))) { + if (key.startsWith(EXTERNAL_KEY_PREFIX)) result.set(key, [key.slice(EXTERNAL_KEY_PREFIX.length)]) + else ids.push(key) + } if (ids.length === 0) return result const [{ data: users }, options] = await Promise.all([ diff --git a/supabase/migrations/20260917004809_space_external_attendees.sql b/supabase/migrations/20260917004809_space_external_attendees.sql new file mode 100644 index 0000000..a5974de --- /dev/null +++ b/supabase/migrations/20260917004809_space_external_attendees.sql @@ -0,0 +1,30 @@ +-- Attendees without a Chambers account on SGA Space bookings (issue #132). +-- +-- attendee_ids can only name Chambers users, so someone from outside -- an +-- interview candidate, a guest from another office -- could not be put on a +-- booking or sent its invite. Their addresses are stored beside the ids, on each +-- booking and on the series that creates weekly ones. +-- +-- The API holds every address to @northeastern.edu and caps the list; the +-- constraint here only bounds its size. +-- +-- Additive: code from before this migration never reads or writes the column. + +alter table public.space_bookings + add column if not exists external_attendees text[] not null default '{}'; + +alter table public.space_booking_series + add column if not exists external_attendees text[] not null default '{}'; + +alter table public.space_bookings + add constraint space_bookings_external_attendees_size + check (cardinality(external_attendees) <= 25); + +alter table public.space_booking_series + add constraint space_booking_series_external_attendees_size + check (cardinality(external_attendees) <= 25); + +comment on column public.space_bookings.external_attendees is + 'Attendees without a Chambers account, by @northeastern.edu address (issue #132). Sent the same invites as attendee_ids.'; +comment on column public.space_booking_series.external_attendees is + 'External attendees every week of the series is created with (issue #132).'; diff --git a/supabase/migrations/20260917150907_request_review_statuses.sql b/supabase/migrations/20260917150907_request_review_statuses.sql new file mode 100644 index 0000000..1c933a2 --- /dev/null +++ b/supabase/migrations/20260917150907_request_review_statuses.sql @@ -0,0 +1,34 @@ +-- Split "Pending" into "Ops Review" and "Awaiting CSC" (issue #128). +-- +-- Room requests and revision requests were Pending until they closed, which hid +-- where the wait was. Much of it is with CSC Operations rather than SGA, and both +-- admins and requesters should be able to see that. An open request is now either +-- with Operational Affairs ('Ops Review') or passed on to CSC ('Awaiting CSC'). +-- +-- 'Pending' is renamed rather than kept alongside: every open request today is in +-- Ops Review, and leaving the old value valid would let a stale caller write a +-- status nothing reads. +-- +-- Deploy with the matching application code. Code from before this migration +-- inserts 'Pending', which the new constraints refuse. + +alter table public.room_requests + drop constraint if exists room_requests_status_check; +alter table public.revision_requests + drop constraint if exists revision_requests_status_check; + +update public.room_requests set status = 'Ops Review' where status = 'Pending'; +update public.revision_requests set status = 'Ops Review' where status = 'Pending'; + +alter table public.room_requests + alter column status set default 'Ops Review'; +alter table public.revision_requests + alter column status set default 'Ops Review'; + +alter table public.room_requests + add constraint room_requests_status_check + check (status = any (array['Ops Review'::text, 'Awaiting CSC'::text, 'Fulfilled'::text, 'Denied'::text])); + +alter table public.revision_requests + add constraint revision_requests_status_check + check (status = any (array['Ops Review'::text, 'Awaiting CSC'::text, 'Done'::text, 'Denied'::text])); diff --git a/supabase/migrations/rollback/20260917_request_review_statuses_rollback.sql b/supabase/migrations/rollback/20260917_request_review_statuses_rollback.sql new file mode 100644 index 0000000..f981fa6 --- /dev/null +++ b/supabase/migrations/rollback/20260917_request_review_statuses_rollback.sql @@ -0,0 +1,26 @@ +-- 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; +-- the old notification bell renders them as a generic update. + +alter table public.room_requests + drop constraint if exists room_requests_status_check; +alter table public.revision_requests + drop constraint if exists revision_requests_status_check; + +update public.room_requests set status = 'Pending' where status in ('Ops Review', 'Awaiting CSC'); +update public.revision_requests set status = 'Pending' where status in ('Ops Review', 'Awaiting CSC'); + +alter table public.room_requests + alter column status set default 'Pending'; +alter table public.revision_requests + alter column status set default 'Pending'; + +alter table public.room_requests + add constraint room_requests_status_check + check (status = any (array['Pending'::text, 'Fulfilled'::text, 'Denied'::text])); + +alter table public.revision_requests + add constraint revision_requests_status_check + check (status = any (array['Pending'::text, 'Done'::text, 'Denied'::text])); diff --git a/supabase/migrations/rollback/20260917_space_external_attendees_rollback.sql b/supabase/migrations/rollback/20260917_space_external_attendees_rollback.sql new file mode 100644 index 0000000..34e6efe --- /dev/null +++ b/supabase/migrations/rollback/20260917_space_external_attendees_rollback.sql @@ -0,0 +1,10 @@ +-- Rollback for 20260917004809_space_external_attendees.sql. +-- +-- External attendees are dropped from every booking and series. They keep any +-- invite already sent, and are not told the booking has changed afterwards. + +alter table public.space_bookings + drop column if exists external_attendees; + +alter table public.space_booking_series + drop column if exists external_attendees;