From dee560a454a15e87e34e2e110bd5c7acdf728c7e Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 9 Sep 2026 20:03:12 -0400 Subject: [PATCH 1/2] fix: bring the missed-reservation email in line with the others (#99) This template did not move when the booking emails were rewritten in #79 and drifted from them. 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". Both shapes the codebase produces are handled now -- Postgres returns HH:MM:SS and the booking forms submit HH:MM. It also took its date already formatted, via a second copy of the date formatter living under a different name. That is probably why the raw times survived so long: the one value the caller prepared looked right, and the two it passed through did not. It takes raw values and formats them here now, like every other template, and formatDateLong is gone -- it was character-for-character what changes.formatDate already did. The subject follows the house style, an em dash rather than a hyphen. Two smaller things while the file was open. The room or table is now named, since the point of the alert is that a room went unused and Operational Affairs would otherwise have to look it up. And a body with no leadership on file reads "Contacts: None on file" rather than trailing off after the colon. Co-Authored-By: Claude Opus 5 --- .../administrator/bookings/one-time/route.ts | 5 +- .../administrator/bookings/tabling/route.ts | 5 +- .../administrator/bookings/weekly/route.ts | 5 +- lib/emails/missed-reservation.ts | 49 +++++++++++++------ 4 files changed, 42 insertions(+), 22 deletions(-) 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 9608c04..0c52b3b 100644 --- a/app/api/administrator/bookings/weekly/route.ts +++ b/app/api/administrator/bookings/weekly/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' @@ -449,7 +449,8 @@ export async function PATCH(request: Request) { await sendMissedReservationEmail({ bodyName, - date: formatDateLong(start_date), + date: start_date, + roomOrTable: room_name, startTime: start_time, endTime: end_time, contacts, 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.'), From d1bf30ada882979d0643ca971d69b2332681272c Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 9 Sep 2026 20:40:24 -0400 Subject: [PATCH 2/2] fix: alert on the week that was missed, and only when it is missed (#99) Two things were wrong with asking "is anything Missed". It described the alert using the series' start date and time no matter which week had actually been missed, so a series where week 7 was missed told Operational Affairs about week 1. That is 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 another alert about a week they had been told about weeks earlier, with the wrong date on it. movedOccurrences -- which #91 added to answer exactly this question -- is what this save changed, so a week now alerts on the edit that missed it and not again afterwards. A series-level Missed is compared against the stored value for the same reason, so resubmitting an already-missed series does not re-alert either. One alert per missed reservation rather than one per save: each missed room is its own incident to chase, and a single email naming one of several would hide the rest. Occurrence values fall back to the series, so the times and room are the ones that actually applied to the week missed. Co-Authored-By: Claude Opus 5 --- .../administrator/bookings/weekly/route.ts | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/app/api/administrator/bookings/weekly/route.ts b/app/api/administrator/bookings/weekly/route.ts index 0c52b3b..89f5b92 100644 --- a/app/api/administrator/bookings/weekly/route.ts +++ b/app/api/administrator/bookings/weekly/route.ts @@ -437,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 { @@ -447,14 +464,30 @@ export async function PATCH(request: Request) { }) const contacts = leaders.map(l => l.fullName).filter(Boolean) - await sendMissedReservationEmail({ - bodyName, - date: start_date, - roomOrTable: room_name, - 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) }