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.
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
'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 ?? [] }
|
|
}
|