From 3ecd4daee1a4095f3d924a615fdaf7701fe5fa56 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Thu, 23 Apr 2026 14:08:13 +0200 Subject: [PATCH] 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) --- apps/portal/src/composables/useFormDraft.ts | 356 ++++++++++++ .../src/pages/register/[public_token].vue | 535 ++++++++++++++++++ apps/portal/src/types/formBuilder.ts | 190 +++++++ .../tests/composables/useFormDraft.test.ts | 174 ++++++ 4 files changed, 1255 insertions(+) create mode 100644 apps/portal/src/composables/useFormDraft.ts create mode 100644 apps/portal/src/pages/register/[public_token].vue create mode 100644 apps/portal/src/types/formBuilder.ts create mode 100644 apps/portal/tests/composables/useFormDraft.test.ts diff --git a/apps/portal/src/composables/useFormDraft.ts b/apps/portal/src/composables/useFormDraft.ts new file mode 100644 index 00000000..3277b37a --- /dev/null +++ b/apps/portal/src/composables/useFormDraft.ts @@ -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 + values: Ref + submitterName: Ref + submitterEmail: Ref + isStarting: Ref + isSaving: Ref + isSubmitting: Ref + lastSavedAt: Ref + saveError: Ref + submitError: Ref + start: () => Promise + setValue: (slug: string, value: unknown) => void + saveField: (slug: string, value: unknown) => void + setSubmitterName: (v: string) => void + setSubmitterEmail: (v: string) => void + saveDraftNow: () => Promise + submitForm: () => Promise + clearSession: () => void +} + +export function useFormDraft( + token: Ref, + options: UseFormDraftOptions = {}, +): UseFormDraftReturn { + const debounceMs = options.debounceMs ?? 800 + + const submission = ref(null) + const values = ref({}) + const dirty = ref({}) + const submitterName = ref('') + const submitterEmail = ref('') + const submitterDirty = ref(false) + const isStarting = ref(false) + const isSaving = ref(false) + const isSubmitting = ref(false) + const lastSavedAt = ref(null) + const saveError = ref(null) + const submitError = ref(null) + + const firstInteractedAt = ref(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 { + 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 { + 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 { + 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 { + 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 } diff --git a/apps/portal/src/pages/register/[public_token].vue b/apps/portal/src/pages/register/[public_token].vue new file mode 100644 index 00000000..96d33ad4 --- /dev/null +++ b/apps/portal/src/pages/register/[public_token].vue @@ -0,0 +1,535 @@ + + + + + diff --git a/apps/portal/src/types/formBuilder.ts b/apps/portal/src/types/formBuilder.ts new file mode 100644 index 00000000..36fe68d1 --- /dev/null +++ b/apps/portal/src/types/formBuilder.ts @@ -0,0 +1,190 @@ +// Mirrors backend form builder enums and public resources. +// Source of truth: api/app/Enums/FormBuilder/* and +// api/app/Http/Resources/FormBuilder/PublicForm(Schema|Submission)Resource.php + +export const FormFieldType = { + TEXT: 'TEXT', + TEXTAREA: 'TEXTAREA', + EMAIL: 'EMAIL', + PHONE: 'PHONE', + NUMBER: 'NUMBER', + DATE: 'DATE', + DATETIME: 'DATETIME', + BOOLEAN: 'BOOLEAN', + RADIO: 'RADIO', + SELECT: 'SELECT', + MULTISELECT: 'MULTISELECT', + CHECKBOX_LIST: 'CHECKBOX_LIST', + FILE_UPLOAD: 'FILE_UPLOAD', + IMAGE_UPLOAD: 'IMAGE_UPLOAD', + SIGNATURE: 'SIGNATURE', + TAG_PICKER: 'TAG_PICKER', + HEADING: 'HEADING', + PARAGRAPH: 'PARAGRAPH', + URL: 'URL', + SECTION_PRIORITY: 'SECTION_PRIORITY', + AVAILABILITY_PICKER: 'AVAILABILITY_PICKER', + TABLE_ROWS: 'TABLE_ROWS', +} as const +export type FormFieldType = typeof FormFieldType[keyof typeof FormFieldType] + +// Backend only ships 'full' | 'half' today; 'third' | 'quarter' are +// forward-compat placeholders matching the future ARCH design. Layout +// maps unknown widths to full width. +export type FormFieldDisplayWidth = 'full' | 'half' | 'third' | 'quarter' + +export const ConditionalOperator = { + equals: 'equals', + not_equals: 'not_equals', + contains: 'contains', + not_contains: 'not_contains', + in: 'in', + not_in: 'not_in', + greater_than: 'greater_than', + less_than: 'less_than', + empty: 'empty', + not_empty: 'not_empty', +} as const +export type ConditionalOperator = typeof ConditionalOperator[keyof typeof ConditionalOperator] + +export interface ConditionalRule { + field_slug: string + operator: ConditionalOperator + value?: unknown +} + +export interface ConditionalGroup { + all?: Array + any?: Array +} + +export interface ConditionalLogic { + show_when?: ConditionalGroup +} + +export interface FormFieldValidationRules { + min?: number + max?: number + pattern?: string + min_selections?: number + max_selections?: number + tag_categories?: string[] + [key: string]: unknown +} + +export type FieldOption = string | { label: string; description?: string | null; value?: string | number | null } + +export interface AvailableTag { + id: string + name: string + category: string +} + +export interface PublicFormField { + id: string + slug: string + field_type: FormFieldType + label: string + help_text: string | null + options: FieldOption[] | null + available_tags: AvailableTag[] | null + validation_rules: FormFieldValidationRules | null + is_required: boolean + display_width: FormFieldDisplayWidth + conditional_logic: ConditionalLogic | null + sort_order: number + form_schema_section_id: string | null +} + +export interface PublicFormSection { + id: string + slug: string + name: string + description: string | null + sort_order: number +} + +export interface PublicFormSchema { + id: string + name: string + slug: string + purpose: string + description: string | null + locale: string + version: number + opened_at: string + consent_version: string | null + submission_deadline: string | null + section_level_submit: boolean + sections: PublicFormSection[] + fields: PublicFormField[] +} + +export type FormSubmissionStatus = 'draft' | 'submitted' | 'reviewed' | 'rejected' | 'approved' + +export interface PublicFormSubmissionValue { + value: unknown + value_anonymised: boolean +} + +export interface PublicFormSubmissionIdentityMatch { + status: 'pending' | 'matched' | 'none' + message: string +} + +export interface PublicFormSubmission { + id: string + form_schema_id: string + status: FormSubmissionStatus + auto_save_count: number + submitted_in_locale: string | null + schema_version_at_submit: number | null + schema_drift: boolean + values: Record + identity_match: PublicFormSubmissionIdentityMatch | null + opened_at: string | null + first_interacted_at: string | null + submitted_at: string | null + submission_duration_seconds: number | null + created_at: string | null + updated_at: string | null +} + +export type FormErrorCode = + | 'TOKEN_EXPIRED' + | 'TOKEN_REVOKED' + | 'SCHEMA_UNPUBLISHED' + | 'SCHEMA_NOT_FOUND' + | 'SUBMISSION_ALREADY_SUBMITTED' + | 'SUBMISSION_NOT_FOUND' + | 'RATE_LIMITED' + +export interface PublicFormErrorBody { + message: string + code?: FormErrorCode | string + errors?: Record +} + +export type FormValues = Record + +export interface StartDraftBody { + idempotency_key: string + opened_at?: string + submitted_in_locale?: string + public_submitter_name?: string + public_submitter_email?: string +} + +export interface SaveDraftBody { + values?: FormValues + first_interacted_at?: string + public_submitter_name?: string + public_submitter_email?: string +} + +export interface SubmitBody { + values?: FormValues + captcha_token?: string + public_submitter_name?: string + public_submitter_email?: string +} diff --git a/apps/portal/tests/composables/useFormDraft.test.ts b/apps/portal/tests/composables/useFormDraft.test.ts new file mode 100644 index 00000000..b98f421d --- /dev/null +++ b/apps/portal/tests/composables/useFormDraft.test.ts @@ -0,0 +1,174 @@ +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' + +vi.mock('@/lib/axios', () => { + const post = vi.fn() + const put = vi.fn() + const get = vi.fn() + + return { apiClient: { post, put, get } } +}) + +import { apiClient } from '@/lib/axios' +import { draftIdempotencyKey, useFormDraft } from '@/composables/useFormDraft' + +interface MockedApi { + post: ReturnType + put: ReturnType + get: ReturnType +} + +const mocked = apiClient as unknown as MockedApi + +function mountWithDraft(token: string, options?: Parameters[1]) { + const draftRef: { value: ReturnType | null } = { value: null } + + const Host = defineComponent({ + setup() { + draftRef.value = useFormDraft(ref(token), options) + + return () => h('div') + }, + }) + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const wrapper = mount(Host, { + global: { plugins: [[VueQueryPlugin, { queryClient }]] }, + }) + + return { wrapper, draftRef } +} + +function submissionFixture(id: string) { + return { + id, + form_schema_id: 'sc_1', + status: 'draft', + auto_save_count: 0, + submitted_in_locale: 'nl', + schema_version_at_submit: null, + schema_drift: false, + values: {}, + identity_match: null, + opened_at: null, + first_interacted_at: null, + submitted_at: null, + submission_duration_seconds: null, + created_at: null, + updated_at: null, + } +} + +function apiSuccess(body: T) { + return { data: { data: body, message: 'ok', success: true } } +} + +function flush(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('useFormDraft', () => { + const TOKEN = 'TKN123' + + beforeEach(() => { + sessionStorage.clear() + vi.clearAllMocks() + }) + + afterEach(() => { + sessionStorage.clear() + }) + + it('derives the sessionStorage key from the token', () => { + expect(draftIdempotencyKey('abc')).toBe('draft_idem:abc') + }) + + it('generates and stores an idempotency_key on first start', async () => { + mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) + + const { draftRef, wrapper } = mountWithDraft(TOKEN) + const draft = draftRef.value + expect(draft).toBeTruthy() + + await draft!.start() + await flush() + + expect(mocked.post).toHaveBeenCalledTimes(1) + const [, body] = mocked.post.mock.calls[0] + expect(body.idempotency_key).toMatch(/^[a-f0-9]{8,30}$/i) + expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBe(body.idempotency_key) + + wrapper.unmount() + }) + + it('reuses the idempotency_key across remounts in the same session', async () => { + mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1'))) + + const first = mountWithDraft(TOKEN) + await first.draftRef.value!.start() + await flush() + const firstKey = mocked.post.mock.calls[0][1].idempotency_key + first.wrapper.unmount() + + mocked.post.mockClear() + mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1'))) + + const second = mountWithDraft(TOKEN) + await second.draftRef.value!.start() + await flush() + const secondKey = mocked.post.mock.calls[0][1].idempotency_key + + expect(secondKey).toBe(firstKey) + second.wrapper.unmount() + }) + + it('debounces field saves and flushes on saveDraftNow', async () => { + mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) + mocked.put.mockResolvedValue(apiSuccess({ ...submissionFixture('s1'), auto_save_count: 1 })) + + const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 }) + const draft = draftRef.value! + + await draft.start() + await flush() + expect(mocked.put).not.toHaveBeenCalled() + + draft.saveField('name', 'Alice') + draft.saveField('name', 'Alice B') + expect(mocked.put).not.toHaveBeenCalled() + + await draft.saveDraftNow() + expect(mocked.put).toHaveBeenCalledTimes(1) + const [, body] = mocked.put.mock.calls[0] + expect(body.values).toEqual({ name: 'Alice B' }) + + wrapper.unmount() + }) + + it('clears the sessionStorage key after a successful submit', async () => { + mocked.post + .mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start draft + .mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' })) // submit + + const { draftRef, wrapper } = mountWithDraft(TOKEN) + const draft = draftRef.value! + + await draft.start() + await flush() + expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBeTruthy() + + // submitForm guards against empty submitter fields — populate them + // so the composable actually hits the submit endpoint. + draft.setSubmitterName('Test') + draft.setSubmitterEmail('test@example.nl') + + const submitted = await draft.submitForm() + await flush() + expect(submitted?.status).toBe('submitted') + expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBeNull() + + wrapper.unmount() + }) +})