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
167 lines
5.7 KiB
TypeScript
167 lines
5.7 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 {
|
|
sessionIdSchema,
|
|
markAttendanceSchema,
|
|
listSessionSignupsSchema,
|
|
} from '@/lib/validations'
|
|
import { requirePermission } from './_guard'
|
|
import { grantCertification } from '../certifications'
|
|
|
|
// ─── Instructor: attendees, attendance, completion ───────────────────────────
|
|
|
|
export async function listSessionSignups(input: unknown) {
|
|
const supabase = await createClient()
|
|
const auth = await requireMember(supabase)
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const v = parseInput(listSessionSignupsSchema, input)
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const { data: canManage } = await supabase.rpc('user_has_permission', {
|
|
uid: member.user_id as string,
|
|
sid: member.space_id,
|
|
perm: 'classes.manage',
|
|
})
|
|
let permitted = !!canManage
|
|
if (!permitted) {
|
|
const { data: canInstruct } = await supabase.rpc('user_has_permission', {
|
|
uid: member.user_id as string,
|
|
sid: member.space_id,
|
|
perm: 'classes.instruct',
|
|
})
|
|
permitted = !!canInstruct
|
|
}
|
|
if (!permitted) return { error: 'You do not have permission to view attendees' }
|
|
|
|
const { data, error } = await supabase
|
|
.from('class_signups')
|
|
.select('id, member_id, status, attended, signed_up_at, space_members(display_name, email)')
|
|
.eq('space_id', member.space_id)
|
|
.eq('session_id', v.data.sessionId)
|
|
.order('signed_up_at', { ascending: true })
|
|
if (error) return { error: error.message }
|
|
return { data: data ?? [] }
|
|
}
|
|
|
|
export async function markAttendance(input: unknown) {
|
|
const gate = await requirePermission('classes.instruct')
|
|
if (!gate.ok) return { error: gate.error }
|
|
const { supabase, member } = gate
|
|
|
|
const v = parseInput(markAttendanceSchema, input)
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const { error } = await supabase
|
|
.from('class_signups')
|
|
.update({ attended: v.data.attended })
|
|
.eq('id', v.data.signupId)
|
|
.eq('space_id', member.space_id)
|
|
if (error) return { error: error.message }
|
|
|
|
revalidatePath('/classes')
|
|
return { data: { id: v.data.signupId } }
|
|
}
|
|
|
|
// Complete a session: mark it completed and, if the class grants a
|
|
// certification, award it to every attended member. The cert award goes
|
|
// through the normal certifications path, so it only happens if the acting
|
|
// instructor also holds certifications.grant; otherwise completion still
|
|
// succeeds and the response says certificates were not issued.
|
|
export async function completeSession(input: unknown) {
|
|
const gate = await requirePermission('classes.instruct')
|
|
if (!gate.ok) return { error: gate.error }
|
|
const { member } = gate
|
|
const supabase = await createClient()
|
|
|
|
const v = parseInput(sessionIdSchema, input)
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const { data: session } = await supabase
|
|
.from('class_sessions')
|
|
.select('id, space_id, status, class_id, classes(title, grants_certification_id)')
|
|
.eq('id', v.data.sessionId)
|
|
.eq('space_id', member.space_id)
|
|
.maybeSingle()
|
|
if (!session) return { error: 'Session not found' }
|
|
if (session.status === 'completed') return { error: 'This session is already completed.' }
|
|
|
|
// The class_sessions UPDATE policy is classes.manage; completing is an
|
|
// instructor action, so this validated transition is written with the
|
|
// service client (same funnel pattern as member signup).
|
|
const admin = createAdminClient()
|
|
const { error: upErr } = await admin
|
|
.from('class_sessions')
|
|
.update({ status: 'completed' })
|
|
.eq('id', v.data.sessionId)
|
|
.eq('space_id', member.space_id)
|
|
if (upErr) return { error: upErr.message }
|
|
|
|
const cls = session.classes
|
|
let issued = 0
|
|
let certSkipped = false
|
|
if (cls?.grants_certification_id) {
|
|
const { data: attended } = await supabase
|
|
.from('class_signups')
|
|
.select('member_id')
|
|
.eq('space_id', member.space_id)
|
|
.eq('session_id', v.data.sessionId)
|
|
.eq('attended', true)
|
|
.neq('status', 'cancelled')
|
|
for (const a of attended ?? []) {
|
|
const res = await grantCertification({
|
|
memberId: (a as { member_id: string }).member_id,
|
|
certificationId: cls.grants_certification_id,
|
|
})
|
|
if ('error' in res && res.error) {
|
|
// Permission error -> instructor lacks certifications.grant; stop and
|
|
// report. "Already holds" is fine to skip silently.
|
|
if (/permission/i.test(res.error)) {
|
|
certSkipped = true
|
|
break
|
|
}
|
|
} else {
|
|
issued += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
await logActivity(supabase, member, 'completed', 'class_session', v.data.sessionId, cls?.title ?? null)
|
|
revalidatePath('/classes')
|
|
revalidatePath('/me')
|
|
return {
|
|
data: {
|
|
id: v.data.sessionId,
|
|
certificatesIssued: issued,
|
|
certificatesSkipped: certSkipped,
|
|
},
|
|
}
|
|
}
|
|
|
|
// The signed-in member's own signups (the /me view). No params; RLS
|
|
// independently restricts a member to their own rows.
|
|
export async function getMyClassSignups() {
|
|
const supabase = await createClient()
|
|
const auth = await requireMember(supabase)
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const { data, error } = await supabase
|
|
.from('class_signups')
|
|
.select('id, status, attended, signed_up_at, class_sessions(id, starts_at, ends_at, location, status, classes(title))')
|
|
.eq('space_id', member.space_id)
|
|
.eq('member_id', member.id)
|
|
.order('signed_up_at', { ascending: false })
|
|
if (error) return { error: error.message }
|
|
return { data: data ?? [] }
|
|
}
|