Root cause: association only ever looked at the dedicated submitter_email
(public 'your email' box / auth email). A form that asks for email as an
ordinary field stored it in answers, so submitter_email stayed null and
nothing matched -- 'Re-link' reported 0 ('already linked') and the
submission never appeared under the member. New pure deriveSubmitterEmail
(explicit > email-type field answer > any email-looking answer; trimmed
lowercase) + 5 tests. submitForm now derives + persists submitter_email
from answers; relinkAllSubmissions re-derives per submission from its
form_snapshot and backfills member_id + submitter_email for existing
rows. Suite 469.
145 lines
5.9 KiB
TypeScript
145 lines
5.9 KiB
TypeScript
// Pure, dependency-free decision logic for the forms/waivers feature.
|
|
// Extracted from the server actions and the builder so the security- and
|
|
// correctness-critical branches can be unit-tested directly. No Supabase,
|
|
// no React, no Next imports here.
|
|
|
|
// ─── CSV escaping (results export) ───────────────────────────────────────────
|
|
|
|
export function csvCell(value: unknown): string {
|
|
if (value === null || value === undefined) return ''
|
|
const s = typeof value === 'string' ? value : JSON.stringify(value)
|
|
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`
|
|
return s
|
|
}
|
|
|
|
// ─── Client IP capture (e-sign defensibility) ────────────────────────────────
|
|
// Only a plausibly IP-shaped token is kept; anything else becomes null so it
|
|
// can never break the inet column or store junk in a waiver record.
|
|
|
|
const IP_RE = /^[0-9a-fA-F:.]{3,45}$/
|
|
|
|
export function parseClientIp(
|
|
forwardedFor: string | null,
|
|
realIp: string | null,
|
|
): string | null {
|
|
const candidate = (forwardedFor ? forwardedFor.split(',')[0] : realIp)?.trim()
|
|
if (!candidate || !IP_RE.test(candidate)) return null
|
|
return candidate
|
|
}
|
|
|
|
// ─── Waiver version bump on update ───────────────────────────────────────────
|
|
// Bump only a published waiver whose legal text or field schema actually
|
|
// changed. Non-blocking re-sign: existing submissions stay valid against the
|
|
// snapshot they captured; the bump only nudges future signers.
|
|
|
|
export function shouldBumpFormVersion(input: {
|
|
kind: string
|
|
status: string
|
|
legalChanged: boolean
|
|
schemaChanged: boolean
|
|
}): boolean {
|
|
return (
|
|
input.kind === 'waiver' &&
|
|
input.status === 'published' &&
|
|
(input.legalChanged || input.schemaChanged)
|
|
)
|
|
}
|
|
|
|
// ─── Required onboarding step satisfaction ───────────────────────────────────
|
|
// Decision for one required step that is NOT already in completed_step_ids.
|
|
// - a non-form required step blocks (must be acknowledged in the flow)
|
|
// - a form step with no configured form id is non-blocking (misconfig)
|
|
// - a form step whose form is missing/unpublished is non-blocking
|
|
// (cannot require a form nobody can fill — fail open, never trap a member)
|
|
// - a form step with a published form is satisfied iff a submission by this
|
|
// member exists (any version — re-sign is non-blocking / auto-satisfy)
|
|
|
|
export function evaluateRequiredFormStep(input: {
|
|
stepType: string
|
|
formId: string | null | undefined
|
|
formPublished: boolean
|
|
submissionExists: boolean
|
|
}): 'block' | 'pass' {
|
|
if (input.stepType !== 'form') return 'block'
|
|
if (!input.formId) return 'pass'
|
|
if (!input.formPublished) return 'pass'
|
|
return input.submissionExists ? 'pass' : 'block'
|
|
}
|
|
|
|
// ─── Builder field keys ──────────────────────────────────────────────────────
|
|
// The builder only ever asks for a label; keys are derived here so they are
|
|
// always valid (/^[a-z0-9_]+$/) and unique within a form. Duplicate or empty
|
|
// labels are made safe, matching the server-side formFieldSchema.
|
|
|
|
export function slugify(s: string, sep: '-' | '_'): string {
|
|
return s
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, sep)
|
|
.replace(new RegExp(`^\\${sep}+|\\${sep}+$`, 'g'), '')
|
|
}
|
|
|
|
export function deriveFieldKeys<T extends { label: string }>(
|
|
fields: T[],
|
|
): (T & { key: string })[] {
|
|
const seen = new Set<string>()
|
|
return fields.map((f, i) => {
|
|
const base = slugify(f.label || '', '_') || `field_${i + 1}`
|
|
let key = base
|
|
let n = 2
|
|
while (seen.has(key)) key = `${base}_${n++}`
|
|
seen.add(key)
|
|
return { ...f, key }
|
|
})
|
|
}
|
|
|
|
// Escape a literal so it can be used as a PostgREST/SQL ILIKE *value* and
|
|
// match exactly (case-insensitively) rather than as a pattern. Email local
|
|
// parts legitimately contain `_`, which is a single-char ILIKE wildcard --
|
|
// without escaping, `[email protected]` would also match `[email protected]`. Order matters:
|
|
// escape the escape char first.
|
|
export function escapeLike(s: string): string {
|
|
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
|
|
}
|
|
|
|
const FORMS_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
// The email used to associate a submission with a member. The dedicated
|
|
// `submitter_email` (the public "your email" box, or the signed-in user's
|
|
// address) wins; otherwise fall back to the answer of an email-type field,
|
|
// then any answer that is itself a valid email. Without this, a form that
|
|
// collects email as an ordinary field never populates submitter_email and
|
|
// so never links. Normalized to trimmed lowercase.
|
|
export function deriveSubmitterEmail(
|
|
fields: Array<{ key: string; type: string }>,
|
|
answers: Record<string, unknown> | null | undefined,
|
|
explicit?: string | null,
|
|
): string | null {
|
|
const norm = (v: unknown) => (typeof v === 'string' ? v.trim().toLowerCase() : '')
|
|
const e = norm(explicit)
|
|
if (e && FORMS_EMAIL_RE.test(e)) return e
|
|
const a = answers ?? {}
|
|
for (const f of fields) {
|
|
if (f.type === 'email') {
|
|
const v = norm(a[f.key])
|
|
if (FORMS_EMAIL_RE.test(v)) return v
|
|
}
|
|
}
|
|
for (const k of Object.keys(a)) {
|
|
const v = norm(a[k])
|
|
if (FORMS_EMAIL_RE.test(v)) return v
|
|
}
|
|
return null
|
|
}
|
|
|
|
// When an email matches more than one member row in a space (e.g. a manually
|
|
// added duplicate), associate deterministically with the earliest-joined one
|
|
// so the result is stable and reproducible.
|
|
export function pickMemberForEmail(
|
|
rows: Array<{ id: string; joined_at: string | null }>,
|
|
): string | null {
|
|
if (rows.length === 0) return null
|
|
return [...rows].sort((a, b) =>
|
|
(a.joined_at ?? '').localeCompare(b.joined_at ?? ''),
|
|
)[0].id
|
|
}
|