diff --git a/api/app/FormBuilder/Bindings/PersonProvisioner.php b/api/app/FormBuilder/Bindings/PersonProvisioner.php index e99c415d..7f561a08 100644 --- a/api/app/FormBuilder/Bindings/PersonProvisioner.php +++ b/api/app/FormBuilder/Bindings/PersonProvisioner.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\FormBuilder\Bindings; use App\Exceptions\FormBuilder\PersonProvisioningException; -use App\Models\CrowdType; +use App\Models\FormBuilder\FormSchema; use App\Models\FormBuilder\FormSubmission; use App\Models\FormBuilder\FormValue; use App\Models\Person; @@ -152,31 +152,39 @@ final readonly class PersonProvisioner /** * Resolve a default crowd_type_id for a freshly-provisioned Person. - * Person.crowd_type_id is NOT NULL on the migration. Session 2 picks - * the first active CrowdType for the submission's organisation. A - * future per-schema setting (`default_crowd_type_id`) is the proper - * resolution but out-of-scope here. + * Person.crowd_type_id is NOT NULL on the migration; the schema + * declares its target CrowdType explicitly via `default_crowd_type_id`. + * + * RFC-WS-6 v1.1 §3 Q8 addendum (was: silent oldest() fallback in + * session 2). The RequiresDefaultCrowdType publish guard prevents + * misconfigured event_registration schemas from publishing; this + * runtime throw is a failsafe for live-table edits between publish + * and apply. * * @throws PersonProvisioningException */ private function resolveCrowdTypeId(FormSubmission $submission): string { - $orgId = (string) $submission->organisation_id; - $crowdType = CrowdType::query() - ->withoutGlobalScopes() - ->where('organisation_id', $orgId) - ->where('is_active', true)->oldest() - ->first(); - - if ($crowdType === null) { + /** @var FormSchema|null $schema */ + $schema = $submission->schema; + if (! $schema instanceof FormSchema) { throw new PersonProvisioningException( - 'no_crowd_type', + 'no_schema', (string) $submission->id, - "no active CrowdType available for organisation {$orgId}", + 'submission has no schema relation loaded', ); } - return (string) $crowdType->id; + $crowdTypeId = $schema->default_crowd_type_id; + if ($crowdTypeId === null) { + throw new PersonProvisioningException( + 'no_default_crowd_type', + (string) $submission->id, + "form_schema {$schema->id} has no default_crowd_type_id set", + ); + } + + return (string) $crowdTypeId; } private function readFormValue(FormSubmission $submission, string $formFieldId): mixed diff --git a/api/app/FormBuilder/Publishing/RequiresDefaultCrowdType.php b/api/app/FormBuilder/Publishing/RequiresDefaultCrowdType.php new file mode 100644 index 00000000..e7d50d05 --- /dev/null +++ b/api/app/FormBuilder/Publishing/RequiresDefaultCrowdType.php @@ -0,0 +1,33 @@ +default_crowd_type_id !== null) { + return PublishGuardResult::passed($this->code()); + } + + return PublishGuardResult::failed( + $this->code(), + 'form_builder_publish_guards.requires_default_crowd_type', + ); + } +} diff --git a/api/app/FormBuilder/Purposes/Guards/EventRegistrationGuards.php b/api/app/FormBuilder/Purposes/Guards/EventRegistrationGuards.php index 83179be7..822823fc 100644 --- a/api/app/FormBuilder/Purposes/Guards/EventRegistrationGuards.php +++ b/api/app/FormBuilder/Purposes/Guards/EventRegistrationGuards.php @@ -12,6 +12,7 @@ use App\FormBuilder\Publishing\IdentityKeyBindingsOnlyInFirstSection; use App\FormBuilder\Publishing\MaxOneIdentityKeyPerTargetEntity; use App\FormBuilder\Publishing\NoAmbiguousTrustLevels; use App\FormBuilder\Publishing\RequiresFieldType; +use App\FormBuilder\Publishing\RequiresDefaultCrowdType; use App\FormBuilder\Publishing\RequiresIdentityKeyBinding; use App\FormBuilder\Publishing\SchemaHasLinkedEvent; use App\FormBuilder\Publishing\TagCategoriesConfiguredOnAllPickers; @@ -27,6 +28,7 @@ final readonly class EventRegistrationGuards implements PurposeGuardProvider { return [ new RequiresIdentityKeyBinding('person', 'email'), + new RequiresDefaultCrowdType(), new MaxOneIdentityKeyPerTargetEntity(), new RequiresFieldType(FormFieldType::EMAIL, 1), new ConditionalRequirement( diff --git a/api/app/Models/FormBuilder/FormSchema.php b/api/app/Models/FormBuilder/FormSchema.php index 16aefb6c..5163c8dc 100644 --- a/api/app/Models/FormBuilder/FormSchema.php +++ b/api/app/Models/FormBuilder/FormSchema.php @@ -7,6 +7,7 @@ namespace App\Models\FormBuilder; use App\Enums\FormBuilder\FormPurpose; use App\Enums\FormBuilder\FormSchemaSnapshotMode; use App\Enums\FormBuilder\FormSubmissionMode; +use App\Models\CrowdType; use App\Models\Organisation; use App\Models\Scopes\OrganisationScope; use App\Models\User; @@ -42,6 +43,7 @@ final class FormSchema extends Model 'name', 'slug', 'purpose', + 'default_crowd_type_id', 'description', 'is_published', 'submission_mode', @@ -88,6 +90,12 @@ final class FormSchema extends Model return $this->belongsTo(Organisation::class); } + /** @return BelongsTo */ + public function defaultCrowdType(): BelongsTo + { + return $this->belongsTo(CrowdType::class, 'default_crowd_type_id'); + } + public function owner(): MorphTo { return $this->morphTo(); diff --git a/api/database/migrations/2026_04_26_120000_add_default_crowd_type_id_to_form_schemas.php b/api/database/migrations/2026_04_26_120000_add_default_crowd_type_id_to_form_schemas.php new file mode 100644 index 00000000..ca33d51f --- /dev/null +++ b/api/database/migrations/2026_04_26_120000_add_default_crowd_type_id_to_form_schemas.php @@ -0,0 +1,47 @@ +ulid('default_crowd_type_id') + ->nullable() + ->after('purpose'); + + $table->index(['organisation_id', 'default_crowd_type_id'], 'fs_org_default_crowd_type_idx'); + }); + } + + public function down(): void + { + Schema::table('form_schemas', function (Blueprint $table): void { + $table->dropIndex('fs_org_default_crowd_type_idx'); + $table->dropColumn('default_crowd_type_id'); + }); + } +}; diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php index dfbdc17f..955d5e6e 100644 --- a/api/database/seeders/FormBuilderDevSeeder.php +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -10,6 +10,7 @@ use App\Enums\FormBuilder\FormSchemaSnapshotMode; use App\Enums\FormBuilder\FormSubmissionMode; use App\Enums\FormBuilder\FormSubmissionStatus; use App\Enums\FormBuilder\FormValueStorageHint; +use App\Models\CrowdType; use App\Models\Event; use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormSchema; @@ -132,6 +133,7 @@ final class FormBuilderDevSeeder 'name' => $event->name.' — registratie', 'slug' => Str::slug($event->slug.'-registratie'), 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'default_crowd_type_id' => self::resolveDefaultCrowdTypeId((string) $event->organisation_id), 'description' => "Registratieformulier voor {$event->name}.", 'is_published' => in_array( $event->status, @@ -391,6 +393,7 @@ final class FormBuilderDevSeeder 'name' => $name, 'slug' => $slug, 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'default_crowd_type_id' => self::resolveDefaultCrowdTypeId((string) $org->id), 'description' => "Demo-formulier voor het end-to-end doorlopen van de vrijwilligersregistratie voor {$event->name}.", 'is_published' => true, 'submission_mode' => FormSubmissionMode::DRAFT_SINGLE, @@ -615,6 +618,46 @@ final class FormBuilderDevSeeder ->count(); } + /** + * RFC-WS-6 v1.1 §3 Q8 addendum — event_registration schemas declare + * a target CrowdType. DevSeeder picks the VOLUNTEER system_type + * (DevSeeder::run() always seeds it for every dev org); falls back + * to the first active CrowdType if VOLUNTEER is somehow missing, + * and creates a minimal CrowdType row as last-resort to satisfy the + * NOT NULL FK on Person.crowd_type_id. + * + * Future seeders MUST keep this lookup contract: schemas need a + * resolvable CrowdType per org or the RequiresDefaultCrowdType + * publish guard will block publish. + */ + private static function resolveDefaultCrowdTypeId(string $organisationId): string + { + $crowdType = CrowdType::query() + ->withoutGlobalScopes() + ->where('organisation_id', $organisationId) + ->where('is_active', true) + ->where('system_type', 'VOLUNTEER') + ->first() + ?? CrowdType::query() + ->withoutGlobalScopes() + ->where('organisation_id', $organisationId) + ->where('is_active', true) + ->oldest() + ->first(); + + if ($crowdType === null) { + $crowdType = CrowdType::create([ + 'organisation_id' => $organisationId, + 'name' => 'Vrijwilliger', + 'system_type' => 'VOLUNTEER', + 'color' => '#10b981', + 'is_active' => true, + ]); + } + + return (string) $crowdType->id; + } + private static function uniqueSchemaSlug(Organisation $org, string $base): string { $candidate = $base; diff --git a/api/lang/nl/form_builder_publish_guards.php b/api/lang/nl/form_builder_publish_guards.php index 733b507b..e3ffc78f 100644 --- a/api/lang/nl/form_builder_publish_guards.php +++ b/api/lang/nl/form_builder_publish_guards.php @@ -7,6 +7,7 @@ declare(strict_types=1); * Dutch only for v1 per CLAUDE.md (Crewli is Dutch-first). */ return [ + 'requires_default_crowd_type' => 'Schema voor vrijwilligers/crew registratie moet een standaard crowd type hebben.', 'requires_identity_key_binding' => 'Het veld voor :entity.:attribute moet als identity-key zijn aangemerkt.', 'max_one_identity_key_per_target_entity' => 'Per doel-entiteit mag maximaal één binding identity-key zijn.', 'requires_field_type' => 'Dit formulier moet ten minste :min_count veld(en) van type :type bevatten.', diff --git a/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php index 866dc508..cb7aa846 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php @@ -80,7 +80,7 @@ final class FormBindingApplicatorIntegrationTest extends TestCase private function makeEventRegistrationSubmission(): FormSubmission { $event = Event::factory()->create(); - CrowdType::factory()->create([ + $crowdType = CrowdType::factory()->create([ 'organisation_id' => $event->organisation_id, 'is_active' => true, ]); @@ -88,6 +88,7 @@ final class FormBindingApplicatorIntegrationTest extends TestCase $schema = FormSchema::factory()->create([ 'organisation_id' => $event->organisation_id, 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + 'default_crowd_type_id' => $crowdType->id, ]); $emailField = FormField::factory()->create([ diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index b86424ea..274ab352 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -42,7 +42,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' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -104,7 +104,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' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); [$libAId, ] = $this->seedLibraryWithBindingJson(); @@ -119,7 +119,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' => 14])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 15])->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/Bindings/PublishChecksRelationalBindingsTest.php b/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php index 8eae011c..d3691495 100644 --- a/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php @@ -54,6 +54,13 @@ final class PublishChecksRelationalBindingsTest extends TestCase ['name' => 'ER', 'purpose' => FormPurpose::EVENT_REGISTRATION->value], $this->actor, ); + // RFC v1.1 §3 Q8 addendum: event_registration schemas need a + // default_crowd_type_id (RequiresDefaultCrowdType publish guard). + $crowdType = \App\Models\CrowdType::factory()->create([ + 'organisation_id' => $this->org->id, + ]); + $schema->default_crowd_type_id = $crowdType->id; + $schema->save(); // WS-6 publish guards require: EMAIL field type, identity_key flag // on person.email, unique trust levels per (entity, attribute). diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index 1f440ad4..2d07691c 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -35,7 +35,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' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic')); $fieldId = $this->seedFieldWithJson([ @@ -156,7 +156,7 @@ final class ConditionalLogicBackfillTest extends TestCase ]); // Roll back only the backfill migration — writes the JSON back. - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $reconstructed = DB::table('form_fields') ->where('id', $fieldId) @@ -183,7 +183,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_top_level_key_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithJson([ 'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], @@ -196,7 +196,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_comparison_operator_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->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 f6ef5dbd..31731a5d 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' => 11])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $fieldId = $this->seedField([ diff --git a/api/tests/Feature/FormBuilder/FormSchemaServicePublishGuardsTest.php b/api/tests/Feature/FormBuilder/FormSchemaServicePublishGuardsTest.php index 277be486..4302220a 100644 --- a/api/tests/Feature/FormBuilder/FormSchemaServicePublishGuardsTest.php +++ b/api/tests/Feature/FormBuilder/FormSchemaServicePublishGuardsTest.php @@ -124,6 +124,11 @@ final class FormSchemaServicePublishGuardsTest extends TestCase 'section_level_submit' => false, 'is_published' => false, ]); + $crowdType = \App\Models\CrowdType::factory()->create([ + 'organisation_id' => $schema->organisation_id, + ]); + $schema->default_crowd_type_id = $crowdType->id; + $schema->save(); $emailField = FormField::factory()->create([ 'form_schema_id' => $schema->id, diff --git a/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSubmitTest.php b/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSubmitTest.php index cb3f0bf5..081c5f28 100644 --- a/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSubmitTest.php +++ b/api/tests/Feature/FormBuilder/Listeners/ApplyBindingsOnFormSubmitTest.php @@ -94,7 +94,7 @@ final class ApplyBindingsOnFormSubmitTest extends TestCase private function makeSubmission(): FormSubmission { $event = Event::factory()->create(); - CrowdType::factory()->create([ + $crowdType = CrowdType::factory()->create([ 'organisation_id' => $event->organisation_id, 'is_active' => true, ]); @@ -102,6 +102,7 @@ final class ApplyBindingsOnFormSubmitTest extends TestCase $schema = FormSchema::factory()->create([ 'organisation_id' => $event->organisation_id, 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + 'default_crowd_type_id' => $crowdType->id, ]); $emailField = FormField::factory()->create([ diff --git a/api/tests/Feature/FormBuilder/Purposes/EventRegistrationGuardsIntegrationTest.php b/api/tests/Feature/FormBuilder/Purposes/EventRegistrationGuardsIntegrationTest.php index 7e19f5f0..3af8482b 100644 --- a/api/tests/Feature/FormBuilder/Purposes/EventRegistrationGuardsIntegrationTest.php +++ b/api/tests/Feature/FormBuilder/Purposes/EventRegistrationGuardsIntegrationTest.php @@ -67,12 +67,45 @@ final class EventRegistrationGuardsIntegrationTest extends TestCase $this->assertInstanceOf(EventRegistrationGuards::class, $provider); } + public function test_requires_default_crowd_type_is_in_guard_list(): void + { + $provider = $this->app->make(EventRegistrationGuards::class); + $codes = array_map( + static fn (\App\FormBuilder\Publishing\PublishGuard $g): string => $g->code(), + $provider->publishGuards(), + ); + $this->assertContains('requires_default_crowd_type', $codes); + } + + public function test_missing_default_crowd_type_fails_specific_guard(): void + { + $schema = $this->buildValidSchema(); + $schema->default_crowd_type_id = null; + $schema->save(); + + $provider = $this->app->make(EventRegistrationGuards::class); + $failedCodes = []; + foreach ($provider->publishGuards() as $guard) { + $result = $guard->evaluate($schema); + if (! $result->passed) { + $failedCodes[] = $guard->code(); + } + } + + $this->assertContains('requires_default_crowd_type', $failedCodes); + } + private function buildValidSchema(): FormSchema { $schema = FormSchema::factory()->create([ 'purpose' => FormPurpose::EVENT_REGISTRATION->value, 'section_level_submit' => false, ]); + $crowdType = \App\Models\CrowdType::factory()->create([ + 'organisation_id' => $schema->organisation_id, + ]); + $schema->default_crowd_type_id = $crowdType->id; + $schema->save(); $emailField = FormField::factory()->create([ 'form_schema_id' => $schema->id, diff --git a/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php b/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php index 057ad1b7..b5fb9f40 100644 --- a/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php +++ b/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php @@ -133,6 +133,16 @@ final class PurposeSchemaLifecycleTest extends TestCase private function seedRequiredBindings(FormSchema $schema, FormPurpose $purpose): void { + if ($purpose === FormPurpose::EVENT_REGISTRATION) { + // RFC v1.1 §3 Q8 addendum: event_registration needs + // default_crowd_type_id (RequiresDefaultCrowdType guard). + $crowdType = \App\Models\CrowdType::factory()->create([ + 'organisation_id' => $schema->organisation_id, + ]); + $schema->default_crowd_type_id = $crowdType->id; + $schema->save(); + } + match ($purpose) { FormPurpose::EVENT_REGISTRATION => [ // WS-6 publish guards require: identity_key flag on email, diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index cd54162a..2e5daa61 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -40,7 +40,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' => 14])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -101,7 +101,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' => 14])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -125,7 +125,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' => 14])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -152,7 +152,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' => 14])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -169,7 +169,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' => 14])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -188,7 +188,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' => 14])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -203,7 +203,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' => 14])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first(); diff --git a/api/tests/Unit/FormBuilder/Bindings/PersonProvisionerTest.php b/api/tests/Unit/FormBuilder/Bindings/PersonProvisionerTest.php index ab587cb2..08da10d5 100644 --- a/api/tests/Unit/FormBuilder/Bindings/PersonProvisionerTest.php +++ b/api/tests/Unit/FormBuilder/Bindings/PersonProvisionerTest.php @@ -123,6 +123,26 @@ final class PersonProvisionerTest extends TestCase }); } + public function test_throws_when_schema_has_no_default_crowd_type(): void + { + $submission = $this->makeSubmissionWithEmail('jan@example.nl'); + // Clear the field that the helper set up to satisfy the new contract. + /** @var FormSchema $schema */ + $schema = $submission->schema; + $schema->default_crowd_type_id = null; + $schema->save(); + $submission = $submission->fresh(['schema']); + + DB::transaction(function () use ($submission): void { + try { + $this->provisioner()->provisionFromSubmission($submission); + $this->fail('Expected PersonProvisioningException'); + } catch (PersonProvisioningException $e) { + $this->assertSame('no_default_crowd_type', $e->reasonCode); + } + }); + } + public function test_throws_when_identity_key_form_value_absent(): void { // Schema has the binding, but no form_value row was written @@ -162,8 +182,12 @@ final class PersonProvisionerTest extends TestCase // PersonProvisioner needs an active CrowdType in the org to set // crowd_type_id on a freshly-provisioned Person (NOT NULL column). - if (! CrowdType::query()->where('organisation_id', $organisation->id)->where('is_active', true)->exists()) { - CrowdType::factory()->create([ + $crowdType = CrowdType::query() + ->where('organisation_id', $organisation->id) + ->where('is_active', true) + ->first(); + if ($crowdType === null) { + $crowdType = CrowdType::factory()->create([ 'organisation_id' => $organisation->id, 'is_active' => true, ]); @@ -171,6 +195,7 @@ final class PersonProvisionerTest extends TestCase $schema = FormSchema::factory()->create([ 'organisation_id' => $organisation->id, + 'default_crowd_type_id' => $crowdType->id, ]); $emailField = FormField::factory()->create([ diff --git a/api/tests/Unit/FormBuilder/Publishing/RequiresDefaultCrowdTypeTest.php b/api/tests/Unit/FormBuilder/Publishing/RequiresDefaultCrowdTypeTest.php new file mode 100644 index 00000000..c38a171b --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/RequiresDefaultCrowdTypeTest.php @@ -0,0 +1,44 @@ +create(); + $crowdType = CrowdType::factory()->create([ + 'organisation_id' => $schema->organisation_id, + ]); + $schema->default_crowd_type_id = $crowdType->id; + $schema->save(); + + $result = (new RequiresDefaultCrowdType())->evaluate($schema->fresh()); + $this->assertTrue($result->passed); + $this->assertSame('requires_default_crowd_type', $result->guardCode); + } + + public function test_fails_when_default_crowd_type_id_null(): void + { + $schema = FormSchema::factory()->create(); + $this->assertNull($schema->default_crowd_type_id); + + $result = (new RequiresDefaultCrowdType())->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame('requires_default_crowd_type', $result->guardCode); + $this->assertSame( + 'form_builder_publish_guards.requires_default_crowd_type', + $result->messageKey, + ); + } +} diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 7c374243..cbf1d2e6 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1,10 +1,16 @@ # Crewli — Core Database Schema > Source: Design Document v1.3 — Section 3.5 -> **Version: 2.6** — Updated April 2026 +> **Version: 2.7** — Updated April 2026 > > **Changelog:** > +> - 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()` +> CrowdType heuristic from session 2's PersonProvisioner. RFC-WS-6.md +> v1.1 §3 Q8 addendum. +> > - v2.6: WS-5d — `form_fields.options` and `form_field_library.options` > JSON columns **dropped**; replaced by a single polymorphic relational > table `form_field_options` (rows owned via `owner_type` / @@ -1953,6 +1959,7 @@ that aggregates the user's submitted, non-test `form_submissions`. | `name` | string | | | `slug` | string | canonical within organisation | | `purpose` | string(50) | Slug matching a key in `PurposeRegistry::all()`; `FormPurpose` enum mirrors the seven v1.0 values | +| `default_crowd_type_id` | ULID nullable | Default CrowdType for event_registration Person provisioning. NULL allowed for non-event_registration purposes; required at publish time for event_registration via `RequiresDefaultCrowdType` guard. **v2.7 — RFC-WS-6 v1.1 §3 Q8 addendum** | | `description` | text nullable | | | `is_published` | bool | default: false | | `submission_mode` | string(20) | `FormSubmissionMode` enum value |