Files
crewli/api/app/FormBuilder/Bindings/PersonProvisioner.php
bert.hausmans fe686b7c8d fix(form-builder): restore FK on form_schemas.default_crowd_type_id (WS-6)
The original session 2.5 migration had to omit this FK due to an
SQLite-only "rebuild on FK add" cascade-delete quirk. Now that the
test infrastructure has moved to MySQL (Task 1 of this session), the
quirk does not apply and the FK is restored to match every other FK
in this table.

Changes:
- New migration `2026_04_28_100000_restore_default_crowd_type_id_foreign_key`
  adds a FOREIGN KEY (default_crowd_type_id) REFERENCES crowd_types(id)
  ON DELETE SET NULL. Deleting a CrowdType nulls the column on dependent
  schemas instead of cascading the schema delete.
- Original migration's comment block rewritten — the SQLite-quirk
  rationale was demonstrably misleading; replaced with a forward-looking
  pointer to the FK-restore migration.
- PersonProvisioner::resolveCrowdTypeId() docblock updated: the runtime
  failsafe is now defense in depth alongside the DB-level FK + publish
  guard, not the sole load-bearing check.

New test (`DefaultCrowdTypeForeignKeyTest`) exercises both the
ON-DELETE-SET-NULL cascade and the existence of the FK in
information_schema.REFERENTIAL_CONSTRAINTS — the second assertion would
have been impossible on SQLite, which is exactly the point.

Migration step counts in 5 backfill tests bumped +1 because the FK-
restore migration sits at the top of the migration stack:
  - FormFieldBindingMigrationTest:           17→18, 15→16
  - ConditionalLogicBackfillTest:             6→7
  - FormFieldConfigBackfillAndDropTest:      12→13
  - FormFieldOptionsBackfillTest:             2→3
  - FormFieldValidationRuleBackfillTest:     15→16

All 1388 tests pass on MySQL (1386 prior + 2 new FK tests). Larastan
baseline unchanged.

Refs: RFC-WS-6.md v1.1 §3 Q9 addendum, WS-6 session 2.5 deviation #1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:10:57 +02:00

