feat(form-builder): public resources — TAG_PICKER tags, schema version, submission shape
S2c D1, D5, D7. PublicFormSchemaResource: - Carries available_tags on every TAG_PICKER field, respecting validation_rules.tag_categories when set. Tags prefetched once per org to avoid N+1. - Surfaces the schema version and an opened_at timestamp so the portal can detect drift between GET-time and submit-time. PublicFormSubmissionResource (new): the submission envelope the portal sees on every lifecycle endpoint. No PII echo — public_submitter_name, public_submitter_email, public_submitter_ip never appear. Admin metadata (review_status, reviewed_by, schema_snapshot, submitter user id) is filtered out. Includes schema_drift computed from schema.version vs schema_version_at_submit, and identity_match read from the §31.1 listener's column with a Dutch message per state (pending|matched|none). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\PersonTag;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -14,6 +17,10 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
* is_admin_only=false; no PII hints, no role_restrictions bleed, no
|
||||
* submissions_count.
|
||||
*
|
||||
* Carries TAG_PICKER `available_tags` so the portal can render the picker
|
||||
* without a second request (S2c D1). Also surfaces `version` + `opened_at`
|
||||
* so the portal can later detect schema drift at submit time (D5).
|
||||
*
|
||||
* @mixin FormSchema
|
||||
*/
|
||||
final class PublicFormSchemaResource extends JsonResource
|
||||
@@ -29,6 +36,9 @@ final class PublicFormSchemaResource extends JsonResource
|
||||
->filter(fn ($f) => (bool) $f->is_portal_visible && ! (bool) $f->is_admin_only)
|
||||
->values();
|
||||
|
||||
$organisationId = $this->organisation_id;
|
||||
$availableTagsByCategory = $this->availableTagsByCategory($organisationId, $visibleFields);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
@@ -36,6 +46,8 @@ final class PublicFormSchemaResource extends JsonResource
|
||||
'purpose' => $this->purpose instanceof \BackedEnum ? $this->purpose->value : $this->purpose,
|
||||
'description' => $this->description,
|
||||
'locale' => $this->locale,
|
||||
'version' => (int) $this->version,
|
||||
'opened_at' => now()->toIso8601String(),
|
||||
'consent_version' => $this->consent_version,
|
||||
'submission_deadline' => optional($this->submission_deadline)->toIso8601String(),
|
||||
'section_level_submit' => (bool) $this->section_level_submit,
|
||||
@@ -46,20 +58,94 @@ final class PublicFormSchemaResource extends JsonResource
|
||||
'description' => $s->description,
|
||||
'sort_order' => (int) $s->sort_order,
|
||||
])->values()->all(),
|
||||
'fields' => $visibleFields->map(fn ($f) => [
|
||||
'id' => $f->id,
|
||||
'slug' => $f->slug,
|
||||
'field_type' => $f->field_type,
|
||||
'label' => $f->label,
|
||||
'help_text' => $f->help_text,
|
||||
'options' => is_array($f->options) ? array_values($f->options) : null,
|
||||
'validation_rules' => $f->validation_rules,
|
||||
'is_required' => (bool) $f->is_required,
|
||||
'display_width' => $f->display_width instanceof \BackedEnum ? $f->display_width->value : $f->display_width,
|
||||
'conditional_logic' => $f->conditional_logic,
|
||||
'sort_order' => (int) $f->sort_order,
|
||||
'form_schema_section_id' => $f->form_schema_section_id,
|
||||
])->values()->all(),
|
||||
'fields' => $visibleFields->map(function (FormField $f) use ($availableTagsByCategory): array {
|
||||
$isTagPicker = $f->field_type === FormFieldType::TAG_PICKER->value;
|
||||
|
||||
return [
|
||||
'id' => $f->id,
|
||||
'slug' => $f->slug,
|
||||
'field_type' => $f->field_type,
|
||||
'label' => $f->label,
|
||||
'help_text' => $f->help_text,
|
||||
'options' => is_array($f->options) ? array_values($f->options) : null,
|
||||
'available_tags' => $isTagPicker
|
||||
? $this->tagsForField($f, $availableTagsByCategory)
|
||||
: null,
|
||||
'validation_rules' => $f->validation_rules,
|
||||
'is_required' => (bool) $f->is_required,
|
||||
'display_width' => $f->display_width instanceof \BackedEnum ? $f->display_width->value : $f->display_width,
|
||||
'conditional_logic' => $f->conditional_logic,
|
||||
'sort_order' => (int) $f->sort_order,
|
||||
'form_schema_section_id' => $f->form_schema_section_id,
|
||||
];
|
||||
})->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch every active person_tag for the org once and bucket by
|
||||
* category so TAG_PICKER fields can apply their own category filter
|
||||
* without N+1 queries.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection<int, FormField> $visibleFields
|
||||
* @return array<string, array<int, array<string, string>>> categoryKey → rows
|
||||
*/
|
||||
private function availableTagsByCategory(?string $organisationId, $visibleFields): array
|
||||
{
|
||||
if ($organisationId === null) {
|
||||
return [];
|
||||
}
|
||||
$hasTagPicker = $visibleFields->contains(fn ($f) => $f->field_type === FormFieldType::TAG_PICKER->value);
|
||||
if (! $hasTagPicker) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = PersonTag::withoutGlobalScopes()
|
||||
->where('organisation_id', $organisationId)
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'category']);
|
||||
|
||||
$grouped = [];
|
||||
foreach ($rows as $tag) {
|
||||
$category = (string) ($tag->category ?? '');
|
||||
$grouped[$category][] = [
|
||||
'id' => (string) $tag->id,
|
||||
'name' => (string) $tag->name,
|
||||
'category' => $category,
|
||||
];
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, array<string, string>>> $byCategory
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function tagsForField(FormField $field, array $byCategory): array
|
||||
{
|
||||
$filter = (array) (($field->validation_rules['tag_categories'] ?? null) ?: []);
|
||||
|
||||
if ($filter === []) {
|
||||
$out = [];
|
||||
foreach ($byCategory as $rows) {
|
||||
foreach ($rows as $row) {
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($filter as $category) {
|
||||
foreach ($byCategory[(string) $category] ?? [] as $row) {
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?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
|
||||
{
|
||||
if ($this->schema_version_at_submit === null) {
|
||||
// Draft phase: drift is "version advanced since the submission
|
||||
// was opened". We use schema.version vs the version the portal
|
||||
// saw via PublicFormSchemaResource at open time — which is
|
||||
// only derivable after submit. For drafts, schema_drift stays
|
||||
// false; the submit response is the authoritative check.
|
||||
return false;
|
||||
}
|
||||
|
||||
$schema = $this->schema;
|
||||
if ($schema === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $schema->version !== (int) $this->schema_version_at_submit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 => '',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user