From c4ae5d27acd5957be9c2b7fce1b2e7124caf6406 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sun, 17 May 2026 00:09:28 -0700 Subject: [PATCH] feat(forms): delete form (confirm+cascade), delete submission, relink-all deleteForm now permanently deletes the form AND all submissions via FK cascade (the submissions-refusal guard is removed); requires an explicit confirm:true (deleteFormSchema) so an accidental call cannot destroy records, and audits the destroyed count. New deleteSubmission (forms.manage, service client since form_submissions has no client write policy, space-scoped, audited). New relinkAllSubmissions (forms.manage): re-runs email->member linking space-wide, members earliest-joined first for deterministic shared-email claim (reuses linkSubmissionsByEmail). --- lib/actions/forms.ts | 77 +++++++++++++++++++++++++++++++++++++++----- lib/validations.ts | 12 +++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/lib/actions/forms.ts b/lib/actions/forms.ts index a6cde59..fbde2d5 100644 --- a/lib/actions/forms.ts +++ b/lib/actions/forms.ts @@ -20,6 +20,8 @@ import { submitFormSchema, linkSubmissionsSchema, memberSubmissionsSchema, + deleteFormSchema, + deleteSubmissionSchema, getPublicFormSchema, } from '@/lib/validations' import { parseFormSchema, validateAnswers } from '@/lib/forms-schema' @@ -199,21 +201,18 @@ export async function deleteForm(input: unknown) { if (!gate.ok) return { error: gate.error } const { supabase, member } = gate - const v = parseInput(formIdSchema, input) + const v = parseInput(deleteFormSchema, input) if (!v.ok) return { error: v.error } - // Deleting a form cascades to form_submissions. Submissions are immutable - // waiver/response records, so refuse to delete a form that has any. Close - // it instead. + // Permanent: deleting the form FK-cascades every form_submission for it + // (including signed waivers). The destructive UI confirm + the required + // `confirm: true` are the safeguards; record how many were destroyed. const admin = createAdminClient() const { count } = await admin .from('form_submissions') .select('id', { count: 'exact', head: true }) .eq('form_id', v.data.formId) .eq('space_id', member.space_id) - if ((count ?? 0) > 0) { - return { error: 'This form has submissions and cannot be deleted. Close it instead.' } - } const { error } = await supabase .from('forms') @@ -222,11 +221,73 @@ export async function deleteForm(input: unknown) { .eq('space_id', member.space_id) if (error) return { error: error.message } - await logActivity(supabase, member, 'deleted', 'form', v.data.formId) + await logActivity( + supabase, member, 'deleted', 'form', v.data.formId, + `form + ${count ?? 0} submission(s) permanently deleted`, + ) revalidatePath('/forms') return { success: true as const } } +// Permanently delete one submission. form_submissions has no client write +// policy (immutable by default), so this goes through the service client +// after the forms.manage gate, scoped by space_id. +export async function deleteSubmission(input: unknown) { + const gate = await requireFormsManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(deleteSubmissionSchema, input) + if (!v.ok) return { error: v.error } + + const admin = createAdminClient() + const { data: row } = await admin + .from('form_submissions') + .select('id, form_id') + .eq('id', v.data.submissionId) + .eq('space_id', member.space_id) + .maybeSingle() + if (!row) return { error: 'Submission not found' } + + const { error } = await admin + .from('form_submissions') + .delete() + .eq('id', v.data.submissionId) + .eq('space_id', member.space_id) + if (error) return { error: error.message } + + await logActivity(supabase, member, 'deleted', 'form_submission', v.data.submissionId) + revalidatePath(`/forms/${row.form_id}/results`) + return { success: true as const } +} + +// Re-run email-match linking across the whole space. Members are processed +// earliest-joined first so a shared email is claimed deterministically +// (matches pickMemberForEmail / migration 039). Only fills NULL member_id. +export async function relinkAllSubmissions() { + const gate = await requireFormsManager() + if (!gate.ok) return { error: gate.error } + const { member } = gate + + const admin = createAdminClient() + const { data: members, error } = await admin + .from('space_members') + .select('id, email, joined_at') + .eq('space_id', member.space_id) + .not('email', 'is', null) + .order('joined_at', { ascending: true }) + if (error) return { error: error.message } + + let linked = 0 + for (const m of members ?? []) { + linked += await linkSubmissionsByEmail(admin, member.space_id, m.id as string, m.email as string) + } + + revalidatePath('/forms') + revalidatePath('/members') + return { data: { linked } } +} + export async function listForms() { const gate = await requireFormsManager() if (!gate.ok) return { error: gate.error } diff --git a/lib/validations.ts b/lib/validations.ts index ffbfe09..817631c 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -677,6 +677,18 @@ export const memberSubmissionsSchema = z.object({ memberId: z.string().uuid('Invalid member ID'), }) +// Deleting a form permanently removes it AND every submission (FK cascade), +// including signed waivers. `confirm` must be explicitly true so an +// accidental/stale client call cannot destroy records. +export const deleteFormSchema = z.object({ + formId: z.string().uuid('Invalid form ID'), + confirm: z.literal(true), +}) + +export const deleteSubmissionSchema = z.object({ + submissionId: z.string().uuid('Invalid submission ID'), +}) + export const getPublicFormSchema = z.object({ space: z.string().min(1).max(100), slug: formSlug,