Files
hackerspace.sh/lib/actions/forms/member.ts
T
Moheeb ZaraandClaude Opus 4.8 f86fff62d0 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>
2026-08-04 06:01:02 -07:00

154 lines
5.4 KiB
TypeScript

'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,
}
}),
}
}