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:
@@ -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
|
||||
|
||||
33
api/app/FormBuilder/Publishing/RequiresDefaultCrowdType.php
Normal file
33
api/app/FormBuilder/Publishing/RequiresDefaultCrowdType.php
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user