Files
crewli/api/app/Services/FormBuilder/FormValueService.php
bert.hausmans ab67ed46ca refactor(form-builder): consolidate subject-type allow-list into purpose registry
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>
2026-04-24 14:35:48 +02:00

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;
}
}