diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac59394 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +RESEND_API_KEY= +OPS_EMAIL= +RESEND_FROM_EMAIL= +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +# Random string used to authenticate kiosk display pages (e.g. display_chambers_2026) +DISPLAY_KEY= +# Slack app credentials (from the Slack app's Basic Information page) +SLACK_BOT_TOKEN= +SLACK_SIGNING_SECRET= + +# Where Auto-Cancel sends its cancellation request. Leave empty in production: +# it falls back to cscreservations@northeastern.edu, which is the point of it. +# +# Outside production an empty value is REFUSED rather than defaulted, so a +# preview deployment or a local server has to name a test recipient here before +# it will send anything. Spell it exactly -- a misnamed variable reads as unset. +CSC_EMAIL= + +# Shared secret for the scheduled warm-up route (/api/cron/warm). +CRON_SECRET= diff --git a/.gitignore b/.gitignore index 92e7b25..1e3ca77 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,11 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# ...except the template, which holds names and comments but no values. It is +# the only list of what Chambers actually reads, and while it was ignored the +# correct spellings existed nowhere but the source -- which is how CSC_EMAIL got +# misnamed in Vercel, silently fell back to the real address, and mailed CSC. +!.env.example # vercel .vercel diff --git a/app/(dashboard)/bookings/auto-cancel-modal.tsx b/app/(dashboard)/bookings/auto-cancel-modal.tsx new file mode 100644 index 0000000..6e54a5a --- /dev/null +++ b/app/(dashboard)/bookings/auto-cancel-modal.tsx @@ -0,0 +1,319 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import BookingModal from './booking-modal' + +interface CancellationLine { + /** `${source}:${id}` — how the server identifies this row when it comes back. */ + key: string + date: string + startTime: string + endTime: string + reservationCode: string + roomOrTable: string + bodyName: string + bookingType: 'One-Time Room' | 'Weekly Room' | 'Tabling' + /** What this booking becomes in Chambers once CSC has been asked. */ + resultingStatus: 'Cancelled' | 'Virtual' + /** False when no cancellation request said which, and this fell back to Cancelled. */ + outcomeFromRequest: boolean +} + +/** 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' +} + +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'}` +} + +/** + * Lists every reservation marked for cancellation and lets an admin choose which + * ones to ask CSC to release. + * + * Nothing is selected when this opens. Including a reservation is an act, not a + * default -- the send is an email to a university office that will act on it and + * a status change in Chambers, neither of which can be taken back. + */ +export default function AutoCancelModal({ onClose }: { onClose: () => void }) { + const [lines, setLines] = useState([]) + 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) + setError('') + try { + const res = await fetch('/api/administrator/cancellations/auto-cancel') + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Could not load the list.') + const data = await res.json() + 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. + setSelected(new Set()) + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not load the list.') + setLines([]) + setSkipped([]) + } + setLoading(false) + }, []) + + useEffect(() => { load() }, [load]) + + const toggle = (key: string) => { + setSelected(prev => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + const allSelected = lines.length > 0 && selected.size === lines.length + + // "mark cancelled" is a lie once a Virtual is in the selection -- that meeting + // is not cancelled, it is moving online. + const selectionHasVirtual = lines.some(l => selected.has(l.key) && l.resultingStatus === 'Virtual') + const sendLabel = selectionHasVirtual + ? `Send & apply statuses (${selected.size})` + : `Send & mark cancelled (${selected.size})` + + const send = async () => { + setSending(true) + setError('') + const res = await fetch('/api/administrator/cancellations/auto-cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keys: [...selected] }), + }) + const data = await res.json().catch(() => ({})) + 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. + load() + } + setSending(false) + } + + if (sentCount !== null) { + return ( + +
+

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

+

+ {sentSplit && sentSplit.virtual > 0 ? ( + <> + {sentSplit.cancelled > 0 && <>{sentSplit.cancelled} now marked Cancelled, and } + {sentSplit.virtual} marked Virtual — those meetings still happen, without the room. + + ) : ( + <>Those {sentCount === 1 ? 'booking is' : 'bookings are'} now marked Cancelled in Chambers. + )}{' '} + 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. +

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

+ 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 each booking then takes the status its cancellation asked for — + Cancelled, or Virtual if the meeting is moving online. +

+ +
+
+

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

