feat(portal): persist submitter details through draft lifecycle

Adds submitterName/submitterEmail state and setters to useFormDraft and
wires them through start/saveDraft/submit. Previously the Contactgegevens
name/email were held in a local page ref and never made it into any
request body, so submissions landed in the DB with NULL submitter fields
and a mid-form reload wiped whatever the user had typed.

- useFormDraft: internal submitterName/submitterEmail refs with setters
  that mark the draft dirty (same debounced-PUT path as field values),
  sessionStorage resume via draft_submitter:{token}, and a
  MISSING_SUBMITTER guard in submitForm so empty fields surface as
  submitError without hitting the endpoint.
- register/[public_token].vue: deletes the local submitter refs and
  reads/writes through the composable; onSubmit pre-validates and
  bounces the user back to the Contactgegevens step with a snackbar
  when fields are missing.
- SaveDraftBody / SubmitBody: optional public_submitter_name and
  public_submitter_email per the documented backend contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 14:08:13 +02:00
parent 102b6006fa
commit 3ecd4daee1
4 changed files with 1255 additions and 0 deletions

View File

@@ -0,0 +1,356 @@
import { ref, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import {
extractErrorBody,
extractRetryAfterSeconds,
useCreateFormDraft,
useSaveFormDraft,
useSubmitForm,
} from '@/composables/api/usePublicForm'
import type { FormValues, PublicFormSubmission, SaveDraftBody } from '@/types/formBuilder'
/** sessionStorage key for reusing an idempotency key across reloads. */
export function draftIdempotencyKey(token: string): string {
return `draft_idem:${token}`
}
/** sessionStorage key for resuming submitter name/email across reloads. */
export function draftSubmitterStorageKey(token: string): string {
return `draft_submitter:${token}`
}
function generateIdempotencyKey(): string {
const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (arr: Uint8Array) => Uint8Array } }).crypto
if (c?.randomUUID) {
// UUID v4 (36 chars) exceeds backend max:30. Backend expects 6..30
// chars so compress to 24 hex chars (still collision-resistant).
return c.randomUUID().replace(/-/g, '').slice(0, 24)
}
if (c?.getRandomValues) {
const buf = new Uint8Array(12)
c.getRandomValues(buf)
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('')
}
// Last-resort fallback — still within 6..30.
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`.slice(0, 30)
}
interface UseFormDraftOptions {
/** Preferred locale string for `submitted_in_locale` (e.g. `"nl"`). */
locale?: string
/** Debounce for auto-save after a field blur, in ms. */
debounceMs?: number
}
export type SubmitterClientError = Error & { code: 'MISSING_SUBMITTER' }
export interface UseFormDraftReturn {
submission: Ref<PublicFormSubmission | null>
values: Ref<FormValues>
submitterName: Ref<string>
submitterEmail: Ref<string>
isStarting: Ref<boolean>
isSaving: Ref<boolean>
isSubmitting: Ref<boolean>
lastSavedAt: Ref<Date | null>
saveError: Ref<unknown>
submitError: Ref<unknown>
start: () => Promise<PublicFormSubmission | null>
setValue: (slug: string, value: unknown) => void
saveField: (slug: string, value: unknown) => void
setSubmitterName: (v: string) => void
setSubmitterEmail: (v: string) => void
saveDraftNow: () => Promise<void>
submitForm: () => Promise<PublicFormSubmission | null>
clearSession: () => void
}
export function useFormDraft(
token: Ref<string | null | undefined>,
options: UseFormDraftOptions = {},
): UseFormDraftReturn {
const debounceMs = options.debounceMs ?? 800
const submission = ref<PublicFormSubmission | null>(null)
const values = ref<FormValues>({})
const dirty = ref<FormValues>({})
const submitterName = ref<string>('')
const submitterEmail = ref<string>('')
const submitterDirty = ref(false)
const isStarting = ref(false)
const isSaving = ref(false)
const isSubmitting = ref(false)
const lastSavedAt = ref<Date | null>(null)
const saveError = ref<unknown>(null)
const submitError = ref<unknown>(null)
const firstInteractedAt = ref<string | null>(null)
const { mutateAsync: startDraft } = useCreateFormDraft(token)
const { mutateAsync: saveDraft } = useSaveFormDraft(token)
const { mutateAsync: submitMutation } = useSubmitForm(token)
function readStoredKey(): string | null {
const t = token.value
if (!t) return null
try {
return sessionStorage.getItem(draftIdempotencyKey(t))
}
catch {
return null
}
}
function writeStoredKey(value: string): void {
const t = token.value
if (!t) return
try {
sessionStorage.setItem(draftIdempotencyKey(t), value)
}
catch {
// storage disabled — drafts won't survive reload
}
}
function persistSubmitter(): void {
const t = token.value
if (!t) return
try {
sessionStorage.setItem(
draftSubmitterStorageKey(t),
JSON.stringify({ name: submitterName.value, email: submitterEmail.value }),
)
}
catch {
// storage disabled
}
}
function restoreSubmitter(): void {
const t = token.value
if (!t) return
try {
const raw = sessionStorage.getItem(draftSubmitterStorageKey(t))
if (!raw) return
const parsed = JSON.parse(raw) as { name?: unknown; email?: unknown }
if (typeof parsed.name === 'string') submitterName.value = parsed.name
if (typeof parsed.email === 'string') submitterEmail.value = parsed.email
}
catch {
// malformed — ignore
}
}
function clearSession(): void {
const t = token.value
if (!t) return
try {
sessionStorage.removeItem(draftIdempotencyKey(t))
sessionStorage.removeItem(draftSubmitterStorageKey(t))
}
catch {
// noop
}
}
async function start(): Promise<PublicFormSubmission | null> {
if (!token.value) return null
if (submission.value) return submission.value
isStarting.value = true
try {
// Hydrate submitter state from a prior session *before* posting the
// draft so the first create call already carries any name/email the
// user had typed on a previous mount.
restoreSubmitter()
let key = readStoredKey()
if (!key) {
key = generateIdempotencyKey()
writeStoredKey(key)
}
const created = await startDraft({
idempotency_key: key,
opened_at: new Date().toISOString(),
submitted_in_locale: options.locale,
public_submitter_name: submitterName.value || undefined,
public_submitter_email: submitterEmail.value || undefined,
})
submission.value = created
const hydrated: FormValues = {}
for (const [slug, entry] of Object.entries(created.values ?? {})) {
hydrated[slug] = entry?.value
}
values.value = { ...hydrated, ...values.value }
return created
}
finally {
isStarting.value = false
}
}
function setValue(slug: string, value: unknown): void {
values.value = { ...values.value, [slug]: value }
dirty.value = { ...dirty.value, [slug]: value }
}
function setSubmitterName(v: string): void {
submitterName.value = v
submitterDirty.value = true
persistSubmitter()
}
function setSubmitterEmail(v: string): void {
submitterEmail.value = v
submitterDirty.value = true
persistSubmitter()
}
function markInteracted(): void {
if (!firstInteractedAt.value) firstInteractedAt.value = new Date().toISOString()
}
async function flushDirty(): Promise<void> {
if (!submission.value) return
const keys = Object.keys(dirty.value)
const hadSubmitterDirty = submitterDirty.value
if (keys.length === 0 && !hadSubmitterDirty) return
const snapshot = { ...dirty.value }
dirty.value = {}
submitterDirty.value = false
isSaving.value = true
saveError.value = null
try {
const body: SaveDraftBody = {
first_interacted_at: firstInteractedAt.value ?? undefined,
public_submitter_name: submitterName.value || undefined,
public_submitter_email: submitterEmail.value || undefined,
}
if (keys.length > 0) body.values = snapshot
const updated = await saveDraft({
submissionId: submission.value.id,
body,
})
submission.value = updated
lastSavedAt.value = new Date()
}
catch (err) {
// Restore the dirty set so the next save retries these values.
dirty.value = { ...snapshot, ...dirty.value }
if (hadSubmitterDirty) submitterDirty.value = true
saveError.value = err
}
finally {
isSaving.value = false
}
}
function saveField(slug: string, value: unknown): void {
markInteracted()
setValue(slug, value)
}
async function saveDraftNow(): Promise<void> {
markInteracted()
if (!submission.value) await start()
await flushDirty()
}
// Debounced background save whenever the dirty surface changes — values
// or submitter.
watchDebounced(
() => Object.keys(dirty.value).length + (submitterDirty.value ? 1 : 0),
count => {
if (count > 0 && submission.value) void flushDirty()
},
{ debounce: debounceMs },
)
// Abandon draft session when token changes (hot route swap / dev).
watch(token, () => {
submission.value = null
values.value = {}
dirty.value = {}
submitterName.value = ''
submitterEmail.value = ''
submitterDirty.value = false
lastSavedAt.value = null
})
async function submitForm(): Promise<PublicFormSubmission | null> {
submitError.value = null
// Submitter name/email are required at final submit. Surface a
// client-side error without hitting the endpoint so the page can
// bounce the user back to the Contactgegevens step.
const name = submitterName.value.trim()
const email = submitterEmail.value.trim()
if (!name || !email) {
const err = new Error('Submitter name and email are required.') as SubmitterClientError
err.code = 'MISSING_SUBMITTER'
submitError.value = err
return null
}
if (!submission.value) {
await start()
if (!submission.value) return null
}
// Flush any pending auto-save so the submit merges with server state.
await flushDirty()
isSubmitting.value = true
try {
const submitted = await submitMutation({
submissionId: submission.value.id,
body: {
values: values.value,
public_submitter_name: submitterName.value,
public_submitter_email: submitterEmail.value,
},
})
submission.value = submitted
clearSession()
return submitted
}
catch (err) {
submitError.value = err
const body = extractErrorBody(err)
if (body?.code === 'SUBMISSION_ALREADY_SUBMITTED') clearSession()
return null
}
finally {
isSubmitting.value = false
}
}
return {
submission,
values,
submitterName,
submitterEmail,
isStarting,
isSaving,
isSubmitting,
lastSavedAt,
saveError,
submitError,
start,
setValue,
saveField,
setSubmitterName,
setSubmitterEmail,
saveDraftNow,
submitForm,
clearSession,
}
}
export { extractErrorBody, extractRetryAfterSeconds }