From c6327c93833ae6c860d8622138a44a8a19f9f7f2 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Tue, 8 Sep 2026 20:50:06 -0400 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20Auto-Cancel=20=E2=80=94=20email?= =?UTF-8?q?=20CSC=20one=20request=20for=20every=20pending=20cancellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admin opens Cancellations, presses Auto-Cancel, optionally narrows by booking type and date range, reviews the list, and sends CSC a single email asking for each reservation to be released. Every booking marked Pending Cancellation is collected, and each line carries the date, the time and the reservation code. The weekly half is the part worth reading twice. weekly_room_occurrences.status is an override where NULL means "inherit from the series" -- 340 of the 385 occurrence rows carry a NULL status. A series marked Pending Cancellation therefore has occurrences that are pending without any occurrence row saying so, and matching on the occurrence's own column would silently skip every series-level cancellation: the case with the most dates behind it. Room, times and reservation code inherit the same way, so each is resolved against the series before it reaches the email. That resolution is a pure exported function because it is the piece most likely to be wrong and the hardest to observe -- no series is currently pending, so there is no live data to exercise it. Nothing changes status. The email asks CSC to release the rooms; CSC is the authority on whether that happened, and marking anything 'Cancelled' here would assert an outcome Chambers has not been told. The existing "Mark as Done" control stays the step that closes the loop. Two guards, because this is the only mail Chambers sends outside SGA and it cannot be recalled. The admin approves the actual list rather than a count, and the send re-runs the query server-side rather than trusting the posted body -- otherwise a client could mail any set of dates and codes it liked to an external address. The approved count travels with the request, and a mismatch answers 409 rather than sending a list nobody agreed to. The send is awaited rather than deferred like the member-facing emails: it is the entire point of the request, and an admin told it went needs that to be true. Reservations with no code on file are still listed, flagged in the preview and marked in the email, rather than dropped -- CSC can find those by date and room, and silently omitting them would lose a cancellation. The recipient defaults to cscreservations@northeastern.edu and is overridable via CSC_EMAIL so a staging deployment can point somewhere harmless. Replies go to OPS_EMAIL rather than the no-reply sender. Verified against the live database with Resend intercepted, so nothing was sent: the collector returns exactly the 6 rows an independent SQL query finds, with the same codes, rooms and bodies, ordered by date then time. Type filters give 0/0/6 for tabling/one-time/weekly, date bounds give 2, 4 and 1 rows, and a malformed date is rejected rather than passed through. The inheritance helper resolves an all-NULL occurrence to the series' pending status, lets an occurrence override beat it, and falls back safely with no series at all. The email addresses cscreservations@northeastern.edu with the reply-to set, and its table fits the 600px frame with no cell overflow. Through the real modal: filters update the count live, the send button disables on an empty list, and the POST carries the reviewed count with the filters. Co-Authored-By: Claude Opus 5 --- .../bookings/auto-cancel-modal.tsx | 219 ++++++++++++++++++ .../bookings/cancellations-tab.tsx | 25 +- .../cancellations/auto-cancel/route.ts | 115 +++++++++ lib/emails/csc-cancellation-request.ts | 113 +++++++++ lib/pending-cancellations.ts | 194 ++++++++++++++++ 5 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 app/(dashboard)/bookings/auto-cancel-modal.tsx create mode 100644 app/api/administrator/cancellations/auto-cancel/route.ts create mode 100644 lib/emails/csc-cancellation-request.ts create mode 100644 lib/pending-cancellations.ts diff --git a/app/(dashboard)/bookings/auto-cancel-modal.tsx b/app/(dashboard)/bookings/auto-cancel-modal.tsx new file mode 100644 index 0000000..98ac2f8 --- /dev/null +++ b/app/(dashboard)/bookings/auto-cancel-modal.tsx @@ -0,0 +1,219 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import BookingModal from './booking-modal' + +interface CancellationLine { + date: string + startTime: string + endTime: string + reservationCode: string | null + roomOrTable: string + bodyName: string + bookingType: 'One-Time Room' | 'Weekly Room' | 'Tabling' +} + +type TypeFilter = 'all' | 'One-Time Room' | 'Weekly Room' | 'Tabling' + +const TYPE_FILTERS: { value: TypeFilter; label: string }[] = [ + { value: 'all', label: 'All types' }, + { value: 'One-Time Room', label: 'One-Time' }, + { value: 'Weekly Room', label: 'Weekly' }, + { value: 'Tabling', label: 'Tabling' }, +] + +const inputCls = "w-full bg-[#0f2a4a] border border-[#1e5080] rounded-lg px-3 py-2 text-sm text-[#f0f6ff] focus:outline-none focus:ring-2 focus:ring-[#c8102e]/30 focus:border-[#c8102e] transition" +const labelCls = "block text-xs font-medium text-[#93b8d8] mb-1" + +function formatDate(d: string) { + return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) +} +function formatTime(t: string) { + if (!t) return '—' + const [h, m] = t.split(':').map(Number) + if (Number.isNaN(h)) return '—' + return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${h >= 12 ? 'PM' : 'AM'}` +} + +/** + * Builds the cancellation request CSC receives, and shows it before it goes. + * + * The preview is not a courtesy. This is the only mail Chambers sends outside + * SGA, it goes to a university office that will act on it, and it cannot be + * recalled -- so the admin approves the actual list rather than a count, and the + * send button says who it is going to. + */ +export default function AutoCancelModal({ onClose }: { onClose: () => void }) { + const [type, setType] = useState('all') + const [from, setFrom] = useState('') + const [to, setTo] = useState('') + const [lines, setLines] = useState([]) + const [recipient, setRecipient] = useState('') + const [loading, setLoading] = useState(true) + const [sending, setSending] = useState(false) + const [error, setError] = useState('') + const [sentCount, setSentCount] = useState(null) + + const loadPreview = useCallback(async () => { + setLoading(true) + setError('') + try { + const qs = new URLSearchParams({ type }) + if (from) qs.set('from', from) + if (to) qs.set('to', to) + const res = await fetch(`/api/administrator/cancellations/auto-cancel?${qs}`) + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Could not load the preview.') + const data = await res.json() + setLines(data.lines ?? []) + setRecipient(data.recipient ?? '') + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not load the preview.') + setLines([]) + } + setLoading(false) + }, [type, from, to]) + + useEffect(() => { loadPreview() }, [loadPreview]) + + const send = async () => { + setSending(true) + setError('') + const res = await fetch('/api/administrator/cancellations/auto-cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // The count the admin actually approved. The server refuses if the set + // moved while this was open. + body: JSON.stringify({ type, from: from || null, to: to || null, expectedCount: lines.length }), + }) + const data = await res.json().catch(() => ({})) + if (res.ok) { + setSentCount(data.sent ?? lines.length) + } else { + setError(data.error || 'The request could not be sent.') + // Whatever went wrong, the list on screen may no longer be the truth. + loadPreview() + } + setSending(false) + } + + if (sentCount !== null) { + return ( + +
+

+ Sent {sentCount} reservation{sentCount === 1 ? '' : 's'} to {recipient}. +

+

+ Nothing in Chambers has changed status. CSC releases the rooms, and these bookings stay + Pending Cancellation until that is confirmed and you mark the request done. +

+ +
+
+ ) + } + + return ( + +
+

+ Collects every booking marked Pending Cancellation and + emails CSC a single request listing the date, time and reservation code of each. +

+ +
+ +
+ {TYPE_FILTERS.map(f => ( + + ))} +
+
+ +
+
+ + setFrom(e.target.value)} className={inputCls} /> +
+
+ + setTo(e.target.value)} className={inputCls} /> +
+
+ +
+

+ {loading + ? 'Finding reservations…' + : lines.length === 0 + // The empty state below says the rest; "0 reservations will be + // listed" above it just says the same thing twice. + ? 'Nothing to send' + : `${lines.length} reservation${lines.length === 1 ? '' : 's'} will be listed`} +

+ + {!loading && lines.length > 0 && ( +
+ {lines.map((l, i) => ( +
+
+ {formatDate(l.date)} + {formatTime(l.startTime)} – {formatTime(l.endTime)} +
+
+ {l.bodyName} · {l.roomOrTable} + {l.reservationCode + ? {l.reservationCode} + /* Sent anyway -- CSC can still find it by date and room, and + hiding it would quietly drop a cancellation. */ + : no code} +
+
+ ))} +
+ )} + + {!loading && lines.length === 0 && ( +

Nothing is marked Pending Cancellation for these filters.

+ )} +
+ + {error &&

{error}

} + + {lines.some(l => !l.reservationCode) && !loading && ( +

+ Some reservations have no code on file. They will still be listed, by date and room, and flagged for CSC. +

+ )} + +
+ + +
+
+
+ ) +} diff --git a/app/(dashboard)/bookings/cancellations-tab.tsx b/app/(dashboard)/bookings/cancellations-tab.tsx index bda30ac..ad813ac 100644 --- a/app/(dashboard)/bookings/cancellations-tab.tsx +++ b/app/(dashboard)/bookings/cancellations-tab.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { Skeleton } from '@/app/_components/skeleton' import { usePendingActionsWatch } from '../pending-actions-watch' +import AutoCancelModal from './auto-cancel-modal' function CancellationsTabSkeleton() { return ( @@ -64,6 +65,7 @@ export default function CancellationsTab({ onCountChange }: CancellationsTabProp const [cancellations, setCancellations] = useState([]) const [loading, setLoading] = useState(true) const [updating, setUpdating] = useState(null) + const [showAutoCancel, setShowAutoCancel] = useState(false) const { isDanger, registerOrigin } = usePendingActionsWatch() const fetchCancellations = async () => { @@ -89,14 +91,34 @@ export default function CancellationsTab({ onCountChange }: CancellationsTabProp setUpdating(null) } + const header = ( +
+

Cancellation Requests

+ +
+ ) + if (loading) return + // Deliberately still rendered when there are no cancellation *requests*: a + // booking can be set to Pending Cancellation directly by an admin without one, + // and those are exactly what Auto-Cancel is for. if (cancellations.length === 0) return ( -

No cancellation requests found.

+
+ {header} +

No cancellation requests found.

+ {showAutoCancel && setShowAutoCancel(false)} />} +
) return (
+ {header} {cancellations.map(c => (
))} + {showAutoCancel && setShowAutoCancel(false)} />}
) } \ No newline at end of file diff --git a/app/api/administrator/cancellations/auto-cancel/route.ts b/app/api/administrator/cancellations/auto-cancel/route.ts new file mode 100644 index 0000000..9be046d --- /dev/null +++ b/app/api/administrator/cancellations/auto-cancel/route.ts @@ -0,0 +1,115 @@ +import { createClient } from '@/lib/supabase/server' +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 { sendCscCancellationRequest } from '@/lib/emails/csc-cancellation-request' +import { collectPending, parseFilters, describeScope } from '@/lib/pending-cancellations' + +const adminSupabase = createAdminClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! +) + +/** + * Where the cancellation request goes. + * + * Overridable so a staging deployment can point at an inbox that is not CSC's. + * Nothing else in Chambers emails outside the university's student government, + * so this is the one address worth being able to redirect. + */ +const CSC_EMAIL = process.env.CSC_EMAIL || 'cscreservations@northeastern.edu' + +/** + * Preview. Returns exactly what a POST would send, so the admin confirms against + * the list that will actually go out rather than a description of it. + */ +export async function GET(request: Request) { + const supabase = await createClient() + + const user = await getAuthedUserWithLiveRoles(supabase) + if (!user || !user.app_metadata?.is_admin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const rateLimitRes = await checkRateLimit(user.id) + if (rateLimitRes) return rateLimitRes + + const { type, from, to } = parseFilters(new URL(request.url)) + const lines = await collectPending(type, from, to) + + return NextResponse.json({ lines, recipient: CSC_EMAIL, scopeNote: describeScope(type, from, to) }) +} + +/** + * Sends the request to CSC. + * + * Deliberately changes no booking status. The email asks CSC to release the + * rooms; CSC is the authority on whether that happened, and marking anything + * 'Cancelled' here would assert an outcome Chambers has not been told. The + * existing "Mark as Done" control on the cancellation request stays the human + * step for closing the loop. + * + * The list is recomputed here rather than taken from the request body. A client + * could otherwise post any set of dates and codes it liked to an external + * address, and the preview could in any case be minutes stale. + */ +export async function POST(request: Request) { + const supabase = await createClient() + + const user = await getAuthedUserWithLiveRoles(supabase) + if (!user || !user.app_metadata?.is_admin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const rateLimitRes = await checkRateLimit(user.id) + if (rateLimitRes) return rateLimitRes + + const body = await request.json().catch(() => ({})) + const url = new URL(request.url) + for (const k of ['type', 'from', 'to']) { + if (typeof body?.[k] === 'string') url.searchParams.set(k, body[k]) + } + const { type, from, to } = parseFilters(url) + + const lines = await collectPending(type, from, to) + if (!lines.length) { + return NextResponse.json( + { error: 'Nothing is marked Pending Cancellation for those filters.' }, + { status: 400 } + ) + } + + // The admin confirmed a specific number of reservations. If the set has moved + // since -- someone edited a booking in another tab -- stop rather than mail CSC + // a list nobody approved. + if (typeof body?.expectedCount === 'number' && body.expectedCount !== lines.length) { + return NextResponse.json( + { + error: `The list changed while you were reviewing it: ${body.expectedCount} reservation${body.expectedCount === 1 ? '' : 's'} became ${lines.length}. Reload the preview and check it before sending.`, + }, + { status: 409 } + ) + } + + const { data: profile } = await adminSupabase + .from('users').select('full_name').eq('id', user.id).single() + + try { + await sendCscCancellationRequest({ + lines, + requestedBy: profile?.full_name || user.email || 'Chambers administrator', + scopeNote: describeScope(type, from, to), + to: CSC_EMAIL, + replyTo: process.env.OPS_EMAIL || undefined, + }) + } catch (e) { + // Awaited, not fire-and-forget like the member-facing emails: this one is the + // entire point of the request, and an admin who is told it sent needs that to + // be true. + console.error('CSC cancellation request failed:', e) + return NextResponse.json({ error: 'The email could not be sent. Nothing was changed.' }, { status: 502 }) + } + + return NextResponse.json({ success: true, sent: lines.length, recipient: CSC_EMAIL }) +} diff --git a/lib/emails/csc-cancellation-request.ts b/lib/emails/csc-cancellation-request.ts new file mode 100644 index 0000000..fb42d12 --- /dev/null +++ b/lib/emails/csc-cancellation-request.ts @@ -0,0 +1,113 @@ +import { resend } from '@/lib/resend' +import { sanitize, buildEmailHtml } from './utils' +import { formatDate, formatTime } from './changes' + +/** + * One reservation CSC is being asked to release. + * + * Date, time and reservation code are what the request is actually made of -- + * the code is what CSC keys on. Room and body ride along as context so a person + * reading the mail can sanity-check a code before acting on it. + */ +export interface CancellationLine { + date: string + startTime: string + endTime: string + reservationCode: string | null + roomOrTable: string + bodyName: string + bookingType: 'One-Time Room' | 'Weekly Room' | 'Tabling' +} + +interface CscCancellationRequestParams { + lines: CancellationLine[] + /** Who pressed the button, so CSC has a name to reply to. */ + requestedBy: string + /** Describes the filter that produced this list, for the body of the mail. */ + scopeNote: string + to: string + replyTo?: string +} + +/** Shown in place of a code that was never recorded, so a row is never blank. */ +const NO_CODE = '— no code on file —' + +/** + * Asks CSC to cancel a batch of reservations. + * + * This is the one email Chambers sends outside the university's student + * government -- every other template goes to members. It is therefore explicit + * about who is asking and what is being asked, and it never claims a booking has + * been cancelled: CSC releases the room, and Chambers finds out afterwards. + */ +export async function sendCscCancellationRequest(params: CscCancellationRequestParams) { + const { lines, requestedBy, scopeNote, to, replyTo } = params + if (!lines.length) return + + const sRequestedBy = sanitize(requestedBy) + const sScopeNote = sanitize(scopeNote) + const count = lines.length + const plural = count === 1 ? 'reservation' : 'reservations' + + const textRows = lines + .map(l => [ + ` ${formatDate(l.date)}`, + ` ${formatTime(l.startTime)} to ${formatTime(l.endTime)}`, + ` Reservation code: ${l.reservationCode ? sanitize(l.reservationCode) : NO_CODE}`, + ` Room/Table: ${sanitize(l.roomOrTable)} (${sanitize(l.bodyName)}, ${l.bookingType})`, + '', + ].join('\n')) + .join('\n') + + const htmlRows = lines + .map(l => ` + + ${formatDate(l.date)} + ${formatTime(l.startTime)} – ${formatTime(l.endTime)} + ${ + l.reservationCode + ? `${sanitize(l.reservationCode)}` + : `${NO_CODE}` + } + ${sanitize(l.roomOrTable)}
${sanitize(l.bodyName)} + `) + .join('') + + await resend.emails.send({ + from: process.env.RESEND_FROM_EMAIL!, + to, + // So a reply lands with the Operational Affairs inbox rather than the + // no-reply sender the rest of the system uses. + ...(replyTo ? { replyTo } : {}), + subject: `Reservation Cancellation Request — Northeastern SGA (${count} ${plural})`, + text: `Hello, + +Northeastern's Student Government Association would like to cancel the ${count} ${plural} listed below. Each was marked for cancellation in Chambers, SGA's room management system. + +${sScopeNote} + +${textRows} +Requested by ${sRequestedBy}, Northeastern SGA Operational Affairs. + +If any of these cannot be released, or a reservation code does not match your records, please reply to this message and we will follow up. + +Thank you, +Northeastern SGA — Operational Affairs`, + html: buildEmailHtml(` +

Hello,

+

Northeastern's Student Government Association would like to cancel the ${count} ${plural} listed below. Each was marked for cancellation in Chambers, SGA's room management system.

+

${sScopeNote}

+ + + + + + + + ${htmlRows} +
DateTimeReservation
code
Room / Table
+

Requested by ${sRequestedBy}, Northeastern SGA Operational Affairs.

+

If any of these cannot be released, or a reservation code does not match your records, please reply to this message and we will follow up.

+ `), + }) +} diff --git a/lib/pending-cancellations.ts b/lib/pending-cancellations.ts new file mode 100644 index 0000000..8c62f94 --- /dev/null +++ b/lib/pending-cancellations.ts @@ -0,0 +1,194 @@ +import { createClient as createAdminClient } from '@supabase/supabase-js' +import type { CancellationLine } from './emails/csc-cancellation-request' + +const adminSupabase = createAdminClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! +) + +/** + * An occurrence's effective values, after inheritance. + * + * Every one of these columns is an override on weekly_room_occurrences where + * NULL means "take the series' value" -- 340 of 385 occurrence rows carry a NULL + * status alone. Pulled out as a pure function because it is the part of + * Auto-Cancel most likely to be wrong and the hardest to observe: a series-level + * cancellation produces occurrences that are pending without any occurrence row + * saying so, and reading only the occurrence would skip the whole series. + */ +export function resolveOccurrence( + occ: { + status: string | null + start_time: string | null + end_time: string | null + room_name: string | null + reservation_code: string | null + }, + series: { + status: string | null + start_time: string | null + end_time: string | null + room_name: string | null + reservation_code: string | null + } | null +) { + return { + status: occ.status ?? series?.status ?? null, + startTime: occ.start_time ?? series?.start_time ?? '', + endTime: occ.end_time ?? series?.end_time ?? '', + roomOrTable: occ.room_name ?? series?.room_name ?? 'Not recorded', + reservationCode: occ.reservation_code ?? series?.reservation_code ?? null, + } +} + +const PENDING = 'Pending Cancellation' + +export type BookingTypeFilter = 'all' | 'One-Time Room' | 'Weekly Room' | 'Tabling' + +interface BodyRef { name: string } +interface BookingRef { id: string; type: string; purpose: string; bodies: BodyRef | BodyRef[] | null } + +/** Supabase types an embedded to-one as an array in some shapes; normalise it. */ +function bodyNameOf(booking: BookingRef | BookingRef[] | null): string { + const b = Array.isArray(booking) ? booking[0] : booking + if (!b) return 'Unknown body' + const body = Array.isArray(b.bodies) ? b.bodies[0] : b.bodies + return body?.name ?? 'Unknown body' +} + +/** + * Every dated reservation currently marked for cancellation, across all three + * booking types. + * + * The weekly half is the part worth reading twice. weekly_room_occurrences.status + * is an override where NULL means "inherit from the series", so a series marked + * Pending Cancellation has occurrences that are pending without saying so + * themselves -- 340 of the 385 occurrence rows carry a NULL status. Matching only + * on the occurrence's own column would silently skip every series-level + * cancellation, which is the case that matters most: it is the one with the most + * dates behind it. + * + * room_name, times and reservation_code inherit the same way, so each is resolved + * against the series before it goes anywhere near the email. + */ +export async function collectPending( + type: BookingTypeFilter, + from: string | null, + to: string | null +): Promise { + const lines: CancellationLine[] = [] + const inRange = (d: string) => (!from || d >= from) && (!to || d <= to) + + if (type === 'all' || type === 'One-Time Room') { + const { data } = await adminSupabase + .from('one_time_room_bookings') + .select('room_name, booking_date, start_time, end_time, reservation_code, bookings(id, type, purpose, bodies(name))') + .eq('status', PENDING) + + for (const r of (data ?? []) as unknown as (Record & { bookings: BookingRef | null })[]) { + if (!inRange(r.booking_date)) continue + lines.push({ + date: r.booking_date, + startTime: r.start_time, + endTime: r.end_time, + reservationCode: r.reservation_code || null, + roomOrTable: r.room_name || 'Not recorded', + bodyName: bodyNameOf(r.bookings), + bookingType: 'One-Time Room', + }) + } + } + + if (type === 'all' || type === 'Tabling') { + const { data } = await adminSupabase + .from('tabling_sessions') + .select('location, session_date, start_time, end_time, reservation_code, tabling_bookings(reservation_code, bookings(id, type, purpose, bodies(name)))') + .eq('status', PENDING) + + for (const r of (data ?? []) as unknown as (Record & { + tabling_bookings: { reservation_code: string | null; bookings: BookingRef | null } | null + })[]) { + if (!inRange(r.session_date)) continue + const parent = Array.isArray(r.tabling_bookings) ? r.tabling_bookings[0] : r.tabling_bookings + lines.push({ + date: r.session_date, + startTime: r.start_time, + endTime: r.end_time, + // The session's own code wins; the booking's is the fallback, matching + // how the tabling editor treats it. + reservationCode: r.reservation_code || parent?.reservation_code || null, + roomOrTable: r.location || 'Not recorded', + bodyName: bodyNameOf(parent?.bookings ?? null), + bookingType: 'Tabling', + }) + } + } + + if (type === 'all' || type === 'Weekly Room') { + const { data } = await adminSupabase + .from('weekly_room_occurrences') + .select(` + occurrence_date, room_name, start_time, end_time, status, reservation_code, + weekly_room_bookings(room_name, start_time, end_time, status, reservation_code, + bookings(id, type, purpose, bodies(name))) + `) + + for (const r of (data ?? []) as unknown as { + occurrence_date: string + room_name: string | null + start_time: string | null + end_time: string | null + status: string | null + reservation_code: string | null + weekly_room_bookings: { + room_name: string | null + start_time: string | null + end_time: string | null + status: string | null + reservation_code: string | null + bookings: BookingRef | null + } | null + }[]) { + const series = Array.isArray(r.weekly_room_bookings) ? r.weekly_room_bookings[0] : r.weekly_room_bookings + const eff = resolveOccurrence(r, series) + if (eff.status !== PENDING) continue + if (!inRange(r.occurrence_date)) continue + + lines.push({ + date: r.occurrence_date, + startTime: eff.startTime, + endTime: eff.endTime, + reservationCode: eff.reservationCode, + roomOrTable: eff.roomOrTable, + bodyName: bodyNameOf(series?.bookings ?? null), + bookingType: 'Weekly Room', + }) + } + } + + // Chronological: CSC works through a list of dates, not a list of bodies. + return lines.sort((a, b) => (a.date === b.date ? a.startTime.localeCompare(b.startTime) : a.date.localeCompare(b.date))) +} + +export function parseFilters(url: URL) { + const rawType = url.searchParams.get('type') ?? 'all' + const type: BookingTypeFilter = + rawType === 'One-Time Room' || rawType === 'Weekly Room' || rawType === 'Tabling' ? rawType : 'all' + const iso = /^\d{4}-\d{2}-\d{2}$/ + const fromRaw = url.searchParams.get('from') + const toRaw = url.searchParams.get('to') + return { + type, + from: fromRaw && iso.test(fromRaw) ? fromRaw : null, + to: toRaw && iso.test(toRaw) ? toRaw : null, + } +} + +export function describeScope(type: BookingTypeFilter, from: string | null, to: string | null): string { + const what = type === 'all' ? 'All booking types' : type + if (from && to) return `${what}, for dates between ${from} and ${to}.` + if (from) return `${what}, for dates from ${from} onward.` + if (to) return `${what}, for dates up to ${to}.` + return `${what}, with no date limit.` +} + From 267f63f3e8ad1b43abc1f74b8f35cba40d2b9489 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Tue, 8 Sep 2026 21:00:22 -0400 Subject: [PATCH 02/11] feat: Auto-Cancel marks what it sends, skips codeless, copies Ops Three changes to the behaviour agreed on review. Sending now cancels. The reservations included in the request are marked 'Cancelled' once CSC has been asked, rather than being left Pending Cancellation for a human to close out. Order matters and is deliberate: the email goes first, and statuses move only after it has been accepted. Marking first and then failing to send would leave a booking cancelled in Chambers that CSC still holds a room for -- the one outcome worth designing against, because nobody would go looking for it. If the mail fails, nothing is touched and the response says so. An occurrence whose status was inherited gets 'Cancelled' written onto the occurrence itself, so only the dates actually sent stop being pending and the rest of the series is untouched. Each affected booking also gets an audit_logs row, so the change appears in the Audit tab beside every other status change rather than looking like it happened by itself. Best effort: the mail is out and the statuses have moved, so failing the request over a missing log would only invite a resend. Codeless reservations are dropped rather than listed. A reservation code is the only handle CSC has on a booking, so one without it can be neither requested nor -- now that sending cancels -- cancelled, since doing so would put Chambers and CSC out of step over a request CSC could not act on. They are set aside and shown in the preview and on the confirmation, because they are still outstanding and someone has to chase them by hand. Whitespace counts as absent: a code column holding " " is a blank someone tabbed through. Ops is copied on every request, from OPS_EMAIL, so the division holds the record rather than only the sender's mailbox. This also closes the duplicate-send gap raised on the PR. Marked-Cancelled rows no longer match the query, so a second run cannot re-send them, and no extra column or send log is needed. Verified without sending mail or writing to the database. The code rule rejects null, undefined, empty and whitespace and accepts a real code padded or not. The send addresses CSC with OPS_EMAIL on cc and reply-to. The rows the updates would target were checked against the database read-only: all six ids exist as occurrences, all six parent booking ids are correct for the audit rows, all six are currently pending, and no pending row is missed -- so the statement would hit exactly the intended rows and nothing else. Through the real modal with skipped rows present: the coded two are listed, the two without codes appear in their own block marked not sent and not cancelled, the footer names both recipients, and the confirmation reports what was cancelled and what was left alone. Co-Authored-By: Claude Opus 5 --- .../bookings/auto-cancel-modal.tsx | 76 +++++++++++---- .../cancellations/auto-cancel/route.ts | 83 +++++++++++++--- lib/emails/csc-cancellation-request.ts | 28 +++--- lib/pending-cancellations.ts | 96 ++++++++++++++++--- 4 files changed, 227 insertions(+), 56 deletions(-) diff --git a/app/(dashboard)/bookings/auto-cancel-modal.tsx b/app/(dashboard)/bookings/auto-cancel-modal.tsx index 98ac2f8..7c653ed 100644 --- a/app/(dashboard)/bookings/auto-cancel-modal.tsx +++ b/app/(dashboard)/bookings/auto-cancel-modal.tsx @@ -7,7 +7,15 @@ interface CancellationLine { date: string startTime: string endTime: string - reservationCode: string | null + reservationCode: string + roomOrTable: string + bodyName: string + bookingType: 'One-Time Room' | 'Weekly Room' | 'Tabling' +} + +/** Pending, but with no reservation code, so it can neither be sent nor cancelled. */ +interface SkippedReservation { + date: string roomOrTable: string bodyName: string bookingType: 'One-Time Room' | 'Weekly Room' | 'Tabling' @@ -48,7 +56,9 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) { const [from, setFrom] = useState('') const [to, setTo] = useState('') const [lines, setLines] = useState([]) + const [skipped, setSkipped] = useState([]) const [recipient, setRecipient] = useState('') + const [cc, setCc] = useState(null) const [loading, setLoading] = useState(true) const [sending, setSending] = useState(false) const [error, setError] = useState('') @@ -65,10 +75,13 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) { if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Could not load the preview.') const data = await res.json() setLines(data.lines ?? []) + setSkipped(data.skipped ?? []) setRecipient(data.recipient ?? '') + setCc(data.cc ?? null) } catch (e) { setError(e instanceof Error ? e.message : 'Could not load the preview.') setLines([]) + setSkipped([]) } setLoading(false) }, [type, from, to]) @@ -101,12 +114,18 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) {

- Sent {sentCount} reservation{sentCount === 1 ? '' : 's'} to {recipient}. + Sent {sentCount} reservation{sentCount === 1 ? '' : 's'} to {recipient} + {cc && <>, copying {cc}}.

- Nothing in Chambers has changed status. CSC releases the rooms, and these bookings stay - Pending Cancellation until that is confirmed and you mark the request done. + Those {sentCount === 1 ? 'booking is' : 'bookings are'} now marked Cancelled in Chambers.

+ {skipped.length > 0 && ( +

+ {skipped.length} reservation{skipped.length === 1 ? '' : 's'} had no reservation code and + {skipped.length === 1 ? ' was' : ' were'} left alone — neither sent nor cancelled. Add the code, or cancel with CSC by hand. +

+ )} @@ -119,8 +138,9 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) {

- Collects every booking marked Pending Cancellation and - emails CSC a single request listing the date, time and reservation code of each. + Collects every booking marked Pending Cancellation that + has a reservation code, emails CSC a single request listing the date, time and code of each, + then marks them Cancelled.

@@ -174,11 +194,7 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) {
{l.bodyName} · {l.roomOrTable} - {l.reservationCode - ? {l.reservationCode} - /* Sent anyway -- CSC can still find it by date and room, and - hiding it would quietly drop a cancellation. */ - : no code} + {l.reservationCode}
))} @@ -186,15 +202,43 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) { )} {!loading && lines.length === 0 && ( -

Nothing is marked Pending Cancellation for these filters.

+

+ {skipped.length > 0 + ? 'Nothing with a reservation code is marked Pending Cancellation for these filters.' + : 'Nothing is marked Pending Cancellation for these filters.'} +

)}
{error &&

{error}

} - {lines.some(l => !l.reservationCode) && !loading && ( -

- Some reservations have no code on file. They will still be listed, by date and room, and flagged for CSC. + {/* + Shown, not hidden. These are pending cancellations that this tool + cannot action -- without a code CSC has nothing to look up, and + cancelling them in Chambers anyway would put the two systems out of + step. An admin needs to know they are still outstanding. + */} + {skipped.length > 0 && !loading && ( +

+

+ {skipped.length} skipped — no reservation code +

+

+ Not sent and not cancelled. Add the code to the booking, or handle these with CSC directly. +

+
    + {skipped.slice(0, 5).map((sk, i) => ( +
  • {formatDate(sk.date)} · {sk.bodyName} · {sk.roomOrTable}
  • + ))} + {skipped.length > 5 &&
  • …and {skipped.length - 5} more
  • } +
+
+ )} + + {!loading && lines.length > 0 && ( +

+ Goes to {recipient || 'CSC'} + {cc && <>, copying {cc}}.

)} @@ -204,7 +248,7 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) { disabled={sending || loading || lines.length === 0} className="px-4 py-2 bg-[#c8102e] hover:bg-[#a00d24] text-white text-sm rounded-lg font-medium transition-colors disabled:opacity-50" > - {sending ? 'Sending…' : `Send request to ${recipient || 'CSC'}`} + {sending ? 'Sending…' : `Send & mark cancelled (${lines.length})`} @@ -138,93 +130,85 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) {

- Collects every booking marked Pending Cancellation that - has a reservation code, emails CSC a single request listing the date, time and code of each, - then marks them Cancelled. + Every booking marked Pending Cancellation is listed below. + Tick the ones to include; CSC gets a single request with the date, time and reservation code of each, + and those bookings are then marked Cancelled.

-
- -
- {TYPE_FILTERS.map(f => ( +
+
+

+ {loading + ? 'Loading…' + : lines.length === 0 + ? 'Nothing pending cancellation' + : `${selected.size} of ${lines.length} selected`} +

+ {!loading && lines.length > 0 && ( - ))} -
-
- -
-
- - setFrom(e.target.value)} className={inputCls} /> -
-
- - setTo(e.target.value)} className={inputCls} /> + )}
-
- -
-

- {loading - ? 'Finding reservations…' - : lines.length === 0 - // The empty state below says the rest; "0 reservations will be - // listed" above it just says the same thing twice. - ? 'Nothing to send' - : `${lines.length} reservation${lines.length === 1 ? '' : 's'} will be listed`} -

{!loading && lines.length > 0 && ( -
- {lines.map((l, i) => ( -
-
- {formatDate(l.date)} - {formatTime(l.startTime)} – {formatTime(l.endTime)} -
-
- {l.bodyName} · {l.roomOrTable} - {l.reservationCode} -
-
- ))} +
+ {lines.map(l => { + const isOn = selected.has(l.key) + return ( + + ) + })}
)} {!loading && lines.length === 0 && (

{skipped.length > 0 - ? 'Nothing with a reservation code is marked Pending Cancellation for these filters.' - : 'Nothing is marked Pending Cancellation for these filters.'} + ? 'Nothing with a reservation code is marked Pending Cancellation.' + : 'Nothing is marked Pending Cancellation.'}

)}
- {error &&

{error}

} - {/* - Shown, not hidden. These are pending cancellations that this tool - cannot action -- without a code CSC has nothing to look up, and - cancelling them in Chambers anyway would put the two systems out of - step. An admin needs to know they are still outstanding. + Shown, not hidden. These are pending cancellations this tool cannot + action -- without a code CSC has nothing to look up, and cancelling them + in Chambers anyway would put the two systems out of step. They are not + selectable, and an admin needs to know they are still outstanding. */} {skipped.length > 0 && !loading && (

- {skipped.length} skipped — no reservation code + {skipped.length} not listed — no reservation code

- Not sent and not cancelled. Add the code to the booking, or handle these with CSC directly. + These cannot be sent or cancelled here. Add the code to the booking, or handle them with CSC directly.

    {skipped.slice(0, 5).map((sk, i) => ( @@ -235,7 +219,9 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) {
)} - {!loading && lines.length > 0 && ( + {error &&

{error}

} + + {!loading && selected.size > 0 && (

Goes to {recipient || 'CSC'} {cc && <>, copying {cc}}. @@ -245,10 +231,10 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) {

NU Student Gov. Association

-

v1.13.8

+

v1.14.0

{userName && (

{getGreeting()},
{userName}

diff --git a/app/faq/page.tsx b/app/faq/page.tsx index dea11cd..1611c8a 100644 --- a/app/faq/page.tsx +++ b/app/faq/page.tsx @@ -32,18 +32,36 @@ export default async function FaqPage() {
-

v1.14.0

+

v1.15.0

- We don't exactly know yet! If there's anything you'd like to see, send a Slack DM to the Vice President of Operational Affairs ({vpName}) and the Digital Innovation Manager ({dimName}). + We don't exactly know yet! If there's anything you'd like to see, send a Slack DM to the Vice President of Operational Affairs ({vpName}) and the Digital Innovation Manager ({dimName}).

-
+
+ +
+
+

v1.14.0 — released

+

+ Your booking cards in My Rooms now lead with what the booking is for rather than which body it belongs to, and clicking one opens its full details. Full Body and Weekly Senate sessions carry a link straight to Attendance Manager. +

+

+ Emails do more. You now get one when a booking is created, not only when it changes, and an update email says exactly what moved rather than just restating where the booking now is. If a single week of a weekly booking is edited, the email is about that week instead of the whole series. +

+

+ Room requests now ask how many people you expect, so Operational Affairs can book a room that actually fits without having to come back and ask. The SGA Spaces calendar fills the page rather than sitting in a small scrolling box. +

+

+ For administrators: revision requests can now be denied with a reason, instead of sitting on the list forever when the change cannot be made. Auto-Cancel, on the Cancellations tab, sends CSC a single request covering whichever pending cancellations you select, and marks each one with the outcome its request asked for. +

+
+

v2.0.0

- Operational Affairs is working to standardize account management across SGA custom projects (Chambers, SenatePath, Attendance Manager, Aplio, and more). Once centralized accounts have been successfully tested on our products, they'll be implemented fully as v2.0.0. + Operational Affairs is working to standardize account management across SGA custom projects (Chambers, SenatePath, Attendance Manager, Aplio, and more). Once centralized accounts have been successfully tested on our products, they'll be implemented fully as v2.0.0.

diff --git a/package-lock.json b/package-lock.json index c1fd858..7cc8c12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chambers", - "version": "1.13.8", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chambers", - "version": "1.13.8", + "version": "1.14.0", "dependencies": { "@supabase/ssr": "^0.9.0", "@supabase/supabase-js": "^2.99.1", diff --git a/package.json b/package.json index dcb4d5b..0defb8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chambers", - "version": "1.13.8", + "version": "1.14.0", "private": true, "scripts": { "dev": "next dev", From 30364c5ebae5f64e82e2792b28e29bb3be85b1e5 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 9 Sep 2026 11:17:14 -0400 Subject: [PATCH 07/11] fix: apply the revised cancellation wording to the HTML body too bfb4b34 rewrote the plain-text body of the CSC request but left the HTML one saying the old thing, so the two halves of the same email disagreed -- and since almost every mail client renders the HTML part, the revision was invisible to nearly everyone who received it. The HTML now carries the same words as the text: the shorter opening, "Requested by ." without the trailing division, the reply-all sentence, and the sign-off, which the HTML had never had at all. Wording is mirrored rather than reinterpreted -- these are the sentences from bfb4b34, only marked up. The on the count and on the requester's name is kept from the existing markup. Verified by rendering both bodies from the real template with Resend intercepted: they now read the same, line for line. Co-Authored-By: Claude Opus 5 --- lib/emails/csc-cancellation-request.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/emails/csc-cancellation-request.ts b/lib/emails/csc-cancellation-request.ts index 215c2d8..5826c61 100644 --- a/lib/emails/csc-cancellation-request.ts +++ b/lib/emails/csc-cancellation-request.ts @@ -116,7 +116,7 @@ Thank you, SGA Operational Affairs Team`, html: buildEmailHtml(`

Hello,

-

Northeastern's Student Government Association would like to cancel the ${count} ${plural} listed below. Each was marked for cancellation in Chambers, SGA's room management system.

+

SGA would like to cancel the ${count} ${plural} listed below:

${sScopeNote}

@@ -127,8 +127,9 @@ SGA Operational Affairs Team`, ${htmlRows}
-

Requested by ${sRequestedBy}, Northeastern SGA Operational Affairs.

-

If any of these cannot be released, or a reservation code does not match your records, please reply to this message and we will follow up.

+

Requested by ${sRequestedBy}.

+

If any of these cannot be cancelled, or if any information presented does not match your records, please reply all to this message.

+

Thank you,
SGA Operational Affairs Team

`), }) } From 0df58994f2a3e8766d542ed59a2c9c2bc0655839 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 9 Sep 2026 11:31:34 -0400 Subject: [PATCH 08/11] fix: refuse to mail the real CSC address outside production A test run went to CSC. The recipient fell back to the real address whenever CSC_EMAIL was unset, so a safe test depended on an environment variable being set, in the right Vercel scope, on a deployment created after it was added -- with no sign when any of that was untrue. Auto-Cancel exists only on this branch, so the run could only have been a preview deployment or a local server, and both took the default. Outside production the default is now refused rather than used. A preview or local deployment must name its recipient explicitly, and if it has not the request fails with an explanation instead of reaching a university office. The failure mode becomes "your test did not send", which costs a minute, rather than "CSC received a real cancellation request", which costs a retraction. Production is untouched: VERCEL_ENV is 'production' there and the default applies, so nothing needs configuring for the feature to work in earnest. Refused before anything happens, so a misconfigured environment can neither send nor cancel. The preview reports the block too, so the modal says sending is disabled and why, rather than letting the button be pressed and fail. Also: the destination was a grey footnote beside the button, and a real send went out with the real address on screen the whole time. It is now a bordered block above the button, red and reading "This goes to CSC for real" when it does, and stating the substitute when redirected. A footnote earned what it got. Carries the cancellation-request closing that prompted this session: sending now marks the requests it fully covered as Done, so nobody closes by hand what Auto-Cancel did. Only when every pending reservation a request covers went out -- an occurrence-scoped request covers one row and closes when selected, but sending three weeks of a five-week series request does not finish it, and closing it would drop the remaining two off the Cancellations tab with nothing done. Verified with nothing sent. The guard refuses local-dev with no override, preview with no override, preview with the variable empty or whitespace, and accepts an explicit override in preview or production; production with no override still resolves to CSC. The coverage rule is a pure exported function because no series-scoped request exists in the data to exercise it. All three modal states render: blocked disables the button and explains why, the real address shows red as "This goes to CSC for real", and a redirect shows the substitute. Co-Authored-By: Claude Opus 5 --- .../bookings/auto-cancel-modal.tsx | 42 +++++++-- .../cancellations/auto-cancel/route.ts | 87 +++++++++++++++++-- lib/emails/csc-cancellation-request.ts | 6 ++ lib/pending-cancellations.ts | 67 +++++++++++--- 4 files changed, 174 insertions(+), 28 deletions(-) diff --git a/app/(dashboard)/bookings/auto-cancel-modal.tsx b/app/(dashboard)/bookings/auto-cancel-modal.tsx index 558e31d..6e54a5a 100644 --- a/app/(dashboard)/bookings/auto-cancel-modal.tsx +++ b/app/(dashboard)/bookings/auto-cancel-modal.tsx @@ -50,12 +50,15 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) { const [skipped, setSkipped] = useState([]) const [selected, setSelected] = useState>(new Set()) const [recipient, setRecipient] = useState('') + const [recipientIsReal, setRecipientIsReal] = useState(false) + const [blocked, setBlocked] = useState(null) const [cc, setCc] = useState(null) const [loading, setLoading] = useState(true) const [sending, setSending] = useState(false) const [error, setError] = useState('') const [sentCount, setSentCount] = useState(null) const [sentSplit, setSentSplit] = useState<{ cancelled: number; virtual: number } | null>(null) + const [requestsClosed, setRequestsClosed] = useState(0) const load = useCallback(async () => { setLoading(true) @@ -67,6 +70,8 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) { setLines(data.lines ?? []) setSkipped(data.skipped ?? []) setRecipient(data.recipient ?? '') + setRecipientIsReal(!!data.recipientIsReal) + setBlocked(data.blocked ?? null) setCc(data.cc ?? null) // Any previous ticks are dropped on a reload. The list may have moved, and // carrying a selection across it would mean approving rows never seen. @@ -111,6 +116,7 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) { if (res.ok) { setSentCount(data.sent ?? selected.size) if (typeof data.cancelled === 'number') setSentSplit({ cancelled: data.cancelled, virtual: data.virtual ?? 0 }) + setRequestsClosed(data.requestsClosed ?? 0) } else { setError(data.error || 'The request could not be sent.') // Whatever went wrong, the list on screen may no longer be the truth. @@ -138,6 +144,11 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) { )}{' '} Anything you left unticked is untouched and still pending.

+ {requestsClosed > 0 && ( +

+ {requestsClosed} cancellation request{requestsClosed === 1 ? '' : 's'} closed — no need to mark {requestsClosed === 1 ? 'it' : 'them'} done. +

+ )} @@ -261,17 +272,36 @@ export default function AutoCancelModal({ onClose }: { onClose: () => void }) { {error &&

{error}

} - {!loading && selected.size > 0 && ( -

- Goes to {recipient || 'CSC'} - {cc && <>, copying {cc}}. -

+ {/* + Loud, and above the button rather than beside it. This used to be a + grey footnote; a test run went to the real CSC inbox with the address + on screen the whole time, which is the outcome a footnote earns. + */} + {!loading && blocked && ( +
+

Sending is disabled here

+

{blocked}

+
+ )} + + {!loading && !blocked && selected.size > 0 && ( +
+

+ {recipientIsReal ? 'This goes to CSC for real:' : 'Redirected — this is not CSC:'} +

+

{recipient}

+ {cc &&

copying {cc}

} +
)}