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\Models\FormBuilder\FormSubmission;
|
||||||
use App\Enums\FormBuilder\FormSubmissionStatus;
|
use App\Enums\FormBuilder\FormSubmissionStatus;
|
||||||
use App\Services\FormBuilder\FormFieldRuleBuilder;
|
use App\Services\FormBuilder\FormFieldRuleBuilder;
|
||||||
|
use App\Services\FormBuilder\FormSubmissionDuplicateDetector;
|
||||||
use App\Services\FormBuilder\FormSubmissionService;
|
use App\Services\FormBuilder\FormSubmissionService;
|
||||||
use App\Services\FormBuilder\FormValueService;
|
use App\Services\FormBuilder\FormValueService;
|
||||||
use App\Services\FormBuilder\PublicFormTokenResolver;
|
use App\Services\FormBuilder\PublicFormTokenResolver;
|
||||||
@@ -39,6 +40,7 @@ final class PublicFormSubmissionController extends Controller
|
|||||||
private readonly FormSubmissionService $submissionService,
|
private readonly FormSubmissionService $submissionService,
|
||||||
private readonly FormValueService $valueService,
|
private readonly FormValueService $valueService,
|
||||||
private readonly FormFieldRuleBuilder $ruleBuilder,
|
private readonly FormFieldRuleBuilder $ruleBuilder,
|
||||||
|
private readonly FormSubmissionDuplicateDetector $duplicateDetector,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(StartPublicDraftRequest $request, string $publicToken): JsonResponse
|
public function store(StartPublicDraftRequest $request, string $publicToken): JsonResponse
|
||||||
@@ -149,6 +151,11 @@ final class PublicFormSubmissionController extends Controller
|
|||||||
$submission = $this->submissionService->submit($submission->refresh(), null);
|
$submission = $this->submissionService->submit($submission->refresh(), null);
|
||||||
RateLimiter::hit('form-submit:'.$publicToken.':'.$request->ip(), 3600);
|
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));
|
return $this->created(new PublicFormSubmissionResource($submission));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ final class PublicFormSubmissionResource extends JsonResource
|
|||||||
|
|
||||||
$schemaDrift = $this->computeSchemaDrift();
|
$schemaDrift = $this->computeSchemaDrift();
|
||||||
$identityMatch = $this->formatIdentityMatch();
|
$identityMatch = $this->formatIdentityMatch();
|
||||||
|
$duplicateSubmission = $this->formatDuplicateSubmission();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
@@ -58,6 +59,7 @@ final class PublicFormSubmissionResource extends JsonResource
|
|||||||
'schema_drift' => $schemaDrift,
|
'schema_drift' => $schemaDrift,
|
||||||
'values' => $values,
|
'values' => $values,
|
||||||
'identity_match' => $identityMatch,
|
'identity_match' => $identityMatch,
|
||||||
|
'duplicate_submission' => $duplicateSubmission,
|
||||||
'opened_at' => optional($this->opened_at)->toIso8601String(),
|
'opened_at' => optional($this->opened_at)->toIso8601String(),
|
||||||
'first_interacted_at' => optional($this->first_interacted_at)->toIso8601String(),
|
'first_interacted_at' => optional($this->first_interacted_at)->toIso8601String(),
|
||||||
'submitted_at' => optional($this->submitted_at)->toIso8601String(),
|
'submitted_at' => optional($this->submitted_at)->toIso8601String(),
|
||||||
@@ -95,6 +97,74 @@ final class PublicFormSubmissionResource extends JsonResource
|
|||||||
return (int) $atOpen !== (int) $other;
|
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
|
* Identity-match signal per ARCH §31.1. Populated by the
|
||||||
* TriggerPersonIdentityMatchOnFormSubmit listener on
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\FormBuilder;
|
||||||
|
|
||||||
|
use App\Enums\FormBuilder\FormPurpose;
|
||||||
|
use App\Enums\FormBuilder\FormSubmissionStatus;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\FormBuilder\FormSchema;
|
||||||
|
use App\Models\FormBuilder\FormSubmission;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Services\FormBuilder\FormSubmissionDuplicateDetector;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class FormSubmissionDuplicateDetectorTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Organisation $org;
|
||||||
|
|
||||||
|
private Event $event;
|
||||||
|
|
||||||
|
private FormSchema $schema;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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<string, mixed> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\FormBuilder;
|
||||||
|
|
||||||
|
use App\Enums\FormBuilder\FormFieldType;
|
||||||
|
use App\Enums\FormBuilder\FormPurpose;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\FormBuilder\FormField;
|
||||||
|
use App\Models\FormBuilder\FormSchema;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end: duplicate_submission shows up in the submit response
|
||||||
|
* body when (and only when) a prior submitted submission exists on
|
||||||
|
* the same schema with the same email.
|
||||||
|
*/
|
||||||
|
final class PublicFormSubmissionDuplicateResponseTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Organisation $org;
|
||||||
|
|
||||||
|
private Event $event;
|
||||||
|
|
||||||
|
private FormSchema $schema;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/portal/components.d.ts
vendored
1
apps/portal/components.d.ts
vendored
@@ -33,6 +33,7 @@ declare module 'vue' {
|
|||||||
CustomRadiosWithImage: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithImage.vue')['default']
|
CustomRadiosWithImage: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithImage.vue')['default']
|
||||||
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
|
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
|
||||||
DropZone: typeof import('./src/@core/components/DropZone.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']
|
EventCard: typeof import('./src/components/portal/EventCard.vue')['default']
|
||||||
FieldAvailabilityPicker: typeof import('./src/components/public-form/FieldAvailabilityPicker.vue')['default']
|
FieldAvailabilityPicker: typeof import('./src/components/public-form/FieldAvailabilityPicker.vue')['default']
|
||||||
FieldBoolean: typeof import('./src/components/public-form/FieldBoolean.vue')['default']
|
FieldBoolean: typeof import('./src/components/public-form/FieldBoolean.vue')['default']
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PublicFormSubmissionDuplicate } from '@/types/formBuilder'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: PublicFormSubmissionDuplicate | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Backend is the single source of truth for copy (see
|
||||||
|
// PublicFormSubmissionResource::formatDuplicateSubmission for plural
|
||||||
|
// agreement + Dutch long date formatting). Frontend keeps a fallback
|
||||||
|
// for the three pieces the backend always sets, so a future response
|
||||||
|
// that trims `title` / `body` still renders a coherent hint.
|
||||||
|
const FALLBACK_TITLE = 'Je hebt je eerder al aangemeld'
|
||||||
|
|
||||||
|
const dutchDateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDutchDate(iso: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
try {
|
||||||
|
return dutchDateFormatter.format(new Date(iso))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackBody(data: PublicFormSubmissionDuplicate): string {
|
||||||
|
const date = formatDutchDate(data.first_submitted_at)
|
||||||
|
|
||||||
|
return data.count === 1
|
||||||
|
? `Op ${date} heb je dit formulier ook al ingevuld. De organisator ziet beide aanmeldingen en neemt zo snel mogelijk contact op.`
|
||||||
|
: `Je hebt dit formulier al ${data.count} keer eerder ingevuld (voor het eerst op ${date}). De organisator ziet alle aanmeldingen en neemt zo snel mogelijk contact op.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (!props.data) return ''
|
||||||
|
|
||||||
|
return props.data.title?.trim() || FALLBACK_TITLE
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = computed(() => {
|
||||||
|
if (!props.data) return ''
|
||||||
|
const fromBackend = props.data.body?.trim()
|
||||||
|
if (fromBackend) return fromBackend
|
||||||
|
|
||||||
|
return fallbackBody(props.data)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VAlert
|
||||||
|
v-if="data"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
prominent
|
||||||
|
class="duplicate-submission-hint mb-4"
|
||||||
|
>
|
||||||
|
<div class="text-subtitle-1 font-weight-medium mb-1">
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2">
|
||||||
|
{{ body }}
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import DuplicateSubmissionHint from './DuplicateSubmissionHint.vue'
|
||||||
import IdentityMatchBanner from './IdentityMatchBanner.vue'
|
import IdentityMatchBanner from './IdentityMatchBanner.vue'
|
||||||
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
||||||
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
||||||
@@ -6,7 +7,12 @@ import { formatFieldValue } from '@/composables/formatFieldValue'
|
|||||||
import type { FormStep } from '@/composables/useFormSteps'
|
import type { FormStep } from '@/composables/useFormSteps'
|
||||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||||
import { FormFieldType } from '@/types/formBuilder'
|
import { FormFieldType } from '@/types/formBuilder'
|
||||||
import type { FormValues, PublicFormField, PublicFormSubmissionIdentityMatch } from '@/types/formBuilder'
|
import type {
|
||||||
|
FormValues,
|
||||||
|
PublicFormField,
|
||||||
|
PublicFormSubmissionDuplicate,
|
||||||
|
PublicFormSubmissionIdentityMatch,
|
||||||
|
} from '@/types/formBuilder'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
steps: FormStep[]
|
steps: FormStep[]
|
||||||
@@ -14,6 +20,7 @@ const props = defineProps<{
|
|||||||
submitterName?: string
|
submitterName?: string
|
||||||
submitterEmail?: string
|
submitterEmail?: string
|
||||||
identityMatch?: PublicFormSubmissionIdentityMatch | null
|
identityMatch?: PublicFormSubmissionIdentityMatch | null
|
||||||
|
duplicateSubmission?: PublicFormSubmissionDuplicate | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// TanStack Query calls — these hit the same cache the field components
|
// TanStack Query calls — these hit the same cache the field components
|
||||||
@@ -67,10 +74,14 @@ function answerableFields(step: FormStep): PublicFormField[] {
|
|||||||
<VDivider />
|
<VDivider />
|
||||||
|
|
||||||
<VCardText
|
<VCardText
|
||||||
v-if="identityMatch"
|
v-if="duplicateSubmission || identityMatch"
|
||||||
class="pa-6 pb-0"
|
class="pa-6 pb-0"
|
||||||
>
|
>
|
||||||
|
<!-- Duplicate hint first: it's about the act of submitting.
|
||||||
|
Identity match second: it's about who you are. -->
|
||||||
|
<DuplicateSubmissionHint :data="duplicateSubmission ?? null" />
|
||||||
<IdentityMatchBanner
|
<IdentityMatchBanner
|
||||||
|
v-if="identityMatch"
|
||||||
:status="identityMatch.status"
|
:status="identityMatch.status"
|
||||||
:message="identityMatch.message"
|
:message="identityMatch.message"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ function formatReviewValue(field: PublicFormField): string {
|
|||||||
:submitter-name="draft.submitterName.value"
|
:submitter-name="draft.submitterName.value"
|
||||||
:submitter-email="draft.submitterEmail.value"
|
:submitter-email="draft.submitterEmail.value"
|
||||||
:identity-match="draft.submission.value?.identity_match ?? null"
|
:identity-match="draft.submission.value?.identity_match ?? null"
|
||||||
|
:duplicate-submission="draft.submission.value?.duplicate_submission ?? null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Data state -->
|
<!-- Data state -->
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ export interface PublicFormSubmissionIdentityMatch {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicFormSubmissionDuplicate {
|
||||||
|
count: number
|
||||||
|
first_submitted_at: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicFormSubmission {
|
export interface PublicFormSubmission {
|
||||||
id: string
|
id: string
|
||||||
form_schema_id: string
|
form_schema_id: string
|
||||||
@@ -142,6 +149,7 @@ export interface PublicFormSubmission {
|
|||||||
schema_drift: boolean
|
schema_drift: boolean
|
||||||
values: Record<string, PublicFormSubmissionValue>
|
values: Record<string, PublicFormSubmissionValue>
|
||||||
identity_match: PublicFormSubmissionIdentityMatch | null
|
identity_match: PublicFormSubmissionIdentityMatch | null
|
||||||
|
duplicate_submission: PublicFormSubmissionDuplicate | null
|
||||||
opened_at: string | null
|
opened_at: string | null
|
||||||
first_interacted_at: string | null
|
first_interacted_at: string | null
|
||||||
submitted_at: string | null
|
submitted_at: string | null
|
||||||
|
|||||||
80
apps/portal/tests/unit/DuplicateSubmissionHint.spec.ts
Normal file
80
apps/portal/tests/unit/DuplicateSubmissionHint.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import DuplicateSubmissionHint from '@/components/public-form/DuplicateSubmissionHint.vue'
|
||||||
|
import type { PublicFormSubmissionDuplicate } from '@/types/formBuilder'
|
||||||
|
|
||||||
|
function mountHint(data: PublicFormSubmissionDuplicate | null) {
|
||||||
|
return mount(DuplicateSubmissionHint, {
|
||||||
|
props: { data },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
VAlert: {
|
||||||
|
name: 'VAlert',
|
||||||
|
props: ['type', 'variant', 'prominent'],
|
||||||
|
template: '<div class="v-alert-stub" :data-type="type" :data-variant="variant"><slot/></div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DuplicateSubmissionHint', () => {
|
||||||
|
it('renders nothing when data is null', () => {
|
||||||
|
const w = mountHint(null)
|
||||||
|
|
||||||
|
expect(w.find('.v-alert-stub').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the backend title and body when provided', () => {
|
||||||
|
const w = mountHint({
|
||||||
|
count: 1,
|
||||||
|
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||||
|
title: 'Je hebt je eerder al aangemeld',
|
||||||
|
body: 'Op 22 april 2026 heb je dit formulier ook al ingevuld.',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(w.text()).toContain('Je hebt je eerder al aangemeld')
|
||||||
|
expect(w.text()).toContain('Op 22 april 2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to singular copy when the backend body is missing (count=1)', () => {
|
||||||
|
const w = mountHint({
|
||||||
|
count: 1,
|
||||||
|
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fallback title + body.
|
||||||
|
expect(w.text()).toContain('Je hebt je eerder al aangemeld')
|
||||||
|
expect(w.text()).toMatch(/Op\s+22\s+april\s+2026.*ook al ingevuld/)
|
||||||
|
expect(w.text()).toContain('De organisator ziet beide aanmeldingen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to plural copy with count when the backend body is missing', () => {
|
||||||
|
const w = mountHint({
|
||||||
|
count: 3,
|
||||||
|
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(w.text()).toContain('3 keer eerder ingevuld')
|
||||||
|
expect(w.text()).toContain('22 april 2026')
|
||||||
|
expect(w.text()).toContain('De organisator ziet alle aanmeldingen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders as a warning-typed tonal VAlert', () => {
|
||||||
|
const w = mountHint({
|
||||||
|
count: 1,
|
||||||
|
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||||
|
title: 'x',
|
||||||
|
body: 'y',
|
||||||
|
})
|
||||||
|
|
||||||
|
const alert = w.find('.v-alert-stub')
|
||||||
|
expect(alert.exists()).toBe(true)
|
||||||
|
expect(alert.attributes('data-type')).toBe('warning')
|
||||||
|
expect(alert.attributes('data-variant')).toBe('tonal')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user