diff --git a/__tests__/notifications-logic.test.ts b/__tests__/notifications-logic.test.ts index a89e5f2..ccdd3b1 100644 --- a/__tests__/notifications-logic.test.ts +++ b/__tests__/notifications-logic.test.ts @@ -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(' { + 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', + equipmentName: '', + }) + expect(r.html).not.toContain('') + expect(r.html).toContain('<img>') + expect(r.html).toContain('a<b>') + }) +}) diff --git a/lib/actions/equipment.ts b/lib/actions/equipment.ts index 9670d77..228e1f0 100644 --- a/lib/actions/equipment.ts +++ b/lib/actions/equipment.ts @@ -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 } } diff --git a/lib/notifications-logic.ts b/lib/notifications-logic.ts index d7b6248..3de20d0 100644 --- a/lib/notifications-logic.ts +++ b/lib/notifications-logic.ts @@ -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 = + `
` + + lines + .map(l => { + if (l === '') return '
' + const safe = escapeHtml(l) + const linked = safe.replace( + /(https?:\/\/[^\s]+)/g, + '
$1', + ) + return `

${linked}

` + }) + .join('') + + `
` + 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 = - `
` + - lines - .map(l => { - if (l === '') return '
' - const safe = escapeHtml(l) - const linked = safe.replace( - /(https?:\/\/[^\s]+)/g, - '$1', - ) - return `

${linked}

` - }) - .join('') + - `
` + 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.`, + ) + lines.push('', `View your reservations: ${input.manageUrl}`) + } + + const { html, text } = renderShell(lines) return { subject, html, text } }