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
+87
View File
@@ -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: '<b>x</b>',
})
expect(r.html).not.toContain('<b>x</b>')
expect(r.html).toContain('&lt;b&gt;x&lt;/b&gt;')
})
})