From b6a3a17b0afbab7e08bf2466abf25b330c5e790e Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Thu, 23 Apr 2026 22:26:58 +0200 Subject: [PATCH] feat(form-builder): detect duplicate submissions by email on same form schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../PublicFormSubmissionController.php | 7 + .../PublicFormSubmissionResource.php | 70 +++++++ .../FormSubmissionDuplicateDetector.php | 104 +++++++++ .../FormSubmissionDuplicateDetectorTest.php | 197 ++++++++++++++++++ ...licFormSubmissionDuplicateResponseTest.php | 160 ++++++++++++++ apps/portal/components.d.ts | 1 + .../public-form/DuplicateSubmissionHint.vue | 68 ++++++ .../public-form/FormConfirmation.vue | 15 +- .../src/pages/register/[public_token].vue | 1 + apps/portal/src/types/formBuilder.ts | 8 + .../unit/DuplicateSubmissionHint.spec.ts | 80 +++++++ 11 files changed, 709 insertions(+), 2 deletions(-) create mode 100644 api/app/Services/FormBuilder/FormSubmissionDuplicateDetector.php create mode 100644 api/tests/Feature/FormBuilder/FormSubmissionDuplicateDetectorTest.php create mode 100644 api/tests/Feature/FormBuilder/PublicFormSubmissionDuplicateResponseTest.php create mode 100644 apps/portal/src/components/public-form/DuplicateSubmissionHint.vue create mode 100644 apps/portal/tests/unit/DuplicateSubmissionHint.spec.ts diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormSubmissionController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormSubmissionController.php index 17a34288..6d091045 100644 --- a/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormSubmissionController.php +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormSubmissionController.php @@ -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)); } diff --git a/api/app/Http/Resources/FormBuilder/PublicFormSubmissionResource.php b/api/app/Http/Resources/FormBuilder/PublicFormSubmissionResource.php index b9ab699e..96c296e9 100644 --- a/api/app/Http/Resources/FormBuilder/PublicFormSubmissionResource.php +++ b/api/app/Http/Resources/FormBuilder/PublicFormSubmissionResource.php @@ -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|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 diff --git a/api/app/Services/FormBuilder/FormSubmissionDuplicateDetector.php b/api/app/Services/FormBuilder/FormSubmissionDuplicateDetector.php new file mode 100644 index 00000000..448bd92f --- /dev/null +++ b/api/app/Services/FormBuilder/FormSubmissionDuplicateDetector.php @@ -0,0 +1,104 @@ + + */ + 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; + } +} diff --git a/api/tests/Feature/FormBuilder/FormSubmissionDuplicateDetectorTest.php b/api/tests/Feature/FormBuilder/FormSubmissionDuplicateDetectorTest.php new file mode 100644 index 00000000..2976a6db --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormSubmissionDuplicateDetectorTest.php @@ -0,0 +1,197 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'owner_type' => 'event', + 'owner_id' => $this->event->id, + ]); + } + + /** @param array $overrides */ + private function submission(array $overrides = []): FormSubmission + { + return FormSubmission::create(array_merge([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => null, + 'subject_id' => null, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now(), + 'is_test' => false, + 'public_submitter_email' => 'default@example.test', + ], $overrides)); + } + + public function test_returns_empty_when_no_other_submissions_exist(): void + { + $current = $this->submission(['public_submitter_email' => 'only@example.test']); + + $priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current); + + $this->assertTrue($priors->isEmpty()); + } + + public function test_returns_one_prior_for_same_email_same_schema_submitted(): void + { + $this->submission([ + 'public_submitter_email' => 'dup@example.test', + 'submitted_at' => now()->subDay(), + ]); + $current = $this->submission(['public_submitter_email' => 'dup@example.test']); + + $priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current); + + $this->assertSame(1, $priors->count()); + } + + public function test_returns_priors_ordered_by_submitted_at_ascending(): void + { + $first = $this->submission([ + 'public_submitter_email' => 'dup@example.test', + 'submitted_at' => now()->subDays(3), + ]); + $second = $this->submission([ + 'public_submitter_email' => 'dup@example.test', + 'submitted_at' => now()->subDays(1), + ]); + $current = $this->submission(['public_submitter_email' => 'dup@example.test']); + + $priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current); + + $this->assertSame(2, $priors->count()); + $this->assertSame($first->id, $priors[0]->id); + $this->assertSame($second->id, $priors[1]->id); + } + + public function test_excludes_current_submission(): void + { + $current = $this->submission(['public_submitter_email' => 'solo@example.test']); + + $priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current); + + $this->assertFalse($priors->contains('id', $current->id)); + } + + public function test_excludes_drafts(): void + { + $this->submission([ + 'public_submitter_email' => 'dup@example.test', + 'status' => FormSubmissionStatus::DRAFT->value, + 'submitted_at' => null, + ]); + $current = $this->submission(['public_submitter_email' => 'dup@example.test']); + + $priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current); + + $this->assertTrue($priors->isEmpty()); + } + + public function test_excludes_other_schemas_with_same_email(): void + { + $otherSchema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'owner_type' => 'event', + 'owner_id' => $this->event->id, + ]); + FormSubmission::create([ + 'form_schema_id' => $otherSchema->id, + 'subject_type' => null, + 'subject_id' => null, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now()->subDay(), + 'is_test' => false, + 'public_submitter_email' => 'scope@example.test', + ]); + $current = $this->submission(['public_submitter_email' => 'scope@example.test']); + + $priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current); + + $this->assertTrue($priors->isEmpty()); + } + + public function test_returns_empty_when_current_submission_has_no_email(): void + { + $current = $this->submission(['public_submitter_email' => null]); + + $priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current); + + $this->assertTrue($priors->isEmpty()); + } + + public function test_email_match_is_case_insensitive_and_trimmed(): void + { + $this->submission([ + 'public_submitter_email' => ' Dup@Example.TEST ', + 'submitted_at' => now()->subDay(), + ]); + $current = $this->submission(['public_submitter_email' => 'dup@example.test']); + + $priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current); + + $this->assertSame(1, $priors->count()); + } + + public function test_format_for_response_shapes_count_and_first_date(): void + { + $first = $this->submission([ + 'public_submitter_email' => 'dup@example.test', + 'submitted_at' => now()->subDays(3), + ]); + $this->submission([ + 'public_submitter_email' => 'dup@example.test', + 'submitted_at' => now()->subDay(), + ]); + $current = $this->submission(['public_submitter_email' => 'dup@example.test']); + + $payload = app(FormSubmissionDuplicateDetector::class)->formatForResponse($current); + + $this->assertIsArray($payload); + $this->assertSame(2, $payload['count']); + $this->assertSame( + $first->submitted_at->toIso8601String(), + $payload['first_submitted_at'], + ); + } + + public function test_format_for_response_returns_null_when_no_priors(): void + { + $current = $this->submission(['public_submitter_email' => 'only@example.test']); + + $payload = app(FormSubmissionDuplicateDetector::class)->formatForResponse($current); + + $this->assertNull($payload); + } +} diff --git a/api/tests/Feature/FormBuilder/PublicFormSubmissionDuplicateResponseTest.php b/api/tests/Feature/FormBuilder/PublicFormSubmissionDuplicateResponseTest.php new file mode 100644 index 00000000..e5381351 --- /dev/null +++ b/api/tests/Feature/FormBuilder/PublicFormSubmissionDuplicateResponseTest.php @@ -0,0 +1,160 @@ +seed(RoleSeeder::class); + Config::set('form_builder.captcha.required_for_purposes', []); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'owner_type' => 'event', + 'owner_id' => $this->event->id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'motivatie', + 'label' => 'Motivatie', + 'is_portal_visible' => true, + 'is_admin_only' => false, + ]); + } + + private function submitAs(string $email, string $idempotencyKey, ?string $schemaOverride = null): array + { + $token = $schemaOverride ?? $this->schema->public_token; + + $create = $this->postJson( + "/api/v1/public/forms/{$token}/submissions", + [ + 'idempotency_key' => $idempotencyKey, + 'public_submitter_name' => 'Bart', + 'public_submitter_email' => $email, + ], + ); + $create->assertCreated(); + $submissionId = $create->json('data.id'); + + $submit = $this->postJson( + "/api/v1/public/forms/{$token}/submissions/{$submissionId}/submit", + ['values' => ['motivatie' => 'x']], + ); + $submit->assertCreated(); + + return $submit->json('data'); + } + + public function test_first_submit_has_null_duplicate_submission(): void + { + $data = $this->submitAs('test@example.test', 'dup-regression-001'); + + $this->assertNull($data['duplicate_submission']); + } + + public function test_second_submit_same_email_same_schema_exposes_count_one(): void + { + $this->submitAs('test@example.test', 'dup-regression-010'); + $data = $this->submitAs('test@example.test', 'dup-regression-011'); + + $this->assertIsArray($data['duplicate_submission']); + $this->assertSame(1, $data['duplicate_submission']['count']); + $this->assertNotEmpty($data['duplicate_submission']['first_submitted_at']); + } + + public function test_third_submit_exposes_count_two_and_first_date_points_to_first(): void + { + $first = $this->submitAs('test@example.test', 'dup-regression-020'); + $this->submitAs('test@example.test', 'dup-regression-021'); + $third = $this->submitAs('test@example.test', 'dup-regression-022'); + + $this->assertSame(2, $third['duplicate_submission']['count']); + $this->assertSame( + $first['submitted_at'], + $third['duplicate_submission']['first_submitted_at'], + 'first_submitted_at must continue to point at the oldest submission', + ); + } + + public function test_different_email_same_schema_sees_no_duplicate(): void + { + $this->submitAs('first@example.test', 'dup-regression-030'); + $data = $this->submitAs('second@example.test', 'dup-regression-031'); + + $this->assertNull($data['duplicate_submission']); + } + + public function test_same_email_different_schema_sees_no_duplicate(): void + { + $this->submitAs('test@example.test', 'dup-regression-040'); + + $otherSchema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'owner_type' => 'event', + 'owner_id' => $this->event->id, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + FormField::factory()->create([ + 'form_schema_id' => $otherSchema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'motivatie', + 'label' => 'Motivatie', + 'is_portal_visible' => true, + 'is_admin_only' => false, + ]); + + $data = $this->submitAs('test@example.test', 'dup-regression-041', $otherSchema->public_token); + + $this->assertNull($data['duplicate_submission']); + } + + public function test_duplicate_submission_block_includes_dutch_title_and_body(): void + { + $this->submitAs('test@example.test', 'dup-regression-050'); + $data = $this->submitAs('test@example.test', 'dup-regression-051'); + + $this->assertSame('Je hebt je eerder al aangemeld', $data['duplicate_submission']['title']); + $this->assertStringContainsString( + 'De organisator ziet beide aanmeldingen', + $data['duplicate_submission']['body'], + ); + } +} diff --git a/apps/portal/components.d.ts b/apps/portal/components.d.ts index 277ac411..5553626a 100644 --- a/apps/portal/components.d.ts +++ b/apps/portal/components.d.ts @@ -33,6 +33,7 @@ declare module 'vue' { CustomRadiosWithImage: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithImage.vue')['default'] DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default'] DropZone: typeof import('./src/@core/components/DropZone.vue')['default'] + DuplicateSubmissionHint: typeof import('./src/components/public-form/DuplicateSubmissionHint.vue')['default'] EventCard: typeof import('./src/components/portal/EventCard.vue')['default'] FieldAvailabilityPicker: typeof import('./src/components/public-form/FieldAvailabilityPicker.vue')['default'] FieldBoolean: typeof import('./src/components/public-form/FieldBoolean.vue')['default'] diff --git a/apps/portal/src/components/public-form/DuplicateSubmissionHint.vue b/apps/portal/src/components/public-form/DuplicateSubmissionHint.vue new file mode 100644 index 00000000..b24e8ec8 --- /dev/null +++ b/apps/portal/src/components/public-form/DuplicateSubmissionHint.vue @@ -0,0 +1,68 @@ + + + diff --git a/apps/portal/src/components/public-form/FormConfirmation.vue b/apps/portal/src/components/public-form/FormConfirmation.vue index 37ca815f..9876eb77 100644 --- a/apps/portal/src/components/public-form/FormConfirmation.vue +++ b/apps/portal/src/components/public-form/FormConfirmation.vue @@ -1,4 +1,5 @@