diff --git a/api/app/Models/Company.php b/api/app/Models/Company.php index 2078b84b..06b1bf0f 100644 --- a/api/app/Models/Company.php +++ b/api/app/Models/Company.php @@ -5,10 +5,10 @@ declare(strict_types=1); namespace App\Models; use App\Models\Scopes\OrganisationScope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -21,13 +21,14 @@ final class Company extends Model protected static function booted(): void { - static::addGlobalScope(new OrganisationScope()); + self::addGlobalScope(new OrganisationScope); } protected $fillable = [ 'organisation_id', 'name', 'type', + 'kvk_number', 'contact_first_name', 'contact_last_name', 'contact_email', diff --git a/api/database/factories/CompanyFactory.php b/api/database/factories/CompanyFactory.php index f4d9118e..b4d9442f 100644 --- a/api/database/factories/CompanyFactory.php +++ b/api/database/factories/CompanyFactory.php @@ -18,6 +18,7 @@ final class CompanyFactory extends Factory 'organisation_id' => Organisation::factory(), 'name' => fake('nl_NL')->company(), 'type' => fake()->randomElement(['supplier', 'partner', 'agency', 'venue', 'other']), + 'kvk_number' => fake()->numerify('########'), 'contact_first_name' => fake('nl_NL')->firstName(), 'contact_last_name' => fake('nl_NL')->lastName(), 'contact_email' => fake()->companyEmail(), diff --git a/api/database/migrations/2026_04_28_140000_add_kvk_number_to_companies_table.php b/api/database/migrations/2026_04_28_140000_add_kvk_number_to_companies_table.php new file mode 100644 index 00000000..ac7580cd --- /dev/null +++ b/api/database/migrations/2026_04_28_140000_add_kvk_number_to_companies_table.php @@ -0,0 +1,38 @@ +string('kvk_number')->nullable()->after('type'); + $table->index('kvk_number'); + }); + } + + public function down(): void + { + Schema::table('companies', function (Blueprint $table): void { + $table->dropIndex(['kvk_number']); + $table->dropColumn('kvk_number'); + }); + } +}; diff --git a/api/database/schema/mysql-schema.sql b/api/database/schema/mysql-schema.sql index 880f203c..120140fc 100644 --- a/api/database/schema/mysql-schema.sql +++ b/api/database/schema/mysql-schema.sql @@ -111,6 +111,7 @@ CREATE TABLE `companies` ( `organisation_id` char(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `type` enum('supplier','partner','agency','venue','other') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `kvk_number` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `contact_first_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `contact_last_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `contact_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, @@ -120,6 +121,7 @@ CREATE TABLE `companies` ( `deleted_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), KEY `companies_organisation_id_index` (`organisation_id`), + KEY `companies_kvk_number_index` (`kvk_number`), CONSTRAINT `companies_organisation_id_foreign` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -1747,3 +1749,4 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (152,'2026_04_27_10 INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (153,'2026_04_27_100001_backfill_form_field_options',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (154,'2026_04_27_100002_drop_form_field_options_json_columns',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (155,'2026_04_28_100000_restore_default_crowd_type_id_foreign_key',2); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (156,'2026_04_28_140000_add_kvk_number_to_companies_table',3); diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 4b7abfac..f4ad47a5 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -57,7 +57,7 @@ final class FormFieldBindingMigrationTest extends TestCase // validation-rules-backfill, create-validation-rules) + // 2 WS-6 migrations (action-failures, apply-status) + // 2 WS-5a migrations (drop-binding-cols, create-bindings) = 16. - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -119,7 +119,7 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { // Walk back the full WS-5d + WS-5c + WS-6 + WS-5b + WS-5a stack (16 migrations). - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); [$fieldAId] = $this->seedFieldsWithBindingJson(); [$libAId] = $this->seedLibraryWithBindingJson(); @@ -134,7 +134,7 @@ final class FormFieldBindingMigrationTest extends TestCase // the pre-WS-5b state (conditional-logic, validation-rules, configs // and options tables gone, validation_rules + options JSON columns // reappear on source tables; binding contract intact). - $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_options')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions')); diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index b7e055ba..908d6700 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -49,7 +49,7 @@ final class ConditionalLogicBackfillTest extends TestCase // create-options + WS-5c drop-cl-col + WS-5c backfill-cl // migrations to land in the conditional-logic JSON-era state with // no relational form_field_options table yet. - $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic')); $fieldId = $this->seedFieldWithJson([ @@ -170,7 +170,7 @@ final class ConditionalLogicBackfillTest extends TestCase ]); // Roll back only the backfill migration — writes the JSON back. - $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $reconstructed = DB::table('form_fields') ->where('id', $fieldId) @@ -203,7 +203,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_top_level_key_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $this->seedFieldWithJson([ 'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], @@ -216,7 +216,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_comparison_operator_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $this->seedFieldWithJson([ 'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]], diff --git a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php index 05cdc917..9792bace 100644 --- a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php +++ b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php @@ -30,7 +30,7 @@ final class FormFieldConfigBackfillAndDropTest extends TestCase // Roll back 4 WS-5c migrations + 2 WS-6 migrations + 5 WS-5b // migrations = 11, to get the pre-WS-5b state where the JSON column // still exists on form_fields / form_field_library. - $this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 14])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $fieldId = $this->seedField([ diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php index fae17695..22920583 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php @@ -47,7 +47,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Roll back only the backfill migration (latest WS-5d step). // Leaves the form_field_options table in place, JSON columns // present on the source tables, and snapshots in pre-WS-5d shape. - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->assertTrue(Schema::hasTable('form_field_options')); $this->assertTrue(Schema::hasColumn('form_fields', 'options')); @@ -136,7 +136,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_rollback_reconstructs_json_columns_and_snapshots(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson(); $submissionId = $this->seedSubmissionWithSnapshot($selectId); @@ -149,7 +149,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Step back over only the backfill migration → JSON columns repopulate // and snapshots revert to flat-string-array shape. - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->assertSame(0, DB::table('form_field_options')->count()); $select = DB::table('form_fields')->where('id', $selectId)->first(); @@ -168,7 +168,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']); $this->expectException(\RuntimeException::class); @@ -178,7 +178,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_contains_non_string_entry(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ ['label' => 'A'], @@ -192,7 +192,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_is_object_shape(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ 'XS' => 'Extra small', @@ -206,7 +206,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_translations_length_mismatch(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([ 'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3 ])); @@ -218,7 +218,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_non_string_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([ 'de' => ['options' => ['Klein', 42]], ])); @@ -230,7 +230,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_oversized_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([ 'de' => ['options' => [str_repeat('x', 256)]], ])); @@ -242,7 +242,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->seedTemplateWithSnapshotRaw([ 'fields' => [[ 'id' => (string) Str::ulid(), diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index f493d9ab..33ba0996 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -53,7 +53,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // validation-rules-backfill + create-validation-rules) = 14. // Brings us to the pre-WS-5b state: validation_rules JSON column // present, no relational tables for WS-5b/c/d. - $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -114,7 +114,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -138,7 +138,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -165,7 +165,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -182,7 +182,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -201,7 +201,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // full-back-then-full-forward cycle — rolling back all WS-5b // migrations restores the pre-WS-5b state (columns present on // source tables; validation rules relational table gone). - $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -216,7 +216,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // Roll back WS-5b fully → column reappears and carries canonical JSON // reconstructed from the relational rows. - $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first(); diff --git a/api/tests/Unit/Models/CompanyKvkNumberTest.php b/api/tests/Unit/Models/CompanyKvkNumberTest.php new file mode 100644 index 00000000..85074df5 --- /dev/null +++ b/api/tests/Unit/Models/CompanyKvkNumberTest.php @@ -0,0 +1,36 @@ +create(['kvk_number' => '12345678']); + $this->assertSame('12345678', $company->fresh()->kvk_number); + } + + public function test_kvk_number_is_nullable(): void + { + $company = Company::factory()->create(['kvk_number' => null]); + $this->assertNull($company->fresh()->kvk_number); + } + + public function test_kvk_number_is_indexed(): void + { + $row = DB::selectOne( + 'SELECT INDEX_NAME FROM information_schema.STATISTICS ' + ."WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'companies' AND COLUMN_NAME = 'kvk_number'" + ); + $this->assertNotNull($row, 'Expected index on companies.kvk_number'); + } +} diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 76958974..d8f3bc65 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1,10 +1,17 @@ # Crewli — Core Database Schema > Source: Design Document v1.3 — Section 3.5 -> **Version: 2.7** — Updated April 2026 +> **Version: 2.8** — Updated April 2026 > > **Changelog:** > +> - v2.8: WS-6 session 3a.5 — `companies.kvk_number` column added +> (nullable, indexed). Aligns with the binding-target registry's +> B2B identity-key candidate. Registry entries renamed/removed in +> the same session to match real model columns; consistency test +> extended with model-existence + column-existence assertions. +> RFC-WS-6.md v1.2 §3 Q9 addendum. +> > - v2.7: WS-6 session 2.5 — `form_schemas.default_crowd_type_id` column > added (nullable; required at publish time for event_registration via > RequiresDefaultCrowdType guard). Replaces the silent `oldest()` @@ -909,13 +916,14 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; | `organisation_id` | ULID FK | → organisations | | `name` | string | | | `type` | enum | `supplier\|partner\|agency\|venue\|other` | +| `kvk_number` | string nullable | KvK registration number (Dutch Chamber of Commerce). Indexed. **v2.8 — WS-6 sessie 3a.5** | | `contact_first_name` | string nullable | | | `contact_last_name` | string nullable | | | `contact_email` | string nullable | | | `contact_phone` | string nullable | | | `deleted_at` | timestamp nullable | Soft delete | -**Indexes:** `(organisation_id)` +**Indexes:** `(organisation_id)`, `(kvk_number)` **Soft delete:** yes ---