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}> */ 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}> $bindings * @return array{form_field_id:string, binding:array} * * @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}> $bindings * @param array{form_field_id:string, binding:array} $identityBinding * @return array */ 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; } }