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:
2026-04-23 22:26:58 +02:00
parent e95f9a75f6
commit b6a3a17b0a
11 changed files with 709 additions and 2 deletions

View File

@@ -33,6 +33,7 @@ declare module 'vue' {
CustomRadiosWithImage: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithImage.vue')['default']
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
DropZone: typeof import('./src/@core/components/DropZone.vue')['default']
DuplicateSubmissionHint: typeof import('./src/components/public-form/DuplicateSubmissionHint.vue')['default']
EventCard: typeof import('./src/components/portal/EventCard.vue')['default']
FieldAvailabilityPicker: typeof import('./src/components/public-form/FieldAvailabilityPicker.vue')['default']
FieldBoolean: typeof import('./src/components/public-form/FieldBoolean.vue')['default']

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { PublicFormSubmissionDuplicate } from '@/types/formBuilder'
const props = defineProps<{
data: PublicFormSubmissionDuplicate | null
}>()
// Backend is the single source of truth for copy (see
// PublicFormSubmissionResource::formatDuplicateSubmission for plural
// agreement + Dutch long date formatting). Frontend keeps a fallback
// for the three pieces the backend always sets, so a future response
// that trims `title` / `body` still renders a coherent hint.
const FALLBACK_TITLE = 'Je hebt je eerder al aangemeld'
const dutchDateFormatter = new Intl.DateTimeFormat('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
function formatDutchDate(iso: string): string {
if (!iso) return ''
try {
return dutchDateFormatter.format(new Date(iso))
} catch {
return ''
}
}
function fallbackBody(data: PublicFormSubmissionDuplicate): string {
const date = formatDutchDate(data.first_submitted_at)
return data.count === 1
? `Op ${date} heb je dit formulier ook al ingevuld. De organisator ziet beide aanmeldingen en neemt zo snel mogelijk contact op.`
: `Je hebt dit formulier al ${data.count} keer eerder ingevuld (voor het eerst op ${date}). De organisator ziet alle aanmeldingen en neemt zo snel mogelijk contact op.`
}
const title = computed(() => {
if (!props.data) return ''
return props.data.title?.trim() || FALLBACK_TITLE
})
const body = computed(() => {
if (!props.data) return ''
const fromBackend = props.data.body?.trim()
if (fromBackend) return fromBackend
return fallbackBody(props.data)
})
</script>
<template>
<VAlert
v-if="data"
type="warning"
variant="tonal"
prominent
class="duplicate-submission-hint mb-4"
>
<div class="text-subtitle-1 font-weight-medium mb-1">
{{ title }}
</div>
<div class="text-body-2">
{{ body }}
</div>
</VAlert>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import DuplicateSubmissionHint from './DuplicateSubmissionHint.vue'
import IdentityMatchBanner from './IdentityMatchBanner.vue'
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
@@ -6,7 +7,12 @@ import { formatFieldValue } from '@/composables/formatFieldValue'
import type { FormStep } from '@/composables/useFormSteps'
import { usePublicFormToken } from '@/composables/publicFormInjection'
import { FormFieldType } from '@/types/formBuilder'
import type { FormValues, PublicFormField, PublicFormSubmissionIdentityMatch } from '@/types/formBuilder'
import type {
FormValues,
PublicFormField,
PublicFormSubmissionDuplicate,
PublicFormSubmissionIdentityMatch,
} from '@/types/formBuilder'
const props = defineProps<{
steps: FormStep[]
@@ -14,6 +20,7 @@ const props = defineProps<{
submitterName?: string
submitterEmail?: string
identityMatch?: PublicFormSubmissionIdentityMatch | null
duplicateSubmission?: PublicFormSubmissionDuplicate | null
}>()
// TanStack Query calls — these hit the same cache the field components
@@ -67,10 +74,14 @@ function answerableFields(step: FormStep): PublicFormField[] {
<VDivider />
<VCardText
v-if="identityMatch"
v-if="duplicateSubmission || identityMatch"
class="pa-6 pb-0"
>
<!-- Duplicate hint first: it's about the act of submitting.
Identity match second: it's about who you are. -->
<DuplicateSubmissionHint :data="duplicateSubmission ?? null" />
<IdentityMatchBanner
v-if="identityMatch"
:status="identityMatch.status"
:message="identityMatch.message"
/>

View File

@@ -276,6 +276,7 @@ function formatReviewValue(field: PublicFormField): string {
:submitter-name="draft.submitterName.value"
:submitter-email="draft.submitterEmail.value"
:identity-match="draft.submission.value?.identity_match ?? null"
:duplicate-submission="draft.submission.value?.duplicate_submission ?? null"
/>
<!-- Data state -->

View File

@@ -132,6 +132,13 @@ export interface PublicFormSubmissionIdentityMatch {
message: string
}
export interface PublicFormSubmissionDuplicate {
count: number
first_submitted_at: string
title: string
body: string
}
export interface PublicFormSubmission {
id: string
form_schema_id: string
@@ -142,6 +149,7 @@ export interface PublicFormSubmission {
schema_drift: boolean
values: Record<string, PublicFormSubmissionValue>
identity_match: PublicFormSubmissionIdentityMatch | null
duplicate_submission: PublicFormSubmissionDuplicate | null
opened_at: string | null
first_interacted_at: string | null
submitted_at: string | null

View 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')
})
})