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.
This commit is contained in:
Moheeb Zara
2026-05-19 15:22:50 -07:00
parent 64ffa4ea63
commit 105b62f28a
3 changed files with 317 additions and 2 deletions
+76
View File
@@ -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'