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..89f5b92 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'
@@ -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,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/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}