Regenerate types/database.ts from a local Supabase built from the canonical scripts/schema.sql. The running dev DB was stale (missing the 'stripe' payment_platform value, members_with_permission, spaces.host_requires_card, payments.link_status, dues_payment_methods.space_id), so it was rebuilt from schema.sql first. With real relationship metadata the 11 `as unknown as … // TODO(types)` join casts are no longer needed: - me portal, session-attendance, member-certifications, equipment-reservations: narrow the action result with `'data' in res` instead of a blanket cast. - stripe / door embeds read the now-typed relation directly. - completeSession + listEquipmentReservations route the embedded-select query through the fully-typed createClient() rather than the wide ServerSupabase gate client (the pattern documented in auth-helpers.ts); equipment disambiguates its two space_members FKs with an explicit !fkey hint. Collapse the hand-maintained governance rows/enums in types/domain/governance.ts into Tables<>/Enums<> aliases. Tighten duesPaymentMethodSchema.platform to the DUES_LINK_PLATFORMS literal union (the old `as unknown as [string,...]` erased it), which the now-correct payment_platform enum requires. The hand-added class_signup_tx / class_cancel_tx RPC entries are now generated natively. typecheck 0 · lint 0 errors · 645 tests · build ok
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { createAdminClient } from '@/lib/supabase/admin'
|
|
import {
|
|
requireMember,
|
|
logActivity,
|
|
parseInput,
|
|
} from '@/lib/auth-helpers'
|
|
import {
|
|
createSessionSchema,
|
|
updateSessionSchema,
|
|
sessionIdSchema,
|
|
} from '@/lib/validations'
|
|
import { requirePermission } from './_guard'
|
|
import { renderClassEmail, classDedupeKey } from '@/lib/notifications-logic'
|
|
import {
|
|
enqueueNotification,
|
|
resolveMemberContact,
|
|
getSpaceName,
|
|
buildManageUrl,
|
|
} from '@/lib/notifications/enqueue'
|
|
|
|
// ─── Sessions (classes.manage) ───────────────────────────────────────────────
|
|
|
|
export async function createSession(input: unknown) {
|
|
const gate = await requirePermission('classes.manage')
|
|
if (!gate.ok) return { error: gate.error }
|
|
const { supabase, member } = gate
|
|
|
|
const v = parseInput(createSessionSchema, input)
|
|
if (!v.ok) return { error: v.error }
|
|
const s = v.data
|
|
|
|
const { data: cls } = await supabase
|
|
.from('classes')
|
|
.select('id')
|
|
.eq('id', s.classId)
|
|
.eq('space_id', member.space_id)
|
|
.maybeSingle()
|
|
if (!cls) return { error: 'Class not found in this space' }
|
|
|
|
const { data, error } = await supabase
|
|
.from('class_sessions')
|
|
.insert({
|
|
class_id: s.classId,
|
|
space_id: member.space_id,
|
|
starts_at: s.starts_at,
|
|
ends_at: s.ends_at ?? null,
|
|
location: s.location ?? null,
|
|
capacity: s.capacity ?? null,
|
|
notes: s.notes ?? null,
|
|
})
|
|
.select('id')
|
|
.single()
|
|
if (error) return { error: error.message }
|
|
|
|
await logActivity(supabase, member, 'created', 'class_session', data.id as string)
|
|
revalidatePath('/classes')
|
|
return { data: { id: data.id as string } }
|
|
}
|
|
|
|
export async function updateSession(input: unknown) {
|
|
const gate = await requirePermission('classes.manage')
|
|
if (!gate.ok) return { error: gate.error }
|
|
const { supabase, member } = gate
|
|
|
|
const v = parseInput(updateSessionSchema, input)
|
|
if (!v.ok) return { error: v.error }
|
|
const u = v.data
|
|
|
|
// Completion runs cert-on-completion side effects; force it through
|
|
// completeSession instead of a bare status flip here.
|
|
if (u.status === 'completed') {
|
|
return { error: 'Use completeSession to complete a session.' }
|
|
}
|
|
|
|
const patch: Record<string, unknown> = {}
|
|
if (u.starts_at !== undefined) patch.starts_at = u.starts_at
|
|
if (u.ends_at !== undefined) patch.ends_at = u.ends_at ?? null
|
|
if (u.location !== undefined) patch.location = u.location ?? null
|
|
if (u.capacity !== undefined) patch.capacity = u.capacity ?? null
|
|
if (u.notes !== undefined) patch.notes = u.notes ?? null
|
|
if (u.status !== undefined) patch.status = u.status
|
|
if (Object.keys(patch).length === 0) return { data: { id: u.sessionId } }
|
|
|
|
const { error } = await supabase
|
|
.from('class_sessions')
|
|
.update(patch)
|
|
.eq('id', u.sessionId)
|
|
.eq('space_id', member.space_id)
|
|
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.
|
|
// Wrapped: the session update is committed; transient DB errors in the
|
|
// fan-out must never surface to the action's result.
|
|
if (u.status === 'cancelled') {
|
|
try {
|
|
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,
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[updateSession] class_session_cancelled fan-out failed:', e instanceof Error ? e.message : e)
|
|
}
|
|
}
|
|
|
|
revalidatePath('/classes')
|
|
return { data: { id: u.sessionId } }
|
|
}
|
|
|
|
export async function deleteSession(input: unknown) {
|
|
const gate = await requirePermission('classes.manage')
|
|
if (!gate.ok) return { error: gate.error }
|
|
const { supabase, member } = gate
|
|
|
|
const v = parseInput(sessionIdSchema, input)
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const { count } = await supabase
|
|
.from('class_signups')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('session_id', v.data.sessionId)
|
|
.neq('status', 'cancelled')
|
|
if ((count ?? 0) > 0) {
|
|
return { error: 'This session has signups. Cancel it instead of deleting.' }
|
|
}
|
|
|
|
const { error } = await supabase
|
|
.from('class_sessions')
|
|
.delete()
|
|
.eq('id', v.data.sessionId)
|
|
.eq('space_id', member.space_id)
|
|
if (error) return { error: error.message }
|
|
|
|
await logActivity(supabase, member, 'deleted', 'class_session', v.data.sessionId)
|
|
revalidatePath('/classes')
|
|
return { data: { id: v.data.sessionId } }
|
|
}
|
|
|
|
// Upcoming sessions for the member catalog/calendar. Any space member. Counts
|
|
// come from the service client so a member sees "spots left" without being
|
|
// able to read who else signed up (class_signups SELECT hides other members).
|
|
export async function listUpcomingSessions() {
|
|
const supabase = await createClient()
|
|
const auth = await requireMember(supabase)
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const nowIso = new Date().toISOString()
|
|
const { data: sessions, error } = await supabase
|
|
.from('class_sessions')
|
|
.select('id, class_id, starts_at, ends_at, location, capacity, status, notes, classes(title, description, payment_link, capacity, is_active, grants_certification_id, required_form_id)')
|
|
.eq('space_id', member.space_id)
|
|
.neq('status', 'cancelled')
|
|
.gte('starts_at', nowIso)
|
|
.order('starts_at', { ascending: true })
|
|
if (error) return { error: error.message }
|
|
|
|
const rows = (sessions ?? []).filter(
|
|
(s) => s.classes?.is_active !== false,
|
|
)
|
|
const ids = rows.map((s: { id: string }) => s.id)
|
|
|
|
const counts: Record<string, number> = {}
|
|
const mine: Record<string, string> = {}
|
|
// required_form_id -> { slug, title } and whether THIS member has it on file.
|
|
const formMeta: Record<string, { slug: string; title: string }> = {}
|
|
const mySatisfiedForms = new Set<string>()
|
|
let spaceSlug: string | null = null
|
|
if (ids.length > 0) {
|
|
const admin = createAdminClient()
|
|
const { data: agg } = await admin
|
|
.from('class_signups')
|
|
.select('session_id, status')
|
|
.in('session_id', ids)
|
|
for (const r of agg ?? []) {
|
|
if ((r as { status: string }).status === 'registered') {
|
|
const sid = (r as { session_id: string }).session_id
|
|
counts[sid] = (counts[sid] ?? 0) + 1
|
|
}
|
|
}
|
|
const { data: my } = await supabase
|
|
.from('class_signups')
|
|
.select('session_id, status')
|
|
.in('session_id', ids)
|
|
.eq('member_id', member.id)
|
|
.neq('status', 'cancelled')
|
|
for (const r of my ?? []) {
|
|
mine[(r as { session_id: string }).session_id] = (r as { status: string }).status
|
|
}
|
|
|
|
const formIds = Array.from(
|
|
new Set(
|
|
rows
|
|
.map((s: { classes?: { required_form_id?: string | null } | null }) => s.classes?.required_form_id)
|
|
.filter((x): x is string => !!x),
|
|
),
|
|
)
|
|
if (formIds.length > 0) {
|
|
const { data: forms } = await admin
|
|
.from('forms')
|
|
.select('id, slug, title')
|
|
.in('id', formIds)
|
|
for (const f of forms ?? []) {
|
|
formMeta[f.id as string] = { slug: f.slug as string, title: f.title as string }
|
|
}
|
|
const { data: subs } = await admin
|
|
.from('form_submissions')
|
|
.select('form_id')
|
|
.eq('space_id', member.space_id)
|
|
.eq('member_id', member.id)
|
|
.in('form_id', formIds)
|
|
for (const sub of subs ?? []) mySatisfiedForms.add(sub.form_id as string)
|
|
const { data: space } = await admin
|
|
.from('spaces')
|
|
.select('slug')
|
|
.eq('id', member.space_id)
|
|
.maybeSingle()
|
|
spaceSlug = (space?.slug as string | undefined) ?? null
|
|
}
|
|
}
|
|
|
|
return {
|
|
data: rows.map((s: Record<string, unknown>) => {
|
|
const cls = s.classes as { required_form_id?: string | null } | null
|
|
const rfid = cls?.required_form_id ?? null
|
|
const meta = rfid ? formMeta[rfid] ?? null : null
|
|
return {
|
|
...s,
|
|
registered_count: counts[s.id as string] ?? 0,
|
|
my_status: mine[s.id as string] ?? null,
|
|
required_form:
|
|
rfid && meta
|
|
? {
|
|
title: meta.title,
|
|
url: spaceSlug ? `/f/${spaceSlug}/${meta.slug}` : null,
|
|
satisfied: mySatisfiedForms.has(rfid),
|
|
}
|
|
: null,
|
|
}
|
|
}),
|
|
}
|
|
}
|