diff --git a/api/app/Exceptions/FormBuilder/InvalidBindingTargetException.php b/api/app/Exceptions/FormBuilder/InvalidBindingTargetException.php new file mode 100644 index 00000000..c00bc894 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/InvalidBindingTargetException.php @@ -0,0 +1,25 @@ +>|null + */ + private ?array $cache = null; + + public function __construct(private readonly ConfigRepository $config) {} + + /** + * @throws UnknownBindingTargetException + */ + public function resolve(string $entity, string $attribute): BindingTargetMeta + { + $entries = $this->resolveTable(); + if (! isset($entries[$entity][$attribute])) { + throw new UnknownBindingTargetException($entity, $attribute); + } + + return $entries[$entity][$attribute]; + } + + public function isKnown(string $entity, string $attribute): bool + { + $entries = $this->resolveTable(); + + return isset($entries[$entity][$attribute]); + } + + public function isIdentityKeyEligible(string $entity, string $attribute): bool + { + return $this->isKnown($entity, $attribute) + && $this->resolve($entity, $attribute)->identityKeyEligible; + } + + /** + * @return list + */ + public function entities(): array + { + return array_keys($this->resolveTable()); + } + + /** + * @return list + */ + public function attributesFor(string $entity): array + { + $entries = $this->resolveTable(); + if (! isset($entries[$entity])) { + return []; + } + + return array_keys($entries[$entity]); + } + + /** + * @throws UnknownBindingTargetException when (entity, attribute) is unknown + * @throws InvalidBindingTargetException when Append is paired with a non-COLLECTION target + */ + public function validateAppendStrategy( + string $entity, + string $attribute, + FormFieldBindingMergeStrategy $strategy, + ): void { + if ($strategy !== FormFieldBindingMergeStrategy::Append) { + return; + } + + $meta = $this->resolve($entity, $attribute); + + if ($meta->type !== BindingTargetType::COLLECTION) { + throw new InvalidBindingTargetException( + entity: $entity, + attribute: $attribute, + reason: "merge_strategy=append requires a COLLECTION target; got {$meta->type->value}", + ); + } + } + + /** + * @return array> + */ + private function resolveTable(): array + { + if ($this->cache !== null) { + return $this->cache; + } + + /** @var array> $raw */ + $raw = $this->config->get('form_builder.binding_targets', []); + + $built = []; + foreach ($raw as $entity => $attributes) { + foreach ($attributes as $attribute => $row) { + $built[$entity][$attribute] = new BindingTargetMeta( + type: BindingTargetType::from($row['type']), + php: $row['php'], + identityKeyEligible: $row['identity_key_eligible'], + ); + } + } + + $this->cache = $built; + + return $built; + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index d66eccac..d32df645 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\BindingTypeRegistry; use App\FormBuilder\Purposes\PurposeRegistry; use App\Models\Company; use App\Models\CrowdList; @@ -82,8 +83,13 @@ class AppServiceProvider extends ServiceProvider base_path('config/form_builder/purposes.php'), 'form_builder.purposes', ); + $this->mergeConfigFrom( + base_path('config/form_builder/binding_targets.php'), + 'form_builder.binding_targets', + ); $this->app->singleton(PurposeRegistry::class); + $this->app->singleton(BindingTypeRegistry::class); // Telescope is a dev-only debugging dashboard. Three-layer // defense keeps it out of production: composer `dont-discover` diff --git a/api/config/form_builder/binding_targets.php b/api/config/form_builder/binding_targets.php new file mode 100644 index 00000000..886ac151 --- /dev/null +++ b/api/config/form_builder/binding_targets.php @@ -0,0 +1,49 @@ + [ + 'email' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true], + 'first_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false], + 'last_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false], + 'date_of_birth' => ['type' => 'scalar', 'php' => 'date', 'identity_key_eligible' => false], + 'phone_number' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false], + 'dietary_preferences' => ['type' => 'collection', 'php' => 'array', 'identity_key_eligible' => false], + ], + 'artist' => [ + 'email' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true], + 'stage_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false], + 'tech_rider' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false], + 'hospitality_rider' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false], + ], + 'company' => [ + 'name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true], + 'email' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true], + 'kvk_number' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true], + 'phone_number' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false], + ], + 'user' => [ + 'email' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => true], + 'first_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false], + 'last_name' => ['type' => 'scalar', 'php' => 'string', 'identity_key_eligible' => false], + ], +]; diff --git a/api/tests/Unit/FormBuilder/Bindings/BindingTypeRegistryTest.php b/api/tests/Unit/FormBuilder/Bindings/BindingTypeRegistryTest.php new file mode 100644 index 00000000..f38cd772 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Bindings/BindingTypeRegistryTest.php @@ -0,0 +1,103 @@ +registry()->resolve('person', 'email'); + + $this->assertSame(BindingTargetType::SCALAR, $meta->type); + $this->assertSame('string', $meta->php); + $this->assertTrue($meta->identityKeyEligible); + } + + public function test_resolve_person_dietary_preferences_returns_collection_array(): void + { + $meta = $this->registry()->resolve('person', 'dietary_preferences'); + + $this->assertSame(BindingTargetType::COLLECTION, $meta->type); + $this->assertSame('array', $meta->php); + $this->assertFalse($meta->identityKeyEligible); + } + + public function test_resolve_unknown_attribute_throws(): void + { + $this->expectException(UnknownBindingTargetException::class); + $this->registry()->resolve('person', 'unknown_field'); + } + + public function test_is_identity_key_eligible_truth_table(): void + { + $registry = $this->registry(); + $this->assertTrue($registry->isIdentityKeyEligible('person', 'email')); + $this->assertFalse($registry->isIdentityKeyEligible('person', 'first_name')); + $this->assertFalse($registry->isIdentityKeyEligible('person', 'unknown_field')); + } + + public function test_validate_append_strategy_rejects_scalar_target(): void + { + $this->expectException(InvalidBindingTargetException::class); + $this->registry()->validateAppendStrategy( + 'person', + 'email', + FormFieldBindingMergeStrategy::Append, + ); + } + + public function test_validate_append_strategy_accepts_collection_target(): void + { + $this->registry()->validateAppendStrategy( + 'person', + 'dietary_preferences', + FormFieldBindingMergeStrategy::Append, + ); + + $this->expectNotToPerformAssertions(); + } + + public function test_validate_append_strategy_skips_other_strategies(): void + { + $this->registry()->validateAppendStrategy( + 'person', + 'email', + FormFieldBindingMergeStrategy::Overwrite, + ); + + $this->expectNotToPerformAssertions(); + } + + public function test_entities_returns_known_entities(): void + { + $entities = $this->registry()->entities(); + sort($entities); + $this->assertSame(['artist', 'company', 'person', 'user'], $entities); + } + + public function test_attributes_for_person_includes_email_and_dietary_preferences(): void + { + $attributes = $this->registry()->attributesFor('person'); + $this->assertContains('email', $attributes); + $this->assertContains('dietary_preferences', $attributes); + } + + public function test_attributes_for_unknown_entity_returns_empty_list(): void + { + $this->assertSame([], $this->registry()->attributesFor('unknown')); + } + + private function registry(): BindingTypeRegistry + { + return $this->app->make(BindingTypeRegistry::class); + } +}