diff --git a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php index 2daa0ad7..c4bd629c 100644 --- a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php +++ b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php @@ -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 $visibleFields + * @return array>> 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>> $byCategory + * @return array> + */ + 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; + } } diff --git a/api/app/Http/Resources/FormBuilder/PublicFormSubmissionResource.php b/api/app/Http/Resources/FormBuilder/PublicFormSubmissionResource.php new file mode 100644 index 00000000..f086bb48 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/PublicFormSubmissionResource.php @@ -0,0 +1,115 @@ + + */ + 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|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 => '', + }, + ]; + } +}