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