Q6 of ARCH-CONSOLIDATION-ADDENDUM-2026-04-24: the allowed `form_submissions.subject_type` values are now derived from `PurposeRegistry::allSubjectTypes()` instead of the parallel `config/form_subjects.php` file. - CreateFormSubmissionRequest validates `subject_type` against the registry via constructor-injected PurposeRegistry. - FormSubmissionController and FormValueService resolve the subject FQCN through `Relation::getMorphedModel()` — the morph-map is the single source of truth for alias → model mapping. - `config/form_subjects.php` is deleted. `MorphMapAlignmentTest` keeps the registry and morph-map aligned going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
368 lines
13 KiB
PHP
368 lines
13 KiB
PHP
<?php
|
|
|
|
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;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Writes form values with per-field RBAC (FieldAccessService) and pattern
|
|
* A/C entity mirror writes (ARCH §6.1, §6.6). The FormValueObserver handles
|
|
* typed-column + pivot rebuilding — the service only persists the JSON value.
|
|
*/
|
|
final class FormValueService
|
|
{
|
|
public function __construct(
|
|
private readonly FieldAccessService $fieldAccess,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $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 form_fields.validation_rules JSON per
|
|
* S2c D8. The FormFieldRuleBuilder already surfaces min/max/regex
|
|
* shortcuts at the request layer; this is where the deeper checks
|
|
* (is_unique, validation_rules.unique) live.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function validateAgainstFieldRules(FormField $field, mixed $raw, FormSubmission $submission): array
|
|
{
|
|
$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;
|
|
}
|
|
|
|
if (isset($rules['min']) && is_numeric($rules['min']) && is_numeric($raw) && (float) $raw < (float) $rules['min']) {
|
|
$errors[] = sprintf('Minimum is %s.', (string) $rules['min']);
|
|
}
|
|
if (isset($rules['max']) && is_numeric($rules['max']) && is_numeric($raw) && (float) $raw > (float) $rules['max']) {
|
|
$errors[] = sprintf('Maximum is %s.', (string) $rules['max']);
|
|
}
|
|
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.';
|
|
}
|
|
|
|
$unique = (bool) $field->is_unique || (bool) ($rules['unique'] ?? false);
|
|
if ($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<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);
|
|
|
|
/** @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->binding;
|
|
if (! is_array($binding) || ($binding['mode'] ?? null) === null) {
|
|
return;
|
|
}
|
|
|
|
$mode = (string) $binding['mode'];
|
|
if (! in_array($mode, ['entity_owned', 'mirrored'], true)) {
|
|
return;
|
|
}
|
|
|
|
$entity = (string) ($binding['entity'] ?? '');
|
|
$column = (string) ($binding['column'] ?? '');
|
|
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;
|
|
}
|
|
}
|