feat(certifications): server actions + page guard
certifications.ts: create/update/delete/list cert types (gated by certifications.manage; delete refused once granted, archive instead), and grant/revoke/renew/listMemberCertifications/getMyCertifications (gated by certifications.grant, the Instructor capability). Grant verifies cert and target member are in-space and the cert is active, computes expiry from validity_months unless overridden, and relies on the partial unique index for one active grant per member+cert. Revoke is soft; nothing is ever deleted. getMyCertifications takes no params (member resolved from session; RLS restricts to own rows). certifications-guard.ts mirrors the forms guard for the admin pages. Barrel re-export and API_REFERENCE updated.
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import {
|
||||
requireMember,
|
||||
logActivity,
|
||||
parseInput,
|
||||
type Member,
|
||||
type ServerSupabase,
|
||||
} from '@/lib/auth-helpers'
|
||||
import {
|
||||
createCertificationSchema,
|
||||
updateCertificationSchema,
|
||||
certificationIdSchema,
|
||||
grantCertificationSchema,
|
||||
revokeCertificationSchema,
|
||||
renewCertificationSchema,
|
||||
listMemberCertificationsSchema,
|
||||
} from '@/lib/validations'
|
||||
import { computeExpiry } from '@/lib/certifications-logic'
|
||||
|
||||
type Gate =
|
||||
| { ok: true; supabase: ServerSupabase; member: Member }
|
||||
| { ok: false; error: string }
|
||||
|
||||
// Permission gate. Errors here are advisory UX; the RLS on certifications /
|
||||
// member_certifications independently enforces the same permission, so a
|
||||
// bypass cannot write.
|
||||
async function requirePermission(perm: 'certifications.manage' | 'certifications.grant'): Promise<Gate> {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { ok: false, error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data: allowed, error } = await supabase.rpc('user_has_permission', {
|
||||
uid: member.user_id as string,
|
||||
sid: member.space_id,
|
||||
perm,
|
||||
})
|
||||
if (error) return { ok: false, error: error.message }
|
||||
if (!allowed) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
perm === 'certifications.manage'
|
||||
? 'You do not have permission to manage certification types'
|
||||
: 'You do not have permission to award or revoke certifications',
|
||||
}
|
||||
}
|
||||
return { ok: true, supabase, member }
|
||||
}
|
||||
|
||||
function isUniqueViolation(message: string): boolean {
|
||||
return /duplicate key value|already exists|unique constraint/i.test(message)
|
||||
}
|
||||
|
||||
// ─── Certification types (certifications.manage) ─────────────────────────────
|
||||
|
||||
export async function createCertification(input: unknown) {
|
||||
const gate = await requirePermission('certifications.manage')
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(createCertificationSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
const c = v.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('certifications')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
name: c.name,
|
||||
description: c.description ?? null,
|
||||
validity_months: c.validity_months ?? null,
|
||||
created_by: member.id,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (isUniqueViolation(error.message)) {
|
||||
return { error: 'A certification with that name already exists in this space.' }
|
||||
}
|
||||
return { error: error.message }
|
||||
}
|
||||
|
||||
await logActivity(supabase, member, 'created', 'certification', data.id as string, c.name)
|
||||
revalidatePath('/certifications')
|
||||
return { data: { id: data.id as string } }
|
||||
}
|
||||
|
||||
export async function updateCertification(input: unknown) {
|
||||
const gate = await requirePermission('certifications.manage')
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(updateCertificationSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
const u = v.data
|
||||
|
||||
const patch: Record<string, unknown> = {}
|
||||
if (u.name !== undefined) patch.name = u.name
|
||||
if (u.description !== undefined) patch.description = u.description ?? null
|
||||
if (u.validity_months !== undefined) patch.validity_months = u.validity_months ?? null
|
||||
if (u.is_active !== undefined) patch.is_active = u.is_active
|
||||
if (Object.keys(patch).length === 0) return { data: { id: u.certificationId } }
|
||||
|
||||
const { error } = await supabase
|
||||
.from('certifications')
|
||||
.update(patch)
|
||||
.eq('id', u.certificationId)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) {
|
||||
if (isUniqueViolation(error.message)) {
|
||||
return { error: 'A certification with that name already exists in this space.' }
|
||||
}
|
||||
return { error: error.message }
|
||||
}
|
||||
|
||||
await logActivity(supabase, member, 'updated', 'certification', u.certificationId)
|
||||
revalidatePath('/certifications')
|
||||
return { data: { id: u.certificationId } }
|
||||
}
|
||||
|
||||
export async function deleteCertification(input: unknown) {
|
||||
const gate = await requirePermission('certifications.manage')
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(certificationIdSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
// A cert that has ever been granted is part of members' records; archive it
|
||||
// (is_active=false) instead of destroying history.
|
||||
const { count } = await supabase
|
||||
.from('member_certifications')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('certification_id', v.data.certificationId)
|
||||
if ((count ?? 0) > 0) {
|
||||
return { error: 'This certification has been granted to members. Archive it instead of deleting.' }
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('certifications')
|
||||
.delete()
|
||||
.eq('id', v.data.certificationId)
|
||||
.eq('space_id', member.space_id)
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'deleted', 'certification', v.data.certificationId)
|
||||
revalidatePath('/certifications')
|
||||
return { data: { id: v.data.certificationId } }
|
||||
}
|
||||
|
||||
// Readable by anyone who can manage OR grant (the admin list and the per-member
|
||||
// award panel both need the catalog). RLS already scopes to the caller's space.
|
||||
export async function listCertifications() {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data: allowed } = await supabase.rpc('user_has_permission', {
|
||||
uid: member.user_id as string,
|
||||
sid: member.space_id,
|
||||
perm: 'certifications.manage',
|
||||
})
|
||||
let permitted = !!allowed
|
||||
if (!permitted) {
|
||||
const { data: canGrant } = await supabase.rpc('user_has_permission', {
|
||||
uid: member.user_id as string,
|
||||
sid: member.space_id,
|
||||
perm: 'certifications.grant',
|
||||
})
|
||||
permitted = !!canGrant
|
||||
}
|
||||
if (!permitted) return { error: 'You do not have permission to view certifications' }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('certifications')
|
||||
.select('id, name, description, validity_months, is_active, created_at, updated_at')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('is_active', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
if (error) return { error: error.message }
|
||||
return { data: data ?? [] }
|
||||
}
|
||||
|
||||
// ─── Grants (certifications.grant — the Instructor capability) ───────────────
|
||||
|
||||
export async function grantCertification(input: unknown) {
|
||||
const gate = await requirePermission('certifications.grant')
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(grantCertificationSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
const g = v.data
|
||||
|
||||
// Cert must exist, be in this space, and be active.
|
||||
const { data: cert, error: certErr } = await supabase
|
||||
.from('certifications')
|
||||
.select('id, name, validity_months, is_active, space_id')
|
||||
.eq('id', g.certificationId)
|
||||
.eq('space_id', member.space_id)
|
||||
.single()
|
||||
if (certErr || !cert) return { error: 'Certification not found' }
|
||||
if (!cert.is_active) return { error: 'That certification is archived and cannot be granted.' }
|
||||
|
||||
// Target member must be in the same space.
|
||||
const { data: target, error: targetErr } = await supabase
|
||||
.from('space_members')
|
||||
.select('id, space_id, display_name')
|
||||
.eq('id', g.memberId)
|
||||
.eq('space_id', member.space_id)
|
||||
.single()
|
||||
if (targetErr || !target) return { error: 'Member not found in this space' }
|
||||
|
||||
const grantedAt = new Date().toISOString()
|
||||
const expiresAt = g.expires_at ?? computeExpiry(grantedAt, cert.validity_months)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('member_certifications')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
member_id: g.memberId,
|
||||
certification_id: g.certificationId,
|
||||
granted_by: member.id,
|
||||
granted_at: grantedAt,
|
||||
expires_at: expiresAt,
|
||||
note: g.note ?? null,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (isUniqueViolation(error.message)) {
|
||||
return { error: 'This member already holds an active grant of that certification.' }
|
||||
}
|
||||
return { error: error.message }
|
||||
}
|
||||
|
||||
await logActivity(supabase, member, 'granted', 'certification', g.certificationId, cert.name)
|
||||
revalidatePath('/members')
|
||||
revalidatePath('/me')
|
||||
return { data: { id: data.id as string } }
|
||||
}
|
||||
|
||||
export async function revokeCertification(input: unknown) {
|
||||
const gate = await requirePermission('certifications.grant')
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(revokeCertificationSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const { data: grant, error: loadErr } = await supabase
|
||||
.from('member_certifications')
|
||||
.select('id, space_id, revoked_at, certification_id')
|
||||
.eq('id', v.data.memberCertificationId)
|
||||
.eq('space_id', member.space_id)
|
||||
.single()
|
||||
if (loadErr || !grant) return { error: 'Grant not found' }
|
||||
if (grant.revoked_at) return { error: 'That certification is already revoked.' }
|
||||
|
||||
const { error } = await supabase
|
||||
.from('member_certifications')
|
||||
.update({
|
||||
revoked_at: new Date().toISOString(),
|
||||
revoked_by: member.id,
|
||||
revoked_reason: v.data.reason ?? null,
|
||||
})
|
||||
.eq('id', v.data.memberCertificationId)
|
||||
.eq('space_id', member.space_id)
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'revoked', 'certification', grant.certification_id as string)
|
||||
revalidatePath('/members')
|
||||
revalidatePath('/me')
|
||||
return { data: { id: v.data.memberCertificationId } }
|
||||
}
|
||||
|
||||
// Renew a still-valid or expired (but not revoked) grant: reset granted_at to
|
||||
// now and recompute expiry from the cert's current validity. A revoked grant
|
||||
// is terminal — issue a fresh grant instead.
|
||||
export async function renewCertification(input: unknown) {
|
||||
const gate = await requirePermission('certifications.grant')
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(renewCertificationSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const { data: grant, error: loadErr } = await supabase
|
||||
.from('member_certifications')
|
||||
.select('id, space_id, revoked_at, certification_id, note')
|
||||
.eq('id', v.data.memberCertificationId)
|
||||
.eq('space_id', member.space_id)
|
||||
.single()
|
||||
if (loadErr || !grant) return { error: 'Grant not found' }
|
||||
if (grant.revoked_at) return { error: 'That certification is revoked. Grant a new one instead.' }
|
||||
|
||||
const { data: cert, error: certErr } = await supabase
|
||||
.from('certifications')
|
||||
.select('validity_months, name')
|
||||
.eq('id', grant.certification_id as string)
|
||||
.eq('space_id', member.space_id)
|
||||
.single()
|
||||
if (certErr || !cert) return { error: 'Certification not found' }
|
||||
|
||||
const grantedAt = new Date().toISOString()
|
||||
const { error } = await supabase
|
||||
.from('member_certifications')
|
||||
.update({
|
||||
granted_at: grantedAt,
|
||||
expires_at: computeExpiry(grantedAt, cert.validity_months),
|
||||
granted_by: member.id,
|
||||
note: v.data.note ?? grant.note ?? null,
|
||||
})
|
||||
.eq('id', v.data.memberCertificationId)
|
||||
.eq('space_id', member.space_id)
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'renewed', 'certification', grant.certification_id as string, cert.name)
|
||||
revalidatePath('/members')
|
||||
revalidatePath('/me')
|
||||
return { data: { id: v.data.memberCertificationId } }
|
||||
}
|
||||
|
||||
// All grants for one member (the per-member award panel). Manager or granter.
|
||||
export async function listMemberCertifications(input: unknown) {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const v = parseInput(listMemberCertificationsSchema, 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: 'certifications.manage',
|
||||
})
|
||||
let permitted = !!canManage
|
||||
if (!permitted) {
|
||||
const { data: canGrant } = await supabase.rpc('user_has_permission', {
|
||||
uid: member.user_id as string,
|
||||
sid: member.space_id,
|
||||
perm: 'certifications.grant',
|
||||
})
|
||||
permitted = !!canGrant
|
||||
}
|
||||
if (!permitted) return { error: 'You do not have permission to view member certifications' }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('member_certifications')
|
||||
.select(
|
||||
'id, member_id, certification_id, granted_at, expires_at, revoked_at, revoked_reason, note, certifications(name, validity_months, is_active)',
|
||||
)
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('member_id', v.data.memberId)
|
||||
.order('granted_at', { ascending: false })
|
||||
if (error) return { error: error.message }
|
||||
return { data: data ?? [] }
|
||||
}
|
||||
|
||||
// The signed-in member's own certifications (the /me view). No params: the
|
||||
// member is resolved from the session, and RLS independently allows a member
|
||||
// to read only their own grants.
|
||||
export async function getMyCertifications() {
|
||||
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('member_certifications')
|
||||
.select(
|
||||
'id, certification_id, granted_at, expires_at, revoked_at, revoked_reason, note, certifications(name, description, validity_months)',
|
||||
)
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('member_id', member.id)
|
||||
.order('granted_at', { ascending: false })
|
||||
if (error) return { error: error.message }
|
||||
return { data: data ?? [] }
|
||||
}
|
||||
@@ -47,3 +47,6 @@ export * from './permissions'
|
||||
|
||||
// Custom forms and waivers
|
||||
export * from './forms'
|
||||
|
||||
// Certifications + Instructor capability
|
||||
export * from './certifications'
|
||||
|
||||
Reference in New Issue
Block a user