diff --git a/api/app/FormBuilder/Bindings/BindingConflictResolver.php b/api/app/FormBuilder/Bindings/BindingConflictResolver.php new file mode 100644 index 00000000..1d78e35f --- /dev/null +++ b/api/app/FormBuilder/Bindings/BindingConflictResolver.php @@ -0,0 +1,202 @@ + + */ + public function resolve(FormSubmission $submission, ?string $sectionId = null): array + { + $snapshot = $submission->schema_snapshot; + if (! is_array($snapshot)) { + return []; + } + + // Resolve section filter: snapshot fields carry section_slug, not + // section_id. Build a slug → id lookup so the caller can pass either. + $sectionSlugFilter = $this->resolveSectionSlugFilter($snapshot, $sectionId); + + // Read every form_value row keyed by form_field_id for fast lookup. + $valuesByFieldId = $this->readValuesByFieldId($submission); + + // Walk fields → bindings, collecting candidate entries. + /** @var list, + * value:mixed, + * value_is_explicit:bool, + * }> $candidates */ + $candidates = []; + foreach ($snapshot['fields'] ?? [] as $field) { + if (! is_array($field)) { + continue; + } + $fieldId = (string) ($field['id'] ?? ''); + if ($fieldId === '') { + continue; + } + if ($sectionSlugFilter !== null && ($field['section_slug'] ?? null) !== $sectionSlugFilter) { + continue; + } + $bindings = $field['bindings'] ?? []; + if (! is_array($bindings)) { + continue; + } + if ($bindings === []) { + continue; + } + if (! array_key_exists($fieldId, $valuesByFieldId)) { + // RFC Q7 candidate-set rule: form_value row must exist. + continue; + } + $sortOrder = (int) ($field['sort_order'] ?? 0); + foreach ($bindings as $binding) { + if (! is_array($binding)) { + continue; + } + $candidates[] = [ + 'form_field_id' => $fieldId, + 'sort_order' => $sortOrder, + 'binding' => $binding, + 'value' => $valuesByFieldId[$fieldId], + 'value_is_explicit' => true, + ]; + } + } + + // Group by (entity, attribute), sort by trust_level DESC then sort_order ASC. + /** @var array>> $groups */ + $groups = []; + foreach ($candidates as $candidate) { + $entity = (string) ($candidate['binding']['entity'] ?? ''); + if ($entity === '') { + continue; + } + $attribute = (string) ($candidate['binding']['column'] ?? ''); + if ($attribute === '') { + continue; + } + $key = $entity . '.' . $attribute; + $groups[$key][] = $candidate; + } + + $resolved = []; + foreach ($groups as $group) { + usort($group, static function (array $a, array $b): int { + $trustA = (int) ($a['binding']['trust_level'] ?? 50); + $trustB = (int) ($b['binding']['trust_level'] ?? 50); + if ($trustA !== $trustB) { + return $trustB <=> $trustA; + } + + return $a['sort_order'] <=> $b['sort_order']; + }); + + $winner = $group[0]; + $resolved[] = $this->makeResolvedBinding($winner); + } + + return $resolved; + } + + /** + * Map a candidate row to a ResolvedBinding via the type registry. + * + * @param array{form_field_id:string, sort_order:int, binding:array, value:mixed, value_is_explicit:bool} $candidate + */ + private function makeResolvedBinding(array $candidate): ResolvedBinding + { + $binding = $candidate['binding']; + $entity = (string) $binding['entity']; + $attribute = (string) $binding['column']; + + $meta = $this->registry->resolve($entity, $attribute); + $strategy = FormFieldBindingMergeStrategy::from( + (string) ($binding['merge_strategy'] ?? FormFieldBindingMergeStrategy::Overwrite->value), + ); + + return new ResolvedBinding( + sourceFormFieldId: $candidate['form_field_id'], + bindingId: (string) ($binding['id'] ?? ''), + targetEntity: $entity, + targetAttribute: $attribute, + targetType: $meta->type, + mergeStrategy: $strategy, + trustLevel: (int) ($binding['trust_level'] ?? 50), + isIdentityKey: (bool) ($binding['is_identity_key'] ?? false), + value: $candidate['value'], + valueIsExplicit: $candidate['value_is_explicit'], + ); + } + + /** + * @return array + */ + private function readValuesByFieldId(FormSubmission $submission): array + { + $rows = FormValue::query() + ->withoutGlobalScopes() + ->where('form_submission_id', $submission->id) + ->get(['form_field_id', 'value']); + + $out = []; + foreach ($rows as $row) { + $out[(string) $row->form_field_id] = $row->value; + } + + return $out; + } + + /** + * Snapshot fields store `section_slug`, not section_id (the slug is + * stable across schema versions). Translate the caller's sectionId + * to a slug if necessary. + * + * @param array $snapshot + */ + private function resolveSectionSlugFilter(array $snapshot, ?string $sectionId): ?string + { + if ($sectionId === null) { + return null; + } + + foreach ($snapshot['sections'] ?? [] as $section) { + if (! is_array($section)) { + continue; + } + if ((string) ($section['id'] ?? '') === $sectionId) { + return (string) ($section['slug'] ?? ''); + } + } + + // Caller may have passed a slug already. + return $sectionId; + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 22406f74..12dd8cea 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Providers; +use App\FormBuilder\Bindings\BindingConflictResolver; use App\FormBuilder\Bindings\BindingTypeRegistry; use App\FormBuilder\Bindings\PersonProvisioner; use App\FormBuilder\Purposes\PurposeRegistry; @@ -92,6 +93,7 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(PurposeRegistry::class); $this->app->singleton(BindingTypeRegistry::class); $this->app->singleton(PersonProvisioner::class); + $this->app->singleton(BindingConflictResolver::class); // Telescope is a dev-only debugging dashboard. Three-layer // defense keeps it out of production: composer `dont-discover` diff --git a/api/tests/Unit/FormBuilder/Bindings/BindingConflictResolverTest.php b/api/tests/Unit/FormBuilder/Bindings/BindingConflictResolverTest.php new file mode 100644 index 00000000..6b8c80a6 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Bindings/BindingConflictResolverTest.php @@ -0,0 +1,215 @@ +makeSubmission([ + $this->fieldRow('email', 'person', 'email', trustLevel: 80, value: 'a@example.nl'), + ]); + + $resolved = $this->resolver()->resolve($submission); + $this->assertCount(1, $resolved); + $this->assertSame('a@example.nl', $resolved[0]->value); + $this->assertSame('person', $resolved[0]->targetEntity); + $this->assertSame(BindingTargetType::SCALAR, $resolved[0]->targetType); + } + + public function test_higher_trust_wins(): void + { + $submission = $this->makeSubmission([ + $this->fieldRow('email_low', 'person', 'email', trustLevel: 30, value: 'low@example.nl'), + $this->fieldRow('email_high', 'person', 'email', trustLevel: 90, value: 'high@example.nl'), + ]); + + $resolved = $this->resolver()->resolve($submission); + $this->assertCount(1, $resolved); + $this->assertSame('high@example.nl', $resolved[0]->value); + } + + public function test_equal_trust_breaks_by_sort_order(): void + { + $submission = $this->makeSubmission([ + $this->fieldRow('first_email', 'person', 'email', trustLevel: 50, value: 'first@example.nl', sortOrder: 1), + $this->fieldRow('second_email', 'person', 'email', trustLevel: 50, value: 'second@example.nl', sortOrder: 0), + ]); + + $resolved = $this->resolver()->resolve($submission); + $this->assertCount(1, $resolved); + // sort_order 0 wins over sort_order 1 at equal trust + $this->assertSame('second@example.nl', $resolved[0]->value); + } + + public function test_field_absent_from_form_values_is_not_a_candidate(): void + { + $submission = $this->makeSubmission([ + $this->fieldRow('email_skipped', 'person', 'email', trustLevel: 90, writeValue: false), + $this->fieldRow('first_name', 'person', 'first_name', trustLevel: 50, value: 'Jan'), + ]); + + $resolved = $this->resolver()->resolve($submission); + $this->assertCount(1, $resolved); + $this->assertSame('person.first_name', $resolved[0]->targetEntity . '.' . $resolved[0]->targetAttribute); + } + + public function test_explicit_null_value_is_a_candidate(): void + { + $submission = $this->makeSubmission([ + $this->fieldRow('email', 'person', 'email', trustLevel: 80, value: ''), + ]); + + $resolved = $this->resolver()->resolve($submission); + $this->assertCount(1, $resolved); + $this->assertTrue($resolved[0]->valueIsExplicit); + } + + public function test_section_filter_includes_only_matching_section(): void + { + $submission = $this->makeSubmission([ + $this->fieldRow('a_email', 'person', 'email', trustLevel: 80, value: 'a@example.nl', sectionSlug: 'section-a'), + $this->fieldRow('b_first_name', 'person', 'first_name', trustLevel: 80, value: 'Jan', sectionSlug: 'section-b'), + ]); + $submission->schema_snapshot = array_merge($submission->schema_snapshot, [ + 'sections' => [ + ['id' => 'sec-a-id', 'slug' => 'section-a'], + ['id' => 'sec-b-id', 'slug' => 'section-b'], + ], + ]); + $submission->save(); + + $resolved = $this->resolver()->resolve($submission, sectionId: 'sec-a-id'); + $this->assertCount(1, $resolved); + $this->assertSame('person.email', $resolved[0]->targetEntity . '.' . $resolved[0]->targetAttribute); + } + + public function test_null_section_id_returns_all_candidates(): void + { + $submission = $this->makeSubmission([ + $this->fieldRow('a_email', 'person', 'email', trustLevel: 80, value: 'a@example.nl', sectionSlug: 'section-a'), + $this->fieldRow('b_first_name', 'person', 'first_name', trustLevel: 80, value: 'Jan', sectionSlug: 'section-b'), + ]); + + $resolved = $this->resolver()->resolve($submission); + $this->assertCount(2, $resolved); + } + + public function test_different_target_groups_resolved_independently(): void + { + $submission = $this->makeSubmission([ + $this->fieldRow('email', 'person', 'email', trustLevel: 80, value: 'a@example.nl'), + $this->fieldRow('first_name', 'person', 'first_name', trustLevel: 70, value: 'Jan'), + $this->fieldRow('last_name', 'person', 'last_name', trustLevel: 60, value: 'Jansen'), + ]); + + $resolved = $this->resolver()->resolve($submission); + $this->assertCount(3, $resolved); + $attributes = array_map(fn (ResolvedBinding $r): string => $r->targetAttribute, $resolved); + $this->assertContains('email', $attributes); + $this->assertContains('first_name', $attributes); + $this->assertContains('last_name', $attributes); + } + + public function test_empty_form_values_returns_empty(): void + { + $submission = $this->makeSubmission([]); + $this->assertSame([], $this->resolver()->resolve($submission)); + } + + private function resolver(): BindingConflictResolver + { + return $this->app->make(BindingConflictResolver::class); + } + + /** + * @param list> $fieldRows + */ + private function makeSubmission(array $fieldRows): FormSubmission + { + $schema = FormSchema::factory()->create(); + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $schema->id, + 'organisation_id' => $schema->organisation_id, + ]); + + $snapshotFields = []; + foreach ($fieldRows as $row) { + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'slug' => $row['slug'], + 'sort_order' => $row['sort_order'], + ]); + + $snapshotFields[] = [ + 'id' => (string) $field->id, + 'slug' => (string) $row['slug'], + 'sort_order' => (int) $row['sort_order'], + 'section_slug' => $row['section_slug'] ?? null, + 'bindings' => [[ + 'id' => 'bnd-' . $row['slug'], + 'mode' => 'entity_owned', + 'entity' => $row['entity'], + 'column' => $row['column'], + 'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value, + 'trust_level' => $row['trust_level'], + 'is_identity_key' => false, + ]], + ]; + + if ($row['write_value'] ?? true) { + $val = new FormValue(); + $val->form_submission_id = $submission->id; + $val->form_field_id = $field->id; + $val->setAttribute('value', $row['value']); + $val->value_anonymised = false; + $val->save(); + } + } + + $submission->schema_snapshot = ['fields' => $snapshotFields]; + $submission->save(); + + return $submission->fresh(); + } + + /** + * @return array + */ + private function fieldRow( + string $slug, + string $entity, + string $column, + int $trustLevel, + mixed $value = null, + int $sortOrder = 0, + bool $writeValue = true, + ?string $sectionSlug = null, + ): array { + return [ + 'slug' => $slug, + 'entity' => $entity, + 'column' => $column, + 'trust_level' => $trustLevel, + 'value' => $value ?? '', + 'sort_order' => $sortOrder, + 'write_value' => $writeValue, + 'section_slug' => $sectionSlug, + ]; + } +}