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:
@@ -8,6 +8,8 @@ import {
|
|||||||
renderDuesEmail,
|
renderDuesEmail,
|
||||||
bookingDedupeKey,
|
bookingDedupeKey,
|
||||||
renderBookingEmail,
|
renderBookingEmail,
|
||||||
|
classDedupeKey,
|
||||||
|
renderClassEmail,
|
||||||
} from '@/lib/notifications-logic'
|
} from '@/lib/notifications-logic'
|
||||||
|
|
||||||
describe('isTerminalAttempt', () => {
|
describe('isTerminalAttempt', () => {
|
||||||
@@ -222,3 +224,88 @@ describe('renderBookingEmail', () => {
|
|||||||
expect(r.html).toContain('a<b>')
|
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('<b>x</b>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+154
-2
@@ -27,6 +27,13 @@ import {
|
|||||||
signupFormEligibility,
|
signupFormEligibility,
|
||||||
} from '@/lib/classes-logic'
|
} from '@/lib/classes-logic'
|
||||||
import { grantCertification } from './certifications'
|
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
|
// A class manager need not hold forms.manage, so form lookups + the
|
||||||
// submission gate use the service client (scoped by space_id) and never
|
// 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 }
|
if (error) return { error: error.message }
|
||||||
|
|
||||||
await logActivity(supabase, member, 'updated', 'class_session', u.sessionId)
|
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')
|
revalidatePath('/classes')
|
||||||
return { data: { id: u.sessionId } }
|
return { data: { id: u.sessionId } }
|
||||||
}
|
}
|
||||||
@@ -457,13 +525,13 @@ export async function signUpForClass(input: unknown) {
|
|||||||
|
|
||||||
const { data: session } = await supabase
|
const { data: session } = await supabase
|
||||||
.from('class_sessions')
|
.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('id', v.data.sessionId)
|
||||||
.eq('space_id', member.space_id)
|
.eq('space_id', member.space_id)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
if (!session) return { error: 'Session not found' }
|
if (!session) return { error: 'Session not found' }
|
||||||
const cls = (session as {
|
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
|
}).classes
|
||||||
if (cls && cls.is_active === false) return { error: 'This class is no longer available.' }
|
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
|
const status = row.signup_status as string
|
||||||
|
|
||||||
await logActivity(supabase, member, 'signed_up', 'class_session', v.data.sessionId)
|
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('/classes')
|
||||||
revalidatePath('/me')
|
revalidatePath('/me')
|
||||||
return { data: { id: row.signup_id as string, status } }
|
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)
|
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('/classes')
|
||||||
revalidatePath('/me')
|
revalidatePath('/me')
|
||||||
return { data: { id: row.cancelled_id as string } }
|
return { data: { id: row.cancelled_id as string } }
|
||||||
|
|||||||
@@ -202,6 +202,82 @@ export function renderDuesEmail(input: DuesEmailInput): RenderedEmail {
|
|||||||
return { subject, html, text }
|
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) ─────────────────────────────────────────
|
// ─── Booking (equipment reservation) ─────────────────────────────────────────
|
||||||
|
|
||||||
export type BookingNotificationType = 'booking_confirmed' | 'booking_cancelled'
|
export type BookingNotificationType = 'booking_confirmed' | 'booking_cancelled'
|
||||||
|
|||||||
Reference in New Issue
Block a user