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
+154 -2
View File
@@ -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 } }