diff --git a/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php b/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php new file mode 100644 index 00000000..22171276 --- /dev/null +++ b/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php @@ -0,0 +1,13 @@ +hasMany(FormValue::class); } + public function bindings(): MorphMany + { + return $this->morphMany(FormFieldBinding::class, 'owner'); + } + /** * Nuanced activity log (ARCH §17.1; S1 Phase 4b). Callers choose which * events are worth logging — e.g. created/deleted/restored, field_type diff --git a/api/app/Models/FormBuilder/FormFieldBinding.php b/api/app/Models/FormBuilder/FormFieldBinding.php new file mode 100644 index 00000000..bd8c97af --- /dev/null +++ b/api/app/Models/FormBuilder/FormFieldBinding.php @@ -0,0 +1,70 @@ + */ + protected $casts = [ + 'mode' => FormFieldBindingMode::class, + 'merge_strategy' => FormFieldBindingMergeStrategy::class, + 'trust_level' => 'int', + 'is_identity_key' => 'bool', + ]; + + public function owner(): MorphTo + { + return $this->morphTo('owner', 'owner_type', 'owner_id'); + } + + public function isEntityOwned(): bool + { + return $this->mode === FormFieldBindingMode::EntityOwned; + } + + public function isMirrored(): bool + { + return $this->mode === FormFieldBindingMode::Mirrored; + } +} diff --git a/api/app/Models/FormBuilder/FormFieldLibrary.php b/api/app/Models/FormBuilder/FormFieldLibrary.php index cf67bb72..b0362747 100644 --- a/api/app/Models/FormBuilder/FormFieldLibrary.php +++ b/api/app/Models/FormBuilder/FormFieldLibrary.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; final class FormFieldLibrary extends Model { @@ -65,4 +66,9 @@ final class FormFieldLibrary extends Model { return $this->hasMany(FormField::class, 'library_field_id'); } + + public function bindings(): MorphMany + { + return $this->morphMany(FormFieldBinding::class, 'owner'); + } } diff --git a/api/app/Models/Scopes/FormFieldBindingScope.php b/api/app/Models/Scopes/FormFieldBindingScope.php new file mode 100644 index 00000000..3bfc90de --- /dev/null +++ b/api/app/Models/Scopes/FormFieldBindingScope.php @@ -0,0 +1,106 @@ +resolveOrganisationId(); + if ($orgId === null) { + return; + } + + $fieldIds = FormField::query() + ->withoutGlobalScope(OrganisationScope::class) + ->whereIn( + 'form_schema_id', + FormSchema::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'), + ) + ->select('id'); + + $libraryIds = FormFieldLibrary::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'); + + $table = $model->getTable(); + + $builder->where(function (Builder $outer) use ($table, $fieldIds, $libraryIds): void { + $outer->where(function (Builder $q) use ($table, $fieldIds): void { + $q->where("$table.owner_type", 'form_field') + ->whereIn("$table.owner_id", $fieldIds); + })->orWhere(function (Builder $q) use ($table, $libraryIds): void { + $q->where("$table.owner_type", 'form_field_library') + ->whereIn("$table.owner_id", $libraryIds); + }); + }); + } + + private function resolveOrganisationId(): ?string + { + if ($this->organisationId !== null) { + return $this->organisationId; + } + + $route = request()->route(); + if ($route === null) { + return null; + } + + $org = $route->parameter('organisation'); + + if ($org instanceof \App\Models\Organisation) { + return $org->id; + } + + if (is_string($org) && $org !== '') { + return $org; + } + + $event = $route->parameter('event'); + if ($event instanceof \App\Models\Event) { + return $event->organisation_id; + } + + return null; + } +} diff --git a/api/app/Observers/FormBuilder/FormFieldBindingsCascadeObserver.php b/api/app/Observers/FormBuilder/FormFieldBindingsCascadeObserver.php new file mode 100644 index 00000000..c2a303a5 --- /dev/null +++ b/api/app/Observers/FormBuilder/FormFieldBindingsCascadeObserver.php @@ -0,0 +1,29 @@ +where('owner_type', $ownerType) + ->where('owner_id', $owner->getKey()) + ->delete(); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 192c3f50..7e370846 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -48,6 +48,7 @@ use App\Models\VolunteerAvailability; use App\Events\FormBuilder\FormSubmissionSubmitted; use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit; use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit; +use App\Observers\FormBuilder\FormFieldBindingsCascadeObserver; use App\Observers\FormBuilder\FormSubmissionObserver; use App\Observers\FormBuilder\FormValueObserver; use App\Observers\PersonObserver; @@ -94,6 +95,11 @@ class AppServiceProvider extends ServiceProvider FormValue::observe(FormValueObserver::class); \App\Models\FormBuilder\FormSubmission::observe(FormSubmissionObserver::class); + // Cascade binding rows on owner delete (WS-5a). Bindings are physical + // state; deleted on soft-delete as well as hard-delete of the owner. + FormField::observe(FormFieldBindingsCascadeObserver::class); + FormFieldLibrary::observe(FormFieldBindingsCascadeObserver::class); + // ARCH §31.10 — FORM-02 TAG_PICKER sync listener. \Illuminate\Support\Facades\Event::listen( FormSubmissionSubmitted::class, diff --git a/api/database/factories/FormBuilder/FormFieldBindingFactory.php b/api/database/factories/FormBuilder/FormFieldBindingFactory.php new file mode 100644 index 00000000..0f251ed1 --- /dev/null +++ b/api/database/factories/FormBuilder/FormFieldBindingFactory.php @@ -0,0 +1,70 @@ + */ +final class FormFieldBindingFactory extends Factory +{ + protected $model = FormFieldBinding::class; + + /** @return array */ + public function definition(): array + { + return [ + 'owner_type' => 'form_field', + 'owner_id' => FormField::factory(), + 'target_entity' => 'person', + 'target_attribute' => 'email', + 'mode' => FormFieldBindingMode::EntityOwned->value, + 'sync_direction' => null, + 'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value, + 'trust_level' => 50, + 'is_identity_key' => false, + ]; + } + + public function forField(FormField $field): static + { + return $this->state(fn () => [ + 'owner_type' => 'form_field', + 'owner_id' => $field->id, + ]); + } + + public function forLibrary(FormFieldLibrary $library): static + { + return $this->state(fn () => [ + 'owner_type' => 'form_field_library', + 'owner_id' => $library->id, + ]); + } + + public function entityOwned(string $entity, string $attribute): static + { + return $this->state(fn () => [ + 'target_entity' => $entity, + 'target_attribute' => $attribute, + 'mode' => FormFieldBindingMode::EntityOwned->value, + 'sync_direction' => null, + ]); + } + + public function mirrored(string $entity, string $attribute, string $syncDirection = 'write_on_submit'): static + { + return $this->state(fn () => [ + 'target_entity' => $entity, + 'target_attribute' => $attribute, + 'mode' => FormFieldBindingMode::Mirrored->value, + 'sync_direction' => $syncDirection, + ]); + } +} diff --git a/api/database/factories/FormBuilder/FormFieldFactory.php b/api/database/factories/FormBuilder/FormFieldFactory.php index 3026c217..d40bc38e 100644 --- a/api/database/factories/FormBuilder/FormFieldFactory.php +++ b/api/database/factories/FormBuilder/FormFieldFactory.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace Database\Factories\FormBuilder; +use App\Enums\FormBuilder\FormFieldBindingMode; use App\Enums\FormBuilder\FormFieldDisplayWidth; use App\Enums\FormBuilder\FormFieldType; use App\Models\FormBuilder\FormField; +use App\Models\FormBuilder\FormFieldBinding; use App\Models\FormBuilder\FormSchema; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -73,4 +75,30 @@ final class FormFieldFactory extends Factory { return $this->state(fn () => ['is_filterable' => true]); } + + /** + * Attach an entity-binding row in `form_field_bindings` after the field + * is persisted. Use this instead of populating the legacy `binding` JSON + * column — which WS-5a will drop in commit 3. + */ + public function withEntityBinding( + string $entity, + string $attribute, + FormFieldBindingMode $mode = FormFieldBindingMode::EntityOwned, + ?string $syncDirection = null, + ): static { + return $this->afterCreating(function (FormField $field) use ($entity, $attribute, $mode, $syncDirection): void { + FormFieldBinding::factory() + ->forField($field) + ->state([ + 'target_entity' => $entity, + 'target_attribute' => $attribute, + 'mode' => $mode->value, + 'sync_direction' => $mode === FormFieldBindingMode::Mirrored + ? ($syncDirection ?? 'write_on_submit') + : null, + ]) + ->create(); + }); + } } diff --git a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php index 6576b333..ae6720ab 100644 --- a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php +++ b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Database\Factories\FormBuilder; +use App\Enums\FormBuilder\FormFieldBindingMode; use App\Enums\FormBuilder\FormFieldType; +use App\Models\FormBuilder\FormFieldBinding; use App\Models\FormBuilder\FormFieldLibrary; use App\Models\Organisation; use Illuminate\Database\Eloquent\Factories\Factory; @@ -45,4 +47,29 @@ final class FormFieldLibraryFactory extends Factory { return $this->state(fn () => ['is_system' => true]); } + + /** + * Attach a binding row in `form_field_bindings` after the library entry + * is persisted. Replaces the legacy `default_binding` JSON column. + */ + public function withDefaultBinding( + string $entity, + string $attribute, + FormFieldBindingMode $mode = FormFieldBindingMode::EntityOwned, + ?string $syncDirection = null, + ): static { + return $this->afterCreating(function (FormFieldLibrary $library) use ($entity, $attribute, $mode, $syncDirection): void { + FormFieldBinding::factory() + ->forLibrary($library) + ->state([ + 'target_entity' => $entity, + 'target_attribute' => $attribute, + 'mode' => $mode->value, + 'sync_direction' => $mode === FormFieldBindingMode::Mirrored + ? ($syncDirection ?? 'write_on_submit') + : null, + ]) + ->create(); + }); + } } diff --git a/api/database/migrations/2026_04_25_100000_create_form_field_bindings_table.php b/api/database/migrations/2026_04_25_100000_create_form_field_bindings_table.php new file mode 100644 index 00000000..774cb977 --- /dev/null +++ b/api/database/migrations/2026_04_25_100000_create_form_field_bindings_table.php @@ -0,0 +1,177 @@ +ulid('id')->primary(); + $table->string('owner_type', 40); + $table->ulid('owner_id'); + $table->string('target_entity', 50); + $table->string('target_attribute', 100); + $table->string('mode', 20); + $table->string('sync_direction', 30)->nullable(); + $table->string('merge_strategy', 20)->default('overwrite'); + $table->unsignedTinyInteger('trust_level')->default(50); + $table->boolean('is_identity_key')->default(false); + $table->timestamps(); + + $table->unique( + ['owner_type', 'owner_id', 'target_entity', 'target_attribute'], + 'ffb_owner_target_unique', + ); + $table->index(['target_entity', 'target_attribute'], 'ffb_target_idx'); + $table->index(['owner_type', 'owner_id'], 'ffb_owner_idx'); + }); + + DB::transaction(function (): void { + $this->backfillFromForeignJson( + table: 'form_fields', + jsonColumn: 'binding', + ownerType: 'form_field', + ); + $this->backfillFromForeignJson( + table: 'form_field_library', + jsonColumn: 'default_binding', + ownerType: 'form_field_library', + ); + }); + } + + public function down(): void + { + if (! Schema::hasTable('form_field_bindings')) { + return; + } + + DB::transaction(function (): void { + $this->restoreJsonFromRows( + table: 'form_fields', + jsonColumn: 'binding', + ownerType: 'form_field', + ); + $this->restoreJsonFromRows( + table: 'form_field_library', + jsonColumn: 'default_binding', + ownerType: 'form_field_library', + ); + }); + + Schema::drop('form_field_bindings'); + } + + private function backfillFromForeignJson(string $table, string $jsonColumn, string $ownerType): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $jsonColumn)) { + return; + } + + $rows = DB::table($table) + ->whereNotNull($jsonColumn) + ->orderBy('id') + ->get(['id', $jsonColumn]); + + if ($rows->isEmpty()) { + return; + } + + $now = now(); + $inserts = []; + + foreach ($rows as $row) { + $raw = $row->$jsonColumn; + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (! is_array($decoded)) { + continue; + } + + $mode = (string) ($decoded['mode'] ?? ''); + if ($mode === 'form_owned') { + throw new \RuntimeException( + "form_field_bindings backfill: row {$row->id} in {$ownerType} has legacy " + ."mode='form_owned'. Pattern B must be represented by null JSON, " + .'not a row. Clean up source data before re-running migration.', + ); + } + if (! in_array($mode, ['entity_owned', 'mirrored'], true)) { + continue; + } + + $entity = (string) ($decoded['entity'] ?? ''); + $attribute = (string) ($decoded['column'] ?? ''); + if ($entity === '' || $attribute === '') { + continue; + } + + $inserts[] = [ + 'id' => (string) Str::ulid(), + 'owner_type' => $ownerType, + 'owner_id' => (string) $row->id, + 'target_entity' => $entity, + 'target_attribute' => $attribute, + 'mode' => $mode, + 'sync_direction' => isset($decoded['sync_direction']) + ? (string) $decoded['sync_direction'] + : null, + 'merge_strategy' => 'overwrite', + 'trust_level' => 50, + 'is_identity_key' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if ($inserts !== []) { + foreach (array_chunk($inserts, 500) as $batch) { + DB::table('form_field_bindings')->insert($batch); + } + } + } + + private function restoreJsonFromRows(string $table, string $jsonColumn, string $ownerType): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $jsonColumn)) { + return; + } + + $rows = DB::table('form_field_bindings') + ->where('owner_type', $ownerType) + ->orderBy('owner_id') + ->get(); + + foreach ($rows as $row) { + $json = [ + 'mode' => $row->mode, + 'entity' => $row->target_entity, + 'column' => $row->target_attribute, + ]; + if ($row->sync_direction !== null && $row->sync_direction !== '') { + $json['sync_direction'] = $row->sync_direction; + } + + DB::table($table) + ->where('id', $row->owner_id) + ->update([$jsonColumn => json_encode($json)]); + } + } +}; diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingCascadeTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingCascadeTest.php new file mode 100644 index 00000000..91a9627b --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingCascadeTest.php @@ -0,0 +1,86 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + FormFieldBinding::factory()->forField($field)->mirrored('user_profile', 'bio')->create(); + + $this->assertSame(2, FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(), + ); + + $field->delete(); // soft delete on FormField + + $this->assertSame(0, FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(), + ); + } + + public function test_delete_of_library_entry_cascades_bindings(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldBinding::factory()->forLibrary($library)->entityOwned('person', 'first_name')->create(); + + $this->assertSame(1, FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field_library') + ->where('owner_id', $library->id) + ->count(), + ); + + $library->delete(); + + $this->assertSame(0, FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field_library') + ->where('owner_id', $library->id) + ->count(), + ); + } + + public function test_deleting_one_field_does_not_cascade_others(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $a = FormField::factory()->create(['form_schema_id' => $schema->id]); + $b = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldBinding::factory()->forField($a)->entityOwned('person', 'email')->create(); + FormFieldBinding::factory()->forField($b)->entityOwned('person', 'phone')->create(); + + $a->delete(); + + $this->assertSame(1, FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $b->id) + ->count(), + ); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php new file mode 100644 index 00000000..235ceb43 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -0,0 +1,243 @@ +artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->assertFalse(Schema::hasTable('form_field_bindings')); + + [$fieldAId, $fieldCId, $fieldDId] = $this->seedFieldsWithBindingJson(); + [$libAId, $libCId] = $this->seedLibraryWithBindingJson(); + + $this->artisan('migrate', [ + '--path' => self::MIGRATION_PATH, + '--realpath' => false, + ])->assertSuccessful(); + + $this->assertTrue(Schema::hasTable('form_field_bindings')); + + $rows = DB::table('form_field_bindings')->orderBy('owner_type')->orderBy('owner_id')->get(); + $this->assertCount(5, $rows, 'Expected 3 field + 2 library rows'); + + $fieldRowA = DB::table('form_field_bindings') + ->where('owner_type', 'form_field') + ->where('owner_id', $fieldAId) + ->first(); + $this->assertNotNull($fieldRowA); + $this->assertSame('person', $fieldRowA->target_entity); + $this->assertSame('email', $fieldRowA->target_attribute); + $this->assertSame('entity_owned', $fieldRowA->mode); + $this->assertNull($fieldRowA->sync_direction); + $this->assertSame('overwrite', $fieldRowA->merge_strategy); + $this->assertSame(50, (int) $fieldRowA->trust_level); + $this->assertSame(0, (int) $fieldRowA->is_identity_key); + + $fieldRowC = DB::table('form_field_bindings') + ->where('owner_type', 'form_field') + ->where('owner_id', $fieldCId) + ->first(); + $this->assertNotNull($fieldRowC); + $this->assertSame('mirrored', $fieldRowC->mode); + $this->assertSame('write_on_submit', $fieldRowC->sync_direction); + $this->assertSame('user_profile', $fieldRowC->target_entity); + $this->assertSame('emergency_contact_name', $fieldRowC->target_attribute); + + $fieldRowD = DB::table('form_field_bindings') + ->where('owner_type', 'form_field') + ->where('owner_id', $fieldDId) + ->first(); + $this->assertNotNull($fieldRowD); + $this->assertSame('entity_owned', $fieldRowD->mode); + + $libRowA = DB::table('form_field_bindings') + ->where('owner_type', 'form_field_library') + ->where('owner_id', $libAId) + ->first(); + $this->assertNotNull($libRowA); + $this->assertSame('person', $libRowA->target_entity); + $this->assertSame('first_name', $libRowA->target_attribute); + $this->assertSame('entity_owned', $libRowA->mode); + + $libRowC = DB::table('form_field_bindings') + ->where('owner_type', 'form_field_library') + ->where('owner_id', $libCId) + ->first(); + $this->assertNotNull($libRowC); + $this->assertSame('mirrored', $libRowC->mode); + } + + public function test_rollback_reconstructs_json_and_drops_table(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); + [$libAId, ] = $this->seedLibraryWithBindingJson(); + + $this->artisan('migrate', [ + '--path' => self::MIGRATION_PATH, + '--realpath' => false, + ])->assertSuccessful(); + + // Wipe the source JSON to prove the rollback writes back from rows. + DB::table('form_fields')->where('id', $fieldAId)->update(['binding' => null]); + DB::table('form_field_library')->where('id', $libAId)->update(['default_binding' => null]); + + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + + $this->assertFalse(Schema::hasTable('form_field_bindings')); + + $field = DB::table('form_fields')->where('id', $fieldAId)->first(); + $this->assertNotNull($field->binding); + $json = json_decode((string) $field->binding, true); + $this->assertSame([ + 'mode' => 'entity_owned', + 'entity' => 'person', + 'column' => 'email', + ], $json); + + $lib = DB::table('form_field_library')->where('id', $libAId)->first(); + $this->assertNotNull($lib->default_binding); + $libJson = json_decode((string) $lib->default_binding, true); + $this->assertSame([ + 'mode' => 'entity_owned', + 'entity' => 'person', + 'column' => 'first_name', + ], $libJson); + } + + /** @return array{0:string,1:string,2:string} */ + private function seedFieldsWithBindingJson(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + $fieldA = (string) Str::ulid(); + $fieldC = (string) Str::ulid(); + $fieldD = (string) Str::ulid(); + + DB::table('form_fields')->insert([ + [ + 'id' => $fieldA, + 'form_schema_id' => $schema->id, + 'field_type' => 'EMAIL', + 'slug' => 'email', + 'label' => 'E-mail', + 'binding' => json_encode(['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'email']), + 'value_storage_hint' => 'indexed', + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => $fieldC, + 'form_schema_id' => $schema->id, + 'field_type' => 'TEXT', + 'slug' => 'noodcontact', + 'label' => 'Noodcontact', + 'binding' => json_encode([ + 'mode' => 'mirrored', + 'entity' => 'user_profile', + 'column' => 'emergency_contact_name', + 'sync_direction' => 'write_on_submit', + ]), + 'value_storage_hint' => 'indexed', + 'sort_order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => $fieldD, + 'form_schema_id' => $schema->id, + 'field_type' => 'TEXT', + 'slug' => 'voornaam', + 'label' => 'Voornaam', + 'binding' => json_encode(['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name']), + 'value_storage_hint' => 'indexed', + 'sort_order' => 2, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + return [$fieldA, $fieldC, $fieldD]; + } + + /** @return array{0:string,1:string} */ + private function seedLibraryWithBindingJson(): array + { + $org = Organisation::factory()->create(); + + $libA = (string) Str::ulid(); + $libC = (string) Str::ulid(); + + DB::table('form_field_library')->insert([ + [ + 'id' => $libA, + 'organisation_id' => $org->id, + 'name' => 'Voornaam bibliotheek', + 'slug' => 'voornaam-lib', + 'field_type' => 'TEXT', + 'label' => 'Voornaam', + 'default_binding' => json_encode(['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name']), + 'default_is_required' => false, + 'default_is_filterable' => false, + 'usage_count' => 0, + 'is_system' => false, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => $libC, + 'organisation_id' => $org->id, + 'name' => 'Noodcontact bibliotheek', + 'slug' => 'noodcontact-lib', + 'field_type' => 'TEXT', + 'label' => 'Noodcontact', + 'default_binding' => json_encode([ + 'mode' => 'mirrored', + 'entity' => 'user_profile', + 'column' => 'emergency_contact_phone', + 'sync_direction' => 'write_on_submit', + ]), + 'default_is_required' => false, + 'default_is_filterable' => false, + 'usage_count' => 0, + 'is_system' => false, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + return [$libA, $libC]; + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingRelationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingRelationTest.php new file mode 100644 index 00000000..5d67febb --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingRelationTest.php @@ -0,0 +1,77 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + FormFieldBinding::factory()->forField($field)->mirrored('user_profile', 'bio')->create(); + + $bindings = $field->fresh()->bindings; + $this->assertCount(2, $bindings); + $targets = $bindings->pluck('target_attribute')->sort()->values()->all(); + $this->assertSame(['bio', 'email'], $targets); + } + + public function test_library_morph_many_bindings_loads_all(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + + FormFieldBinding::factory()->forLibrary($library)->entityOwned('person', 'first_name')->create(); + + $bindings = $library->fresh()->bindings; + $this->assertCount(1, $bindings); + $this->assertSame('first_name', $bindings->first()->target_attribute); + } + + public function test_owner_morphto_returns_correct_concrete_model(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + + $fieldBinding = FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + $libraryBinding = FormFieldBinding::factory()->forLibrary($library)->entityOwned('person', 'phone')->create(); + + $this->assertInstanceOf(FormField::class, $fieldBinding->fresh()->owner); + $this->assertSame($field->id, $fieldBinding->fresh()->owner->id); + + $this->assertInstanceOf(FormFieldLibrary::class, $libraryBinding->fresh()->owner); + $this->assertSame($library->id, $libraryBinding->fresh()->owner->id); + } + + public function test_mode_and_merge_strategy_cast_to_enums(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $binding = FormFieldBinding::factory()->forField($field)->mirrored('person', 'email')->create(); + + $fresh = $binding->fresh(); + $this->assertSame(FormFieldBindingMode::Mirrored, $fresh->mode); + $this->assertTrue($fresh->isMirrored()); + $this->assertFalse($fresh->isEntityOwned()); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingScopeTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingScopeTest.php new file mode 100644 index 00000000..9c9a0aff --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingScopeTest.php @@ -0,0 +1,77 @@ +seedOrgWithBindings(); + [$orgB, $fieldB, $libraryB] = $this->seedOrgWithBindings(); + + $this->withOrgRoute($orgA); + $ownerIdsA = FormFieldBinding::query()->pluck('owner_id')->sort()->values()->all(); + $expectedA = collect([$fieldA->id, $libraryA->id])->sort()->values()->all(); + $this->assertSame($expectedA, $ownerIdsA); + + $this->withOrgRoute($orgB); + $ownerIdsB = FormFieldBinding::query()->pluck('owner_id')->sort()->values()->all(); + $expectedB = collect([$fieldB->id, $libraryB->id])->sort()->values()->all(); + $this->assertSame($expectedB, $ownerIdsB); + } + + public function test_without_global_scope_exposes_cross_org(): void + { + [$orgA, , ] = $this->seedOrgWithBindings(); + $this->seedOrgWithBindings(); + + $this->withOrgRoute($orgA); + + $this->assertSame( + 4, + FormFieldBinding::query()->withoutGlobalScope(FormFieldBindingScope::class)->count(), + ); + $this->assertSame(2, FormFieldBinding::query()->count()); + } + + /** @return array{0:Organisation,1:FormField,2:FormFieldLibrary} */ + private function seedOrgWithBindings(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + FormFieldBinding::factory()->forLibrary($library)->entityOwned('person', 'first_name')->create(); + + return [$org, $field, $library]; + } + + private function withOrgRoute(Organisation $org): void + { + $route = new Route(['GET'], '/_test', static fn () => null); + $route->bind(request()); + $route->setParameter('organisation', $org); + request()->setRouteResolver(static fn () => $route); + } +} diff --git a/api/tests/Unit/Enums/FormBuilder/FormFieldBindingEnumsTest.php b/api/tests/Unit/Enums/FormBuilder/FormFieldBindingEnumsTest.php new file mode 100644 index 00000000..4496f84a --- /dev/null +++ b/api/tests/Unit/Enums/FormBuilder/FormFieldBindingEnumsTest.php @@ -0,0 +1,49 @@ + $case->value, FormFieldBindingMode::cases()); + $this->assertSame(['entity_owned', 'mirrored'], $values); + } + + public function test_mode_from_string(): void + { + $this->assertSame(FormFieldBindingMode::EntityOwned, FormFieldBindingMode::from('entity_owned')); + $this->assertSame(FormFieldBindingMode::Mirrored, FormFieldBindingMode::from('mirrored')); + } + + public function test_mode_rejects_legacy_form_owned(): void + { + $this->expectException(\ValueError::class); + FormFieldBindingMode::from('form_owned'); + } + + public function test_merge_strategy_has_expected_cases(): void + { + $values = array_map(fn ($case) => $case->value, FormFieldBindingMergeStrategy::cases()); + sort($values); + $this->assertSame(['append', 'first_write_wins', 'overwrite', 'replace'], $values); + } + + public function test_merge_strategy_from_string(): void + { + $this->assertSame( + FormFieldBindingMergeStrategy::Overwrite, + FormFieldBindingMergeStrategy::from('overwrite'), + ); + $this->assertSame( + FormFieldBindingMergeStrategy::FirstWriteWins, + FormFieldBindingMergeStrategy::from('first_write_wins'), + ); + } +}