feat(form-builder): per-schema default_crowd_type_id replaces silent oldest() heuristic (WS-6)

Session 2's PersonProvisioner picked CrowdType::oldest() for the org —
silently wrong for multi-crowd_type orgs (Volunteer + Crew + Press are
three distinct crowd_types in one org). Schemas now declare their
target crowd_type explicitly via form_schemas.default_crowd_type_id.
RequiresDefaultCrowdType publish guard prevents misconfigured
event_registration schemas from publishing.

PersonProvisioner: oldest() fallback removed entirely. Misconfiguration
throws no_default_crowd_type at runtime; publish guard prevents it at
config time.

Migration uses a plain ulid() column without DB-level FK because
SQLite's table-rebuild on ALTER ADD FOREIGN KEY cascade-deletes
form_fields rows (form_fields.form_schema_id has cascadeOnDelete on
form_schemas). Application-level integrity via FormSchema::defaultCrowdType()
belongsTo + the publish guard + the runtime failsafe — three load-bearing
checks, none of which require the DB-level constraint.

Three pre-existing migration backfill tests bumped step counts +1 to
account for the new migration sitting between WS-5c and WS-5d:
FormFieldBindingMigrationTest (16→17, 14→15), FormFieldConfigBackfillAndDropTest
(11→12), FormFieldValidationRuleBackfillTest (14→15),
ConditionalLogicBackfillTest (5→6).

Six event_registration test fixtures updated to set default_crowd_type_id
to satisfy the new publish guard.

FormBuilderDevSeeder.resolveDefaultCrowdTypeId() — VOLUNTEER → first-active
→ create-as-needed fallback chain; documented contract for future seeders.

SCHEMA.md updated to v2.7.
Refs: RFC-WS-6.md v1.1 §3 Q8 addendum (Task 4 of this session)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 23:47:32 +02:00
parent 1fdd254a8a
commit d2059e3cff
20 changed files with 311 additions and 36 deletions

View File

@@ -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

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Publishing;
use App\Models\FormBuilder\FormSchema;
/**
* RFC-WS-6 v1.1 §3 Q8 addendum event_registration schemas must
* declare a default_crowd_type_id so PersonProvisioner can land
* new registrants in the right CrowdType. Replaces the silent
* oldest() heuristic from session 2.
*/
final readonly class RequiresDefaultCrowdType implements PublishGuard
{
public function code(): string
{
return 'requires_default_crowd_type';
}
public function evaluate(FormSchema $schema): PublishGuardResult
{
if ($schema->default_crowd_type_id !== null) {
return PublishGuardResult::passed($this->code());
}
return PublishGuardResult::failed(
$this->code(),
'form_builder_publish_guards.requires_default_crowd_type',
);
}
}

View File

@@ -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(

View File

@@ -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<CrowdType, $this> */
public function defaultCrowdType(): BelongsTo
{
return $this->belongsTo(CrowdType::class, 'default_crowd_type_id');
}
public function owner(): MorphTo
{
return $this->morphTo();