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. 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. * * @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) { throw new PersonProvisioningException( 'no_crowd_type', (string) $submission->id, "no active CrowdType available for organisation {$orgId}", ); } return (string) $crowdType->id; } 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; } }