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:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
104
api/app/Services/FormBuilder/FormSubmissionDuplicateDetector.php
Normal file
104
api/app/Services/FormBuilder/FormSubmissionDuplicateDetector.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user