-
Senate Session Types Shown in My Rooms
+
+
Senate Session Types You Follow
+
+ Deselected types are hidden from My Rooms, and Chambers stops emailing and alerting you about them.
+
+
{SENATE_TYPES.map(type => (
diff --git a/app/(dashboard)/sga-spaces/page.tsx b/app/(dashboard)/sga-spaces/page.tsx
index 63aed00..216eb03 100644
--- a/app/(dashboard)/sga-spaces/page.tsx
+++ b/app/(dashboard)/sga-spaces/page.tsx
@@ -458,6 +458,7 @@ export default function SGASpacesPage() {
editBookingId={editBooking.id}
initialTitle={editBooking.title}
initialAttendees={editBooking.attendees}
+ minHoursAdvance={minHoursAdvance}
onClose={() => setEditBooking(null)}
onSuccess={() => {
setEditBooking(null)
diff --git a/app/(dashboard)/sga-spaces/space-booking-modal.tsx b/app/(dashboard)/sga-spaces/space-booking-modal.tsx
index a9e48b6..59cf1a7 100644
--- a/app/(dashboard)/sga-spaces/space-booking-modal.tsx
+++ b/app/(dashboard)/sga-spaces/space-booking-modal.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import TimePicker from '../bookings/time-picker'
import DateField from '@/app/_components/date-field'
+import { advanceNoticeError } from '@/lib/spaces-advance-notice'
interface User {
id: string
@@ -29,6 +30,8 @@ interface SpaceBookingModalProps {
initialAttendees?: User[]
onCancelBooking?: () => Promise
spaces?: Space[]
+ /** Hours of notice required before newly claimed time. 0 disables the rule. */
+ minHoursAdvance?: number
}
function isoToDateAndTime(iso: string): { date: string; time: string } {
@@ -61,6 +64,7 @@ export default function SpaceBookingModal({
initialAttendees = [],
onCancelBooking,
spaces,
+ minHoursAdvance = 0,
}: SpaceBookingModalProps) {
const isEditing = !!editBookingId
@@ -132,6 +136,18 @@ export default function SpaceBookingModal({
}
}
+ // The same rule the server applies, run as the form changes so an edit that
+ // would be refused says so before it is submitted (issue #94). Only for edits:
+ // a new booking cannot be drawn inside the notice window in the first place,
+ // and warning about the slot you have not finished picking would be noise.
+ const noticeWarning = isEditing && date
+ ? advanceNoticeError(
+ { start: dateAndTimeToIso(date, startTime), end: endTimeToIso(date, endTime) },
+ { start: initialStart, end: initialEnd },
+ minHoursAdvance
+ )
+ : null
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
@@ -303,6 +319,14 @@ export default function SpaceBookingModal({
)}
+ {/* Advance notice — a warning, not an error: the booking as it stands is
+ fine, it is the pending change that would be refused. */}
+ {noticeWarning && !error && (
+
+ {noticeWarning}
+
+ )}
+
{/* Error */}
{error && (
@@ -321,7 +345,7 @@ export default function SpaceBookingModal({
{submitting ? (isEditing ? 'Saving…' : 'Booking…') : (isEditing ? 'Save Changes' : 'Confirm Booking')}
diff --git a/app/(dashboard)/sga-spaces/space-calendar.tsx b/app/(dashboard)/sga-spaces/space-calendar.tsx
index e0d8e75..a35c104 100644
--- a/app/(dashboard)/sga-spaces/space-calendar.tsx
+++ b/app/(dashboard)/sga-spaces/space-calendar.tsx
@@ -219,23 +219,22 @@ export default function SpaceCalendar({
// ── Mouse interaction ────────────────────────────────────────────────────────
const handleOverlayMouseMove = useCallback((e: React.MouseEvent, dayIdx: number) => {
- if (!canBook) {
- setOverlayCursor('default')
- return
- }
const slot = slotFromClientY(e.clientY)
- if (isSlotBlocked(dayIdx, slot) || isSlotInNoticeZone(dayIdx, slot)) {
+ // Your own booking is reachable first, before the blocked and notice-zone
+ // guards. Those guards are about claiming *new* time, and opening a booking
+ // you already hold claims nothing -- it is how you shorten or cancel it
+ // (issue #94). Deciding this here rather than in the guards keeps a blackout
+ // or the notice window from swallowing the click on a booking sitting inside
+ // it, which is what made such a booking impossible to touch at all.
+ const ownBooking = bookingsByDay[dayIdx].find(
+ bs => slot >= bs.startSlot && slot < bs.endSlot && bs.booking.creator_id === currentUserId
+ )
+ if (ownBooking) {
+ setOverlayCursor('pointer')
+ setHoveredBookingId(ownBooking.booking.id)
+ } else if (!canBook || isSlotBlocked(dayIdx, slot) || isSlotInNoticeZone(dayIdx, slot) || isSlotBooked(dayIdx, slot)) {
setOverlayCursor('default')
setHoveredBookingId(null)
- } else if (isSlotBooked(dayIdx, slot)) {
- const hit = bookingsByDay[dayIdx].find(bs => slot >= bs.startSlot && slot < bs.endSlot)
- if (hit && hit.booking.creator_id === currentUserId) {
- setOverlayCursor('pointer')
- setHoveredBookingId(hit.booking.id)
- } else {
- setOverlayCursor('default')
- setHoveredBookingId(null)
- }
} else {
setOverlayCursor('crosshair')
setHoveredBookingId(null)
@@ -245,14 +244,19 @@ export default function SpaceCalendar({
const handleColumnMouseDown = useCallback((e: React.MouseEvent, dayIdx: number) => {
e.preventDefault()
const slot = slotFromClientY(e.clientY)
- if (isSlotBlocked(dayIdx, slot) || isSlotInNoticeZone(dayIdx, slot)) return
- if (isSlotBooked(dayIdx, slot)) {
- if (currentUserId && onBookingClick) {
- const hit = bookingsByDay[dayIdx].find(bs => slot >= bs.startSlot && slot < bs.endSlot)
- if (hit && hit.booking.creator_id === currentUserId) onBookingClick(hit.booking)
+ // Same order as the hover handler above: your own booking opens even inside
+ // the notice window (issue #94).
+ if (currentUserId && onBookingClick) {
+ const hit = bookingsByDay[dayIdx].find(
+ bs => slot >= bs.startSlot && slot < bs.endSlot && bs.booking.creator_id === currentUserId
+ )
+ if (hit) {
+ onBookingClick(hit.booking)
+ return
}
- return
}
+ if (isSlotBlocked(dayIdx, slot) || isSlotInNoticeZone(dayIdx, slot)) return
+ if (isSlotBooked(dayIdx, slot)) return
if (!canBook) return
dragRef.current = { dayIdx, startSlot: slot, currentSlot: slot }
setDragPreview({ dayIdx, startSlot: slot, endSlot: slot + 1 })
diff --git a/app/api/administrator/bodies/route.ts b/app/api/administrator/bodies/route.ts
index d8a1169..16cb921 100644
--- a/app/api/administrator/bodies/route.ts
+++ b/app/api/administrator/bodies/route.ts
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { checkRateLimit } from '@/lib/check-rate-limit'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { isManagementRole } from '@/lib/admin-roles'
+import { isBodyType } from '@/lib/body-types'
export async function GET() {
const supabase = await createClient()
@@ -17,7 +18,7 @@ export async function GET() {
const { data: bodies } = await supabase
.from('bodies')
- .select('id, name, division, is_active, body_open')
+ .select('id, name, division, is_active, body_open, body_type, slack_channel_id, slack_reminders_enabled')
.order('name', { ascending: true })
return NextResponse.json({ bodies: bodies || [] })
@@ -40,11 +41,18 @@ export async function POST(request: Request) {
const rateLimitRes = await checkRateLimit(user.id)
if (rateLimitRes) return rateLimitRes
- const { name, division } = await request.json()
+ const { name, division, body_type } = await request.json()
+
+ if (body_type !== undefined && !isBodyType(body_type)) {
+ return NextResponse.json({ error: 'Invalid body type.' }, { status: 400 })
+ }
const { error } = await supabase
.from('bodies')
- .insert({ name, division })
+ // Falls back to the column default rather than guessing from the name. The
+ // migration's name-based backfill was a one-off for bodies that predate the
+ // column; someone creating one now is looking at the type picker.
+ .insert({ name, division, ...(body_type ? { body_type } : {}) })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
@@ -68,11 +76,51 @@ export async function PATCH(request: Request) {
const rateLimitRes = await checkRateLimit(user.id)
if (rateLimitRes) return rateLimitRes
- const { id, name, division, is_active, body_open } = await request.json()
+ const {
+ id, name, division, is_active, body_open,
+ body_type, slack_channel_id, slack_reminders_enabled,
+ } = await request.json()
+
+ if (body_type !== undefined && !isBodyType(body_type)) {
+ return NextResponse.json({ error: 'Invalid body type.' }, { status: 400 })
+ }
+
+ // A channel *id* (C0123ABCDEF), not a name. Storing '#committee-chat' would be
+ // accepted silently by Postgres and then never resolve, leaving a committee
+ // whose reminders look configured and never arrive -- so reject it here with
+ // something a person can act on.
+ let channelId: string | null | undefined
+ if (slack_channel_id !== undefined) {
+ const trimmed = (slack_channel_id ?? '').toString().trim()
+ if (!trimmed) {
+ channelId = null
+ } else if (!/^[A-Z0-9]{6,32}$/.test(trimmed)) {
+ return NextResponse.json({
+ error: 'That is not a Slack channel ID. Open the channel in Slack, choose View channel details, and copy the ID at the bottom (it looks like C0123ABCDEF).',
+ }, { status: 400 })
+ } else {
+ channelId = trimmed
+ }
+ }
+
+ const updates: Record = {}
+ if (name !== undefined) updates.name = name
+ if (division !== undefined) updates.division = division
+ if (is_active !== undefined) updates.is_active = is_active
+ if (body_open !== undefined) updates.body_open = body_open
+ if (body_type !== undefined) updates.body_type = body_type
+ if (channelId !== undefined) updates.slack_channel_id = channelId
+ if (slack_reminders_enabled !== undefined) {
+ updates.slack_reminders_enabled = !!slack_reminders_enabled
+ }
+
+ if (Object.keys(updates).length === 0) {
+ return NextResponse.json({ error: 'Nothing to update.' }, { status: 400 })
+ }
const { error } = await supabase
.from('bodies')
- .update({ name, division, is_active, body_open })
+ .update(updates)
.eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
diff --git a/app/api/administrator/bookings/one-time/route.ts b/app/api/administrator/bookings/one-time/route.ts
index 1d08fe1..1f3413e 100644
--- a/app/api/administrator/bookings/one-time/route.ts
+++ b/app/api/administrator/bookings/one-time/route.ts
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
-import { sendMissedReservationEmail, formatDateLong } from '@/lib/emails/missed-reservation'
+import { sendMissedReservationEmail } from '@/lib/emails/missed-reservation'
import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated'
import { sendBookingCreatedEmail } from '@/lib/emails/booking-created'
import { changed, collectChanges, formatDate, formatTime } from '@/lib/emails/changes'
@@ -315,7 +315,8 @@ export async function PATCH(request: Request) {
await sendMissedReservationEmail({
bodyName,
- date: formatDateLong(firstSession.booking_date),
+ date: firstSession.booking_date,
+ roomOrTable: firstSession.room_name,
startTime: firstSession.start_time,
endTime: firstSession.end_time,
contacts,
diff --git a/app/api/administrator/bookings/tabling/route.ts b/app/api/administrator/bookings/tabling/route.ts
index 5b7b7d1..6b18f36 100644
--- a/app/api/administrator/bookings/tabling/route.ts
+++ b/app/api/administrator/bookings/tabling/route.ts
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
-import { sendMissedReservationEmail, formatDateLong } from '@/lib/emails/missed-reservation'
+import { sendMissedReservationEmail } from '@/lib/emails/missed-reservation'
import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated'
import { sendBookingCreatedEmail } from '@/lib/emails/booking-created'
import { changed, collectChanges, formatDate, formatTime } from '@/lib/emails/changes'
@@ -325,7 +325,8 @@ export async function PATCH(request: Request) {
await sendMissedReservationEmail({
bodyName,
- date: formatDateLong(sessions[0].session_date),
+ date: sessions[0].session_date,
+ roomOrTable: sessions[0].location,
startTime: sessions[0].start_time,
endTime: sessions[0].end_time,
contacts,
diff --git a/app/api/administrator/bookings/weekly/route.ts b/app/api/administrator/bookings/weekly/route.ts
index 096ecfd..89f5b92 100644
--- a/app/api/administrator/bookings/weekly/route.ts
+++ b/app/api/administrator/bookings/weekly/route.ts
@@ -1,10 +1,11 @@
import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
-import { sendMissedReservationEmail, formatDateLong } from '@/lib/emails/missed-reservation'
+import { sendMissedReservationEmail } from '@/lib/emails/missed-reservation'
import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated'
import { sendBookingCreatedEmail } from '@/lib/emails/booking-created'
import { changed, collectChanges, formatDate, formatTime } from '@/lib/emails/changes'
+import { occurrenceMoved } from '@/lib/weekly-occurrences'
import { checkRateLimit } from '@/lib/check-rate-limit'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { waitUntil } from '@vercel/functions'
@@ -43,6 +44,20 @@ const adminSupabase = createAdminClient(
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
+/** One stored occurrence as read back before the regeneration below. */
+interface PrevOccurrenceRow {
+ occurrence_date: string
+ room_name: string | null
+ start_time: string | null
+ end_time: string | null
+ status: string | null
+ reservation_code: string | null
+ purpose: string | null
+ senate_type: string | null
+ hidden: boolean | null
+ is_event: boolean | null
+}
+
function getWeeklyDates(startDate: string, endDate: string): string[] {
const dates: string[] = []
const current = new Date(startDate + 'T00:00:00')
@@ -204,7 +219,9 @@ export async function PATCH(request: Request) {
.single(),
adminSupabase
.from('weekly_room_occurrences')
- .select('occurrence_date, room_name, start_time, end_time, status, reservation_code, purpose')
+ .select(
+ 'occurrence_date, room_name, start_time, end_time, status, reservation_code, purpose, senate_type, hidden, is_event'
+ )
.eq('weekly_booking_id', weekly_id),
])
@@ -287,28 +304,60 @@ export async function PATCH(request: Request) {
.single()
const bodyName = bodyData?.name ?? 'Unknown'
- // The audience is the whole scope, not just the owning body -- see resolveBookingRecipients for
- // the divisional/multi fan-out policy.
const scopedRow: ScopedRow = {
id: booking_id,
body_id: selection.value.body_id,
scope: selection.value.scope,
division: selection.value.division,
}
- const recipients = await resolveBookingRecipients(adminSupabase, scopedRow)
+ // Which weeks this edit actually moved, in date order.
+ //
+ // This used to be "the first week carrying any override", which is a different
+ // question and usually a different week: an override set on week 1 months ago
+ // is still an override today, so every later edit to the series was reported
+ // against week 1 -- with an empty change list, because week 1 had not in fact
+ // moved (issue #91). Comparing each week against what was stored for it is the
+ // only way to name the week the administrator touched.
+ const prevByDate = new Map(
+ ((prevOccurrences ?? []) as PrevOccurrenceRow[]).map(o => [o.occurrence_date, o])
+ )
+ const movedOccurrences = newOccurrences.filter(o =>
+ occurrenceMoved(prevByDate.get(o.occurrence_date), o)
+ )
- // `hidden != null` rather than a truthiness test: an occurrence forced
- // visible (false) has been changed just as much as one forced hidden.
+ // Series-level fields. Compared against what was on the row before this
+ // request rather than against the payload, so an edit that resubmits a field
+ // unchanged does not report it as a change.
//
- // Hoisted out of the alerts block below because the email needs it too: an
- // edit to a single week used to be described using the series' start date, so
- // someone told "your booking changed" was pointed at a date months earlier
- // than the one that had actually moved (issue #79).
- const overriddenOcc = newOccurrences.find(
- o => o.room_name || o.start_time || o.end_time || o.status || o.reservation_code
- || o.purpose || o.hidden != null
+ // Computed here rather than in the email block below because the audience
+ // depends on it: whether this edit is about particular sessions or about the
+ // whole series decides which Senate session types it is about.
+ const seriesChanges = collectChanges(
+ changed('Purpose', prevBooking?.purpose, purpose),
+ changed('Room', prevWeekly?.room_name, room_name),
+ changed('Start date', prevWeekly?.start_date, start_date, formatDate),
+ changed('End date', prevWeekly?.end_date, end_date, formatDate),
+ changed('Start time', prevWeekly?.start_time, start_time, formatTime),
+ changed('End time', prevWeekly?.end_time, end_time, formatTime),
+ changed('Status', prevWeekly?.status, status),
+ changed('Reservation code', prevWeekly?.reservation_code, reservation_code || null),
)
- const changedOcc = overriddenOcc ?? newOccurrences[0]
+
+ // The sessions this notification is about: the weeks that moved, or -- when
+ // the series itself moved -- all of them. Senate members who have deselected
+ // every one of these session types drop out of the audience (issues #92, #93).
+ const notifiedSenateTypes = (seriesChanges.length === 0 ? movedOccurrences : newOccurrences)
+ .map(o => o.senate_type)
+
+ // The audience is the whole scope, not just the owning body -- see resolveBookingRecipients for
+ // the divisional/multi fan-out policy.
+ const recipients = await resolveBookingRecipients(adminSupabase, scopedRow, {
+ senateTypes: notifiedSenateTypes,
+ })
+
+ // The alert points at the earliest week that moved, falling back to the start
+ // of the series when the edit was series-wide.
+ const alertOcc = movedOccurrences[0] ?? newOccurrences[0]
if (recipients.length && auditLog) {
await adminSupabase.from('user_alerts').insert(
@@ -317,8 +366,8 @@ export async function PATCH(request: Request) {
audit_log_id: auditLog.id,
booking_id,
booking_type: 'Weekly Room',
- booking_date: changedOcc?.occurrence_date ?? start_date,
- start_time: changedOcc?.start_time ?? start_time,
+ booking_date: alertOcc?.occurrence_date ?? start_date,
+ start_time: alertOcc?.start_time ?? start_time,
}))
)
}
@@ -330,60 +379,38 @@ export async function PATCH(request: Request) {
try {
const emails = recipients.map(r => r.email)
- // Series-level fields. Compared against what was on the row before this
- // request rather than against the payload, so an edit that resubmits a
- // field unchanged does not report it as a change.
- const seriesChanges = collectChanges(
- changed('Purpose', prevBooking?.purpose, purpose),
- changed('Room', prevWeekly?.room_name, room_name),
- changed('Start date', prevWeekly?.start_date, start_date, formatDate),
- changed('End date', prevWeekly?.end_date, end_date, formatDate),
- changed('Start time', prevWeekly?.start_time, start_time, formatTime),
- changed('End time', prevWeekly?.end_time, end_time, formatTime),
- changed('Status', prevWeekly?.status, status),
- changed('Reservation code', prevWeekly?.reservation_code, reservation_code || null),
- )
-
- // When exactly one week carries overrides and the series itself did not
- // move, the edit was to that week -- so the email describes that week.
- // If the series moved too, the series is the story and a per-week
- // heading would understate it.
- const targetOcc = overriddenOcc && seriesChanges.length === 0 ? overriddenOcc : null
-
- if (targetOcc) {
- const prev = (prevOccurrences ?? []).find(
- (o: { occurrence_date: string }) => o.occurrence_date === targetOcc.occurrence_date
- )
+ // When the series itself did not move, the edit was to the weeks that
+ // moved -- so the email describes those weeks. If the series moved too,
+ // the series is the story and a per-week heading would understate it.
+ const targetOccs = seriesChanges.length === 0 ? movedOccurrences : []
+
+ const sessions = targetOccs.map(occ => {
+ const prev = prevByDate.get(occ.occurrence_date)
// An occurrence field that is null inherits from the series, so the
// comparison is between effective values -- otherwise clearing an
// override would read as a change to nothing.
- const occChanges = collectChanges(
- changed('Room', prev?.room_name ?? prevWeekly?.room_name, targetOcc.room_name ?? room_name),
- changed('Start time', prev?.start_time ?? prevWeekly?.start_time, targetOcc.start_time ?? start_time, formatTime),
- changed('End time', prev?.end_time ?? prevWeekly?.end_time, targetOcc.end_time ?? end_time, formatTime),
- changed('Status', prev?.status ?? prevWeekly?.status, targetOcc.status ?? status),
- changed('Purpose', prev?.purpose ?? prevBooking?.purpose, targetOcc.purpose ?? purpose),
- changed('Reservation code', prev?.reservation_code ?? prevWeekly?.reservation_code, targetOcc.reservation_code ?? (reservation_code || null)),
+ const changes = collectChanges(
+ changed('Room', prev?.room_name ?? prevWeekly?.room_name, occ.room_name ?? room_name),
+ changed('Start time', prev?.start_time ?? prevWeekly?.start_time, occ.start_time ?? start_time, formatTime),
+ changed('End time', prev?.end_time ?? prevWeekly?.end_time, occ.end_time ?? end_time, formatTime),
+ changed('Status', prev?.status ?? prevWeekly?.status, occ.status ?? status),
+ changed('Purpose', prev?.purpose ?? prevBooking?.purpose, occ.purpose ?? purpose),
+ changed('Reservation code', prev?.reservation_code ?? prevWeekly?.reservation_code, occ.reservation_code ?? (reservation_code || null)),
)
- const index = newOccurrences.findIndex(o => o.occurrence_date === targetOcc.occurrence_date)
-
- await sendBookingUpdatedEmail({
- bodyName,
- purpose: targetOcc.purpose ?? purpose,
- roomOrTable: targetOcc.room_name || room_name || 'N/A',
- date: targetOcc.occurrence_date,
- startTime: targetOcc.start_time || start_time,
- endTime: targetOcc.end_time || end_time,
- status: targetOcc.status || status,
- changes: occChanges,
- occurrence: {
- position: index >= 0 ? `week ${index + 1} of ${newOccurrences.length}` : null,
- },
- recipients: emails,
- })
- return
- }
+ const index = newOccurrences.findIndex(o => o.occurrence_date === occ.occurrence_date)
+
+ return {
+ date: occ.occurrence_date,
+ startTime: occ.start_time || start_time,
+ endTime: occ.end_time || end_time,
+ roomOrTable: occ.room_name || room_name || 'N/A',
+ status: occ.status || status,
+ purpose: occ.purpose ?? purpose,
+ position: index >= 0 ? `week ${index + 1} of ${newOccurrences.length}` : null,
+ changes,
+ }
+ })
await sendBookingUpdatedEmail({
bodyName,
@@ -394,6 +421,7 @@ export async function PATCH(request: Request) {
endTime: end_time,
status,
changes: seriesChanges,
+ sessions,
recipients: emails,
})
} catch (e) {
@@ -409,8 +437,25 @@ export async function PATCH(request: Request) {
.eq('booking_id', booking_id)
.eq('status', 'Pending')
- const isMissed = status === 'Missed' || occurrences.some((o: { status: string | null }) => o.status === 'Missed')
- if (isMissed) {
+ // Which weeks this save actually marked Missed.
+ //
+ // Two things were wrong with asking "is anything Missed" (issue #99). It
+ // described the alert using the series' start date and time no matter which
+ // week had been missed -- the same defect as #91, in the one caller that
+ // rewrite did not reach. And because a missed week stays Missed, the condition
+ // stayed true forever: every later edit to the series sent Operational Affairs
+ // another alert about a week they had been told about weeks earlier.
+ //
+ // movedOccurrences is what this save changed, so a week only alerts on the
+ // edit that missed it.
+ const newlyMissedWeeks = movedOccurrences.filter(o => o.status === 'Missed')
+
+ // A series-level Missed applies to every week at once, so there is no single
+ // week to name and the series is the story. Compared against the stored value
+ // so resubmitting an already-missed series does not re-alert either.
+ const seriesNewlyMissed = status === 'Missed' && prevWeekly?.status !== 'Missed'
+
+ if (seriesNewlyMissed || newlyMissedWeeks.length) {
waitUntil(
(async () => {
try {
@@ -419,13 +464,30 @@ export async function PATCH(request: Request) {
})
const contacts = leaders.map(l => l.fullName).filter(Boolean)
- await sendMissedReservationEmail({
- bodyName,
- date: formatDateLong(start_date),
- startTime: start_time,
- endTime: end_time,
- contacts,
- })
+ // One alert per missed reservation rather than one per save. Each
+ // missed room is its own incident for Operational Affairs to chase,
+ // and a single email naming one of several would hide the rest.
+ const missed = seriesNewlyMissed
+ ? [{ date: start_date, startTime: start_time, endTime: end_time, roomOrTable: room_name }]
+ : newlyMissedWeeks.map(o => ({
+ date: o.occurrence_date,
+ // Null on an occurrence means inherit, so these are the values
+ // that actually applied to the week that was missed.
+ startTime: o.start_time ?? start_time,
+ endTime: o.end_time ?? end_time,
+ roomOrTable: o.room_name ?? room_name,
+ }))
+
+ for (const m of missed) {
+ await sendMissedReservationEmail({
+ bodyName,
+ date: m.date,
+ roomOrTable: m.roomOrTable,
+ startTime: m.startTime,
+ endTime: m.endTime,
+ contacts,
+ })
+ }
} catch (e) {
console.error('Resend email failed:', e)
}
diff --git a/app/api/administrator/cancellations/auto-cancel/route.ts b/app/api/administrator/cancellations/auto-cancel/route.ts
index 4025049..d51330e 100644
--- a/app/api/administrator/cancellations/auto-cancel/route.ts
+++ b/app/api/administrator/cancellations/auto-cancel/route.ts
@@ -3,8 +3,14 @@ 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'
+import { sendCscCancellationRequest } from '@/lib/emails/csc-cancellation-request'
+import {
+ applyCancellationOutcomes,
+ cancellationAuditRows,
+ collectPending,
+ lineKey,
+ requestsFullyCovered,
+} from '@/lib/pending-cancellations'
const adminSupabase = createAdminClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
@@ -182,36 +188,10 @@ export async function POST(request: Request) {
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})`)
- }
- }
+ // Shared with marking a request Done by hand, which has to reach the same
+ // result: the same request resolved either way should leave the database in
+ // the same state.
+ const failures = await applyCancellationOutcomes(selected)
// Close the cancellation requests this send acted on, so nobody has to go and
// press "Mark as Done" for work Auto-Cancel already did.
@@ -241,14 +221,7 @@ export async function POST(request: Request) {
// 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()]
+ const auditRows = cancellationAuditRows(selected, user.id)
if (auditRows.length) {
const { error } = await adminSupabase.from('audit_logs').insert(auditRows)
if (error) console.error('Auto-Cancel audit log failed:', error)
diff --git a/app/api/administrator/cancellations/route.ts b/app/api/administrator/cancellations/route.ts
index b502f41..bba3293 100644
--- a/app/api/administrator/cancellations/route.ts
+++ b/app/api/administrator/cancellations/route.ts
@@ -3,6 +3,11 @@ 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 {
+ applyCancellationOutcomes,
+ cancellationAuditRows,
+ collectPending,
+} from '@/lib/pending-cancellations'
const adminSupabase = createAdminClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
@@ -23,7 +28,7 @@ export async function GET() {
const { data: cancellations } = await supabase
.from('cancellation_requests')
.select(`
- id, scope, status, created_at, cancellation_type, occurrence_id, booking_id,
+ id, scope, status, created_at, cancellation_type, occurrence_id, occurrence_date, booking_id,
bookings(id, type, purpose, bodies(name)),
users(full_name)
`)
@@ -39,12 +44,20 @@ export async function GET() {
if (c.scope === 'occurrence' && c.occurrence_id) {
if (bookingType === 'Weekly Room') {
- const { data: occ } = await adminSupabase
+ // Matched on the stored date first, and only then on the id. The id
+ // does not survive an edit to the booking -- the PATCH handler
+ // regenerates every occurrence -- which is why these rows showed no
+ // date at all (issue #96).
+ const q = adminSupabase
.from('weekly_room_occurrences')
- .select('occurrence_date, reservation_code')
- .eq('id', c.occurrence_id)
- .single()
- occurrence_date = occ?.occurrence_date ?? null
+ .select('occurrence_date, reservation_code, weekly_room_bookings!inner(booking_id)')
+ const { data: occ } = c.occurrence_date
+ ? await q
+ .eq('occurrence_date', c.occurrence_date)
+ .eq('weekly_room_bookings.booking_id', c.booking_id)
+ .maybeSingle()
+ : await q.eq('id', c.occurrence_id).maybeSingle()
+ occurrence_date = occ?.occurrence_date ?? c.occurrence_date ?? null
reservation_code = occ?.reservation_code ?? null
} else if (bookingType === 'One-Time Room') {
const { data: session } = await adminSupabase
@@ -88,7 +101,10 @@ export async function GET() {
}
}
- return { ...c, occurrence_date, reservation_code }
+ // The stored date is the fallback for every type: even where the code
+ // lookup fails because the row was regenerated, the request still knows
+ // which date it was about.
+ return { ...c, occurrence_date: occurrence_date ?? c.occurrence_date ?? null, reservation_code }
})
)
@@ -107,6 +123,38 @@ export async function PATCH(request: Request) {
if (rateLimitRes) return rateLimitRes
const { id } = await request.json()
+ if (!id) return NextResponse.json({ error: 'A cancellation request id is required.' }, { status: 400 })
+
+ const { data: req } = await adminSupabase
+ .from('cancellation_requests')
+ .select('id')
+ .eq('id', id)
+ .maybeSingle()
+
+ if (!req) return NextResponse.json({ error: 'Cancellation request not found.' }, { status: 404 })
+
+ // Which dated reservations this request covers, and what each is due. Read
+ // from the same collector Auto-Cancel uses, so the two agree about whose row a
+ // request owns -- including the inheritance that makes a series-level
+ // cancellation produce pending occurrences that do not say so themselves.
+ //
+ // `skipped` is included here where Auto-Cancel excludes it. A missing
+ // reservation code means CSC cannot be asked, which is why Auto-Cancel will
+ // not touch those rows; it says nothing about whether an administrator has
+ // dealt with it. Marking Done by hand is that administrator saying they have.
+ const { lines, skipped } = await collectPending()
+ const covered = [...lines, ...skipped].filter(l => l.cancellationRequestId === id)
+
+ const failures = await applyCancellationOutcomes(covered)
+
+ // One entry per booking touched, so the change shows up in the Audit tab
+ // beside every other status change rather than appearing to have happened by
+ // itself. Best effort, as in Auto-Cancel.
+ const auditRows = cancellationAuditRows(covered, user.id)
+ if (auditRows.length) {
+ const { error: auditError } = await adminSupabase.from('audit_logs').insert(auditRows)
+ if (auditError) console.error('Cancellation done audit log failed:', auditError)
+ }
const { error } = await adminSupabase
.from('cancellation_requests')
@@ -115,5 +163,13 @@ export async function PATCH(request: Request) {
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
- return NextResponse.json({ success: true })
+ return NextResponse.json({
+ success: true,
+ applied: covered.length,
+ cancelled: covered.filter(l => l.resultingStatus === 'Cancelled').length,
+ virtual: covered.filter(l => l.resultingStatus === 'Virtual').length,
+ // The request is closed either way -- the admin has said it is handled -- but
+ // they need to know if a status did not move with it.
+ ...(failures.length ? { statusUpdateFailed: failures } : {}),
+ })
}
\ No newline at end of file
diff --git a/app/api/cancellation-requests/route.ts b/app/api/cancellation-requests/route.ts
index 5878c43..f2605bd 100644
--- a/app/api/cancellation-requests/route.ts
+++ b/app/api/cancellation-requests/route.ts
@@ -28,12 +28,39 @@ export async function POST(request: Request) {
const bookingType = guard.row.type
+ // The date this request is about, resolved now while occurrence_id still
+ // names a live row (issue #96).
+ //
+ // It will not always. The weekly PATCH handler deletes and reinserts every
+ // occurrence on each save, so an edit to the booking leaves occurrence_id
+ // pointing at nothing while the values live on under new ids, carried across
+ // on the date. Storing the date is what lets the request still be matched to
+ // its reservation afterwards -- without it, marking the request Done finds
+ // nothing to do, and Auto-Cancel cannot tell which request asked for what.
+ let occurrenceDate: string | null = null
+ if (scope === 'occurrence' && occurrence_id) {
+ if (bookingType === 'One-Time Room') {
+ const { data } = await adminSupabase
+ .from('one_time_room_bookings').select('booking_date').eq('id', occurrence_id).maybeSingle()
+ occurrenceDate = data?.booking_date ?? null
+ } else if (bookingType === 'Tabling') {
+ const { data } = await adminSupabase
+ .from('tabling_sessions').select('session_date').eq('id', occurrence_id).maybeSingle()
+ occurrenceDate = data?.session_date ?? null
+ } else {
+ const { data } = await adminSupabase
+ .from('weekly_room_occurrences').select('occurrence_date').eq('id', occurrence_id).maybeSingle()
+ occurrenceDate = data?.occurrence_date ?? null
+ }
+ }
+
// Create cancellation request
const { error: requestError } = await adminSupabase
.from('cancellation_requests')
.insert({
booking_id,
occurrence_id: occurrence_id || null,
+ occurrence_date: occurrenceDate,
requested_by: user.id,
scope,
status: 'Pending',
diff --git a/app/api/cron/slack-reminders/route.ts b/app/api/cron/slack-reminders/route.ts
new file mode 100644
index 0000000..27db2ce
--- /dev/null
+++ b/app/api/cron/slack-reminders/route.ts
@@ -0,0 +1,180 @@
+import { createClient as createAdminClient } from '@supabase/supabase-js'
+import { NextResponse } from 'next/server'
+import { postSlackMessage } from '@/lib/slack'
+import { SLACK_REMINDER_BODY_TYPES } from '@/lib/body-types'
+import {
+ REMINDER_HOUR,
+ appZoneParts,
+ nextDay,
+ resolveMeeting,
+ formatReminder,
+ type ReminderCandidate,
+} from '@/lib/meeting-reminders'
+
+/**
+ * Posts tomorrow's committee meetings to each committee's Slack channel
+ * (issue #95).
+ *
+ * Driven by .github/workflows/slack-reminders.yml, following the same pattern as
+ * /api/cron/warm: a scheduled GitHub Action rather than a Vercel cron, because
+ * Hobby plans cap Vercel crons at one a day and this needs to retry.
+ *
+ * That retrying is why the action fires several times in the morning. GitHub
+ * schedules lag and are occasionally skipped altogether, so one shot at 9am
+ * would silently drop a day's reminders. Two things keep the repeats harmless:
+ * the REMINDER_HOUR gate, so nothing posts overnight when the date first rolls
+ * over, and the unique (weekly_booking_id, occurrence_date) row written after
+ * each post, so the second run of the morning finds the work already done.
+ *
+ * Set CRON_SECRET to gate it. Unlike /api/cron/warm -- which only does trivial
+ * reads and so is safe to leave open -- this one writes and posts to Slack, so
+ * it refuses to run at all without the secret configured.
+ */
+
+const adminSupabase = createAdminClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SERVICE_ROLE_KEY!
+)
+
+/**
+ * `!inner` on all three joins so a row is dropped unless its whole chain exists,
+ * which is what lets the body-level filters below narrow the occurrence rows.
+ */
+const SELECT = `
+ occurrence_date, room_name, start_time, end_time, status, purpose, hidden, weekly_booking_id,
+ weekly_room_bookings!inner(
+ room_name, start_time, end_time, status,
+ bookings!inner(
+ purpose, hidden,
+ bodies!inner(name, body_type, slack_channel_id, slack_reminders_enabled)
+ )
+ )
+`
+
+/** PostgREST types an embedded to-one relation as a possible array. */
+function one(v: T | T[] | null | undefined): T | undefined {
+ return Array.isArray(v) ? v[0] : v ?? undefined
+}
+
+export async function GET(request: Request) {
+ const secret = process.env.CRON_SECRET
+ if (!secret) {
+ console.error('slack-reminders: CRON_SECRET is not set, refusing to run')
+ return NextResponse.json({ error: 'Not configured' }, { status: 503 })
+ }
+ if (request.headers.get('authorization') !== `Bearer ${secret}`) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { date: today, hour } = appZoneParts()
+
+ // Before the posting hour there is nothing to do. The date rolls over at
+ // midnight Eastern, and an overnight run would otherwise ping every committee
+ // channel at 1am.
+ if (hour < REMINDER_HOUR) {
+ return NextResponse.json({ ok: true, skipped: 'before posting hour', hour })
+ }
+
+ const target = nextDay(today)
+
+ const { data, error } = await adminSupabase
+ .from('weekly_room_occurrences')
+ .select(SELECT)
+ .eq('occurrence_date', target)
+ .in('weekly_room_bookings.bookings.bodies.body_type', [...SLACK_REMINDER_BODY_TYPES])
+ .eq('weekly_room_bookings.bookings.bodies.slack_reminders_enabled', true)
+ .not('weekly_room_bookings.bookings.bodies.slack_channel_id', 'is', null)
+
+ if (error) {
+ console.error('slack-reminders query failed:', error)
+ return NextResponse.json({ error: error.message }, { status: 500 })
+ }
+
+ const candidates: ReminderCandidate[] = []
+ for (const row of data ?? []) {
+ const series = one(row.weekly_room_bookings)
+ const booking = one(series?.bookings)
+ const body = one(booking?.bodies)
+ if (!series || !booking || !body) continue
+
+ candidates.push({
+ occurrence_date: row.occurrence_date,
+ room_name: row.room_name,
+ start_time: row.start_time,
+ end_time: row.end_time,
+ status: row.status,
+ purpose: row.purpose,
+ hidden: row.hidden,
+ weekly_booking_id: row.weekly_booking_id,
+ series: {
+ room_name: series.room_name,
+ start_time: series.start_time,
+ end_time: series.end_time,
+ status: series.status,
+ },
+ booking: { purpose: booking.purpose, hidden: booking.hidden },
+ body: { name: body.name, slack_channel_id: body.slack_channel_id },
+ })
+ }
+
+ const meetings = candidates
+ .map(resolveMeeting)
+ .filter((m): m is NonNullable => m !== null)
+
+ if (meetings.length === 0) {
+ return NextResponse.json({ ok: true, date: target, posted: 0, considered: 0 })
+ }
+
+ // One read for the whole day rather than a lookup per meeting.
+ const { data: alreadyPosted } = await adminSupabase
+ .from('slack_meeting_reminders')
+ .select('weekly_booking_id')
+ .eq('occurrence_date', target)
+
+ const done = new Set((alreadyPosted ?? []).map((r: { weekly_booking_id: string }) => r.weekly_booking_id))
+ const pending = meetings.filter(m => !done.has(m.weeklyBookingId))
+
+ let posted = 0
+ const failures: { body: string; error?: string }[] = []
+
+ // Sequential, not Promise.all. These are a handful of messages a day, and
+ // Slack rate-limits chat.postMessage per channel; a burst buys nothing.
+ for (const meeting of pending) {
+ const result = await postSlackMessage(meeting.channelId, formatReminder(meeting))
+
+ if (!result.ok) {
+ // Deliberately no row written: a refused post should be retried by the
+ // next run of the morning, not recorded as delivered.
+ failures.push({ body: meeting.bodyName, error: result.error })
+ continue
+ }
+
+ const { error: insertError } = await adminSupabase
+ .from('slack_meeting_reminders')
+ .insert({
+ weekly_booking_id: meeting.weeklyBookingId,
+ occurrence_date: meeting.date,
+ channel_id: meeting.channelId,
+ })
+
+ // The message is already out. A failure to record that is worth shouting
+ // about, because the next run will post it again.
+ if (insertError) {
+ console.error(
+ `slack-reminders: posted for ${meeting.bodyName} but could not record it:`,
+ insertError.message
+ )
+ }
+
+ posted++
+ }
+
+ return NextResponse.json({
+ ok: true,
+ date: target,
+ considered: meetings.length,
+ posted,
+ skipped: meetings.length - pending.length,
+ failures,
+ })
+}
diff --git a/app/api/slack/command/route.ts b/app/api/slack/command/route.ts
index 5f42314..695106f 100644
--- a/app/api/slack/command/route.ts
+++ b/app/api/slack/command/route.ts
@@ -2,6 +2,8 @@ import { randomBytes } from 'crypto'
import { createClient as createAdminClient } from '@supabase/supabase-js'
import { verifySlackRequest } from '@/lib/slack-verify'
import { checkRateLimit } from '@/lib/check-rate-limit'
+import { ephemeral } from '@/lib/slack'
+import { bodyTypeGetsSlackReminders } from '@/lib/body-types'
const adminSupabase = createAdminClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
@@ -195,6 +197,90 @@ function buildTablingModal(bodies: { id: string; name: string }[]) {
}
}
+/**
+ * /chambers-reminders [on|off] -- the committee's own switch for the meeting
+ * reminders the bot posts (issue #95).
+ *
+ * The channel the command was run in is what identifies the committee, so there
+ * is no body to pick and no way to reach into another committee's settings: you
+ * can only change the reminders for the channel you are standing in. Changing
+ * them additionally requires Leadership of that committee, checked against
+ * board_memberships rather than anything Slack asserts.
+ */
+async function handleRemindersCommand(
+ chambersUserId: string,
+ channelId: string | null,
+ text: string
+): Promise {
+ if (!channelId) return ephemeral('This command has to be run in a channel.')
+
+ const { data: body } = await adminSupabase
+ .from('bodies')
+ .select('id, name, body_type, slack_reminders_enabled')
+ .eq('slack_channel_id', channelId)
+ .maybeSingle()
+
+ if (!body) {
+ return ephemeral(
+ 'This channel is not linked to a Chambers body. Ask an administrator to add its channel ID in Management → Bodies.'
+ )
+ }
+
+ if (!bodyTypeGetsSlackReminders(body.body_type)) {
+ return ephemeral(
+ `${body.name} is a ${body.body_type}, and Chambers only posts meeting reminders for committees.`
+ )
+ }
+
+ const arg = text.trim().toLowerCase()
+ const state = body.slack_reminders_enabled ? 'on' : 'off'
+
+ // No argument reports the current state. Anyone in the channel may ask; only
+ // Leadership may change it, which is checked below and not here.
+ if (!arg) {
+ return ephemeral(
+ `Meeting reminders for ${body.name} are currently *${state}*. Use \`/chambers-reminders on\` or \`/chambers-reminders off\` to change that.`
+ )
+ }
+
+ if (arg !== 'on' && arg !== 'off') {
+ return ephemeral('Usage: `/chambers-reminders on`, `/chambers-reminders off`, or `/chambers-reminders` to see the current setting.')
+ }
+
+ const { data: membership } = await adminSupabase
+ .from('board_memberships')
+ .select('id')
+ .eq('user_id', chambersUserId)
+ .eq('body_id', body.id)
+ .eq('role', 'Leadership')
+ .maybeSingle()
+
+ if (!membership) {
+ return ephemeral(`Only Leadership of ${body.name} can change its meeting reminders.`)
+ }
+
+ const enabled = arg === 'on'
+ if (enabled === body.slack_reminders_enabled) {
+ return ephemeral(`Meeting reminders for ${body.name} are already *${state}*.`)
+ }
+
+ const { error } = await adminSupabase
+ .from('bodies')
+ .update({ slack_reminders_enabled: enabled })
+ .eq('id', body.id)
+
+ if (error) {
+ console.error('slack /chambers-reminders update failed:', error)
+ return ephemeral('Something went wrong saving that. Please try again.')
+ }
+
+ return ephemeral(
+ enabled
+ ? `Meeting reminders for ${body.name} are back *on*. The bot will post here the day before each meeting.`
+ : `Meeting reminders for ${body.name} are *off*. Run \`/chambers-reminders on\` here to start them again.`
+ )
+}
+
export async function POST(request: Request) {
const rawBody = await request.text()
@@ -210,7 +296,7 @@ export async function POST(request: Request) {
const triggerId = params.get('trigger_id')
const slackUserId = params.get('user_id')
- if (!triggerId || !slackUserId) {
+ if (!slackUserId) {
return new Response('Bad Request', { status: 400 })
}
@@ -255,6 +341,17 @@ export async function POST(request: Request) {
})
}
+ if (command === '/chambers-reminders') {
+ return handleRemindersCommand(
+ connection.chambers_user_id,
+ params.get('channel_id'),
+ params.get('text') ?? ''
+ )
+ }
+
+ // Everything below opens a modal, which Slack only allows against a trigger.
+ if (!triggerId) return new Response('Bad Request', { status: 400 })
+
const { data: bodies } = await adminSupabase
.from('bodies')
.select('id, name')
diff --git a/app/api/spaces/bookings/[id]/route.ts b/app/api/spaces/bookings/[id]/route.ts
index abad955..bed754c 100644
--- a/app/api/spaces/bookings/[id]/route.ts
+++ b/app/api/spaces/bookings/[id]/route.ts
@@ -1,10 +1,10 @@
import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
-import { bostonWallClockNow } from '@/lib/boston-time'
import { checkRateLimit } from '@/lib/check-rate-limit'
import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cancelled'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
+import { advanceNoticeError } from '@/lib/spaces-advance-notice'
import { waitUntil } from '@vercel/functions'
const DEFAULT_WEEKLY_HOURS = 18
@@ -102,19 +102,17 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
adminSupabase.from('app_settings').select('min_hours_advance_spaces').eq('id', 1).single(),
])
- // Advance notice check — only enforce if start_time changed and limit > 0
+ // Advance notice applies to the time this edit newly claims, not to whether the
+ // start moved (issue #94). Shortening a booking, pushing its start later or
+ // renaming it releases time or leaves it alone, and needs no notice; only
+ // adding time inside the window is refused.
const minHours: number = settings?.min_hours_advance_spaces ?? 24
- const startTimeChanged = start_time !== existing.start_time
- if (minHours > 0 && startTimeChanged) {
- // Boston wall-clock now, for the reason given in the POST route: start_time
- // is wall-clock digits labelled Z, and Date.now() is a real instant.
- const earliestAllowed = new Date(bostonWallClockNow().getTime() + minHours * 60 * 60 * 1000)
- if (new Date(start_time) < earliestAllowed) {
- return NextResponse.json({
- error: `Bookings must be made at least ${minHours} hour${minHours === 1 ? '' : 's'} in advance.`,
- }, { status: 400 })
- }
- }
+ const noticeError = advanceNoticeError(
+ { start: start_time, end: end_time },
+ { start: existing.start_time, end: existing.end_time },
+ minHours
+ )
+ if (noticeError) return NextResponse.json({ error: noticeError }, { status: 400 })
if (overlapping && overlapping.length > 0) {
return NextResponse.json({ error: 'This time slot overlaps with an existing booking for this space.' }, { status: 400 })
diff --git a/app/api/spaces/bookings/route.ts b/app/api/spaces/bookings/route.ts
index b365f55..a8ccba7 100644
--- a/app/api/spaces/bookings/route.ts
+++ b/app/api/spaces/bookings/route.ts
@@ -1,8 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
-import { bostonWallClockNow } from '@/lib/boston-time'
import { checkRateLimit } from '@/lib/check-rate-limit'
+import { advanceNoticeError } from '@/lib/spaces-advance-notice'
import { sendSpaceBookingConfirmedEmail } from '@/lib/emails/space-booking-confirmed'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { waitUntil } from '@vercel/functions'
@@ -159,19 +159,12 @@ export async function POST(request: Request) {
adminSupabase.from('app_settings').select('min_hours_advance_spaces').eq('id', 1).single(),
])
- // Advance notice check (skipped when limit is 0)
- //
- // Measured from Boston wall-clock now, not Date.now(): start_time carries
- // wall-clock digits with a Z on the end, so comparing it against a real
- // instant made every booking look an offset earlier than it was, and the
- // requirement reject bookings that were comfortably far enough out (issue #87).
+ // Advance notice check (skipped when limit is 0). A creation claims its whole
+ // interval, so the shared rule reduces to the same test it always ran here --
+ // it is shared with the PATCH route, where the interesting case lives (#94).
const minHours: number = settings?.min_hours_advance_spaces ?? 24
- const earliestAllowed = new Date(bostonWallClockNow().getTime() + minHours * 60 * 60 * 1000)
- if (minHours > 0 && new Date(start_time) < earliestAllowed) {
- return NextResponse.json({
- error: `Bookings must be made at least ${minHours} hour${minHours === 1 ? '' : 's'} in advance.`,
- }, { status: 400 })
- }
+ const noticeError = advanceNoticeError({ start: start_time, end: end_time }, null, minHours)
+ if (noticeError) return NextResponse.json({ error: noticeError }, { status: 400 })
if (overlapping && overlapping.length > 0) {
return NextResponse.json({ error: 'This time slot overlaps with an existing booking for this space.' }, { status: 400 })
diff --git a/app/faq/page.tsx b/app/faq/page.tsx
index 1611c8a..2daec35 100644
--- a/app/faq/page.tsx
+++ b/app/faq/page.tsx
@@ -39,6 +39,21 @@ export default async function FaqPage() {
+
+
+ v1.14.1 — released
+
+ Update emails about a weekly booking now describe the week that actually changed. They had been pointing at whichever week carried the oldest override, which was usually not the week anyone had touched — so the email named a date months off and listed no changes at all. If one save moves several weeks, the email now covers each of them rather than only the first.
+
+
+ Senate session types you deselect in Settings now stop the emails and the alerts too, not just the rows in My Rooms. If you follow Full Body but not Office Hours, you will still hear about a change that moved both.
+
+
+ An SGA Space booking inside the advance notice window can be edited again. You can shorten it, start it later, rename it or cancel it outright at any point — only adding time to a booking still needs notice, and extending one that ends outside the window is fine. Previously such a booking could not be opened at all.
+
+
+
+
v1.14.0 — released
diff --git a/lib/app-zone.ts b/lib/app-zone.ts
new file mode 100644
index 0000000..3ffd683
--- /dev/null
+++ b/lib/app-zone.ts
@@ -0,0 +1,25 @@
+/**
+ * The timezone every booking date is expressed in.
+ *
+ * `booking_date`, `occurrence_date` and `session_date` are all DATE columns --
+ * no time, no offset. They mean a calendar day in Boston, because that is where
+ * the rooms are. "Today" therefore has to mean Boston's today, not the server's
+ * and not the viewer's: a student on co-op in California at 10pm PT is still
+ * looking at Northeastern's schedule, and should see the same day their peers on
+ * campus see.
+ *
+ * Pinning it also makes the value reproducible, which is what lets the My Rooms
+ * page be server-rendered at all -- see todayInAppZone().
+ *
+ * Lives in lib/ rather than beside todayInAppZone() because the Slack reminder
+ * job needs it too (issue #95), and a route reaching into app/(dashboard)/ for a
+ * constant is the wrong direction of travel.
+ *
+ * Not to be confused with lib/boston-time.ts, which names the same zone for a
+ * different job. This one is about DATE columns -- which calendar day a booking
+ * falls on. That one is about SGA Spaces timestamps, which store Boston
+ * wall-clock digits with a Z on the end and therefore need a "now" in the same
+ * shape to compare against (issue #87). A booking date has no time of day to get
+ * wrong; a space booking is nothing but one.
+ */
+export const APP_TIME_ZONE = 'America/New_York'
diff --git a/lib/body-types.ts b/lib/body-types.ts
new file mode 100644
index 0000000..6c882dd
--- /dev/null
+++ b/lib/body-types.ts
@@ -0,0 +1,38 @@
+/**
+ * What kind of body a body is (issue #95).
+ *
+ * Committees were distinguished from boards, teams and working groups by their
+ * name and nothing else, so no query could ask for "the committees" without
+ * pattern-matching a string.
+ *
+ * Mirrors bodies_body_type_check in
+ * supabase/migrations/20260909000000_body_types_and_slack_reminders.sql. The two
+ * must agree; if you change one, change the other.
+ */
+export const BODY_TYPES = [
+ 'Committee',
+ 'Board',
+ 'Advisory Board',
+ 'Working Group',
+ 'Team',
+ 'Other',
+] as const
+
+export type BodyType = (typeof BODY_TYPES)[number]
+
+export function isBodyType(v: unknown): v is BodyType {
+ return typeof v === 'string' && (BODY_TYPES as readonly string[]).includes(v)
+}
+
+/**
+ * The types the Slack bot posts weekly meeting reminders for.
+ *
+ * Only committees today, which is what the issue asks for. Kept as a set rather
+ * than an equality check so widening it later -- to working groups, say -- is a
+ * one-line change here and not a hunt through the reminder job.
+ */
+export const SLACK_REMINDER_BODY_TYPES: readonly BodyType[] = ['Committee']
+
+export function bodyTypeGetsSlackReminders(bodyType: string | null | undefined): boolean {
+ return !!bodyType && (SLACK_REMINDER_BODY_TYPES as readonly string[]).includes(bodyType)
+}
diff --git a/lib/booking-scope.ts b/lib/booking-scope.ts
index c891734..a409f95 100644
--- a/lib/booking-scope.ts
+++ b/lib/booking-scope.ts
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { hasLiveAdmin, type AuthedUser } from './auth'
+import { wantsAnySenateSession } from './senate-types'
/**
* Multi-body bookings (issue #19).
@@ -339,11 +340,18 @@ export interface Recipient {
fullName: string
}
+interface RecipientUser {
+ email: string
+ full_name: string
+ is_active: boolean
+ senate_type_preferences: Record | null
+}
+
interface RecipientRow {
user_id: string
body_id: string
role: string
- users: { email: string; full_name: string; is_active: boolean } | { email: string; full_name: string; is_active: boolean }[] | null
+ users: RecipientUser | RecipientUser[] | null
}
/**
@@ -367,11 +375,17 @@ interface RecipientRow {
*
* `leadershipOnly` narrows to Leadership across the whole audience regardless of scope -- used for
* the missed-reservation email, which only ever went to leadership.
+ *
+ * `senateTypes` are the session types this notification is about. Pass them and members of the
+ * Senate who have deselected every one of those types in Settings drop out of the audience --
+ * which is what that preference was always supposed to mean, rather than only hiding the rows on
+ * the My Rooms page (issues #92, #93). Omit it for a notification that is not about particular
+ * sessions; nobody is filtered then.
*/
export async function resolveBookingRecipients(
adminSupabase: SupabaseClient,
row: ScopedRow,
- opts: { leadershipOnly?: boolean } = {}
+ opts: { leadershipOnly?: boolean; senateTypes?: (string | null | undefined)[] } = {}
): Promise {
const bodyIds = await resolveBookingBodyIds(adminSupabase, row)
if (bodyIds.length === 0) return []
@@ -379,11 +393,16 @@ export async function resolveBookingRecipients(
const [{ data }, { data: bookingRow }] = await Promise.all([
adminSupabase
.from('board_memberships')
- .select('user_id, body_id, role, users(email, full_name, is_active)')
+ .select('user_id, body_id, role, users(email, full_name, is_active, senate_type_preferences)')
.in('body_id', bodyIds),
- adminSupabase.from('bookings').select('hidden').eq('id', row.id).maybeSingle(),
+ // bodies(name) is read for the Senate session-type filter below, which keys on the owning
+ // body being the one literally named "Senate" -- the same thing the My Rooms filter keys on.
+ adminSupabase.from('bookings').select('hidden, bodies(name)').eq('id', row.id).maybeSingle(),
])
+ const ownerBody = Array.isArray(bookingRow?.bodies) ? bookingRow?.bodies[0] : bookingRow?.bodies
+ const ownerBodyName = (ownerBody as { name: string } | null | undefined)?.name ?? null
+
// A hidden booking notifies only the people who can manage it, which is exactly the set
// canManageScoped() admits: Leadership anywhere in the booking's audience.
const leadershipOnly = !!opts.leadershipOnly || !!bookingRow?.hidden
@@ -397,6 +416,17 @@ export async function resolveBookingRecipients(
if (leadershipOnly && m.role !== 'Leadership') continue
+ // Applies to Leadership too. Someone who has said they do not follow Office Hours does not
+ // start wanting those emails because they lead the body -- the preference is about what they
+ // read, not about what they are responsible for, and Leadership can still see every session
+ // on the booking itself.
+ if (
+ opts.senateTypes &&
+ !wantsAnySenateSession(user.senate_type_preferences, ownerBodyName, opts.senateTypes)
+ ) {
+ continue
+ }
+
// Divisional: peer bodies contribute only their leadership.
if (
!leadershipOnly &&
diff --git a/lib/emails/booking-updated.ts b/lib/emails/booking-updated.ts
index fd269f4..dd48e85 100644
--- a/lib/emails/booking-updated.ts
+++ b/lib/emails/booking-updated.ts
@@ -2,6 +2,26 @@ import { resend } from '@/lib/resend'
import { sanitize, buildEmailHtml } from './utils'
import { formatDate, formatTime, renderChanges, type BookingChange } from './changes'
+/**
+ * One session of a repeating booking that an edit actually moved.
+ *
+ * The values are the session's own -- effective ones, with any per-week override
+ * already resolved against the series -- so the email can describe the week that
+ * changed rather than the series it belongs to.
+ */
+export interface UpdatedSession {
+ date: string
+ startTime: string
+ endTime: string
+ roomOrTable: string
+ status: string
+ purpose?: string | null
+ /** Where this session sits in the run, e.g. "week 3 of 12". Omitted if unknown. */
+ position?: string | null
+ /** What moved in this session specifically. */
+ changes?: BookingChange[]
+}
+
interface BookingUpdatedEmailParams {
bodyName: string
roomOrTable: string
@@ -11,59 +31,86 @@ interface BookingUpdatedEmailParams {
status: string
recipients: string[]
/**
- * What an administrator actually altered. Optional so a caller that cannot
- * work it out still sends the email it sent before (issue #79).
+ * What an administrator actually altered at the series level. Optional so a
+ * caller that cannot work it out still sends the email it sent before (issue
+ * #79).
*/
changes?: BookingChange[]
/**
- * Set when the edit was to one session of a repeating booking rather than to
- * the series. The email then describes that session -- its date, its time, its
- * room -- instead of restating the series' start date, which is what it used
- * to do no matter which week had been touched.
+ * The sessions of a repeating booking this edit moved, when the series itself
+ * did not move. The email then describes those sessions -- their dates, times
+ * and rooms -- instead of restating the series' start date.
+ *
+ * This is a list rather than a single session because the weekly editor saves
+ * every week at once, so one save can move several of them. Naming only one
+ * meant the other weeks changed silently (issue #91).
*/
- occurrence?: {
- /** Where this session sits in the run, e.g. "week 3 of 12". Omitted if unknown. */
- position?: string | null
- } | null
+ sessions?: UpdatedSession[] | null
/** Shown above the details when set, e.g. the booking's purpose. */
purpose?: string | null
}
+/** The "Body / Room / Date / Time / Status" block, in both bodies of the email. */
+function renderDetails(
+ fields: { label: string; value: string }[]
+): { text: string; html: string } {
+ return {
+ text: fields.map(f => `${f.label}: ${f.value}`).join('\n'),
+ html: fields.map(f => `${f.label}: ${f.value} `).join(''),
+ }
+}
+
+/** One session's heading, change rows and current details, for the multi-session email. */
+function renderSession(session: UpdatedSession): { text: string; html: string } {
+ const heading = session.position
+ ? `${formatDate(session.date)} (${sanitize(session.position)})`
+ : formatDate(session.date)
+
+ // The session's date is already the heading here, so the change rows go in bare.
+ const rendered = renderChanges(session.changes ?? [], { heading: false })
+
+ const details = renderDetails([
+ ...(session.purpose ? [{ label: 'Purpose', value: sanitize(session.purpose) }] : []),
+ { label: 'Room/Table', value: sanitize(session.roomOrTable) },
+ { label: 'Time', value: `${formatTime(session.startTime)} to ${formatTime(session.endTime)}` },
+ { label: 'Status', value: sanitize(session.status) },
+ ])
+
+ return {
+ // Indented under the date, and held apart from the change rows above them:
+ // run together, "- Purpose: Weekly Meeting -> Exec Sync" followed
+ // immediately by "Purpose: Exec Sync" reads as a contradiction.
+ text: `${heading}
+${rendered ? `${rendered.text}\n` : ''}
+${details.text.split('\n').map(l => ` ${l}`).join('\n')}`,
+ html: `
+
+
${heading}
+ ${rendered ? rendered.html : ''}
+
${details.html}
+
`,
+ }
+}
+
export async function sendBookingUpdatedEmail(params: BookingUpdatedEmailParams) {
const {
bodyName, roomOrTable, date, startTime, endTime, status, recipients,
- changes = [], occurrence = null, purpose = null,
+ changes = [], sessions = null, purpose = null,
} = params
if (!recipients.length) return
const sBodyName = sanitize(bodyName)
- const sRoomOrTable = sanitize(roomOrTable)
- const sStatus = sanitize(status)
const sPurpose = purpose ? sanitize(purpose) : null
- const isOccurrence = !!occurrence
- const rendered = renderChanges(changes)
-
- // A recipient scanning a phone should be able to tell from the subject whether
- // this is their whole booking or one week of it.
- const subject = isOccurrence
- ? `Chambers — A Session of Your ${sBodyName} Booking Has Changed`
- : 'Chambers — Your Booking Has Been Updated'
-
- const lead = isOccurrence
- ? `One session of your ${sBodyName} weekly booking has been updated by a Chambers administrator. The rest of the series is unchanged.`
- : `Your ${sBodyName} booking has been updated by a Chambers administrator.`
+ const moved = sessions ?? []
- const dateLabel = isOccurrence ? 'Session date' : 'Date'
- const positionText = occurrence?.position ? `\nSession: ${sanitize(occurrence.position)}` : ''
- const positionHtml = occurrence?.position
- ? `Session: ${sanitize(occurrence.position)} `
- : ''
-
- const purposeText = sPurpose ? `Purpose: ${sPurpose}\n` : ''
- const purposeHtml = sPurpose ? `Purpose: ${sPurpose} ` : ''
-
- const changesText = rendered ? `\nWhat changed:\n${rendered.text}\n` : ''
+ // Three shapes, one email: the whole series moved, one week of it moved, or
+ // several weeks of it moved.
+ const { subject, lead, text, html } = moved.length > 1
+ ? buildMultiSession(sBodyName, moved)
+ : moved.length === 1
+ ? buildSingleSession(sBodyName, moved[0])
+ : buildSeries(sBodyName, sPurpose, { roomOrTable, date, startTime, endTime, status }, changes)
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!,
@@ -71,28 +118,84 @@ export async function sendBookingUpdatedEmail(params: BookingUpdatedEmailParams)
bcc: recipients,
subject,
text: `${lead}
-${changesText}
-${isOccurrence ? 'This session now reads:' : 'The booking now reads:'}
-${purposeText}Body: ${sBodyName}
-Room/Table: ${sRoomOrTable}
-${dateLabel}: ${formatDate(date)}
-Time: ${formatTime(startTime)} to ${formatTime(endTime)}
-Status: ${sStatus}${positionText}
+${text}
If you have questions, please reach out to sgaOperations@northeastern.edu.`,
html: buildEmailHtml(`
${lead}
- ${rendered ? rendered.html : ''}
- ${isOccurrence ? 'This session now reads' : 'The booking now reads'}
-
- ${purposeHtml}Body: ${sBodyName}
- Room/Table: ${sRoomOrTable}
- ${dateLabel}: ${formatDate(date)}
- Time: ${formatTime(startTime)} to ${formatTime(endTime)}
- Status: ${sStatus}
- ${positionHtml}
-
+ ${html}
`),
})
}
+
+function buildSeries(
+ sBodyName: string,
+ sPurpose: string | null,
+ now: { roomOrTable: string; date: string; startTime: string; endTime: string; status: string },
+ changes: BookingChange[]
+) {
+ const rendered = renderChanges(changes)
+ const details = renderDetails([
+ ...(sPurpose ? [{ label: 'Purpose', value: sPurpose }] : []),
+ { label: 'Body', value: sBodyName },
+ { label: 'Room/Table', value: sanitize(now.roomOrTable) },
+ { label: 'Date', value: formatDate(now.date) },
+ { label: 'Time', value: `${formatTime(now.startTime)} to ${formatTime(now.endTime)}` },
+ { label: 'Status', value: sanitize(now.status) },
+ ])
+
+ return {
+ subject: 'Chambers — Your Booking Has Been Updated',
+ lead: `Your ${sBodyName} booking has been updated by a Chambers administrator.`,
+ text: `${rendered ? `What changed:\n${rendered.text}\n\n` : ''}The booking now reads:
+
+${details.text}`,
+ html: `
+ ${rendered ? rendered.html : ''}
+ The booking now reads
+ ${details.html}
`,
+ }
+}
+
+function buildSingleSession(sBodyName: string, session: UpdatedSession) {
+ const rendered = renderChanges(session.changes ?? [])
+ const details = renderDetails([
+ ...(session.purpose ? [{ label: 'Purpose', value: sanitize(session.purpose) }] : []),
+ { label: 'Body', value: sBodyName },
+ { label: 'Room/Table', value: sanitize(session.roomOrTable) },
+ { label: 'Session date', value: formatDate(session.date) },
+ { label: 'Time', value: `${formatTime(session.startTime)} to ${formatTime(session.endTime)}` },
+ { label: 'Status', value: sanitize(session.status) },
+ ...(session.position ? [{ label: 'Session', value: sanitize(session.position) }] : []),
+ ])
+
+ return {
+ // A recipient scanning a phone should be able to tell from the subject
+ // whether this is their whole booking or one week of it.
+ subject: `Chambers — A Session of Your ${sBodyName} Booking Has Changed`,
+ lead: `One session of your ${sBodyName} weekly booking has been updated by a Chambers administrator. The rest of the series is unchanged.`,
+ text: `${rendered ? `What changed:\n${rendered.text}\n\n` : ''}This session now reads:
+
+${details.text}`,
+ html: `
+ ${rendered ? rendered.html : ''}
+ This session now reads
+ ${details.html}
`,
+ }
+}
+
+function buildMultiSession(sBodyName: string, sessions: UpdatedSession[]) {
+ const blocks = sessions.map(renderSession)
+
+ return {
+ subject: `Chambers — ${sessions.length} Sessions of Your ${sBodyName} Booking Have Changed`,
+ lead: `${sessions.length} sessions of your ${sBodyName} weekly booking have been updated by a Chambers administrator. The rest of the series is unchanged.`,
+ text: `These sessions now read:
+
+${blocks.map(b => b.text).join('\n\n')}`,
+ html: `
+ These sessions now read
+ ${blocks.map(b => b.html).join('')}`,
+ }
+}
diff --git a/lib/emails/changes.ts b/lib/emails/changes.ts
index 3ff6c1c..23972c8 100644
--- a/lib/emails/changes.ts
+++ b/lib/emails/changes.ts
@@ -76,8 +76,15 @@ export function collectChanges(...items: (BookingChange | null)[]): BookingChang
* is filed under), and inventing a "What changed" section that lists nothing
* would be worse than leaving it out. The email then reads as it did before, as
* a statement of where the booking now stands.
+ *
+ * `heading: false` drops the "What changed" label, for a caller that already
+ * has a heading of its own above the rows -- the per-session blocks of a
+ * multi-session update email, where repeating it under every date would shout.
*/
-export function renderChanges(changes: BookingChange[]): { text: string; html: string } | null {
+export function renderChanges(
+ changes: BookingChange[],
+ { heading = true }: { heading?: boolean } = {}
+): { text: string; html: string } | null {
if (!changes.length) return null
const text = changes
@@ -85,7 +92,7 @@ export function renderChanges(changes: BookingChange[]): { text: string; html: s
.join('\n')
const html = `
- What changed
+ ${heading ? 'What changed
' : ''}
${changes.map(c => `
diff --git a/lib/emails/missed-reservation.ts b/lib/emails/missed-reservation.ts
index e545eb0..995f061 100644
--- a/lib/emails/missed-reservation.ts
+++ b/lib/emails/missed-reservation.ts
@@ -1,38 +1,55 @@
import { resend } from '@/lib/resend'
import { sanitize, buildEmailHtml } from './utils'
+import { formatDate, formatTime } from './changes'
+/**
+ * The alert Operational Affairs gets when a reservation is marked Missed.
+ *
+ * This template did not move when the booking emails were rewritten in #79, and
+ * drifted (issue #99). It rendered its times straight out of the payload, so an
+ * alert read "Time of Reservation: 18:30:00 to 20:00:00" where every other
+ * Chambers email said "6:30 PM to 8:00 PM"; it carried a second copy of the date
+ * formatter under a different name; and it took its date already formatted,
+ * which is why the raw times were easy to miss -- the one value the caller
+ * prepared looked right, and the two it passed through did not.
+ *
+ * It now takes raw values and formats them here, like every other template.
+ */
interface MissedReservationEmailParams {
bodyName: string
+ /** 'YYYY-MM-DD'. Formatted here, not by the caller. */
date: string
+ /** 'HH:MM' or 'HH:MM:SS' -- both are formatted the same way. */
startTime: string
endTime: string
contacts: string[]
-}
-
-export function formatDateLong(dateStr: string): string {
- return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
- weekday: 'long',
- month: 'long',
- day: 'numeric',
- year: 'numeric',
- })
+ /** The room or table that went unused. Omitted when the caller has none. */
+ roomOrTable?: string | null
}
export async function sendMissedReservationEmail(params: MissedReservationEmailParams) {
- const { bodyName, date, startTime, endTime, contacts } = params
+ const { bodyName, date, startTime, endTime, contacts, roomOrTable = null } = params
const sBodyName = sanitize(bodyName)
- const sContacts = contacts.map(sanitize).join(', ')
+ // An em dash, so a list of names cannot read as one hyphenated name, and a
+ // placeholder rather than an empty line when a body has no leadership on file.
+ const sContacts = contacts.length ? contacts.map(sanitize).join(', ') : 'None on file'
+ const sRoomOrTable = roomOrTable ? sanitize(roomOrTable) : null
+
+ const roomText = sRoomOrTable ? `Room/Table: ${sRoomOrTable}\n` : ''
+ const roomHtml = sRoomOrTable ? `Room/Table: ${sRoomOrTable} ` : ''
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!,
+ // Straight to Operational Affairs rather than bcc'd to a membership: this is
+ // an internal alert about a body, not a notification to it.
to: process.env.OPS_EMAIL!,
- subject: 'Chambers Alert - Reservation Missed',
+ subject: 'Chambers — Reservation Missed',
text: `This is an automatic alert that a SGA reservation was marked as missed by a Chambers administrator.
Responsible Body: ${sBodyName}
-Date of Reservation: ${date}
-Time of Reservation: ${startTime} to ${endTime}
+${roomText}Date of Reservation: ${formatDate(date)}
+Time of Reservation: ${formatTime(startTime)} to ${formatTime(endTime)}
Contacts: ${sContacts}
@@ -41,8 +58,8 @@ For further information, please reach out to the Comptroller.`,
This is an automatic alert that a SGA reservation was marked as missed by a Chambers administrator.
Responsible Body: ${sBodyName}
- Date of Reservation: ${date}
- Time of Reservation: ${startTime} to ${endTime}
+ ${roomHtml}Date of Reservation: ${formatDate(date)}
+ Time of Reservation: ${formatTime(startTime)} to ${formatTime(endTime)}
Contacts: ${sContacts}
`, 'For further information, please reach out to the Comptroller.'),
diff --git a/lib/meeting-reminders.ts b/lib/meeting-reminders.ts
new file mode 100644
index 0000000..1a58c8e
--- /dev/null
+++ b/lib/meeting-reminders.ts
@@ -0,0 +1,174 @@
+import { APP_TIME_ZONE } from '@/lib/app-zone'
+
+/**
+ * Working out which committee meetings the Slack bot should remind a channel
+ * about, and what to say (issue #95).
+ *
+ * Kept apart from the route so the selection rules -- which weeks count, which
+ * are suppressed, how an override resolves against its series -- can be read and
+ * exercised without a database or a Slack workspace.
+ */
+
+/** The local hour, in APP_TIME_ZONE, at or after which the day-before reminder posts. */
+export const REMINDER_HOUR = 9
+
+/**
+ * Statuses that mean the meeting is not happening, so a reminder would be wrong.
+ *
+ * Deliberately short. 'Pending Cancellation' is *not* here: that week may still
+ * go ahead, and the people in the channel are exactly the ones who need to know
+ * it is in doubt -- so it is reported, with its status shown.
+ */
+const NOT_HAPPENING = new Set(['Cancelled', 'Repurposed', 'Missed'])
+
+/** Statuses ordinary enough that naming them in the reminder would be noise. */
+const UNREMARKABLE = new Set(['Reserved', 'Confirmed'])
+
+/** 'YYYY-MM-DD' and the hour, in APP_TIME_ZONE, for an instant. */
+export function appZoneParts(now: Date = new Date()): { date: string; hour: number } {
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ timeZone: APP_TIME_ZONE,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ hour12: false,
+ }).formatToParts(now)
+
+ const get = (type: string) => parts.find(p => p.type === type)?.value ?? ''
+ // Intl renders midnight as '24' in some ICU versions under hour12: false.
+ const hour = Number(get('hour')) % 24
+
+ return { date: `${get('year')}-${get('month')}-${get('day')}`, hour }
+}
+
+/** The day after `date` ('YYYY-MM-DD'), computed on the calendar rather than by adding hours. */
+export function nextDay(date: string): string {
+ const [y, m, d] = date.split('-').map(Number)
+ // UTC noon, so a DST boundary cannot push the arithmetic onto the wrong day.
+ const at = new Date(Date.UTC(y, m - 1, d, 12))
+ at.setUTCDate(at.getUTCDate() + 1)
+ return at.toISOString().slice(0, 10)
+}
+
+/**
+ * One occurrence as the reminder query returns it, with its series and body
+ * attached. Only the fields the rules below touch are declared.
+ */
+export interface ReminderCandidate {
+ occurrence_date: string
+ room_name: string | null
+ start_time: string | null
+ end_time: string | null
+ status: string | null
+ purpose: string | null
+ hidden: boolean | null
+ weekly_booking_id: string
+ series: {
+ room_name: string | null
+ start_time: string | null
+ end_time: string | null
+ status: string | null
+ }
+ booking: {
+ purpose: string | null
+ hidden: boolean | null
+ }
+ body: {
+ name: string
+ slack_channel_id: string | null
+ }
+}
+
+/** A candidate resolved to the values that actually apply to that week. */
+export interface ResolvedMeeting {
+ weeklyBookingId: string
+ date: string
+ channelId: string
+ bodyName: string
+ roomName: string | null
+ startTime: string | null
+ endTime: string | null
+ status: string | null
+ purpose: string | null
+}
+
+/**
+ * Resolves a candidate against its series, or returns null when no reminder
+ * should be posted for it.
+ *
+ * An occurrence field that is null inherits -- from the series for room, times
+ * and status, and from the booking above it for purpose and visibility. That
+ * precedence is the same one My Rooms and the update emails apply.
+ */
+export function resolveMeeting(c: ReminderCandidate): ResolvedMeeting | null {
+ if (!c.body.slack_channel_id) return null
+
+ // A hidden booking is visible only to the people who can manage it, so
+ // announcing it to a channel would disclose it to everyone in that channel.
+ // `?? booking.hidden` is the inheritance: a visible series can hide one week.
+ if (c.hidden ?? c.booking.hidden) return null
+
+ const status = c.status ?? c.series.status
+ if (status && NOT_HAPPENING.has(status)) return null
+
+ return {
+ weeklyBookingId: c.weekly_booking_id,
+ date: c.occurrence_date,
+ channelId: c.body.slack_channel_id,
+ bodyName: c.body.name,
+ roomName: c.room_name ?? c.series.room_name,
+ startTime: c.start_time ?? c.series.start_time,
+ endTime: c.end_time ?? c.series.end_time,
+ status,
+ purpose: c.purpose ?? c.booking.purpose,
+ }
+}
+
+function formatTime(time: string | null): string | null {
+ if (!time) return null
+ const [h, m] = time.split(':').map(Number)
+ if (Number.isNaN(h) || Number.isNaN(m)) return null
+ const ampm = h >= 12 ? 'PM' : 'AM'
+ return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${ampm}`
+}
+
+function formatDate(date: string): string {
+ const [y, m, d] = date.split('-').map(Number)
+ return new Date(Date.UTC(y, m - 1, d, 12)).toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ timeZone: 'UTC',
+ })
+}
+
+/** Slack mrkdwn escaping: only these three characters carry meaning in message text. */
+function esc(s: string): string {
+ return s.replace(/&/g, '&').replace(//g, '>')
+}
+
+/**
+ * The reminder text.
+ *
+ * States what is known and stays quiet about what is not: a week with no room
+ * secured says so rather than printing a dash, and an ordinary status is left
+ * off entirely so that a status line always means something is unusual.
+ */
+export function formatReminder(m: ResolvedMeeting): string {
+ const start = formatTime(m.startTime)
+ const end = formatTime(m.endTime)
+ const when = start && end ? `${start}–${end}` : start ?? 'time to be confirmed'
+
+ const lines = [
+ `:calendar: *${esc(m.bodyName)}* meets tomorrow — ${formatDate(m.date)}, ${when}`,
+ ]
+
+ lines.push(m.roomName ? `*Room:* ${esc(m.roomName)}` : '*Room:* not yet confirmed')
+
+ if (m.purpose?.trim()) lines.push(`*Purpose:* ${esc(m.purpose.trim())}`)
+
+ if (m.status && !UNREMARKABLE.has(m.status)) lines.push(`*Status:* ${esc(m.status)}`)
+
+ return lines.join('\n')
+}
diff --git a/lib/pending-cancellations.ts b/lib/pending-cancellations.ts
index 3dfd252..dd6c974 100644
--- a/lib/pending-cancellations.ts
+++ b/lib/pending-cancellations.ts
@@ -95,11 +95,19 @@ 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'
+/**
+ * Everything a line carries except a code CSC could act on.
+ *
+ * It used to carry only the four display fields, because the only thing anyone
+ * did with a skipped row was print it in the Auto-Cancel preview. Marking a
+ * cancellation request Done has to *act* on these rows (issue #96): the admin
+ * saying they have handled it is not conditional on CSC having had a code to
+ * work from, so the row still needs its status applied and therefore still needs
+ * its id, its table and its outcome.
+ */
+export interface SkippedReservation extends Omit {
+ /** Always null. Its absence is what makes the row skipped. */
+ reservationCode: null
}
/**
@@ -191,20 +199,31 @@ export async function collectPending(): Promise {
// 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')
+ .select('id, booking_id, occurrence_id, occurrence_date, 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()
+ // Keyed on (booking, date), which is what survives an edit. occurrence_id does
+ // not: the weekly PATCH handler regenerates every occurrence row on each save,
+ // so a request made before an edit points at nothing afterwards -- and every
+ // request in production was in exactly that state (issue #96). Falling through
+ // to bySeriesBooking would have been wrong, and falling through to nothing lost
+ // the cancellation_type, so a request that asked to go Virtual came out
+ // Cancelled.
+ const byBookingDate = new Map()
const bySeriesBooking = new Map()
const byBooking = new Map()
+ const dateKey = (bookingId: string, date: string) => `${bookingId}|${date}`
+
for (const pass of ['Pending', 'other'] as const) {
for (const r of (requests ?? []) as {
id: string
booking_id: string | null
occurrence_id: string | null
+ occurrence_date: string | null
scope: string
status: string | null
cancellation_type: string
@@ -217,6 +236,10 @@ export async function collectPending(): Promise {
if (r.occurrence_id && !byOccurrence.has(r.occurrence_id)) {
byOccurrence.set(r.occurrence_id, ref)
}
+ if (r.booking_id && r.occurrence_date) {
+ const k = dateKey(r.booking_id, r.occurrence_date)
+ if (!byBookingDate.has(k)) byBookingDate.set(k, ref)
+ }
if (r.booking_id) {
if (r.scope === 'series' && !bySeriesBooking.has(r.booking_id)) {
bySeriesBooking.set(r.booking_id, ref)
@@ -233,19 +256,17 @@ export async function collectPending(): Promise {
request: RequestRef | undefined,
) => {
const outcome = outcomeOf(request?.type)
- if (hasUsableCode(code)) lines.push({
+ const resolved = {
...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,
- })
+ }
+ // Built once and routed, rather than assembled differently on each branch.
+ // The two used to diverge, and a skipped row lost the id and table that
+ // marking a request Done now needs (issue #96).
+ if (hasUsableCode(code)) lines.push({ ...resolved, reservationCode: code!.trim() })
+ else skipped.push({ ...resolved, reservationCode: null })
}
{
@@ -265,7 +286,7 @@ export async function collectPending(): Promise {
roomOrTable: r.room_name || 'Not recorded',
bodyName: bodyNameOf(r.bookings),
bookingType: 'One-Time Room',
- }, byBooking.get(r.booking_id))
+ }, byBookingDate.get(dateKey(r.booking_id, r.booking_date)) ?? byBooking.get(r.booking_id))
}
}
@@ -291,7 +312,9 @@ export async function collectPending(): Promise {
roomOrTable: r.location || 'Not recorded',
bodyName: bodyNameOf(parent?.bookings ?? null),
bookingType: 'Tabling',
- }, parent?.booking_id ? byBooking.get(parent.booking_id) : undefined)
+ }, parent?.booking_id
+ ? byBookingDate.get(dateKey(parent.booking_id, r.session_date)) ?? byBooking.get(parent.booking_id)
+ : undefined)
}
}
@@ -339,8 +362,14 @@ export async function collectPending(): Promise {
bookingType: 'Weekly Room',
},
// A request naming this exact week wins over one covering the series.
+ // The id is tried first because it is exact when it resolves; the
+ // (booking, date) key is what still works once the row has been
+ // regenerated, which is the usual case rather than the exception.
byOccurrence.get(r.id)
- ?? (series?.booking_id ? bySeriesBooking.get(series.booking_id) : undefined)
+ ?? (series?.booking_id
+ ? byBookingDate.get(dateKey(series.booking_id, r.occurrence_date))
+ ?? bySeriesBooking.get(series.booking_id)
+ : undefined)
)
}
}
@@ -353,3 +382,68 @@ export async function collectPending(): Promise {
}
}
+
+/** Which table each source's `id` belongs to. */
+const TABLE_OF: Record = {
+ one_time: 'one_time_room_bookings',
+ occurrence: 'weekly_room_occurrences',
+ tabling_session: 'tabling_sessions',
+}
+
+/** The rows a cancellation outcome can be written to. */
+export type OutcomeTarget = Pick
+
+/**
+ * Writes each reservation the status it is due, and returns the batches that
+ * failed.
+ *
+ * Grouped by table *and* by status, 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 acted on stop
+ * being pending, and the rest of the series is untouched.
+ *
+ * Shared by Auto-Cancel and by marking a request Done by hand, which have to
+ * agree about this: the same request resolved either way should leave the
+ * database in the same state.
+ */
+export async function applyCancellationOutcomes(rows: OutcomeTarget[]): Promise {
+ const batches = new Map()
+ for (const l of rows) {
+ 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(`Could not mark ${table} as ${status}:`, error)
+ failures.push(`${table} (${status})`)
+ }
+ }
+ return failures
+}
+
+/**
+ * Audit rows for a set of reservations, one per booking and status.
+ *
+ * Keyed on booking *and* status: one booking can contribute both a cancelled
+ * week and a virtual one in the same action, and a single row saying 'Cancelled'
+ * would misreport the other.
+ */
+export function cancellationAuditRows(rows: OutcomeTarget[], adminId: string) {
+ return [...new Map(
+ rows
+ .filter(l => l.bookingId)
+ .map(l => [
+ `${l.bookingId}:${l.resultingStatus}`,
+ { booking_id: l.bookingId, admin_id: adminId, new_status: l.resultingStatus },
+ ])
+ ).values()]
+}
diff --git a/lib/senate-types.ts b/lib/senate-types.ts
new file mode 100644
index 0000000..20af897
--- /dev/null
+++ b/lib/senate-types.ts
@@ -0,0 +1,64 @@
+/**
+ * Senate session types, and the one rule that decides whether a member wants to
+ * hear about a session of a given type.
+ *
+ * The list was declared twice -- once in the Settings modal that writes the
+ * preference, once in the My Rooms code that read it -- and the rule itself
+ * existed only on the client, inside the My Rooms list filter. So deselecting
+ * Office Hours hid those sessions from the page and did nothing else: update
+ * emails and dashboard alerts still went to everyone on the Senate, including
+ * the people who had said they did not want them (issues #92, #93).
+ *
+ * Both now live here, so the page, the emails and the alerts cannot drift apart.
+ */
+
+/**
+ * Session types only exist on bookings owned by the body literally named
+ * "Senate" -- that is what gates the Session Type field in the weekly editor,
+ * and what the My Rooms filter has always keyed on.
+ */
+export const SENATE_BODY_NAME = 'Senate'
+
+export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const
+
+export type SenateType = (typeof SENATE_TYPES)[number]
+
+/**
+ * Whether this member wants to hear about one session.
+ *
+ * Defaults to yes at every step: a non-Senate booking, a Senate session with no
+ * type set, or a type the member has never expressed an opinion about. A
+ * preference has to be deselected explicitly to suppress anything, so a member
+ * who has never opened Settings sees and hears about everything.
+ */
+export function wantsSenateSession(
+ prefs: Record | null | undefined,
+ bodyName: string | null | undefined,
+ senateType: string | null | undefined
+): boolean {
+ if (bodyName !== SENATE_BODY_NAME) return true
+ if (!senateType) return true
+ return prefs?.[senateType] ?? true
+}
+
+/**
+ * Whether this member wants to hear about an edit that touched several sessions
+ * at once.
+ *
+ * One wanted session is enough. Someone who reads Full Body but not Office Hours
+ * still needs the email about a save that moved both, because the Full Body week
+ * moved -- suppressing it would lose a session they asked to know about, which is
+ * a worse failure than one line of Office Hours detail they did not.
+ *
+ * An empty list means the edit was not session-specific (the series moved), so
+ * there is nothing to filter on and everyone hears about it.
+ */
+export function wantsAnySenateSession(
+ prefs: Record | null | undefined,
+ bodyName: string | null | undefined,
+ senateTypes: (string | null | undefined)[]
+): boolean {
+ if (bodyName !== SENATE_BODY_NAME) return true
+ if (!senateTypes.length) return true
+ return senateTypes.some(t => wantsSenateSession(prefs, bodyName, t))
+}
diff --git a/lib/slack.ts b/lib/slack.ts
new file mode 100644
index 0000000..33b77c4
--- /dev/null
+++ b/lib/slack.ts
@@ -0,0 +1,62 @@
+/**
+ * Posting to Slack.
+ *
+ * chat.postMessage was called inline in the slash-command route with its own
+ * fetch and its own header block. Now that the reminder job posts too (issue
+ * #95), the call lives here -- mainly so the failure path does, because Slack
+ * answers a refused post with HTTP 200 and `ok: false`, and a bare fetch reads
+ * that as success. A reminder that silently never arrived is the failure this
+ * feature is most likely to have, so it has to be visible in the logs.
+ */
+
+const SLACK_POST_MESSAGE_URL = 'https://slack.com/api/chat.postMessage'
+
+export interface SlackPostResult {
+ ok: boolean
+ /** Slack's machine-readable reason, e.g. 'not_in_channel', 'channel_not_found'. */
+ error?: string
+}
+
+/**
+ * Sends one message. `channel` is a channel id for a channel post, or a user id
+ * for a DM.
+ *
+ * `text` is used as the notification fallback and as the body; pass `blocks` as
+ * well for a richer layout, and Slack renders those instead while still using
+ * `text` for the push notification and the accessibility label.
+ */
+export async function postSlackMessage(
+ channel: string,
+ text: string,
+ blocks?: unknown[]
+): Promise {
+ try {
+ const res = await fetch(SLACK_POST_MESSAGE_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
+ },
+ body: JSON.stringify(blocks ? { channel, text, blocks } : { channel, text }),
+ })
+
+ // A 200 from Slack is not a delivered message. The common refusals here are
+ // not_in_channel (the bot was never invited) and channel_not_found (the id
+ // is wrong, or the channel is private and the bot cannot see it) -- both of
+ // which look like a working integration that just never posts.
+ const data = (await res.json()) as { ok?: boolean; error?: string }
+ if (!data.ok) {
+ console.error(`Slack chat.postMessage refused for ${channel}:`, data.error)
+ return { ok: false, error: data.error ?? 'unknown_error' }
+ }
+ return { ok: true }
+ } catch (e) {
+ console.error(`Slack chat.postMessage failed for ${channel}:`, e)
+ return { ok: false, error: 'request_failed' }
+ }
+}
+
+/** An ephemeral slash-command reply: visible only to whoever ran the command. */
+export function ephemeral(text: string): Response {
+ return Response.json({ response_type: 'ephemeral', text })
+}
diff --git a/lib/spaces-advance-notice.ts b/lib/spaces-advance-notice.ts
new file mode 100644
index 0000000..89171c9
--- /dev/null
+++ b/lib/spaces-advance-notice.ts
@@ -0,0 +1,89 @@
+import { bostonWallClockNow } from './boston-time'
+
+/**
+ * The advance-notice rule for SGA Spaces, in one place (issue #94).
+ *
+ * The rule exists so that time is *claimed* a set number of hours before it is
+ * used. It follows that the thing to check is the time an edit newly claims, not
+ * whether the start moved: shortening a booking, pushing its start later,
+ * renaming it or cancelling it outright all release time or leave it alone, and
+ * none of them needs notice.
+ *
+ * The PATCH route used to reject any change to start_time that landed inside the
+ * window, which meant a booking that had entered the window could not be
+ * shortened, moved later, or given a different name -- and the calendar would
+ * not even open it, so in practice it could not be touched at all.
+ */
+
+export interface BookingInterval {
+ /** ISO instant. */
+ start: string
+ /** ISO instant. */
+ end: string
+}
+
+/**
+ * The earliest instant `next` claims that `prev` did not, or null when it claims
+ * nothing new.
+ *
+ * `prev` is null for a booking being created, where everything is new.
+ *
+ * The three shapes that claim time:
+ *
+ * start moved earlier the new block runs from the new start
+ * end moved later the new block runs from the *old* end -- which is
+ * why extending a booking whose end is still outside
+ * the window is fine
+ * moved clear of the old the whole interval is new, so it runs from the new
+ * interval start; relocating to next week is not an extension
+ */
+export function earliestNewlyClaimed(
+ next: BookingInterval,
+ prev: BookingInterval | null
+): string | null {
+ if (!prev) return next.start
+
+ const nextStart = Date.parse(next.start)
+ const nextEnd = Date.parse(next.end)
+ const prevStart = Date.parse(prev.start)
+ const prevEnd = Date.parse(prev.end)
+
+ if (nextStart < prevStart) return next.start
+ if (nextEnd > prevEnd) return nextStart > prevEnd ? next.start : prev.end
+ return null
+}
+
+/**
+ * The error to reject this booking with, or null when it is allowed.
+ *
+ * Returning the message rather than a boolean keeps the wording next to the rule
+ * it explains -- an edit and a creation fail for the same reason but need to be
+ * told different things about what to do next.
+ *
+ * `now` defaults to Boston wall-clock now and NOT to Date.now(), which would be
+ * wrong here in a way that is easy to miss: a space booking's start_time holds
+ * Boston wall-clock digits with a Z on the end, so measuring it against a real
+ * instant makes every booking look an offset earlier than it is. That is issue
+ * #87, fixed in 1e3d894, and putting the default here rather than at each call
+ * site is what keeps it fixed -- the browser is a caller too, and its clock is
+ * in whatever zone the viewer is sitting in.
+ */
+export function advanceNoticeError(
+ next: BookingInterval,
+ prev: BookingInterval | null,
+ minHours: number,
+ now: number = bostonWallClockNow().getTime()
+): string | null {
+ if (minHours <= 0) return null
+
+ const earliest = earliestNewlyClaimed(next, prev)
+ if (earliest === null) return null
+
+ if (Date.parse(earliest) >= now + minHours * 60 * 60 * 1000) return null
+
+ const hours = `${minHours} hour${minHours === 1 ? '' : 's'}`
+
+ return prev
+ ? `Adding time to a booking needs at least ${hours} of notice. You can still shorten this booking, start it later, rename it, or cancel it.`
+ : `Bookings must be made at least ${hours} in advance.`
+}
diff --git a/lib/weekly-occurrences.ts b/lib/weekly-occurrences.ts
new file mode 100644
index 0000000..0b64a64
--- /dev/null
+++ b/lib/weekly-occurrences.ts
@@ -0,0 +1,68 @@
+/**
+ * Telling which weeks of a repeating booking an edit actually moved.
+ *
+ * The weekly PATCH deletes and reinserts every occurrence on every save, so
+ * "what changed" cannot be read off the write -- it has to be a comparison
+ * against the rows that were there before.
+ *
+ * This used to be approximated as "the first week carrying any override", which
+ * is a different question and usually a different week: an override set on week
+ * 1 months ago is still an override today, so every later edit to the series was
+ * reported against week 1, with an empty change list, because week 1 had not in
+ * fact moved (issue #91).
+ */
+
+/**
+ * The occurrence columns a PATCH can move. Everything but is_event is an
+ * override, so null is a real value here and means "inherit".
+ *
+ * senate_type, hidden and is_event are compared even though the update email has
+ * no row for them: they still say *which* week an administrator touched, which
+ * is the question this module exists to answer.
+ */
+export const OCCURRENCE_FIELDS = [
+ 'room_name', 'start_time', 'end_time', 'status',
+ 'reservation_code', 'purpose', 'senate_type', 'hidden', 'is_event',
+] as const
+
+export type OccurrenceField = (typeof OCCURRENCE_FIELDS)[number]
+
+/** Any row carrying the override columns -- a stored one or a freshly built one. */
+export type OccurrenceRow = Partial>
+
+/**
+ * Normalises one override for comparison, so a value that only changed shape is
+ * not read as a change: Postgres returns a time as '18:30:00' where the editor
+ * submits '18:30', and a cleared text field arrives as '' where the stored row
+ * holds null.
+ */
+export function normalizeOccurrenceValue(field: OccurrenceField, value: unknown): string | null {
+ // is_event is the one column here that is not an override: an absent value
+ // means "not an event" rather than "inherit", so null and false are the same
+ // state and must not compare as a change. Without this, a week the series had
+ // not previously covered looked edited the moment it was generated.
+ if (field === 'is_event') return value === true ? 'true' : 'false'
+
+ if (value === null || value === undefined) return null
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
+ const s = String(value).trim()
+ if (!s) return null
+ return field === 'start_time' || field === 'end_time' ? s.slice(0, 5) : s
+}
+
+/**
+ * Whether this week's overrides differ from what was stored for it.
+ *
+ * `prev` is undefined for a date the series did not previously cover, which
+ * compares as all-null -- so extending a series does not, by itself, mark the
+ * new weeks as edited. That edit shows up as a change to the series' end date
+ * instead, which is where a recipient would look for it.
+ */
+export function occurrenceMoved(
+ prev: OccurrenceRow | undefined,
+ next: OccurrenceRow
+): boolean {
+ return OCCURRENCE_FIELDS.some(
+ f => normalizeOccurrenceValue(f, prev?.[f]) !== normalizeOccurrenceValue(f, next[f])
+ )
+}
diff --git a/package-lock.json b/package-lock.json
index 7cc8c12..36b0822 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "chambers",
- "version": "1.14.0",
+ "version": "1.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chambers",
- "version": "1.14.0",
+ "version": "1.14.1",
"dependencies": {
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.99.1",
diff --git a/package.json b/package.json
index 0defb8c..54158d9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "chambers",
- "version": "1.14.0",
+ "version": "1.14.1",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/supabase/migrations/20260909000000_body_types_and_slack_reminders.sql b/supabase/migrations/20260909000000_body_types_and_slack_reminders.sql
new file mode 100644
index 0000000..6824be1
--- /dev/null
+++ b/supabase/migrations/20260909000000_body_types_and_slack_reminders.sql
@@ -0,0 +1,109 @@
+-- Body types, and the Chambers Slack bot's weekly meeting reminders (issue #95).
+--
+-- Committees were distinguished from boards, teams and working groups by their
+-- name and nothing else, so no query could ask for "the committees" without
+-- pattern-matching a string. This gives bodies a type, and gives the one type
+-- that needs it -- Committee -- a Slack channel to post meeting reminders to.
+
+-- ---------------------------------------------------------------------------
+-- bodies.body_type
+-- ---------------------------------------------------------------------------
+
+alter table public.bodies
+ add column if not exists body_type text not null default 'Other';
+
+alter table public.bodies
+ drop constraint if exists bodies_body_type_check;
+
+alter table public.bodies
+ add constraint bodies_body_type_check
+ check (body_type in ('Committee', 'Board', 'Advisory Board', 'Working Group', 'Team', 'Other'));
+
+comment on column public.bodies.body_type is
+ 'What kind of body this is. Committee is the only type the Slack bot posts meeting reminders for.';
+
+-- Backfill from the name, which is what has been carrying this distinction all
+-- along. Order matters: 'Advisory Board' has to be tested before 'Board', or the
+-- three advisory boards would come out as plain boards.
+--
+-- Checked against the 31 bodies live at the time of writing: 9 Committee,
+-- 9 Board, 3 Advisory Board, 5 Team, 3 Working Group, and 2 that are genuinely
+-- none of these -- Senate and SGA General -- which land on Other correctly
+-- rather than as a failure to match. Management can change any of them.
+update public.bodies set body_type = case
+ when name ilike '%advisory board%' then 'Advisory Board'
+ when name ilike '%committee%' then 'Committee'
+ when name ilike '%board%' then 'Board'
+ when name ilike '%working group%' then 'Working Group'
+ when name ilike '%team%' then 'Team'
+ else 'Other'
+end;
+
+-- ---------------------------------------------------------------------------
+-- bodies: where the bot posts, and whether it may
+-- ---------------------------------------------------------------------------
+
+alter table public.bodies
+ add column if not exists slack_channel_id text;
+
+-- A channel id (C0123ABCDEF), not a name. Channels get renamed; ids do not, and
+-- a reminder that silently stops posting because someone tidied up a channel
+-- name is worse than no reminder at all.
+comment on column public.bodies.slack_channel_id is
+ 'Slack channel id the bot posts this committee''s meeting reminders to. NULL means no channel is linked, and nothing is posted.';
+
+alter table public.bodies
+ add column if not exists slack_reminders_enabled boolean not null default true;
+
+-- Leadership of the committee turns this off from inside its own channel, with
+-- /chambers-reminders off. Defaults to true so that linking a channel is the
+-- only step needed to start; there is nothing to post to until then anyway.
+comment on column public.bodies.slack_reminders_enabled is
+ 'Whether the bot may post meeting reminders for this body. Toggled by Leadership of the body via /chambers-reminders in the linked channel, or by Management.';
+
+-- ---------------------------------------------------------------------------
+-- slack_meeting_reminders: what has already been posted
+-- ---------------------------------------------------------------------------
+-- The reminder job is scheduled from a GitHub Action, which may run several
+-- times in the posting window (schedules there lag and are occasionally skipped,
+-- so retries are the point). This table is what makes a repeat run a no-op
+-- rather than a second ping.
+--
+-- Keyed on (weekly_booking_id, occurrence_date) rather than on an occurrence id.
+-- The weekly PATCH handler regenerates its occurrences on every save -- it
+-- deletes them all and reinserts, so their ids change -- and a foreign key to
+-- weekly_room_occurrences(id) would forget every reminder the next time anyone
+-- edited the booking, re-posting the lot. weekly_booking_id survives that
+-- regeneration, and the date is the stable identifier within a series. This is
+-- the same reasoning event_tracking already follows in
+-- 20260829001000_occurrence_events.sql.
+--
+-- The pair, rather than the date alone, so a body running two weekly series that
+-- both meet on a Tuesday gets a reminder for each.
+
+create table if not exists public.slack_meeting_reminders (
+ id uuid primary key default gen_random_uuid(),
+ weekly_booking_id uuid not null references public.weekly_room_bookings(id) on delete cascade,
+ occurrence_date date not null,
+ channel_id text not null,
+ posted_at timestamptz not null default now(),
+ unique (weekly_booking_id, occurrence_date)
+);
+
+comment on table public.slack_meeting_reminders is
+ 'One row per meeting reminder the Slack bot has posted. Makes a repeated run of the reminder job a no-op.';
+
+create index if not exists slack_meeting_reminders_date_idx
+ on public.slack_meeting_reminders (occurrence_date);
+
+alter table public.slack_meeting_reminders enable row level security;
+
+-- Written only by the reminder job, which uses the service-role client and
+-- bypasses RLS. Admins can read it to see what the bot has done; nobody else has
+-- any reason to, and there is deliberately no INSERT or UPDATE policy.
+drop policy if exists "slack_meeting_reminders_select_admin" on public.slack_meeting_reminders;
+create policy "slack_meeting_reminders_select_admin"
+ on public.slack_meeting_reminders
+ for select
+ to authenticated
+ using (is_admin());
diff --git a/supabase/migrations/20260909010000_cancellation_requests_occurrence_date.sql b/supabase/migrations/20260909010000_cancellation_requests_occurrence_date.sql
new file mode 100644
index 0000000..b4baa7a
--- /dev/null
+++ b/supabase/migrations/20260909010000_cancellation_requests_occurrence_date.sql
@@ -0,0 +1,60 @@
+-- Cancellation requests remember the date, not just the row id (issue #96).
+--
+-- cancellation_requests.occurrence_id names the dated row a request is about.
+-- For a weekly booking that row does not survive an edit: the weekly PATCH
+-- handler deletes every occurrence and reinserts it on each save, so the ids
+-- change while the values are carried across on the date. The request is left
+-- pointing at nothing.
+--
+-- Every cancellation_requests row in production is currently in that state --
+-- all four of them, including the one still Pending. The consequences are worse
+-- than they look:
+--
+-- * Marking a request Done could not find the reservation it was about, so it
+-- closed the request and changed no booking status. That is the reported bug.
+-- * Auto-Cancel finds those reservations anyway, because it scans by status
+-- rather than by request -- but it cannot attribute them back to a request,
+-- so it loses the cancellation_type and falls back to 'Cancelled'. A request
+-- that asked to go Virtual would have cancelled the meeting outright.
+--
+-- The date is the stable identifier the write model actually preserves, which is
+-- the same conclusion 20260829001000_occurrence_events.sql reached for
+-- event_tracking, and the same one issue #69 reached for calendar UIDs. This
+-- follows it: occurrence_id stays for the rows that still resolve, and
+-- occurrence_date becomes the key that survives a regeneration.
+
+alter table public.cancellation_requests
+ add column if not exists occurrence_date date;
+
+comment on column public.cancellation_requests.occurrence_date is
+ 'The date of the reservation this request is about, for occurrence-scoped requests. The stable key: occurrence_id does not survive an edit to a weekly booking, because the PATCH handler regenerates every occurrence row. NULL for series-scoped requests, and for occurrence-scoped ones created before this column existed whose row had already been regenerated.';
+
+-- Backfill from occurrence_id wherever it still resolves. This sets nothing in
+-- production, where every row has already been orphaned -- there is no record
+-- anywhere of which date those requests named, so they cannot be recovered and
+-- are deliberately left NULL rather than guessed at. It is written for the
+-- environments where the rows are still intact, and so that applying this
+-- migration to a restored backup does the right thing.
+
+update public.cancellation_requests r
+set occurrence_date = o.occurrence_date
+from public.weekly_room_occurrences o
+where r.occurrence_id = o.id
+ and r.occurrence_date is null;
+
+update public.cancellation_requests r
+set occurrence_date = s.booking_date
+from public.one_time_room_bookings s
+where r.occurrence_id = s.id
+ and r.occurrence_date is null;
+
+update public.cancellation_requests r
+set occurrence_date = t.session_date
+from public.tabling_sessions t
+where r.occurrence_id = t.id
+ and r.occurrence_date is null;
+
+-- Pending requests are looked up by (booking_id, occurrence_date) on every
+-- Auto-Cancel preview and every Cancellations tab load.
+create index if not exists cancellation_requests_booking_date_idx
+ on public.cancellation_requests (booking_id, occurrence_date);
diff --git a/supabase/migrations/rollback/20260909_body_types_and_slack_reminders_rollback.sql b/supabase/migrations/rollback/20260909_body_types_and_slack_reminders_rollback.sql
new file mode 100644
index 0000000..61f3160
--- /dev/null
+++ b/supabase/migrations/rollback/20260909_body_types_and_slack_reminders_rollback.sql
@@ -0,0 +1,21 @@
+-- Rollback for 20260909000000_body_types_and_slack_reminders.sql.
+--
+-- Drops the reminder log and the three columns on bodies. Any types Management
+-- had corrected by hand after the backfill are lost, as is every channel link
+-- and every "Leadership turned this off" decision -- so re-applying the
+-- migration will re-infer types from names and re-enable reminders for every
+-- committee whose channel is linked again.
+
+drop table if exists public.slack_meeting_reminders;
+
+alter table public.bodies
+ drop column if exists slack_reminders_enabled;
+
+alter table public.bodies
+ drop column if exists slack_channel_id;
+
+alter table public.bodies
+ drop constraint if exists bodies_body_type_check;
+
+alter table public.bodies
+ drop column if exists body_type;
diff --git a/supabase/migrations/rollback/20260909_cancellation_requests_occurrence_date_rollback.sql b/supabase/migrations/rollback/20260909_cancellation_requests_occurrence_date_rollback.sql
new file mode 100644
index 0000000..db1c7e1
--- /dev/null
+++ b/supabase/migrations/rollback/20260909_cancellation_requests_occurrence_date_rollback.sql
@@ -0,0 +1,11 @@
+-- Rollback for 20260909010000_cancellation_requests_occurrence_date.sql.
+--
+-- Drops the column, which returns cancellation requests to being identified only
+-- by an occurrence_id that does not survive an edit to a weekly booking. Any
+-- dates recorded since the migration are lost, so requests created in between
+-- become orphaned in exactly the way the migration was written to stop.
+
+drop index if exists public.cancellation_requests_booking_date_idx;
+
+alter table public.cancellation_requests
+ drop column if exists occurrence_date;