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).
This commit is contained in:
Moheeb Zara
2026-05-17 00:09:28 -07:00
parent bff8deac55
commit c4ae5d27ac
2 changed files with 81 additions and 8 deletions
+69 -8
View File
@@ -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 }