From 105b62f28a45a914fe579743cc25004e40f72f66 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Tue, 19 May 2026 15:22:50 -0700 Subject: [PATCH] notifications(P4c): class signup registered/waitlisted/promoted + session-cancelled signUpForClass enqueues class_signup_registered or class_signup_waitlisted based on the RPC return shape; recipient is the affected member, so a manager signing someone else up still emails the target. cancelMySignup enqueues class_signup_promoted to whoever was bumped from the waitlist into the session (the promoted_id returned by class_cancel_tx). updateSession, when status flips to 'cancelled', fans out a class_session_cancelled email to every still-active signup (one row per member, dedupe by session + member). No type for plain self-cancel: cancelMySignup is self-only and the rule is "only when someone other than the affected member cancelled," so it has no caller. Adds renderClassEmail + classDedupeKey to notifications-logic; 9 new render/dedupe unit tests cover each type's subject + body and the session-cancelled per-member dedupe shape. Suite 559, build clean. --- __tests__/notifications-logic.test.ts | 87 ++++++++++++++ lib/actions/classes.ts | 156 +++++++++++++++++++++++++- lib/notifications-logic.ts | 76 +++++++++++++ 3 files changed, 317 insertions(+), 2 deletions(-) diff --git a/__tests__/notifications-logic.test.ts b/__tests__/notifications-logic.test.ts index ccdd3b1..e3a7117 100644 --- a/__tests__/notifications-logic.test.ts +++ b/__tests__/notifications-logic.test.ts @@ -8,6 +8,8 @@ import { renderDuesEmail, bookingDedupeKey, renderBookingEmail, + classDedupeKey, + renderClassEmail, } from '@/lib/notifications-logic' describe('isTerminalAttempt', () => { @@ -222,3 +224,88 @@ describe('renderBookingEmail', () => { expect(r.html).toContain('a<b>') }) }) + +describe('classDedupeKey', () => { + it('signup-keyed events use signup_id (stable across promotion)', () => { + expect(classDedupeKey('class_signup_registered', { signupId: 's1' })).toBe( + 'class_signup_registered:s1', + ) + expect(classDedupeKey('class_signup_waitlisted', { signupId: 's1' })).toBe( + 'class_signup_waitlisted:s1', + ) + // Same signup_id, different type after a promotion: distinct dedupe. + expect(classDedupeKey('class_signup_promoted', { signupId: 's1' })).not.toBe( + classDedupeKey('class_signup_waitlisted', { signupId: 's1' }), + ) + }) + it('session-cancelled fans out by (session, member)', () => { + expect( + classDedupeKey('class_session_cancelled', { sessionId: 'sess1', memberId: 'm1' }), + ).toBe('class_session_cancelled:sess1:m1') + expect( + classDedupeKey('class_session_cancelled', { sessionId: 'sess1', memberId: 'm1' }), + ).not.toBe( + classDedupeKey('class_session_cancelled', { sessionId: 'sess1', memberId: 'm2' }), + ) + }) +}) + +describe('renderClassEmail', () => { + const base = { + spaceName: 'Acme', + memberName: 'Ada', + className: 'Intro to Welding', + location: 'Bay 1', + startsAt: '2026-05-22T15:00:00Z', + endsAt: '2026-05-22T17:00:00Z', + manageUrl: 'https://x.test/me', + } as const + + it('registered: confirmation subject + body', () => { + const r = renderClassEmail({ type: 'class_signup_registered', ...base }) + expect(r.subject).toBe('Signed up for Intro to Welding at Acme') + expect(r.text).toContain('Hi Ada,') + expect(r.text).toContain('registered for Intro to Welding at Acme') + expect(r.text).toContain('Bay 1') + expect(r.text).toContain('https://x.test/me') + }) + + it('waitlisted: subject + reassurance copy about being moved up', () => { + const r = renderClassEmail({ type: 'class_signup_waitlisted', ...base }) + expect(r.subject).toBe('Waitlisted for Intro to Welding at Acme') + expect(r.text.toLowerCase()).toContain('waitlist') + expect(r.text.toLowerCase()).toContain('if a spot opens up') + }) + + it('promoted: distinctive subject + "moved from the waitlist" copy', () => { + const r = renderClassEmail({ type: 'class_signup_promoted', ...base }) + expect(r.subject).toBe("You're in: Intro to Welding at Acme") + expect(r.text.toLowerCase()).toContain('moved from the waitlist') + }) + + it('session_cancelled: cancellation subject + message', () => { + const r = renderClassEmail({ type: 'class_session_cancelled', ...base }) + expect(r.subject).toBe('Cancelled: Intro to Welding at Acme') + expect(r.text.toLowerCase()).toContain('has been cancelled') + }) + + it('omits the location line when not provided', () => { + const r = renderClassEmail({ ...base, type: 'class_signup_registered', location: null }) + expect(r.text).not.toContain('Location:') + }) + + it('falls back to a generic class label when title is missing', () => { + const r = renderClassEmail({ ...base, type: 'class_signup_registered', className: '' }) + expect(r.subject).toBe('Signed up for the class at Acme') + }) + + it('escapes HTML in injected fields', () => { + const r = renderClassEmail({ + ...base, + type: 'class_signup_registered', + className: 'x', + }) + expect(r.html).not.toContain('x') + expect(r.html).toContain('<b>x</b>') + }) +}) diff --git a/lib/actions/classes.ts b/lib/actions/classes.ts index 29766f5..7e13363 100644 --- a/lib/actions/classes.ts +++ b/lib/actions/classes.ts @@ -27,6 +27,13 @@ import { signupFormEligibility, } from '@/lib/classes-logic' import { grantCertification } from './certifications' +import { renderClassEmail, classDedupeKey } from '@/lib/notifications-logic' +import { + enqueueNotification, + resolveMemberContact, + getSpaceName, + buildManageUrl, +} from '@/lib/notifications/enqueue' // A class manager need not hold forms.manage, so form lookups + the // submission gate use the service client (scoped by space_id) and never @@ -306,6 +313,67 @@ export async function updateSession(input: unknown) { if (error) return { error: error.message } await logActivity(supabase, member, 'updated', 'class_session', u.sessionId) + + // Session-cancellation fan-out: when an instructor/manager flips a session + // to 'cancelled', every signed-up member needs to know (the only other + // place that lives is /me, and they probably aren't checking it). One + // outbox row per (session, member); dedupe collapses a re-cancel. + if (u.status === 'cancelled') { + const admin = createAdminClient() + const { data: sessRow } = await admin + .from('class_sessions') + .select('starts_at, ends_at, location, classes(title)') + .eq('id', u.sessionId) + .eq('space_id', member.space_id) + .maybeSingle() + const sessShape = sessRow as { + starts_at: string | null + ends_at: string | null + location: string | null + classes?: { title: string | null } | null + } | null + const { data: signups } = await admin + .from('class_signups') + .select('member_id') + .eq('space_id', member.space_id) + .eq('session_id', u.sessionId) + .neq('status', 'cancelled') + const memberIds = Array.from( + new Set(((signups ?? []) as Array<{ member_id: string }>).map(s => s.member_id)), + ) + if (memberIds.length > 0) { + const spaceName = await getSpaceName(admin, member.space_id) + const manageUrl = buildManageUrl(null) + for (const affectedMemberId of memberIds) { + const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId) + if (!contact?.email) continue + const { subject, html, text } = renderClassEmail({ + type: 'class_session_cancelled', + spaceName, + memberName: contact.displayName, + className: sessShape?.classes?.title ?? '', + location: sessShape?.location ?? null, + startsAt: sessShape?.starts_at ?? null, + endsAt: sessShape?.ends_at ?? null, + manageUrl, + }) + await enqueueNotification(admin, { + spaceId: member.space_id, + memberId: affectedMemberId, + type: 'class_session_cancelled', + recipient: contact.email, + subject, + bodyHtml: html, + bodyText: text, + dedupeKey: classDedupeKey('class_session_cancelled', { + sessionId: u.sessionId, + memberId: affectedMemberId, + }), + }) + } + } + } + revalidatePath('/classes') return { data: { id: u.sessionId } } } @@ -457,13 +525,13 @@ export async function signUpForClass(input: unknown) { const { data: session } = await supabase .from('class_sessions') - .select('id, space_id, status, starts_at, ends_at, capacity, classes(capacity, is_active, required_form_id)') + .select('id, space_id, status, starts_at, ends_at, location, capacity, classes(title, capacity, is_active, required_form_id)') .eq('id', v.data.sessionId) .eq('space_id', member.space_id) .maybeSingle() if (!session) return { error: 'Session not found' } const cls = (session as { - classes?: { capacity: number | null; is_active: boolean; required_form_id: string | null } | null + classes?: { title: string | null; capacity: number | null; is_active: boolean; required_form_id: string | null } | null }).classes if (cls && cls.is_active === false) return { error: 'This class is no longer available.' } @@ -531,6 +599,43 @@ export async function signUpForClass(input: unknown) { const status = row.signup_status as string await logActivity(supabase, member, 'signed_up', 'class_session', v.data.sessionId) + + // Signup confirmation to the affected member (target, not actor): a manager + // signing someone else up still emails the booked-for member. Registered + // and waitlisted use different copy; the type derives from the RPC's + // signup_status. Best-effort: never throws into this action. + const signupType = + status === 'registered' + ? ('class_signup_registered' as const) + : status === 'waitlisted' + ? ('class_signup_waitlisted' as const) + : null + if (signupType) { + const contact = await resolveMemberContact(admin, member.space_id, targetMemberId) + if (contact?.email) { + const { subject, html, text } = renderClassEmail({ + type: signupType, + spaceName: await getSpaceName(admin, member.space_id), + memberName: contact.displayName, + className: cls?.title ?? '', + location: (session.location as string | null) ?? null, + startsAt: session.starts_at as string, + endsAt: (session.ends_at as string | null) ?? null, + manageUrl: buildManageUrl(null), + }) + await enqueueNotification(admin, { + spaceId: member.space_id, + memberId: targetMemberId, + type: signupType, + recipient: contact.email, + subject, + bodyHtml: html, + bodyText: text, + dedupeKey: classDedupeKey(signupType, { signupId: row.signup_id }), + }) + } + } + revalidatePath('/classes') revalidatePath('/me') return { data: { id: row.signup_id as string, status } } @@ -565,6 +670,53 @@ export async function cancelMySignup(input: unknown) { } await logActivity(supabase, member, 'cancelled_signup', 'class_session', v.data.sessionId) + + // Waitlist promotion: someone else was bumped from waitlist into the + // session by this cancel. Tell them, since the only place that information + // lives otherwise is /me. Best-effort: never throws into this action. + if (row.promoted_id) { + const { data: promo } = await admin + .from('class_signups') + .select( + 'id, member_id, class_sessions(starts_at, ends_at, location, classes(title))', + ) + .eq('id', row.promoted_id) + .eq('space_id', member.space_id) + .maybeSingle() + const promotedMemberId = (promo?.member_id as string | null) ?? null + const promoSession = (promo as { class_sessions?: { + starts_at: string | null + ends_at: string | null + location: string | null + classes?: { title: string | null } | null + } | null } | null)?.class_sessions + if (promotedMemberId) { + const contact = await resolveMemberContact(admin, member.space_id, promotedMemberId) + if (contact?.email) { + const { subject, html, text } = renderClassEmail({ + type: 'class_signup_promoted', + spaceName: await getSpaceName(admin, member.space_id), + memberName: contact.displayName, + className: promoSession?.classes?.title ?? '', + location: promoSession?.location ?? null, + startsAt: promoSession?.starts_at ?? null, + endsAt: promoSession?.ends_at ?? null, + manageUrl: buildManageUrl(null), + }) + await enqueueNotification(admin, { + spaceId: member.space_id, + memberId: promotedMemberId, + type: 'class_signup_promoted', + recipient: contact.email, + subject, + bodyHtml: html, + bodyText: text, + dedupeKey: classDedupeKey('class_signup_promoted', { signupId: row.promoted_id }), + }) + } + } + } + revalidatePath('/classes') revalidatePath('/me') return { data: { id: row.cancelled_id as string } } diff --git a/lib/notifications-logic.ts b/lib/notifications-logic.ts index 3de20d0..78685ea 100644 --- a/lib/notifications-logic.ts +++ b/lib/notifications-logic.ts @@ -202,6 +202,82 @@ export function renderDuesEmail(input: DuesEmailInput): RenderedEmail { return { subject, html, text } } +// ─── Class signup ──────────────────────────────────────────────────────────── + +export type ClassNotificationType = + | 'class_signup_registered' + | 'class_signup_waitlisted' + | 'class_signup_promoted' + | 'class_session_cancelled' + +// Signup-keyed events use the signup_id (which is stable across a +// waitlist→registered promotion). class_session_cancelled fans out one row +// per (session, member) so each affected member's email is independent. +export function classDedupeKey( + type: ClassNotificationType, + ids: { signupId?: string | null; sessionId?: string | null; memberId?: string | null }, +): string { + if (type === 'class_session_cancelled') { + return notificationDedupeKey([type, ids.sessionId, ids.memberId]) + } + return notificationDedupeKey([type, ids.signupId]) +} + +export type ClassEmailInput = { + type: ClassNotificationType + spaceName: string + memberName?: string | null + className: string + location?: string | null + startsAt: string | null + endsAt?: string | null + manageUrl: string +} + +export function renderClassEmail(input: ClassEmailInput): RenderedEmail { + const space = input.spaceName || 'your hackerspace' + const name = input.memberName?.trim() || null + const greeting = name ? `Hi ${name},` : 'Hi,' + const cls = input.className || 'the class' + const range = formatRange(input.startsAt, input.endsAt) + const location = (input.location || '').trim() + + let subject: string + const lines: string[] = [greeting, ''] + + if (input.type === 'class_signup_registered') { + subject = `Signed up for ${cls} at ${space}` + lines.push(`You are registered for ${cls} at ${space}${range ? ` on ${range}` : ''}.`) + if (location) lines.push(`Location: ${location}.`) + lines.push('', `View your signups: ${input.manageUrl}`) + } else if (input.type === 'class_signup_waitlisted') { + subject = `Waitlisted for ${cls} at ${space}` + lines.push( + `You are on the waitlist for ${cls} at ${space}${range ? ` on ${range}` : ''}.`, + 'You will be moved into the session by email if a spot opens up.', + ) + if (location) lines.push(`Location: ${location}.`) + lines.push('', `View your signups: ${input.manageUrl}`) + } else if (input.type === 'class_signup_promoted') { + subject = `You're in: ${cls} at ${space}` + lines.push( + `A spot opened up and you have been moved from the waitlist into ${cls} at ${space}${range ? ` on ${range}` : ''}.`, + ) + if (location) lines.push(`Location: ${location}.`) + lines.push('', `View your signups: ${input.manageUrl}`) + } else { + subject = `Cancelled: ${cls} at ${space}` + lines.push( + `The session of ${cls} at ${space}${range ? ` scheduled for ${range}` : ''} has been cancelled.`, + '', + `View your signups: ${input.manageUrl}`, + ) + } + + const { html, text } = renderShell(lines) + return { subject, html, text } +} + // ─── Booking (equipment reservation) ───────────────────────────────────────── export type BookingNotificationType = 'booking_confirmed' | 'booking_cancelled'