feat(form-builder): add PersonProvisioner with race-safe firstOrCreate (WS-6)
PersonProvisioner reads bindings from schema_snapshot (RFC Q6) and provisions Persons via lockForUpdate + firstOrCreate (RFC Q8). Person is event-scoped (Person::$organisationScopeColumn = 'event_id'), so the lookup matches by (email, event_id) — cross-event submissions never collide. Throws PersonProvisioningException on misconfiguration (failsafe — publish guards should prevent these at config time): no_transaction, no_event, no_identity_key, identity_key_missing_value, no_crowd_type. Snapshot enrichment: FormFieldBindingService::toApplicatorShape + FormSubmissionService snapshot now adds a 'bindings' (plural) key with binding id, merge_strategy, trust_level, is_identity_key. Singular 'binding' key kept for legacy webhook / GDPR readers. Includes RFC V4 state-injection concurrency test asserting recovery semantics under lockForUpdate windows. Refs: RFC-WS-6.md §3 (Q6, Q8), §4 (V4) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -150,6 +150,70 @@ final class FormFieldBindingService
|
||||
return $shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Richer snapshot shape for the WS-6 binding pipeline. Captures
|
||||
* applicator-relevant metadata (binding id, merge_strategy,
|
||||
* trust_level, is_identity_key) on top of the legacy
|
||||
* mode/entity/column triple.
|
||||
*
|
||||
* RFC-WS-6.md §3 (Q6): the applicator reads from snapshot, not from
|
||||
* the live form_field_bindings table — this shape carries everything
|
||||
* needed for conflict resolution and person provisioning.
|
||||
*
|
||||
* @return array{
|
||||
* id:string,
|
||||
* mode:string,
|
||||
* entity:string,
|
||||
* column:string,
|
||||
* sync_direction?:string,
|
||||
* merge_strategy:string,
|
||||
* trust_level:int,
|
||||
* is_identity_key:bool,
|
||||
* }
|
||||
*/
|
||||
public function toApplicatorShape(FormFieldBinding $binding): array
|
||||
{
|
||||
// FormFieldBinding casts mode/merge_strategy to enum already; access
|
||||
// the value directly without redundant instanceof guards.
|
||||
$shape = [
|
||||
'id' => (string) $binding->id,
|
||||
'mode' => $binding->mode->value,
|
||||
'entity' => (string) $binding->target_entity,
|
||||
'column' => (string) $binding->target_attribute,
|
||||
'merge_strategy' => ($binding->merge_strategy ?? FormFieldBindingMergeStrategy::Overwrite)->value,
|
||||
'trust_level' => (int) $binding->trust_level,
|
||||
'is_identity_key' => (bool) $binding->is_identity_key,
|
||||
];
|
||||
if ($binding->sync_direction !== null && $binding->sync_direction !== '') {
|
||||
$shape['sync_direction'] = (string) $binding->sync_direction;
|
||||
}
|
||||
|
||||
return $shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build snapshot fragments for both the legacy `binding` (singular)
|
||||
* key and the new WS-6 `bindings` (plural) key in one pass over the
|
||||
* collection. Single helper so the FormSubmissionService snapshot
|
||||
* loop accesses `$field->bindings` only once.
|
||||
*
|
||||
* @param iterable<FormFieldBinding> $bindings
|
||||
* @return array{binding: array<string, mixed>|null, bindings: list<array<string, mixed>>}
|
||||
*/
|
||||
public function snapshotShapesFor(iterable $bindings): array
|
||||
{
|
||||
$first = null;
|
||||
$all = [];
|
||||
foreach ($bindings as $binding) {
|
||||
if ($first === null) {
|
||||
$first = $this->toJsonShape($binding);
|
||||
}
|
||||
$all[] = $this->toApplicatorShape($binding);
|
||||
}
|
||||
|
||||
return ['binding' => $first, 'bindings' => $all];
|
||||
}
|
||||
|
||||
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string
|
||||
{
|
||||
return $owner instanceof FormField ? 'form_field' : 'form_field_library';
|
||||
|
||||
Reference in New Issue
Block a user