notifications(P4b): equipment booking confirmed + cancelled emails

reserveEquipment now enqueues a booking_confirmed email to the booked-for
member (always; a manager booking on someone else's behalf still emails
the target). cancelReservation enqueues a booking_cancelled email ONLY
when the actor is not the affected member, so self-cancels stay silent.

Adds renderBookingEmail + bookingDedupeKey to lib/notifications-logic.ts
(extracts a shared renderShell at the same time so the 6 upcoming
templates do not duplicate the HTML wrapper). 6 new render/dedupe unit
tests cover subject lines, range + location lines, fallback copy when
names are missing, and HTML escaping of injected names.

Suite 550, build clean. No schema change (the notifications.type column
is text), no dispatcher change.
This commit is contained in:
Moheeb Zara
2026-05-19 15:19:53 -07:00
parent 84d31ee74b
commit 64ffa4ea63
3 changed files with 241 additions and 17 deletions
+68
View File
@@ -6,6 +6,8 @@ import {
duesDedupeKey,
formatMoney,
renderDuesEmail,
bookingDedupeKey,
renderBookingEmail,
} from '@/lib/notifications-logic'
describe('isTerminalAttempt', () => {
@@ -154,3 +156,69 @@ describe('renderDuesEmail', () => {
expect(r.text).toContain('Hi,')
})
})
describe('bookingDedupeKey', () => {
it('keys by type + reservation id; per-reservation per-event', () => {
expect(bookingDedupeKey('booking_confirmed', 'r1')).toBe('booking_confirmed:r1')
expect(bookingDedupeKey('booking_cancelled', 'r1')).toBe('booking_cancelled:r1')
expect(bookingDedupeKey('booking_confirmed', 'r1')).not.toBe(
bookingDedupeKey('booking_confirmed', 'r2'),
)
})
})
describe('renderBookingEmail', () => {
const base = {
spaceName: 'Acme',
memberName: 'Ada',
equipmentName: 'Laser Cutter',
location: 'Bay 3',
startsAt: '2026-05-22T15:00:00Z',
endsAt: '2026-05-22T17:00:00Z',
manageUrl: 'https://x.test/me',
} as const
it('confirmed: subject names equipment + space; body has range, location, link', () => {
const r = renderBookingEmail({ type: 'booking_confirmed', ...base })
expect(r.subject).toBe('Reservation confirmed: Laser Cutter at Acme')
expect(r.text).toContain('Hi Ada,')
expect(r.text).toContain('Laser Cutter at Acme')
expect(r.text).toContain('Bay 3')
expect(r.text).toContain('https://x.test/me')
expect(r.html).toContain('<a href="https://x.test/me"')
})
it('cancelled: subject + body acknowledge the cancel', () => {
const r = renderBookingEmail({ type: 'booking_cancelled', ...base })
expect(r.subject).toBe('Reservation cancelled: Laser Cutter at Acme')
expect(r.text.toLowerCase()).toContain('was cancelled')
})
it('omits the location line when not provided', () => {
const r = renderBookingEmail({ ...base, type: 'booking_confirmed', location: null })
expect(r.text).not.toContain('Location:')
})
it('falls back to a generic greeting + equipment label when names missing', () => {
const r = renderBookingEmail({
...base,
type: 'booking_confirmed',
memberName: '',
equipmentName: '',
})
expect(r.text).toContain('Hi,')
expect(r.subject).toBe('Reservation confirmed: the equipment at Acme')
})
it('escapes HTML in injected names', () => {
const r = renderBookingEmail({
...base,
type: 'booking_confirmed',
memberName: 'a<b>',
equipmentName: '<img>',
})
expect(r.html).not.toContain('<img>')
expect(r.html).toContain('&lt;img&gt;')
expect(r.html).toContain('a&lt;b&gt;')
})
})
+74 -2
View File
@@ -20,6 +20,13 @@ import {
} from '@/lib/validations'
import { reservationEligibility, hasConflict } from '@/lib/equipment-logic'
import { isCertificationActive } from '@/lib/certifications-logic'
import { renderBookingEmail, bookingDedupeKey } from '@/lib/notifications-logic'
import {
enqueueNotification,
resolveMemberContact,
getSpaceName,
buildManageUrl,
} from '@/lib/notifications/enqueue'
type Gate =
| { ok: true; supabase: ServerSupabase; member: Member }
@@ -243,7 +250,7 @@ export async function reserveEquipment(input: unknown) {
const { data: equip } = await supabase
.from('equipment')
.select('id, space_id, status, is_active, required_certification_id')
.select('id, name, location, space_id, status, is_active, required_certification_id')
.eq('id', r.equipmentId)
.eq('space_id', member.space_id)
.maybeSingle()
@@ -301,6 +308,34 @@ export async function reserveEquipment(input: unknown) {
}
await logActivity(supabase, member, 'reserved', 'equipment', r.equipmentId)
// Booking confirmation goes to the affected member (target), not the actor.
// A manager booking on behalf of someone else still emails the booked-for
// member. Best-effort: the enqueue helper never throws into this action.
const contact = await resolveMemberContact(admin, member.space_id, targetMemberId)
if (contact?.email) {
const { subject, html, text } = renderBookingEmail({
type: 'booking_confirmed',
spaceName: await getSpaceName(admin, member.space_id),
memberName: contact.displayName,
equipmentName: (equip.name as string | null) ?? 'equipment',
location: (equip.location as string | null) ?? null,
startsAt: r.starts_at,
endsAt: r.ends_at,
manageUrl: buildManageUrl(null),
})
await enqueueNotification(admin, {
spaceId: member.space_id,
memberId: targetMemberId,
type: 'booking_confirmed',
recipient: contact.email,
subject,
bodyHtml: html,
bodyText: text,
dedupeKey: bookingDedupeKey('booking_confirmed', data.id as string),
})
}
revalidatePath('/equipment')
revalidatePath('/me')
return { data: { id: data.id as string } }
@@ -318,7 +353,7 @@ export async function cancelReservation(input: unknown) {
const admin = createAdminClient()
const { data: res } = await admin
.from('equipment_reservations')
.select('id, space_id, member_id, status, equipment_id')
.select('id, space_id, member_id, status, equipment_id, starts_at, ends_at')
.eq('id', v.data.reservationId)
.maybeSingle()
if (!res || res.space_id !== member.space_id) return { error: 'Reservation not found' }
@@ -343,6 +378,43 @@ export async function cancelReservation(input: unknown) {
if (error) return { error: error.message }
await logActivity(supabase, member, 'cancelled_reservation', 'equipment', res.equipment_id as string)
// Cancel emails fire ONLY when someone other than the affected member
// cancelled (a manager cancelling on the member's behalf). A self-cancel is
// silent: the actor already saw the UI confirm and an email would be noise.
if (!ownIt) {
const affectedMemberId = res.member_id as string
const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId)
if (contact?.email) {
const { data: equip } = await admin
.from('equipment')
.select('name, location')
.eq('id', res.equipment_id as string)
.eq('space_id', member.space_id)
.maybeSingle()
const { subject, html, text } = renderBookingEmail({
type: 'booking_cancelled',
spaceName: await getSpaceName(admin, member.space_id),
memberName: contact.displayName,
equipmentName: (equip?.name as string | null) ?? 'equipment',
location: (equip?.location as string | null) ?? null,
startsAt: res.starts_at as string,
endsAt: res.ends_at as string,
manageUrl: buildManageUrl(null),
})
await enqueueNotification(admin, {
spaceId: member.space_id,
memberId: affectedMemberId,
type: 'booking_cancelled',
recipient: contact.email,
subject,
bodyHtml: html,
bodyText: text,
dedupeKey: bookingDedupeKey('booking_cancelled', v.data.reservationId),
})
}
}
revalidatePath('/equipment')
revalidatePath('/me')
return { data: { id: v.data.reservationId } }
+98 -14
View File
@@ -104,6 +104,50 @@ function formatDate(iso: string | null | undefined): string {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
}
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
// "Friday, May 22, 2026, 3:00 PM to 5:00 PM" if both sides parse; else best
// effort with whichever side is available. Used by booking + class renders.
function formatRange(
startIso: string | null | undefined,
endIso: string | null | undefined,
): string {
const start = formatDateTime(startIso)
const end = formatDateTime(endIso)
if (start && end) return `${start} to ${end}`
return start || end || ''
}
function renderShell(lines: string[]): { html: string; text: string } {
const text = lines.join('\n').trim() + '\n'
const html =
`<div style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:14px;line-height:1.6;color:#111">` +
lines
.map(l => {
if (l === '') return '<br/>'
const safe = escapeHtml(l)
const linked = safe.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1" style="color:#2563eb">$1</a>',
)
return `<p style="margin:0 0 8px">${linked}</p>`
})
.join('') +
`</div>`
return { html, text }
}
export type DuesEmailInput = {
type: DuesNotificationType
spaceName: string
@@ -154,21 +198,61 @@ export function renderDuesEmail(input: DuesEmailInput): RenderedEmail {
)
}
const text = lines.join('\n').trim() + '\n'
const html =
`<div style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:14px;line-height:1.6;color:#111">` +
lines
.map(l => {
if (l === '') return '<br/>'
const safe = escapeHtml(l)
const linked = safe.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1" style="color:#2563eb">$1</a>',
const { html, text } = renderShell(lines)
return { subject, html, text }
}
// ─── Booking (equipment reservation) ─────────────────────────────────────────
export type BookingNotificationType = 'booking_confirmed' | 'booking_cancelled'
// One row per reservation per event. A cancel-then-re-reserve produces a new
// reservation_id, so confirmation + cancel of the same reservation each fire
// once and never collide.
export function bookingDedupeKey(
type: BookingNotificationType,
reservationId: string,
): string {
return notificationDedupeKey([type, reservationId])
}
export type BookingEmailInput = {
type: BookingNotificationType
spaceName: string
memberName?: string | null
equipmentName: string
location?: string | null
startsAt: string
endsAt: string
manageUrl: string
}
export function renderBookingEmail(input: BookingEmailInput): RenderedEmail {
const space = input.spaceName || 'your hackerspace'
const name = input.memberName?.trim() || null
const greeting = name ? `Hi ${name},` : 'Hi,'
const equipment = input.equipmentName || 'the equipment'
const range = formatRange(input.startsAt, input.endsAt)
const location = (input.location || '').trim()
let subject: string
const lines: string[] = [greeting, '']
if (input.type === 'booking_confirmed') {
subject = `Reservation confirmed: ${equipment} at ${space}`
lines.push(
`Your reservation for ${equipment} at ${space} is confirmed${range ? ` for ${range}` : ''}.`,
)
if (location) lines.push(`Location: ${location}.`)
lines.push('', `View your reservations: ${input.manageUrl}`)
} else {
subject = `Reservation cancelled: ${equipment} at ${space}`
lines.push(
`Your reservation for ${equipment} at ${space}${range ? ` for ${range}` : ''} was cancelled.`,
)
return `<p style="margin:0 0 8px">${linked}</p>`
})
.join('') +
`</div>`
lines.push('', `View your reservations: ${input.manageUrl}`)
}
const { html, text } = renderShell(lines)
return { subject, html, text }
}