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:
Moheeb Zara
2026-05-16 18:55:17 -07:00
parent ddf6717d1b
commit f23abb0050
4 changed files with 433 additions and 0 deletions
+10
View File
@@ -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).