feat(form-builder): extend public form backend for S3a PR 2

- Seed AVAILABILITY_PICKER and SECTION_PRIORITY demo fields in the
  event_registration showcase, and augment seedEchtFeesten with a
  parent-level VOLUNTEER time slot pair + a standard registration-
  visible section whose name duplicates a child section so the
  PublicFormController dedup path is exercised end-to-end.
- Validate SECTION_PRIORITY value shape in FormValueService: arrays of
  { section_id, priority } with unique section_ids + priorities in 1..5,
  max 5 entries, and section_ids scoped to the schema's event tree
  (parent + children). Error envelope is the standard VALIDATION_FAILED
  FieldValidationException shape so the portal renders errors next to
  the field.
- Enrich admin-facing FormSubmissionResource with a nested identity_match
  block mirroring the PublicFormSubmissionResource contract (status only;
  leaves room for future matched_user_id / confidence).
- Lock in the FORM-05 stub contract with 6 tests against the existing
  TriggerPersonIdentityMatchOnFormSubmit listener (no new listener was
  needed — the current one already writes 'pending' for public
  event_registration submissions per ARCH §31.1).
- 24 new backend assertions across seeder, shape validation, listener
  state matrix, and resource serialisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 18:54:58 +02:00
parent d274284fd4
commit 1a87871e94
8 changed files with 957 additions and 0 deletions

View File

@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace App\Services\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormValue;
use App\Models\Scopes\OrganisationScope;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
@@ -85,6 +88,13 @@ final class FormValueService
$errors = [];
$rules = is_array($field->validation_rules) ? $field->validation_rules : [];
if ($field->field_type === FormFieldType::SECTION_PRIORITY->value) {
$shapeErrors = $this->validateSectionPriorityShape($raw, $submission);
if ($shapeErrors !== []) {
return $shapeErrors;
}
}
if ($raw === null || $raw === '' || $raw === []) {
return $errors;
}
@@ -119,6 +129,126 @@ final class FormValueService
return $errors;
}
/**
* Shape validation for SECTION_PRIORITY values per ARCH §5.1:
* `{ section_id, priority }[]` with unique section_ids, unique
* priorities in 1..5, max 5 entries, and section_ids scoped to
* the schema's owner event (parent festival + children).
*
* Empty values pass the `is_required` check lives one layer up in
* the request rule builder.
*
* @return array<int, string> Dutch-language messages for the portal
*/
private function validateSectionPriorityShape(mixed $raw, FormSubmission $submission): array
{
if ($raw === null || $raw === [] || $raw === '') {
return [];
}
if (! is_array($raw) || array_is_list($raw) === false) {
return ['Ongeldig formaat voor sectievoorkeuren.'];
}
$errors = [];
$count = count($raw);
if ($count > 5) {
$errors[] = 'Je kunt maximaal 5 voorkeuren opgeven.';
}
$sectionIds = [];
$priorities = [];
foreach ($raw as $index => $entry) {
if (! is_array($entry)) {
$errors[] = sprintf('Ongeldig voorkeur-element op positie %d.', $index + 1);
continue;
}
$sectionId = $entry['section_id'] ?? null;
$priority = $entry['priority'] ?? null;
if (! is_string($sectionId) || $sectionId === '') {
$errors[] = sprintf('section_id ontbreekt op positie %d.', $index + 1);
}
if (! is_int($priority) && ! (is_string($priority) && ctype_digit($priority))) {
$errors[] = sprintf('priority ontbreekt of is ongeldig op positie %d.', $index + 1);
continue;
}
$priorityInt = (int) $priority;
if ($priorityInt < 1 || $priorityInt > 5) {
$errors[] = sprintf('priority moet tussen 1 en 5 liggen (positie %d).', $index + 1);
}
if (is_string($sectionId) && $sectionId !== '') {
$sectionIds[] = $sectionId;
}
$priorities[] = $priorityInt;
}
if (count($sectionIds) !== count(array_unique($sectionIds))) {
$errors[] = 'Dezelfde sectie mag slechts één keer worden opgegeven.';
}
if (count($priorities) !== count(array_unique($priorities))) {
$errors[] = 'Elke prioriteit mag slechts één keer worden toegekend.';
}
if ($errors !== []) {
return $errors;
}
$allowed = $this->allowedSectionIdsForSubmission($submission);
foreach ($sectionIds as $id) {
if (! in_array($id, $allowed, true)) {
$errors[] = 'Eén of meer secties horen niet bij dit evenement.';
break;
}
}
return $errors;
}
/**
* Resolve the set of festival_sections visible within this schema's
* event scope. Mirrors PublicFormController::festivalEventIds so the
* shape check and the public `/sections` endpoint agree on what is
* addressable.
*
* @return array<int, string>
*/
private function allowedSectionIdsForSubmission(FormSubmission $submission): array
{
$schema = $submission->schema;
if ($schema === null || $schema->owner_type !== 'event' || $schema->owner_id === null) {
return [];
}
$event = Event::query()
->withoutGlobalScope(OrganisationScope::class)
->find($schema->owner_id);
if ($event === null) {
return [];
}
$childIds = Event::query()
->withoutGlobalScope(OrganisationScope::class)
->where('parent_event_id', $event->id)
->pluck('id')
->map(fn ($id) => (string) $id)
->all();
$eventIds = array_values(array_unique(array_merge([(string) $event->id], $childIds)));
return FestivalSection::query()
->withoutGlobalScope(OrganisationScope::class)
->whereIn('event_id', $eventIds)
->pluck('id')
->map(fn ($id) => (string) $id)
->all();
}
private function writeValue(FormSubmission $submission, FormField $field, mixed $raw): void
{
$payload = $this->normalisePayload($field, $raw);