+ {!loading && lines.length > 0 && ( + + )} +
+ + {!loading && lines.length > 0 && ( +
+ {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.' + : 'Nothing is marked Pending Cancellation.'} +

+ )} +
+ + {/* + 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} not listed — no reservation code +

+

+ 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) => ( +
  • {formatDate(sk.date)} · {sk.bodyName} · {sk.roomOrTable}
  • + ))} + {skipped.length > 5 &&
  • …and {skipped.length - 5} more
  • } +
+
+ )} + + {error &&

{error}

} + + {/* + 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}

} +
+ )} + +
+ + +
+
+
+ ) +} 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/(dashboard)/dashboard-shell.tsx b/app/(dashboard)/dashboard-shell.tsx index 9dfdfc9..19bb9af 100644 --- a/app/(dashboard)/dashboard-shell.tsx +++ b/app/(dashboard)/dashboard-shell.tsx @@ -435,7 +435,7 @@ export default function DashboardShell({ Chambers

NU Student Gov. Association

-

v1.13.8

+

v1.14.0

{userName && (

{getGreeting()},
{userName}

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..4025049 --- /dev/null +++ b/app/api/administrator/cancellations/auto-cancel/route.ts @@ -0,0 +1,268 @@ +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, type CancellationLine } from '@/lib/emails/csc-cancellation-request' +import { collectPending, lineKey, requestsFullyCovered } from '@/lib/pending-cancellations' + +const adminSupabase = createAdminClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! +) + +const DEFAULT_CSC_EMAIL = 'cscreservations@northeastern.edu' + +/** + * Where the cancellation request goes, and whether it may be sent at all. + * + * The default used to apply everywhere CSC_EMAIL was unset, which made a test + * run safe only if an environment variable had been set, in the right Vercel + * scope, on a deployment created after it was added -- and gave no sign when any + * of that was not true. It mailed CSC instead. That happened. + * + * So outside production the real address is refused rather than defaulted to. + * A preview deployment or a local server 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 is now "your test did not send", which + * costs a minute, rather than "CSC received a real cancellation request", which + * costs an apology and a retraction. + * + * Production is unchanged: VERCEL_ENV is 'production' there and the default + * applies, so nothing has to be configured for the feature to work in earnest. + */ +function resolveRecipient(): { to: string; isDefault: boolean } | { error: string } { + const override = process.env.CSC_EMAIL?.trim() + if (override) return { to: override, isDefault: false } + + // Vercel sets this to 'production' | 'preview' | 'development'. It is absent + // under `next dev`, which is treated as not-production -- the safe reading. + if (process.env.VERCEL_ENV !== 'production') { + return { + error: + `Refusing to send: this is not the production deployment, and CSC_EMAIL is not set, so the request would go to ${DEFAULT_CSC_EMAIL}. ` + + `Set CSC_EMAIL to a test address for this environment and redeploy, then try again.`, + } + } + + return { to: DEFAULT_CSC_EMAIL, isDefault: true } +} + +/** + * Everything currently marked for cancellation, for the admin to choose from. + * + * Returns the whole set rather than a filtered slice. Auto-Cancel used to take a + * booking type and a date range and act on whatever matched, which made the + * filter -- something you set to look around with -- decide what got cancelled. + * The admin now picks rows explicitly, and this is the list they pick from. + */ +export async function GET() { + 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 { lines, skipped } = await collectPending() + const recipient = resolveRecipient() + + return NextResponse.json({ + lines: lines.map(l => ({ ...l, key: lineKey(l) })), + skipped, + // Surfaced before anything is selected, so the modal can say where this is + // headed -- or refuse up front rather than at the click. + recipient: 'to' in recipient ? recipient.to : null, + recipientIsReal: 'to' in recipient ? recipient.isDefault : false, + blocked: 'error' in recipient ? recipient.error : null, + cc: process.env.OPS_EMAIL || null, + }) +} + +/** + * Sends the request to CSC for the reservations the admin selected, and applies + * the status each one is due. + * + * Not always 'Cancelled'. A cancellation request records whether the meeting is + * off or moving online, and Auto-Cancel applies the one that was actually asked + * for -- 'Virtual' means the meeting still happens without the room. CSC's side + * is identical either way: the reservation is released. + * + * The selection is a choice among what the server finds, never the source of + * truth. Every submitted key is matched back against a freshly collected set: a + * client cannot introduce a date, a code or a booking that is not currently + * pending with a code on file, and the list that goes to CSC is built from the + * server's own rows rather than from anything posted. + * + * Order matters. The email goes first, and the statuses move only once it has + * actually been accepted. Marking first and failing to send would leave a + * booking cancelled in Chambers that CSC still holds a room for -- the one + * outcome worth designing against, since nobody would be looking for it. + * + * Cancellation requests that this send fully covers are marked Done, so nobody + * has to close by hand what Auto-Cancel already did. A request covering more + * dates than were sent stays open -- see the note at the update itself. + * + * A reservation with no code on file can never be selected: CSC identifies a + * booking by its code, so there is nothing to ask them to release, and + * cancelling it here on the strength of a request they could not act on would + * put the two systems out of step. Those are surfaced separately for an admin to + * chase by hand -- see collectPending. + */ +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 requested: string[] = Array.isArray(body?.keys) + ? body.keys.filter((k: unknown): k is string => typeof k === 'string') + : [] + + if (!requested.length) { + return NextResponse.json( + { error: 'Select at least one reservation to cancel.' }, + { status: 400 } + ) + } + + const { lines } = await collectPending() + const available = new Map(lines.map(l => [lineKey(l), l])) + + const selected = requested.map(k => available.get(k)).filter(l => l !== undefined) + const missing = requested.filter(k => !available.has(k)) + + // Something the admin ticked is no longer pending, or lost its reservation + // code, while the modal was open. Refusing the whole request is deliberate: + // quietly sending the remainder would cancel a different set from the one they + // reviewed, and they would have no way to tell. + if (missing.length) { + return NextResponse.json( + { + error: `${missing.length} of the ${requested.length} selected reservation${requested.length === 1 ? '' : 's'} ${missing.length === 1 ? 'is' : 'are'} no longer pending cancellation. Nothing was sent. Reload the list and choose again.`, + stale: missing, + }, + { status: 409 } + ) + } + + const recipient = resolveRecipient() + if ('error' in recipient) { + // Checked after the selection resolves but before anything leaves or moves, + // so a misconfigured environment cannot send and cannot cancel. + return NextResponse.json({ error: recipient.error }, { status: 400 }) + } + + const { data: profile } = await adminSupabase + .from('users').select('full_name').eq('id', user.id).single() + + try { + await sendCscCancellationRequest({ + lines: selected, + requestedBy: profile?.full_name || user.email || 'Chambers administrator', + scopeNote: `${selected.length} reservation${selected.length === 1 ? '' : 's'}, selected individually in Chambers.`, + to: recipient.to, + cc: process.env.OPS_EMAIL || undefined, + 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. Nothing has been marked at this point. + console.error('CSC cancellation request failed:', e) + return NextResponse.json({ error: 'The email could not be sent. No bookings were changed.' }, { status: 502 }) + } + + // Grouped by table *and* by the status each row is due, so a batch containing + // both kinds writes each its own value. Marking everything 'Cancelled' would + // be wrong for a booking whose request said it was going virtual: that meeting + // still happens, it just does not need the room. + // + // An occurrence whose status was inherited gets its value written onto the + // occurrence itself, which is correct -- only the dates actually sent stop + // being pending, and the rest of the series is untouched. + const TABLE_OF: Record = { + one_time: 'one_time_room_bookings', + occurrence: 'weekly_room_occurrences', + tabling_session: 'tabling_sessions', + } + + const batches = new Map() + for (const l of selected) { + const table = TABLE_OF[l.source] + const bucket = `${table}:${l.resultingStatus}` + if (!batches.has(bucket)) batches.set(bucket, { table, status: l.resultingStatus, ids: [] }) + batches.get(bucket)!.ids.push(l.id) + } + + const failures: string[] = [] + for (const { table, status, ids } of batches.values()) { + const { error } = await adminSupabase.from(table).update({ status }).in('id', ids) + if (error) { + console.error(`Auto-Cancel could not mark ${table} as ${status}:`, error) + failures.push(`${table} (${status})`) + } + } + + // Close the cancellation requests this send acted on, so nobody has to go and + // press "Mark as Done" for work Auto-Cancel already did. + // + // Only when every pending reservation a request covers was included. An + // occurrence-scoped request covers exactly one row and is therefore closed + // whenever it is selected, but a series-scoped one covers the whole run: if the + // admin sent three weeks of a five-week cancellation, the request is not + // finished, and marking it Done would drop the remaining two off the + // Cancellations tab with nothing done about them. + const fullyHandled = requestsFullyCovered(lines, new Set(selected.map(lineKey))) + + if (fullyHandled.length) { + // Guarded on Pending so this cannot reopen or re-close a request someone + // resolved by hand while the modal was open. + const { error } = await adminSupabase + .from('cancellation_requests') + .update({ status: 'Done' }) + .in('id', fullyHandled) + .eq('status', 'Pending') + // Best effort, like the audit rows: the mail is out and the statuses have + // moved, and failing the request over this would invite a resend. + if (error) console.error('Auto-Cancel could not close cancellation requests:', error) + } + + // 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: the email is out and the statuses are moved, and + // failing the request over a missing log would invite a resend. + // Keyed on booking *and* status: one booking can contribute both a cancelled + // week and a virtual one in the same batch, and a single row saying 'Cancelled' + // would misreport the other. + const auditRows = [...new Map( + selected + .filter(l => l.bookingId) + .map(l => [`${l.bookingId}:${l.resultingStatus}`, { booking_id: l.bookingId, admin_id: user.id, new_status: l.resultingStatus }]) + ).values()] + if (auditRows.length) { + const { error } = await adminSupabase.from('audit_logs').insert(auditRows) + if (error) console.error('Auto-Cancel audit log failed:', error) + } + + return NextResponse.json({ + success: true, + sent: selected.length, + cancelled: selected.filter(l => l.resultingStatus === 'Cancelled').length, + virtual: selected.filter(l => l.resultingStatus === 'Virtual').length, + requestsClosed: fullyHandled.length, + recipient: recipient.to, + // The mail is already gone, so a failure here is reported rather than thrown: + // the admin needs to know the request went but the statuses did not move. + ...(failures.length ? { statusUpdateFailed: failures } : {}), + }) +} 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/lib/emails/csc-cancellation-request.ts b/lib/emails/csc-cancellation-request.ts new file mode 100644 index 0000000..cbb6922 --- /dev/null +++ b/lib/emails/csc-cancellation-request.ts @@ -0,0 +1,141 @@ +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. + * + * reservationCode is not nullable. A reservation with no code on file cannot be + * cancelled by this route at all -- see collectPending, which sets those aside + * rather than listing them. + */ +export interface CancellationLine { + /** The dated row to mark Cancelled once CSC has been asked. */ + id: string + /** Which table `id` belongs to. */ + source: 'one_time' | 'occurrence' | 'tabling_session' + /** The parent bookings.id, for the audit log entry. */ + bookingId: string + /** + * What this booking becomes in Chambers once CSC has been asked. + * + * A cancellation request records whether the meeting is cancelled outright or + * moving online, and the two are not the same afterwards -- 'Virtual' means it + * still happens, without the room. CSC's side of it is identical either way: + * the reservation is released. + */ + resultingStatus: 'Cancelled' | 'Virtual' + /** + * False when no cancellation request says which, and the outcome fell back to + * 'Cancelled'. Surfaced so an admin can see they are approving a default + * rather than a stated intent. + */ + outcomeFromRequest: boolean + /** + * The cancellation_requests row this reservation came from, so sending can + * close it. Null when the status was set directly by an admin with no request + * behind it -- there is nothing to mark done in that case. + */ + cancellationRequestId: string | null + date: string + startTime: string + endTime: string + reservationCode: string + 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 + /** Operational Affairs, copied on every request so the division has the record. */ + cc?: string + replyTo?: string +} + +/** + * 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, cc, 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: ${sanitize(l.reservationCode)}`, + ` 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)} + ${sanitize(l.reservationCode)} + ${sanitize(l.roomOrTable)}
${sanitize(l.bodyName)} + `) + .join('') + + await resend.emails.send({ + from: process.env.RESEND_FROM_EMAIL!, + to, + ...(cc ? { cc } : {}), + // 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, + +SGA would like to cancel the ${count} ${plural} listed below: + +${sScopeNote} + +${textRows} +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`, + html: buildEmailHtml(` +

Hello,

+

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

+

${sScopeNote}

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

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

+ `), + }) +} diff --git a/lib/pending-cancellations.ts b/lib/pending-cancellations.ts new file mode 100644 index 0000000..3dfd252 --- /dev/null +++ b/lib/pending-cancellations.ts @@ -0,0 +1,355 @@ +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' + +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. + */ +/** + * A reservation that is pending cancellation but has no reservation code. + * + * Kept apart from `lines` rather than dropped silently. CSC identifies a booking + * by its code, so there is nothing to ask them to release -- and because + * Auto-Cancel marks what it sends as Cancelled, listing one of these would mean + * cancelling a booking in Chambers on the strength of a request CSC could not + * act on. They are surfaced in the preview so an admin knows to chase them by + * hand. + */ +/** + * Whether CSC could act on this reservation. + * + * The code is the only handle CSC has on a booking, so one without it can be + * neither requested nor -- since Auto-Cancel marks what it sends -- cancelled in + * Chambers. Whitespace counts as absent: a code column holding " " is a blank + * someone tabbed through, not an identifier. + * + * Exported so the rule is testable on its own. Live data has a code on every + * pending row today, which makes this the branch that would otherwise ship + * unexercised. + */ +export function hasUsableCode(code: string | null | undefined): boolean { + return typeof code === 'string' && code.trim().length > 0 +} + +export interface SkippedReservation { + date: string + roomOrTable: string + bodyName: string + bookingType: 'One-Time Room' | 'Weekly Room' | 'Tabling' +} + +/** + * How the browser names a reservation when it selects one. + * + * Scoped by table because the id alone says nothing about which of the three it + * belongs to, and a selection that resolved against the wrong table would cancel + * the wrong booking. Everything the POST accepts is matched back against a + * freshly collected set, so an unknown key is refused rather than acted on. + */ +export function lineKey(l: { source: CancellationLine['source']; id: string }): string { + return `${l.source}:${l.id}` +} + +/** + * The status a pending reservation should take once CSC has been asked. + * + * A cancellation request carries a cancellation_type: 'Cancellation' when the + * meeting is off, 'Virtual' when it is moving online. Those are different + * afterwards -- a virtual meeting still happens, it just does not need the room + * -- so Auto-Cancel has to apply the one the requester actually asked for rather + * than marking everything Cancelled. + * + * Falls back to 'Cancelled' when nothing says otherwise, which is the common + * case: a booking can be set to Pending Cancellation directly by an admin, with + * no request behind it at all. 'Cancelled' is the plain reading of that, and + * going virtual is a specific thing a requester asks for -- defaulting the other + * way would quietly leave rooms marked as still-meeting. + */ +export interface CancellationOutcome { + status: 'Cancelled' | 'Virtual' + fromRequest: boolean +} + +/** A cancellation request, as indexed for lookup. */ +interface RequestRef { + id: string + type: string +} + +const DEFAULT_OUTCOME: CancellationOutcome = { status: 'Cancelled', fromRequest: false } + +export function outcomeOf(cancellationType: string | null | undefined): CancellationOutcome { + if (cancellationType === 'Virtual') return { status: 'Virtual', fromRequest: true } + if (cancellationType === 'Cancellation') return { status: 'Cancelled', fromRequest: true } + return DEFAULT_OUTCOME +} + +/** + * The cancellation requests a send has finished off. + * + * A request is only done when every pending reservation it covers went out. + * Occurrence-scoped requests cover exactly one row, so selecting one closes it; + * a series-scoped request covers the whole run, and sending three weeks of a + * five-week cancellation does not finish it. Closing it anyway would drop the + * remaining two off the Cancellations tab with nothing done about them. + * + * Pure, and exported, because there are no series-scoped requests in the data + * today -- this is the branch that would otherwise ship unexercised. + */ +export function requestsFullyCovered( + allPending: { cancellationRequestId: string | null; source: CancellationLine['source']; id: string }[], + selectedKeys: Set +): string[] { + const coverage = new Map() + for (const l of allPending) { + if (!l.cancellationRequestId) continue + const c = coverage.get(l.cancellationRequestId) ?? { total: 0, sent: 0 } + c.total += 1 + if (selectedKeys.has(lineKey(l))) c.sent += 1 + coverage.set(l.cancellationRequestId, c) + } + return [...coverage.entries()] + .filter(([, c]) => c.sent > 0 && c.sent === c.total) + .map(([id]) => id) +} + +export interface PendingCancellations { + lines: CancellationLine[] + skipped: SkippedReservation[] +} + +export async function collectPending(): Promise { + const lines: CancellationLine[] = [] + const skipped: SkippedReservation[] = [] + + // Read once and index, rather than a lookup per reservation. Ordered so the + // preferred row is the one that survives into each map: a still-Pending + // request beats a resolved one, and the most recent beats an older one. + const { data: requests } = await adminSupabase + .from('cancellation_requests') + .select('id, booking_id, occurrence_id, scope, status, cancellation_type, created_at') + .order('created_at', { ascending: false }) + + // The id travels with the type: sending closes the request it acted on, so + // knowing *which* row said 'Virtual' matters as much as the value. + const byOccurrence = new Map() + const bySeriesBooking = new Map() + const byBooking = new Map() + + for (const pass of ['Pending', 'other'] as const) { + for (const r of (requests ?? []) as { + id: string + booking_id: string | null + occurrence_id: string | null + scope: string + status: string | null + cancellation_type: string + }[]) { + const ref: RequestRef = { id: r.id, type: r.cancellation_type } + const isPending = r.status === 'Pending' + if (pass === 'Pending' ? !isPending : isPending) continue + // setDefault semantics: the first pass wins, so a Pending request is never + // overwritten by a resolved one. + if (r.occurrence_id && !byOccurrence.has(r.occurrence_id)) { + byOccurrence.set(r.occurrence_id, ref) + } + if (r.booking_id) { + if (r.scope === 'series' && !bySeriesBooking.has(r.booking_id)) { + bySeriesBooking.set(r.booking_id, ref) + } + if (!byBooking.has(r.booking_id)) byBooking.set(r.booking_id, ref) + } + } + } + + /** Routes a row to `lines` or `skipped` on whether CSC could act on it. */ + const add = ( + code: string | null, + line: Omit, + request: RequestRef | undefined, + ) => { + const outcome = outcomeOf(request?.type) + if (hasUsableCode(code)) lines.push({ + ...line, + reservationCode: code!.trim(), + resultingStatus: outcome.status, + outcomeFromRequest: outcome.fromRequest, + cancellationRequestId: request?.id ?? null, + }) + else skipped.push({ + date: line.date, + roomOrTable: line.roomOrTable, + bodyName: line.bodyName, + bookingType: line.bookingType, + }) + } + + { + const { data } = await adminSupabase + .from('one_time_room_bookings') + .select('id, booking_id, 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 })[]) { + add(r.reservation_code, { + id: r.id, + source: 'one_time', + bookingId: r.booking_id, + date: r.booking_date, + startTime: r.start_time, + endTime: r.end_time, + roomOrTable: r.room_name || 'Not recorded', + bodyName: bodyNameOf(r.bookings), + bookingType: 'One-Time Room', + }, byBooking.get(r.booking_id)) + } + } + + { + const { data } = await adminSupabase + .from('tabling_sessions') + .select('id, location, session_date, start_time, end_time, reservation_code, tabling_bookings(id, booking_id, reservation_code, bookings(id, type, purpose, bodies(name)))') + .eq('status', PENDING) + + for (const r of (data ?? []) as unknown as (Record & { + tabling_bookings: { id: string; booking_id: string; reservation_code: string | null; bookings: BookingRef | null } | null + })[]) { + const parent = Array.isArray(r.tabling_bookings) ? r.tabling_bookings[0] : r.tabling_bookings + // The session's own code wins; the booking's is the fallback, matching + // how the tabling editor treats it. + add(r.reservation_code || parent?.reservation_code || null, { + id: r.id, + source: 'tabling_session', + bookingId: parent?.booking_id ?? '', + date: r.session_date, + startTime: r.start_time, + endTime: r.end_time, + roomOrTable: r.location || 'Not recorded', + bodyName: bodyNameOf(parent?.bookings ?? null), + bookingType: 'Tabling', + }, parent?.booking_id ? byBooking.get(parent.booking_id) : undefined) + } + } + + { + const { data } = await adminSupabase + .from('weekly_room_occurrences') + .select(` + id, occurrence_date, room_name, start_time, end_time, status, reservation_code, + weekly_room_bookings(id, booking_id, room_name, start_time, end_time, status, reservation_code, + bookings(id, type, purpose, bodies(name))) + `) + + for (const r of (data ?? []) as unknown as { + id: string + 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: { + id: string + booking_id: string + 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 + + add(eff.reservationCode, { + id: r.id, + source: 'occurrence', + bookingId: series?.booking_id ?? '', + date: r.occurrence_date, + startTime: eff.startTime, + endTime: eff.endTime, + roomOrTable: eff.roomOrTable, + bodyName: bodyNameOf(series?.bookings ?? null), + bookingType: 'Weekly Room', + }, + // A request naming this exact week wins over one covering the series. + byOccurrence.get(r.id) + ?? (series?.booking_id ? bySeriesBooking.get(series.booking_id) : undefined) + ) + } + } + + // Chronological: CSC works through a list of dates, not a list of bodies. + const byDate = (a: { date: string }, b: { date: string }) => a.date.localeCompare(b.date) + return { + lines: lines.sort((a, b) => (a.date === b.date ? a.startTime.localeCompare(b.startTime) : byDate(a, b))), + skipped: skipped.sort(byDate), + } +} + 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",