$slugToValue */ public function upsertMany(FormSubmission $submission, array $slugToValue, ?User $actor): void { $schema = $submission->schema; $fields = FormField::query() ->where('form_schema_id', $schema->id) ->whereIn('slug', array_keys($slugToValue)) ->get() ->keyBy('slug'); $fieldErrors = []; DB::transaction(function () use ($slugToValue, $fields, $submission, $actor, &$fieldErrors): void { foreach ($slugToValue as $slug => $raw) { $field = $fields->get($slug); if ($field === null) { continue; } if ($actor === null) { // Public submission path: portal-visible non-admin fields only. if (! (bool) $field->is_portal_visible || (bool) $field->is_admin_only) { throw new AuthorizationException(sprintf('Not allowed to write field "%s" on public submission.', $slug)); } } elseif (! $this->fieldAccess->canWrite($actor, $field, $submission)) { throw new AuthorizationException(sprintf('Not allowed to write field "%s".', $slug)); } $errors = $this->validateAgainstFieldRules($field, $raw, $submission); if ($errors !== []) { $fieldErrors[$slug] = $errors; continue; } $this->writeValue($submission, $field, $raw); $this->writeEntityMirror($submission, $field, $raw); } }); if ($fieldErrors !== []) { throw new \App\Exceptions\FormBuilder\FieldValidationException($fieldErrors); } } /** * Backstop enforcement of per-field validation rules. Rules are sourced * from the relational `form_field_validation_rules` table via * `FormFieldValidationRuleService::toJsonShape()`, which returns the * canonical flat bag: `min_value`/`max_value` for numeric fields, * `min_length`/`max_length` for strings, `regex` for pattern checks. * The pre-WS-5b ambiguous `min`/`max` keys no longer exist. * * Uniqueness: `is_unique` column is the single source of truth (WS-5b * consolidation — the legacy `validation_rules.unique` JSON fallback * was removed in commit 3). * * @return array */ private function validateAgainstFieldRules(FormField $field, mixed $raw, FormSubmission $submission): array { $errors = []; $rules = $this->validationRuleService->toJsonShape($field->validationRules) ?? []; if ($field->field_type === FormFieldType::SECTION_PRIORITY->value) { $shapeErrors = $this->validateSectionPriorityShape($raw, $submission); if ($shapeErrors !== []) { return $shapeErrors; } } if ($raw === null || $raw === '' || $raw === []) { return $errors; } if (isset($rules['min_value']) && is_numeric($rules['min_value']) && is_numeric($raw) && (float) $raw < (float) $rules['min_value']) { $errors[] = sprintf('Minimum is %s.', (string) $rules['min_value']); } if (isset($rules['max_value']) && is_numeric($rules['max_value']) && is_numeric($raw) && (float) $raw > (float) $rules['max_value']) { $errors[] = sprintf('Maximum is %s.', (string) $rules['max_value']); } if (isset($rules['min_length']) && is_numeric($rules['min_length']) && is_string($raw) && mb_strlen($raw) < (int) $rules['min_length']) { $errors[] = sprintf('Minimaal %d tekens.', (int) $rules['min_length']); } if (isset($rules['max_length']) && is_numeric($rules['max_length']) && is_string($raw) && mb_strlen($raw) > (int) $rules['max_length']) { $errors[] = sprintf('Maximaal %d tekens.', (int) $rules['max_length']); } if (isset($rules['regex']) && is_string($rules['regex']) && is_string($raw) && @preg_match($rules['regex'], $raw) !== 1) { $errors[] = 'Value does not match the expected format.'; } if ($field->is_unique) { $scalar = is_scalar($raw) ? (string) $raw : null; if ($scalar !== null) { $exists = \App\Models\FormBuilder\FormValue::query() ->where('form_field_id', $field->id) ->where('value_indexed', $scalar) ->where('form_submission_id', '!=', $submission->id) ->whereHas('submission', fn ($q) => $q->where('status', \App\Enums\FormBuilder\FormSubmissionStatus::SUBMITTED->value)) ->exists(); if ($exists) { $errors[] = 'This value is already in use for another submission.'; } } } 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 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 */ 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); /** @var FormValue|null $value */ $value = FormValue::query() ->where('form_submission_id', $submission->id) ->where('form_field_id', $field->id) ->first(); if ($value === null) { $value = new FormValue; $value->form_submission_id = $submission->id; $value->form_field_id = $field->id; } $value->setRelation('field', $field); $value->value = $payload; $value->value_anonymised = false; $value->save(); } private function normalisePayload(FormField $field, mixed $raw): mixed { $multi = in_array($field->field_type, [ FormFieldType::MULTISELECT->value, FormFieldType::CHECKBOX_LIST->value, FormFieldType::TAG_PICKER->value, FormFieldType::AVAILABILITY_PICKER->value, FormFieldType::SECTION_PRIORITY->value, FormFieldType::TABLE_ROWS->value, ], true); if ($multi) { return is_array($raw) ? array_values($raw) : []; } return $raw; } private function writeEntityMirror(FormSubmission $submission, FormField $field, mixed $raw): void { $binding = $field->bindings()->first(); if ($binding === null) { return; } $entity = (string) $binding->target_entity; $column = (string) $binding->target_attribute; if ($entity === '' || $column === '') { return; } $registry = config('form_binding.'.$entity); if (! is_array($registry) || ! isset($registry[$column]) || ! ($registry[$column]['writable'] ?? false)) { return; } $target = $this->resolveEntityTarget($submission, $entity); if ($target === null) { // Cross-entity Pattern C (person → user_profile) may have null user_id. Log::info('form-builder.mirror.skipped', [ 'submission_id' => $submission->id, 'field_id' => $field->id, 'entity' => $entity, 'column' => $column, 'reason' => 'target_not_resolvable', ]); return; } $scalar = is_scalar($raw) ? $raw : null; $target->{$column} = $scalar; $target->save(); } private function resolveEntityTarget(FormSubmission $submission, string $entity): ?\Illuminate\Database\Eloquent\Model { $subjectType = $submission->subject_type; $subjectId = $submission->subject_id; if ($subjectId === null) { return null; } if ($subjectType === $entity) { $model = \Illuminate\Database\Eloquent\Relations\Relation::getMorphedModel($entity); if ($model === null || ! class_exists($model)) { return null; } return $model::withoutGlobalScopes()->find($subjectId); } // Cross-entity: person → user_profile via person.user_id if ($entity === 'user_profile' && $subjectType === 'person') { $person = \App\Models\Person::withoutGlobalScopes()->find($subjectId); if ($person === null || $person->user_id === null) { return null; } return \App\Models\UserProfile::firstOrCreate(['user_id' => $person->user_id]); } if ($entity === 'user_profile' && $subjectType === 'user') { return \App\Models\UserProfile::firstOrCreate(['user_id' => $subjectId]); } return null; } }