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'