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}

`, 'For further information, please reach out to the Comptroller.'),