Files
crewli/apps/portal/src/components/public-form/FormConfirmation.vue
bert.hausmans b6a3a17b0a 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>
2026-04-23 22:26:58 +02:00

149 lines
4.3 KiB
Vue

<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'
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,
PublicFormSubmissionDuplicate,
PublicFormSubmissionIdentityMatch,
} from '@/types/formBuilder'
const props = defineProps<{
steps: FormStep[]
values: FormValues
submitterName?: string
submitterEmail?: string
identityMatch?: PublicFormSubmissionIdentityMatch | null
duplicateSubmission?: PublicFormSubmissionDuplicate | null
}>()
// TanStack Query calls — these hit the same cache the field components
// populated during the form render (5-minute staleTime), so there's no
// extra network round-trip on the confirmation page.
const token = usePublicFormToken()
const timeSlotsQuery = usePublicFormTimeSlots(token)
const sectionsQuery = usePublicFormSections(token)
function displayValue(field: PublicFormField): string {
return formatFieldValue(
field,
props.values[field.slug],
timeSlotsQuery.data.value,
sectionsQuery.data.value,
)
}
function isAnswerable(field: PublicFormField): boolean {
return field.field_type !== FormFieldType.HEADING
&& field.field_type !== FormFieldType.PARAGRAPH
}
function answerableFields(step: FormStep): PublicFormField[] {
return step.fields.filter(isAnswerable)
}
</script>
<template>
<div class="d-flex justify-center pa-4">
<VCard
flat
:max-width="720"
class="w-100"
>
<VCardText class="text-center pa-8">
<VIcon
icon="tabler-check"
color="success"
size="64"
class="mb-4"
/>
<h2 class="text-h4 mb-2">
Bedankt voor je inzending!
</h2>
<p class="text-body-1 text-medium-emphasis mb-0">
We nemen zo snel mogelijk contact op.
</p>
</VCardText>
<VDivider />
<VCardText
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"
/>
</VCardText>
<VCardText class="pa-6">
<h3 class="text-subtitle-1 font-weight-medium mb-3">
Contactgegevens
</h3>
<VRow>
<VCol
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
Naam
</p>
<p class="text-body-2 mb-0">
{{ submitterName || '—' }}
</p>
</VCol>
<VCol
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
E-mailadres
</p>
<p class="text-body-2 mb-0">
{{ submitterEmail || '—' }}
</p>
</VCol>
</VRow>
<template
v-for="step in steps"
:key="step.key"
>
<template v-if="step.kind !== 'submitter' && step.kind !== 'review' && answerableFields(step).length > 0">
<VDivider class="my-5" />
<h3 class="text-subtitle-1 font-weight-medium mb-3">
{{ step.title }}
</h3>
<VRow>
<VCol
v-for="field in answerableFields(step)"
:key="field.id"
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
{{ field.label }}
</p>
<p class="text-body-2 mb-0">
{{ displayValue(field) }}
</p>
</VCol>
</VRow>
</template>
</template>
</VCardText>
</VCard>
</div>
</template>