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

@@ -47,6 +47,7 @@ final class PublicFormSubmissionResource extends JsonResource
$schemaDrift = $this->computeSchemaDrift();
$identityMatch = $this->formatIdentityMatch();
$duplicateSubmission = $this->formatDuplicateSubmission();
return [
'id' => $this->id,
@@ -58,6 +59,7 @@ final class PublicFormSubmissionResource extends JsonResource
'schema_drift' => $schemaDrift,
'values' => $values,
'identity_match' => $identityMatch,
'duplicate_submission' => $duplicateSubmission,
'opened_at' => optional($this->opened_at)->toIso8601String(),
'first_interacted_at' => optional($this->first_interacted_at)->toIso8601String(),
'submitted_at' => optional($this->submitted_at)->toIso8601String(),
@@ -95,6 +97,74 @@ final class PublicFormSubmissionResource extends JsonResource
return (int) $atOpen !== (int) $other;
}
/**
* Duplicate-submission signal. The controller sets
* `duplicate_submission_data` transiently on the submission model
* after calling FormSubmissionDuplicateDetector::formatForResponse;
* when null (no priors, missing email, or detector error) the
* response renders null and the portal skips the hint.
*
* Copy source of truth: frontend falls back to Dutch strings but
* the backend attaches `title` + `body` so the portal can render
* without maintaining its own plural-agreement logic.
*
* @return array<string, mixed>|null
*/
private function formatDuplicateSubmission(): ?array
{
$raw = $this->getAttribute('duplicate_submission_data');
if (! is_array($raw)) {
return null;
}
$count = (int) ($raw['count'] ?? 0);
if ($count < 1) {
return null;
}
$firstIso = (string) ($raw['first_submitted_at'] ?? '');
$formattedDate = $firstIso !== ''
? $this->formatDutchLongDate($firstIso)
: '';
return [
'count' => $count,
'first_submitted_at' => $firstIso,
'title' => 'Je hebt je eerder al aangemeld',
'body' => $count === 1
? sprintf(
'Op %s heb je dit formulier ook al ingevuld. De organisator ziet beide aanmeldingen en neemt zo snel mogelijk contact op.',
$formattedDate,
)
: sprintf(
'Je hebt dit formulier al %d keer eerder ingevuld (voor het eerst op %s). De organisator ziet alle aanmeldingen en neemt zo snel mogelijk contact op.',
$count,
$formattedDate,
),
];
}
private function formatDutchLongDate(string $iso): string
{
try {
$formatter = new \IntlDateFormatter(
'nl_NL',
\IntlDateFormatter::LONG,
\IntlDateFormatter::NONE,
'Europe/Amsterdam',
);
$ts = strtotime($iso);
if ($ts === false) {
return '';
}
$out = $formatter->format($ts);
return is_string($out) ? $out : '';
} catch (\Throwable) {
return '';
}
}
/**
* Identity-match signal per ARCH §31.1. Populated by the
* TriggerPersonIdentityMatchOnFormSubmit listener on