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:
356
apps/portal/src/composables/useFormDraft.ts
Normal file
356
apps/portal/src/composables/useFormDraft.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user