From f23abb0050cc18978a9f1f3759067bd9c920158b Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 18:55:17 -0700 Subject: [PATCH] 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. --- docs/API_REFERENCE.md | 10 + lib/actions/certifications.ts | 389 ++++++++++++++++++++++++++++++++++ lib/actions/index.ts | 3 + lib/certifications-guard.ts | 31 +++ 4 files changed, 433 insertions(+) create mode 100644 lib/actions/certifications.ts create mode 100644 lib/certifications-guard.ts diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 285f83b..39d05c2 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -899,3 +899,13 @@ Management actions are gated by `forms.manage` (checked via the `user_has_permis Onboarding integration (Phase 4, migration 027): a `space_onboarding_steps` step can have `step_type = 'form'` with `config.form_id`. The onboarding flow renders the form/waiver and submits via `submitForm`; `finishOnboarding` treats a required form step as satisfied if a submission for that form by the member already exists (any version — re-sign is non-blocking), and fails open if the configured form is missing/unpublished so a misconfiguration cannot trap members. Retro-link (Phase 5): `claimMyAnonymousSubmissions()` is a safe, self-only, verified-email-gated action — it resolves the member/email from the caller's session (no trusted params), so a client can only ever link its own anonymous submissions, and only with a confirmed email (the locked decision). It is invoked best-effort from `joinSpace` (new membership, verified email) and `finishOnboarding`. `addMember`/`importMembers` are deliberately NOT auto-linked: those emails are admin-asserted, not verified, so auto-linking would violate the verified-email rule and risk mis-attributing a waiver; the admin uses the forms.manage `linkSubmissionsForMember` for those (the "admin manual-link" path). + +### Certifications + Instructor: `certifications.ts` (migration 030) +Cert-type actions are gated by `certifications.manage`; grant actions by `certifications.grant` (the Instructor capability). Both checked via the `user_has_permission` RPC; the `certifications` / `member_certifications` RLS independently enforces the same permission. +- `createCertification(input)` / `updateCertification(input)` / `deleteCertification(input)` — manage. Name is unique per space (case-insensitive); friendly error on collision. `validity_months` null = never expires. `deleteCertification` is refused once the cert has been granted to anyone (archive via `updateCertification {is_active:false}` instead — grant history is immutable). +- `listCertifications()` — readable by a manager OR a granter (the admin list and the per-member award panel both need the catalog); returns all cert types for the caller's space (RLS-scoped), active first. +- `grantCertification(input)` — granter. Verifies the cert is in-space and active and the target member is in-space; computes `expires_at` from the cert's `validity_months` at grant time unless an explicit `expires_at` override is supplied; the partial unique index rejects a second active grant of the same cert to the same member with a friendly message. +- `revokeCertification(input)` — granter. Soft revoke (`revoked_at`/`revoked_by`/`revoked_reason`); refused if already revoked. No row is ever deleted (no DELETE policy; history immutable). +- `renewCertification(input)` — granter. Resets `granted_at` to now and recomputes expiry from the cert's current validity; a revoked grant is terminal (issue a fresh grant instead). +- `listMemberCertifications({ memberId })` — manager or granter; all grants for one member (joined with the cert) for the per-member award panel. +- `getMyCertifications()` — the signed-in member's own grants. No params: the member is resolved from the session and RLS independently restricts a member to their own rows (the `/me` view). diff --git a/lib/actions/certifications.ts b/lib/actions/certifications.ts new file mode 100644 index 0000000..8732bab --- /dev/null +++ b/lib/actions/certifications.ts @@ -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 { + 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 = {} + 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 ?? [] } +} diff --git a/lib/actions/index.ts b/lib/actions/index.ts index ea39efc..e68c448 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -47,3 +47,6 @@ export * from './permissions' // Custom forms and waivers export * from './forms' + +// Certifications + Instructor capability +export * from './certifications' diff --git a/lib/certifications-guard.ts b/lib/certifications-guard.ts new file mode 100644 index 0000000..2447cb4 --- /dev/null +++ b/lib/certifications-guard.ts @@ -0,0 +1,31 @@ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' + +// Server-component guard for the /certifications management pages. Mirrors +// the forms guard: resolve the member, check certifications.manage via the +// same RPC the RLS uses, redirect non-managers before any data is fetched. +// The RLS independently enforces this; the redirect is UX, not the boundary. +export async function requireCertificationsManagerPage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: member } = await supabase + .from('space_members') + .select('id, space_id, role, user_id, display_name') + .eq('user_id', user.id) + .in('status', ['current', 'unverified', 'late']) + .single() + if (!member) redirect('/login') + + const { data: allowed } = await supabase.rpc('user_has_permission', { + uid: user.id, + sid: member.space_id, + perm: 'certifications.manage', + }) + if (!allowed) redirect('/dashboard') + + return { supabase, member } +}