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:
@@ -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>
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user