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>
195 lines
6.9 KiB
PHP
195 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Resources\FormBuilder;
|
|
|
|
use App\Models\FormBuilder\FormSubmission;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\JsonResource;
|
|
|
|
/**
|
|
* Public-facing submission response for /api/v1/public/forms/... endpoints
|
|
* (S2c D7). No PII echo — public_submitter_name / email / ip are stored
|
|
* server-side but never reflected back. Admin metadata (review_status,
|
|
* reviewed_by, schema_snapshot) is never exposed.
|
|
*
|
|
* Shape guarantees:
|
|
* - `identity_match.status` ∈ null | 'pending' | 'matched' | 'none'
|
|
* - `schema_drift` is true when the draft's form_schema.version has
|
|
* advanced since draft creation (D5 contract)
|
|
* - `values` is keyed by field_slug for easy rehydration on the portal
|
|
*
|
|
* @mixin FormSubmission
|
|
*/
|
|
final class PublicFormSubmissionResource extends JsonResource
|
|
{
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function toArray(Request $request): array
|
|
{
|
|
$this->resource->loadMissing(['schema', 'values.field']);
|
|
|
|
$values = [];
|
|
foreach ($this->values as $value) {
|
|
$slug = $value->field?->slug;
|
|
if ($slug === null) {
|
|
continue;
|
|
}
|
|
$values[$slug] = [
|
|
'value' => $value->value,
|
|
'value_anonymised' => (bool) $value->value_anonymised,
|
|
];
|
|
}
|
|
|
|
$status = $this->status instanceof \BackedEnum ? $this->status->value : $this->status;
|
|
|
|
$schemaDrift = $this->computeSchemaDrift();
|
|
$identityMatch = $this->formatIdentityMatch();
|
|
$duplicateSubmission = $this->formatDuplicateSubmission();
|
|
|
|
return [
|
|
'id' => $this->id,
|
|
'form_schema_id' => $this->form_schema_id,
|
|
'status' => $status,
|
|
'auto_save_count' => (int) $this->auto_save_count,
|
|
'submitted_in_locale' => $this->submitted_in_locale,
|
|
'schema_version_at_submit' => $this->schema_version_at_submit,
|
|
'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(),
|
|
'submission_duration_seconds' => $this->submission_duration_seconds,
|
|
'created_at' => optional($this->created_at)->toIso8601String(),
|
|
'updated_at' => optional($this->updated_at)->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
private function computeSchemaDrift(): bool
|
|
{
|
|
// The draft was opened against version_at_open; at submit time the
|
|
// schema's current version is frozen into version_at_submit. Drift
|
|
// means the organiser edited the schema between the portal loading
|
|
// the form and the user hitting submit.
|
|
//
|
|
// Drafts without a submit stamp keep the comparison live against
|
|
// the current schema.version so an organiser edit during an open
|
|
// draft also surfaces drift on subsequent PUT/GET calls.
|
|
|
|
$atOpen = $this->schema_version_at_open;
|
|
if ($atOpen === null) {
|
|
return false;
|
|
}
|
|
|
|
$other = $this->schema_version_at_submit;
|
|
if ($other === null) {
|
|
$schema = $this->schema;
|
|
if ($schema === null) {
|
|
return false;
|
|
}
|
|
$other = (int) $schema->version;
|
|
}
|
|
|
|
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
|
|
* FormSubmissionSubmitted. Read from the dedicated
|
|
* form_submissions.identity_match_status column.
|
|
*
|
|
* @return array<string, string>|null
|
|
*/
|
|
private function formatIdentityMatch(): ?array
|
|
{
|
|
$raw = $this->identity_match_status;
|
|
if ($raw === null || $raw === '') {
|
|
return null;
|
|
}
|
|
$status = $raw instanceof \BackedEnum ? $raw->value : (string) $raw;
|
|
|
|
return [
|
|
'status' => $status,
|
|
'message' => match ($status) {
|
|
'pending' => 'We controleren of je al bekend bent bij de organisator. Je gegevens worden gekoppeld zodra zij dit bevestigen.',
|
|
'matched' => 'Je account is gekoppeld aan een bekende deelnemer.',
|
|
'none' => 'Geen bestaand account gevonden — je wordt als nieuwe deelnemer geregistreerd.',
|
|
default => '',
|
|
},
|
|
];
|
|
}
|
|
}
|