feat(form-builder): detect duplicate submissions by email on same form schema
Informational hint on the confirmation page when the same email has already submitted the form. Not a block — the submission proceeds normally. Privacy-safe: only shown to the submitter themselves. Scope: same form_schema_id only. Cross-form/cross-event detection would leak info about other forms. - New FormSubmissionDuplicateDetector service queries by form_submissions.public_submitter_email (trim + case-insensitive) scoped to the schema, status=submitted, excluding the current submission. Errors are swallowed + logged so a detector failure never blocks the submit response. - PublicFormSubmissionController enriches the submit response by setting a transient duplicate_submission_data attribute on the submission before resource serialisation. - PublicFormSubmissionResource serialises a duplicate_submission block with count, first_submitted_at, plus backend-authored Dutch title + body (plural-agreement + IntlDateFormatter for "23 april 2026"-style long-form dates). Null when no priors, no email, or detector error. - DuplicateSubmissionHint.vue (warning-typed tonal VAlert) above IdentityMatchBanner on FormConfirmation. Prefers backend copy with Intl-based Dutch date fallback for safety. - 16 new backend assertions across the detector and the full submit-response flow; 5 new Vitest assertions for the hint. Note on scope: spec suggested extracting email from values via schema binding; the codebase's public flow captures submitter email in a guaranteed column (public_submitter_email) populated by the stepper's Contactgegevens step. Using that directly is both simpler and more correct for the duplicate-by-submitter semantic. When FORM-05's binding-based extractor lands, this detector can migrate without changing its public API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
80
apps/portal/tests/unit/DuplicateSubmissionHint.spec.ts
Normal file
80
apps/portal/tests/unit/DuplicateSubmissionHint.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import DuplicateSubmissionHint from '@/components/public-form/DuplicateSubmissionHint.vue'
|
||||
import type { PublicFormSubmissionDuplicate } from '@/types/formBuilder'
|
||||
|
||||
function mountHint(data: PublicFormSubmissionDuplicate | null) {
|
||||
return mount(DuplicateSubmissionHint, {
|
||||
props: { data },
|
||||
global: {
|
||||
stubs: {
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type', 'variant', 'prominent'],
|
||||
template: '<div class="v-alert-stub" :data-type="type" :data-variant="variant"><slot/></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('DuplicateSubmissionHint', () => {
|
||||
it('renders nothing when data is null', () => {
|
||||
const w = mountHint(null)
|
||||
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers the backend title and body when provided', () => {
|
||||
const w = mountHint({
|
||||
count: 1,
|
||||
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||
title: 'Je hebt je eerder al aangemeld',
|
||||
body: 'Op 22 april 2026 heb je dit formulier ook al ingevuld.',
|
||||
})
|
||||
|
||||
expect(w.text()).toContain('Je hebt je eerder al aangemeld')
|
||||
expect(w.text()).toContain('Op 22 april 2026')
|
||||
})
|
||||
|
||||
it('falls back to singular copy when the backend body is missing (count=1)', () => {
|
||||
const w = mountHint({
|
||||
count: 1,
|
||||
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||
title: '',
|
||||
body: '',
|
||||
})
|
||||
|
||||
// Fallback title + body.
|
||||
expect(w.text()).toContain('Je hebt je eerder al aangemeld')
|
||||
expect(w.text()).toMatch(/Op\s+22\s+april\s+2026.*ook al ingevuld/)
|
||||
expect(w.text()).toContain('De organisator ziet beide aanmeldingen')
|
||||
})
|
||||
|
||||
it('falls back to plural copy with count when the backend body is missing', () => {
|
||||
const w = mountHint({
|
||||
count: 3,
|
||||
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||
title: '',
|
||||
body: '',
|
||||
})
|
||||
|
||||
expect(w.text()).toContain('3 keer eerder ingevuld')
|
||||
expect(w.text()).toContain('22 april 2026')
|
||||
expect(w.text()).toContain('De organisator ziet alle aanmeldingen')
|
||||
})
|
||||
|
||||
it('renders as a warning-typed tonal VAlert', () => {
|
||||
const w = mountHint({
|
||||
count: 1,
|
||||
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||
title: 'x',
|
||||
body: 'y',
|
||||
})
|
||||
|
||||
const alert = w.find('.v-alert-stub')
|
||||
expect(alert.exists()).toBe(true)
|
||||
expect(alert.attributes('data-type')).toBe('warning')
|
||||
expect(alert.attributes('data-variant')).toBe('tonal')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user