Files
crewli/api/app/Http/Resources/FormBuilder/PublicFormSubmissionResource.php
bert.hausmans 71d2b4294d feat(form-builder): schema drift detection + PUT auto_save_count
S2c D5 completion: schema_version_at_open column + drift semantics.

- Migration 2026_04_22_100002 adds unsignedInteger schema_version_at_open.
  Recorded by FormSubmissionService::createDraft at the moment the
  portal first renders the form.
- PublicFormSubmissionResource.schema_drift now compares
  schema_version_at_open vs schema_version_at_submit (or
  schema.version for active drafts) so organiser edits during an
  open draft surface as drift on subsequent PUT/submit responses.
- PublicFormSubmissionController::update routes through
  FormSubmissionService::saveDraft so auto_save_count increments
  and the FormSubmissionDraftUpdated event fires per PUT.
- bootstrap/app.php: FormRequest ValidationException on
  /api/v1/public/forms/* is now re-wrapped into the D6 envelope with
  code=VALIDATION_FAILED, so public endpoints emit one consistent
  error shape regardless of layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:03:12 +02:00

125 lines
4.5 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();
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,
'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;
}
/**
* 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 => '',
},
];
}
}