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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user