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

@@ -17,6 +17,7 @@ use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Services\FormBuilder\FormFieldRuleBuilder;
use App\Services\FormBuilder\FormSubmissionDuplicateDetector;
use App\Services\FormBuilder\FormSubmissionService;
use App\Services\FormBuilder\FormValueService;
use App\Services\FormBuilder\PublicFormTokenResolver;
@@ -39,6 +40,7 @@ final class PublicFormSubmissionController extends Controller
private readonly FormSubmissionService $submissionService,
private readonly FormValueService $valueService,
private readonly FormFieldRuleBuilder $ruleBuilder,
private readonly FormSubmissionDuplicateDetector $duplicateDetector,
) {}
public function store(StartPublicDraftRequest $request, string $publicToken): JsonResponse
@@ -149,6 +151,11 @@ final class PublicFormSubmissionController extends Controller
$submission = $this->submissionService->submit($submission->refresh(), null);
RateLimiter::hit('form-submit:'.$publicToken.':'.$request->ip(), 3600);
// Transient attribute for the resource — not persisted, purely
// a response-shaping hint. Detector swallows its own errors so
// a detector failure never blocks the submit response.
$submission->duplicate_submission_data = $this->duplicateDetector->formatForResponse($submission);
return $this->created(new PublicFormSubmissionResource($submission));
}