*/ 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|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|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 => '', }, ]; } }