Promote RFC-WS-6 to v1.1 with two §3 addenda capturing the post-session-2
cleanup decisions; align ARCH-BINDINGS.md §6.4 (Person provisioning)
with the v1.1 text. No architectural reversals — corrections + one
schema addition.
§3 Q8 v1.1 addendum — Person provisioning is scoped by `event_id`:
- Q8 v1.0 said `Person::firstOrCreate(['email', 'organisation_id'], ...)`.
That is incorrect against the actual model: `Person::$organisationScopeColumn`
is `event_id`. The provisioner looks up and creates by `(email, event_id)`.
- Same email registering across two events in the same org → two distinct
Person rows. Cross-event identity reconciliation remains the job of
`PersonIdentityService` (out of scope WS-6).
- Failsafe: `PersonProvisioningException('no_event', ...)` when
`submission.event_id` is null on event_registration; publish guard
`SchemaHasLinkedEvent` blocks at config time.
§3 Q9 v1.1 addendum — `form_schemas.default_crowd_type_id` replaces
`CrowdType::oldest()`:
- Session 2's PersonProvisioner used a silent oldest()-in-org heuristic
for the new Person's `crowd_type_id` (NOT NULL). Fragile, undocumented,
cross-org broken.
- v1.1 adds `form_schemas.default_crowd_type_id` (nullable ULID) as the
explicit, versioned schema attribute. `RequiresDefaultCrowdType` publish
guard wires into `EventRegistrationGuards`. Runtime failsafe in
`PersonProvisioner::resolveCrowdTypeId()` throws
`PersonProvisioningException('no_default_crowd_type', ...)` when null.
- Schema-level FK omitted intentionally (SQLite cascade-delete on
ALTER TABLE ADD FOREIGN KEY observed in WS-5b/c backfill tests).
Application-level integrity (publish guard + runtime failsafe +
Eloquent `belongsTo`) is sufficient because writes always go through
`FormSchemaService::publish()`.
- Snapshot impact: none. Provisioning reads from live FormSchema by
FK; audit replay uses whatever the schema's current
`default_crowd_type_id` is at retry time.
ARCH-BINDINGS.md §6.4:
- Now references "RFC Q8 + Q9, v1.1" in the heading.
- Default-crowd-type bullet replaces "first active CrowdType in the org"
(the session-2 oldest() heuristic) with the schema attribute lookup.
- Multi-tenancy paragraph clarified for cross-event scoping.
Cross-references touched up:
- `PersonProvisioner::resolveCrowdTypeId()` docblock: §3 Q8 → §3 Q9.
- `RequiresDefaultCrowdType` class docblock: §3 Q8 → §3 Q9.
- `SCHEMA.md` v2.7 changelog and `default_crowd_type_id` column note:
§3 Q8 → §3 Q9.
Document history entry added in §10 documenting v1.1 + the snapshot
dual-key cleanup and route-model-binding fix landed in earlier commits
on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
242 lines
8.1 KiB
PHP
242 lines
8.1 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). 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
|
|
{
|
|
/** @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;
|
|
}
|
|
}
|