Files
crewli/api/app/Services/FormBuilder/FormSubmissionDuplicateDetector.php
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

105 lines
3.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\FormBuilder;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Models\FormBuilder\FormSubmission;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Detect whether the same submitter email already has submitted
* submissions on the same form schema. Used to render an informational
* hint on the confirmation page — NOT a block. The submission itself
* proceeds normally regardless of what this returns.
*
* Scope: same form_schema_id only. Cross-form detection would leak
* information about other forms and isn't useful UX.
*
* Email source: form_submissions.public_submitter_email (the column
* populated from the stepper's Contactgegevens step). Not values-based
* via the schema's binding config — that hypothetical helper doesn't
* exist in the codebase today and the public flow already has a
* guaranteed email column. When FORM-05's binding-based extractor
* lands, this detector can migrate to it without changing its public
* API.
*
* Failure mode: swallow any exception with Log::error and return an
* empty collection / null. Duplicate detection must never break the
* submit response.
*/
final class FormSubmissionDuplicateDetector
{
/**
* Prior submitted submissions on the same schema from the same
* email address, ordered oldest-first. Excludes the current
* submission. Empty when there is no email to compare, when the
* submission isn't yet persisted, or when detection throws.
*
* @return Collection<int, FormSubmission>
*/
public function findPriorSubmissions(FormSubmission $current): Collection
{
try {
$email = $this->normaliseEmail($current->public_submitter_email);
if ($email === null) {
return new Collection;
}
if ($current->form_schema_id === null || $current->id === null) {
return new Collection;
}
return FormSubmission::query()
->where('form_schema_id', $current->form_schema_id)
->where('status', FormSubmissionStatus::SUBMITTED->value)
->whereRaw('LOWER(TRIM(public_submitter_email)) = ?', [$email])
->where('id', '!=', $current->id)
->orderBy('submitted_at')
->get();
} catch (Throwable $e) {
Log::error('form-builder.duplicate-detector.failed', [
'submission_id' => $current->id ?? null,
'message' => $e->getMessage(),
]);
return new Collection;
}
}
/**
* Shape the detector output for the public submission response.
* Returns null when no priors exist (the common case) so the
* resource can render a nullable block.
*
* @return array{count: int, first_submitted_at: string}|null
*/
public function formatForResponse(FormSubmission $current): ?array
{
$priors = $this->findPriorSubmissions($current);
if ($priors->isEmpty()) {
return null;
}
$first = $priors->first();
return [
'count' => $priors->count(),
'first_submitted_at' => optional($first->submitted_at)->toIso8601String() ?? '',
];
}
private function normaliseEmail(?string $raw): ?string
{
if ($raw === null) {
return null;
}
$normalised = strtolower(trim($raw));
return $normalised === '' ? null : $normalised;
}
}