243 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\FormBuilder\Bindings;
use App\Exceptions\FormBuilder\PersonProvisioningException;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormValue;
use App\Models\Person;
use Illuminate\Support\Facades\DB;
/**
* RFC-WS-6 §3 (Q8) — provisions a Person for an event_registration
* submission. Reads bindings from `schema_snapshot.fields[*].bindings`
* (RFC Q6 — snapshot is truth). Race-safe via lockForUpdate +
* firstOrCreate semantics: callers MUST be inside a DB::transaction.
*
* Multi-tenancy note: Person is scoped by `event_id` (see
* Person::$organisationScopeColumn). The provisioner lookups by
* `(email, event_id)`; cross-event submissions never collide.
*/
final readonly class PersonProvisioner
{
public function __construct(private BindingTypeRegistry $registry) {}
/**
* @throws PersonProvisioningException
*/
public function provisionFromSubmission(FormSubmission $submission): Person
{
if (DB::transactionLevel() < 1) {
throw new PersonProvisioningException(
'no_transaction',
(string) $submission->id,
'PersonProvisioner must be invoked inside DB::transaction',
);
}
if ($submission->event_id === null) {
throw new PersonProvisioningException(
'no_event',
(string) $submission->id,
'event_registration submission has no event_id',
);
}
$bindings = $this->extractPersonBindings($submission);
$identityBinding = $this->findIdentityKeyBinding($bindings, $submission);
$emailValue = $this->readFormValue($submission, $identityBinding['form_field_id']);
if (! is_string($emailValue) || $emailValue === '') {
throw new PersonProvisioningException(
'identity_key_missing_value',
(string) $submission->id,
"identity-key field {$identityBinding['form_field_id']} has no usable value",
);
}
$existing = Person::query()
->withoutGlobalScopes()
->where('email', $emailValue)
->where('event_id', $submission->event_id)
->lockForUpdate()
->first();
if ($existing !== null) {
return $existing;
}
$attributes = $this->buildCreateAttributes($submission, $bindings, $identityBinding);
$attributes['email'] = $emailValue;
$attributes['event_id'] = $submission->event_id;
$attributes['crowd_type_id'] = $this->resolveCrowdTypeId($submission);
// firstOrCreate semantics: an identical row created concurrently
// (between our lockForUpdate window and the insert) surfaces via
// the unique-constraint and is reread.
return Person::query()
->withoutGlobalScopes()
->firstOrCreate(
[
'email' => $emailValue,
'event_id' => $submission->event_id,
],
$attributes,
);
}
/**
* @return list<array{form_field_id:string, binding:array<string,mixed>}>
*/
private function extractPersonBindings(FormSubmission $submission): array
{
$snapshot = $submission->schema_snapshot;
if (! is_array($snapshot)) {
return [];
}
$fields = $snapshot['fields'] ?? [];
$out = [];
foreach ($fields as $field) {
if (! is_array($field)) {
continue;
}
$fieldId = (string) ($field['id'] ?? '');
if ($fieldId === '') {
continue;
}
$bindings = $field['bindings'] ?? [];
if (! is_array($bindings)) {
continue;
}
foreach ($bindings as $binding) {
if (! is_array($binding)) {
continue;
}
if (($binding['entity'] ?? null) !== 'person') {
continue;
}
$out[] = [
'form_field_id' => $fieldId,
'binding' => $binding,
];
}
}
return $out;
}
/**
* @param list<array{form_field_id:string, binding:array<string,mixed>}> $bindings
* @return array{form_field_id:string, binding:array<string,mixed>}
*
* @throws PersonProvisioningException
*/
private function findIdentityKeyBinding(array $bindings, FormSubmission $submission): array
{
foreach ($bindings as $entry) {
if (($entry['binding']['is_identity_key'] ?? false) === true) {
return $entry;
}
}
throw new PersonProvisioningException(
'no_identity_key',
(string) $submission->id,
'no person.* binding flagged is_identity_key=true on this schema',
);
}
/**
* Resolve a default crowd_type_id for a freshly-provisioned Person.
* 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 Q9 addendum (was: silent oldest() fallback in
* session 2). Defense in depth: the FK on form_schemas (added in
* session 2.6 after the SQLite → MySQL test switch) gives DB-level
* referential integrity; the RequiresDefaultCrowdType publish guard
* blocks publish when null; this runtime throw is the failsafe for
* live-table edits between publish and apply.
*
* @throws PersonProvisioningException
*/
private function resolveCrowdTypeId(FormSubmission $submission): string
{
/** @var FormSchema|null $schema */
$schema = $submission->schema;
if (! $schema instanceof FormSchema) {
throw new PersonProvisioningException(
'no_schema',
(string) $submission->id,
'submission has no schema relation loaded',
);
}
$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
{
// Use Eloquent so the JSON cast on `value` is applied. The
// query builder's value() returns the raw column (JSON-encoded
// string), which would round-trip incorrectly for scalars.
$row = FormValue::query()
->withoutGlobalScopes()
->where('form_submission_id', $submission->id)
->where('form_field_id', $formFieldId)
->first();
return $row?->value;
}
/**
* @param list<array{form_field_id:string, binding:array<string,mixed>}> $bindings
* @param array{form_field_id:string, binding:array<string,mixed>} $identityBinding
* @return array<string, mixed>
*/
private function buildCreateAttributes(
FormSubmission $submission,
array $bindings,
array $identityBinding,
): array {
$personFillable = (new Person)->getFillable();
$attributes = [];
foreach ($bindings as $entry) {
if ($entry['form_field_id'] === $identityBinding['form_field_id']) {
// identity-key value is set explicitly on the create call
continue;
}
$column = (string) ($entry['binding']['column'] ?? '');
if ($column === '') {
continue;
}
if (! in_array($column, $personFillable, true)) {
continue;
}
if (! $this->registry->isKnown('person', $column)) {
continue;
}
$value = $this->readFormValue($submission, $entry['form_field_id']);
if ($value === null) {
continue;
}
$attributes[$column] = $value;
}
return $attributes;
}
}