refactor(actions): split forms.ts into a folder (slice 5)
805-line lib/actions/forms.ts → lib/actions/forms/{manage,submit,member}.ts
('use server') + _guard.ts (requireFormsManager/isUniqueViolation) + index.ts
barrel. All 15 actions preserved verbatim (incl. linkSubmissionsByEmail, still
imported by members.ts). tsc 0, tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
39bcdd9677
commit
f86fff62d0
@@ -0,0 +1,153 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { getAuthMember, logActivity, parseInput } from '@/lib/auth-helpers'
|
||||
import {
|
||||
linkSubmissionsSchema,
|
||||
memberSubmissionsSchema,
|
||||
} from '@/lib/validations'
|
||||
import { escapeLike } from '@/lib/forms-logic'
|
||||
import { requireFormsManager } from './_guard'
|
||||
|
||||
// Associate prior unlinked submissions in a space with a member by email
|
||||
// (case-insensitive, ILIKE-escaped so `_`/`%` in an address are literal).
|
||||
// Only fills NULL member_id (never re-points an existing link). Shared by
|
||||
// member create/email-change and submit-time linking. The caller is trusted
|
||||
// to have established the email belongs to the member; see the note on the
|
||||
// anon submit path in submitForm.
|
||||
export async function linkSubmissionsByEmail(
|
||||
admin: ReturnType<typeof createAdminClient>,
|
||||
spaceId: string,
|
||||
memberId: string,
|
||||
email: string | null | undefined,
|
||||
): Promise<number> {
|
||||
if (!email) return 0
|
||||
const { data } = await admin
|
||||
.from('form_submissions')
|
||||
.update({ member_id: memberId })
|
||||
.eq('space_id', spaceId)
|
||||
.is('member_id', null)
|
||||
.ilike('submitter_email', escapeLike(email.toLowerCase()))
|
||||
.select('id')
|
||||
return data?.length ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-service retro-link (Phase 5). Links the caller's prior ANONYMOUS
|
||||
* submissions to their own member row, but only when their email is verified
|
||||
* (the locked decision: retro-link to an account only on a verified email).
|
||||
*
|
||||
* Safe to expose: it takes no trusted parameters. It resolves the member and
|
||||
* email strictly from the caller's authenticated session, so a client can
|
||||
* only ever link submissions to their own account, and only their own
|
||||
* verified email. Idempotent (already-linked rows have a non-null member_id
|
||||
* and are excluded). This is the concrete "email-verification" hook; it is
|
||||
* also invoked best-effort from joinSpace and finishOnboarding.
|
||||
*/
|
||||
export async function claimMyAnonymousSubmissions() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
if (!user.email || !user.email_confirmed_at) return { data: { linked: 0 } }
|
||||
|
||||
const member = await getAuthMember(supabase)
|
||||
if (!member) return { data: { linked: 0 } }
|
||||
|
||||
const admin = createAdminClient()
|
||||
const { data: linked } = await admin
|
||||
.from('form_submissions')
|
||||
.update({ member_id: member.id })
|
||||
.eq('space_id', member.space_id)
|
||||
.is('member_id', null)
|
||||
.eq('submitter_email', user.email.toLowerCase())
|
||||
.select('id')
|
||||
|
||||
return { data: { linked: linked?.length ?? 0 } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retro-link prior anonymous submissions to a member by matching email.
|
||||
*
|
||||
* The forms.manage "admin manual-link" tool. Automatic email-match linking
|
||||
* also runs at submit time and on member create/email-change/backfill (see
|
||||
* linkSubmissionsByEmail and the submitForm note) per the 2026-05 product
|
||||
* decision; this manual tool remains for explicit re-linking.
|
||||
*/
|
||||
export async function linkSubmissionsForMember(input: unknown) {
|
||||
const gate = await requireFormsManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(linkSubmissionsSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
const email = v.data.email.toLowerCase()
|
||||
|
||||
const { data: target } = await supabase
|
||||
.from('space_members')
|
||||
.select('id')
|
||||
.eq('id', v.data.memberId)
|
||||
.eq('space_id', member.space_id)
|
||||
.single()
|
||||
if (!target) return { error: 'Member not found in this space' }
|
||||
|
||||
const admin = createAdminClient()
|
||||
const { data: linked, error } = await admin
|
||||
.from('form_submissions')
|
||||
.update({ member_id: v.data.memberId })
|
||||
.eq('space_id', member.space_id)
|
||||
.is('member_id', null)
|
||||
.eq('submitter_email', email)
|
||||
.select('id')
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(
|
||||
supabase,
|
||||
member,
|
||||
'linked_submissions',
|
||||
'space_member',
|
||||
v.data.memberId,
|
||||
`${linked?.length ?? 0} submission(s) for ${email}`,
|
||||
)
|
||||
return { data: { linked: linked?.length ?? 0 } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The forms a given member has submitted (for the per-member panel on the
|
||||
* members page). forms.manage gated; the gate's client honors the
|
||||
* form_submissions/forms RLS (forms.manage), so no service-client bypass.
|
||||
* Metadata only -- answers stay in the audited results surface.
|
||||
*/
|
||||
export async function listMemberSubmissions(input: unknown) {
|
||||
const gate = await requireFormsManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(memberSubmissionsSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('form_submissions')
|
||||
.select('id, form_id, form_version, created_at, forms(title, kind, slug)')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('member_id', v.data.memberId)
|
||||
.order('created_at', { ascending: false })
|
||||
if (error) return { error: error.message }
|
||||
|
||||
return {
|
||||
data: (data ?? []).map(s => {
|
||||
const f = s.forms as { title: string; kind: string; slug: string } | { title: string; kind: string; slug: string }[] | null
|
||||
const form = Array.isArray(f) ? f[0] : f
|
||||
return {
|
||||
id: s.id as string,
|
||||
formId: s.form_id as string,
|
||||
title: form?.title ?? 'Form',
|
||||
kind: form?.kind ?? 'form',
|
||||
version: s.form_version as number,
|
||||
submittedAt: s.created_at as string,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user