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

@@ -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"
/>