feat(form-builder): FormBindingApplicator + BindingActivityLogger (WS-6)
Orchestrates per-purpose subject resolution + binding conflict resolution + per-binding writes per RFC Q4/Q7/Q9. Per-binding failures captured in BindingPassResult, not thrown — partial failures are expected and recoverable. Catastrophic failures (no transaction, unknown purpose, missing schema) throw FormBindingApplicatorException and bubble. Per-strategy null-winner matrix implemented via a NO_OP sentinel: overwrite=write null, append=noop, replace=conditional, first_write_wins= write only into null target. Append is collection-only with set-merge semantics (deduplicated array_merge). Identity-key bindings are skipped during apply — the subject resolver already used them for lookup/provisioning; re-writing is a no-op or a clobber. Activity log hierarchical: one bindings_pass_completed parent + N binding_applied children with parent_activity_id linkage (RFC Q12). Failed bindings get error_class/error_message in their activity entry in addition to their FormSubmissionActionFailure row (deliberate dual source of truth). Refs: RFC-WS-6.md §3 (Q4, Q7, Q9, Q12) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\FormBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* RFC-WS-6 §3 (Q3) — catastrophic applicator failure that bubbles to
|
||||
* the caller. Per-binding failures are captured in BindingPassResult,
|
||||
* not thrown.
|
||||
*/
|
||||
final class FormBindingApplicatorException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $reasonCode,
|
||||
public readonly string $submissionId,
|
||||
?string $message = null,
|
||||
) {
|
||||
parent::__construct($message ?? "FormBindingApplicator failed: {$reasonCode} (submission {$submissionId})");
|
||||
}
|
||||
}
|
||||
63
api/app/FormBuilder/Bindings/BindingActivityLogger.php
Normal file
63
api/app/FormBuilder/Bindings/BindingActivityLogger.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\FormBuilder\Bindings;
|
||||
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
/**
|
||||
* RFC-WS-6 §3 (Q12) — hierarchical activity log for the binding
|
||||
* pipeline. One pass-level activity (form_submission.bindings_pass_completed)
|
||||
* with N child activities (form_submission.binding_applied), linked via
|
||||
* properties.parent_activity_id.
|
||||
*
|
||||
* Failed bindings get their own binding_applied activity entry too,
|
||||
* with `error_class` / `error_message` in properties — in addition to
|
||||
* their FormSubmissionActionFailure row (deliberate dual source of
|
||||
* truth: activity_log is the human timeline, action_failures is the
|
||||
* machine-replayable workflow).
|
||||
*/
|
||||
final class BindingActivityLogger
|
||||
{
|
||||
public function logPass(FormSubmission $submission, BindingPassResult $result): void
|
||||
{
|
||||
$passActivity = activity()
|
||||
->performedOn($submission)
|
||||
->withProperties([
|
||||
'binding_count' => count($result->applications),
|
||||
'succeeded' => $result->successCount(),
|
||||
'failed' => $result->failureCount(),
|
||||
'apply_status' => $result->applyStatus()->value,
|
||||
'person_provisioned' => $result->provisionedSubjectType === 'person',
|
||||
'subject_type' => $result->provisionedSubjectType,
|
||||
'subject_id' => $result->provisionedSubjectId,
|
||||
])
|
||||
->log('form_submission.bindings_pass_completed');
|
||||
|
||||
$parentActivityId = $passActivity instanceof Activity ? (string) $passActivity->id : null;
|
||||
|
||||
foreach ($result->applications as $application) {
|
||||
$properties = [
|
||||
'parent_activity_id' => $parentActivityId,
|
||||
'binding_id' => $application->bindingId,
|
||||
'target_entity' => $application->targetEntity,
|
||||
'target_attribute' => $application->targetAttribute,
|
||||
'success' => $application->success,
|
||||
'old_value' => $application->oldValue,
|
||||
'new_value' => $application->newValue,
|
||||
'source_submission_id' => (string) $submission->id,
|
||||
];
|
||||
if (! $application->success) {
|
||||
$properties['error_class'] = $application->exceptionClass;
|
||||
$properties['error_message'] = $application->exceptionMessage;
|
||||
}
|
||||
|
||||
activity()
|
||||
->performedOn($submission)
|
||||
->withProperties($properties)
|
||||
->log('form_submission.binding_applied');
|
||||
}
|
||||
}
|
||||
}
|
||||
207
api/app/FormBuilder/Bindings/FormBindingApplicator.php
Normal file
207
api/app/FormBuilder/Bindings/FormBindingApplicator.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\FormBuilder\Bindings;
|
||||
|
||||
use App\Enums\FormBuilder\BindingTargetType;
|
||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
|
||||
use App\FormBuilder\Purposes\PurposeRegistry;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* RFC-WS-6 §3 — orchestrator for the binding pipeline. Calls the
|
||||
* subject resolver, the conflict resolver, and writes target attributes.
|
||||
*
|
||||
* - Q4: caller MUST be inside a DB::transaction; this method does
|
||||
* not open its own. Per-binding write failures are captured in the
|
||||
* result, not thrown. Catastrophic failures (no transaction,
|
||||
* unknown purpose, missing schema) bubble.
|
||||
* - Q7: per-strategy null-winner matrix via
|
||||
* FormFieldBindingMergeStrategy::nullWinnerBehaviour().
|
||||
* - Q9: subject resolution via per-purpose PurposeSubjectResolver.
|
||||
* - Q10: optional sectionId for future section-level apply.
|
||||
* - Q12: hierarchical activity log via BindingActivityLogger.
|
||||
*/
|
||||
final readonly class FormBindingApplicator
|
||||
{
|
||||
public function __construct(
|
||||
private PurposeRegistry $purposeRegistry,
|
||||
private BindingConflictResolver $conflictResolver,
|
||||
private BindingTypeRegistry $typeRegistry,
|
||||
private BindingActivityLogger $activityLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws FormBindingApplicatorException
|
||||
*/
|
||||
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
|
||||
{
|
||||
if (DB::transactionLevel() < 1) {
|
||||
throw new FormBindingApplicatorException(
|
||||
'no_transaction',
|
||||
(string) $submission->id,
|
||||
'FormBindingApplicator must be invoked inside DB::transaction',
|
||||
);
|
||||
}
|
||||
|
||||
/** @var \App\Models\FormBuilder\FormSchema|null $schema */
|
||||
$schema = $submission->schema;
|
||||
if ($schema === null) {
|
||||
throw new FormBindingApplicatorException(
|
||||
'no_schema',
|
||||
(string) $submission->id,
|
||||
);
|
||||
}
|
||||
$purposeValue = $schema->purpose->value;
|
||||
if (! $this->purposeRegistry->has($purposeValue)) {
|
||||
throw new FormBindingApplicatorException(
|
||||
'unknown_purpose',
|
||||
(string) $submission->id,
|
||||
"purpose '{$purposeValue}' not registered",
|
||||
);
|
||||
}
|
||||
|
||||
$resolver = $this->purposeRegistry->subjectResolverFor($purposeValue);
|
||||
$subject = $resolver->resolveOrProvision($submission);
|
||||
|
||||
if (! $subject instanceof Model) {
|
||||
// Anonymous-allowed (incident_report). No bindings to apply.
|
||||
$result = new BindingPassResult(
|
||||
formSubmissionId: (string) $submission->id,
|
||||
provisionedSubjectType: null,
|
||||
provisionedSubjectId: null,
|
||||
applications: [],
|
||||
);
|
||||
$this->activityLogger->logPass($submission, $result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$resolved = $this->conflictResolver->resolve($submission, $sectionId);
|
||||
|
||||
// Persist subject identity for the result + apply each binding.
|
||||
$applications = [];
|
||||
foreach ($resolved as $binding) {
|
||||
// Skip identity-key bindings — the resolver already used them
|
||||
// for subject lookup in EventRegistration's PersonProvisioner
|
||||
// path. Writing them again is a no-op at best, a clobber at
|
||||
// worst.
|
||||
if ($binding->isIdentityKey) {
|
||||
continue;
|
||||
}
|
||||
$applications[] = $this->applyOne($subject, $binding);
|
||||
}
|
||||
|
||||
$result = new BindingPassResult(
|
||||
formSubmissionId: (string) $submission->id,
|
||||
provisionedSubjectType: $this->morphAlias($subject),
|
||||
provisionedSubjectId: (string) $subject->getKey(),
|
||||
applications: $applications,
|
||||
);
|
||||
|
||||
$this->activityLogger->logPass($submission, $result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function applyOne(Model $subject, ResolvedBinding $binding): BindingApplicationResult
|
||||
{
|
||||
try {
|
||||
// Defensive: BindingTypeRegistry validates Append-against-scalar
|
||||
// at publish time; runtime check is a failsafe for live-table
|
||||
// edits between publish and apply.
|
||||
$this->typeRegistry->validateAppendStrategy(
|
||||
$binding->targetEntity,
|
||||
$binding->targetAttribute,
|
||||
$binding->mergeStrategy,
|
||||
);
|
||||
|
||||
$oldValue = $subject->getAttribute($binding->targetAttribute);
|
||||
$newValue = $this->computeNewValue($oldValue, $binding);
|
||||
|
||||
if ($newValue === self::NO_OP) {
|
||||
return BindingApplicationResult::succeeded(
|
||||
bindingId: $binding->bindingId,
|
||||
targetEntity: $binding->targetEntity,
|
||||
targetAttribute: $binding->targetAttribute,
|
||||
oldValue: $oldValue,
|
||||
newValue: $oldValue,
|
||||
);
|
||||
}
|
||||
|
||||
$subject->setAttribute($binding->targetAttribute, $newValue);
|
||||
$subject->save();
|
||||
|
||||
return BindingApplicationResult::succeeded(
|
||||
bindingId: $binding->bindingId,
|
||||
targetEntity: $binding->targetEntity,
|
||||
targetAttribute: $binding->targetAttribute,
|
||||
oldValue: $oldValue,
|
||||
newValue: $newValue,
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return BindingApplicationResult::failed(
|
||||
bindingId: $binding->bindingId,
|
||||
targetEntity: $binding->targetEntity,
|
||||
targetAttribute: $binding->targetAttribute,
|
||||
e: $e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private const NO_OP = '__binding_noop_sentinel__';
|
||||
|
||||
private function computeNewValue(mixed $oldValue, ResolvedBinding $binding): mixed
|
||||
{
|
||||
$newValue = $binding->value;
|
||||
$strategy = $binding->mergeStrategy;
|
||||
|
||||
// Per-strategy matrix. RFC §3 Q7.
|
||||
if ($newValue === null) {
|
||||
$behaviour = $strategy->nullWinnerBehaviour();
|
||||
return match ($behaviour) {
|
||||
'write' => null,
|
||||
'noop' => self::NO_OP,
|
||||
'conditional' => $oldValue === null ? null : self::NO_OP,
|
||||
default => self::NO_OP,
|
||||
};
|
||||
}
|
||||
|
||||
return match ($strategy) {
|
||||
FormFieldBindingMergeStrategy::Overwrite => $newValue,
|
||||
FormFieldBindingMergeStrategy::Append => $this->appendCollection($oldValue, $newValue, $binding),
|
||||
FormFieldBindingMergeStrategy::Replace => $oldValue === null ? $newValue : self::NO_OP,
|
||||
FormFieldBindingMergeStrategy::FirstWriteWins => $oldValue === null ? $newValue : self::NO_OP,
|
||||
};
|
||||
}
|
||||
|
||||
private function appendCollection(mixed $oldValue, mixed $newValue, ResolvedBinding $binding): mixed
|
||||
{
|
||||
if ($binding->targetType !== BindingTargetType::COLLECTION) {
|
||||
// Defensive — publish guard should prevent this. Throwing
|
||||
// gets the failure into BindingApplicationResult::failed.
|
||||
throw new \InvalidArgumentException(
|
||||
"merge_strategy=append requires COLLECTION target; got {$binding->targetType->value}",
|
||||
);
|
||||
}
|
||||
|
||||
$current = is_array($oldValue) ? $oldValue : [];
|
||||
$incoming = is_array($newValue) ? $newValue : [$newValue];
|
||||
|
||||
// Set semantics: dedupe via array_unique. Preserves insertion order
|
||||
// for stable activity log output.
|
||||
$merged = array_values(array_unique(array_merge($current, $incoming), SORT_REGULAR));
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
private function morphAlias(Model $subject): string
|
||||
{
|
||||
return $subject->getMorphClass();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\FormBuilder\Bindings\BindingActivityLogger;
|
||||
use App\FormBuilder\Bindings\BindingConflictResolver;
|
||||
use App\FormBuilder\Bindings\BindingTypeRegistry;
|
||||
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||
use App\FormBuilder\Bindings\PersonProvisioner;
|
||||
use App\FormBuilder\Purposes\PurposeRegistry;
|
||||
use App\Models\Company;
|
||||
@@ -94,6 +96,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->singleton(BindingTypeRegistry::class);
|
||||
$this->app->singleton(PersonProvisioner::class);
|
||||
$this->app->singleton(BindingConflictResolver::class);
|
||||
$this->app->singleton(BindingActivityLogger::class);
|
||||
$this->app->singleton(FormBindingApplicator::class);
|
||||
|
||||
// Telescope is a dev-only debugging dashboard. Three-layer
|
||||
// defense keeps it out of production: composer `dont-discover`
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings;
|
||||
|
||||
use App\Enums\FormBuilder\ApplyStatus;
|
||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\FormBuilder\Bindings\BindingPassResult;
|
||||
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormFieldBinding;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use App\Models\Person;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class FormBindingApplicatorIntegrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_event_registration_happy_path(): void
|
||||
{
|
||||
$submission = $this->makeEventRegistrationSubmission();
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => $this->applicator()->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$this->assertSame('person', $result->provisionedSubjectType);
|
||||
|
||||
// Person was created with email + first_name + last_name from bindings.
|
||||
$person = Person::query()->withoutGlobalScopes()->where('email', 'jan@example.nl')->first();
|
||||
$this->assertNotNull($person);
|
||||
$this->assertSame('Jan', $person->first_name);
|
||||
$this->assertSame('Jansen', $person->last_name);
|
||||
|
||||
// Activity log: one pass-level + N child entries.
|
||||
$passActivity = Activity::query()
|
||||
->where('description', 'form_submission.bindings_pass_completed')
|
||||
->where('subject_id', $submission->id)
|
||||
->first();
|
||||
$this->assertNotNull($passActivity);
|
||||
$this->assertSame(2, (int) $passActivity->properties->get('binding_count'));
|
||||
|
||||
$childActivities = Activity::query()
|
||||
->where('description', 'form_submission.binding_applied')
|
||||
->where('subject_id', $submission->id)
|
||||
->get();
|
||||
$this->assertCount(2, $childActivities);
|
||||
foreach ($childActivities as $child) {
|
||||
$this->assertSame((string) $passActivity->id, $child->properties->get('parent_activity_id'));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_no_transaction_guard_present(): void
|
||||
{
|
||||
// RefreshDatabase wraps every PHPUnit test in a transaction; the
|
||||
// guard is exercised via the listener path (ApplyBindingsOnFormSubmit)
|
||||
// which opens its own transaction explicitly. Verify the guard
|
||||
// exists in the source.
|
||||
$reflection = new \ReflectionClass(FormBindingApplicator::class);
|
||||
$source = file_get_contents($reflection->getFileName());
|
||||
$this->assertStringContainsString('no_transaction', $source);
|
||||
$this->assertStringContainsString('DB::transactionLevel()', $source);
|
||||
}
|
||||
|
||||
private function applicator(): FormBindingApplicator
|
||||
{
|
||||
return $this->app->make(FormBindingApplicator::class);
|
||||
}
|
||||
|
||||
private function makeEventRegistrationSubmission(): FormSubmission
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
CrowdType::factory()->create([
|
||||
'organisation_id' => $event->organisation_id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $event->organisation_id,
|
||||
'purpose' => FormPurpose::EVENT_REGISTRATION->value,
|
||||
]);
|
||||
|
||||
$emailField = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::EMAIL->value,
|
||||
'slug' => 'email',
|
||||
]);
|
||||
$emailBinding = FormFieldBinding::factory()->forField($emailField)
|
||||
->entityOwned('person', 'email')
|
||||
->create([
|
||||
'is_identity_key' => true,
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => 80,
|
||||
]);
|
||||
|
||||
$firstNameField = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => 'first_name',
|
||||
]);
|
||||
$firstNameBinding = FormFieldBinding::factory()->forField($firstNameField)
|
||||
->entityOwned('person', 'first_name')
|
||||
->create([
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => 70,
|
||||
]);
|
||||
|
||||
$lastNameField = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => 'last_name',
|
||||
]);
|
||||
$lastNameBinding = FormFieldBinding::factory()->forField($lastNameField)
|
||||
->entityOwned('person', 'last_name')
|
||||
->create([
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => 60,
|
||||
]);
|
||||
|
||||
$submission = FormSubmission::factory()
|
||||
->forEvent($event)
|
||||
->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'subject_type' => 'person',
|
||||
]);
|
||||
|
||||
$submission->schema_snapshot = [
|
||||
'fields' => [
|
||||
$this->snapshotField($emailField, $emailBinding, 'person', 'email', identityKey: true, trustLevel: 80),
|
||||
$this->snapshotField($firstNameField, $firstNameBinding, 'person', 'first_name', trustLevel: 70),
|
||||
$this->snapshotField($lastNameField, $lastNameBinding, 'person', 'last_name', trustLevel: 60),
|
||||
],
|
||||
];
|
||||
$submission->save();
|
||||
|
||||
$this->writeValue($submission->id, $emailField->id, 'jan@example.nl');
|
||||
$this->writeValue($submission->id, $firstNameField->id, 'Jan');
|
||||
$this->writeValue($submission->id, $lastNameField->id, 'Jansen');
|
||||
|
||||
return $submission->fresh();
|
||||
}
|
||||
|
||||
private function writeValue(string $submissionId, string $fieldId, mixed $value): void
|
||||
{
|
||||
$row = new FormValue();
|
||||
$row->form_submission_id = $submissionId;
|
||||
$row->form_field_id = $fieldId;
|
||||
$row->setAttribute('value', $value);
|
||||
$row->value_anonymised = false;
|
||||
$row->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function snapshotField(
|
||||
FormField $field,
|
||||
FormFieldBinding $binding,
|
||||
string $entity,
|
||||
string $column,
|
||||
bool $identityKey = false,
|
||||
int $trustLevel = 50,
|
||||
): array {
|
||||
return [
|
||||
'id' => (string) $field->id,
|
||||
'slug' => (string) $field->slug,
|
||||
'sort_order' => (int) $field->sort_order,
|
||||
'bindings' => [[
|
||||
'id' => (string) $binding->id,
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => $entity,
|
||||
'column' => $column,
|
||||
'merge_strategy' => 'overwrite',
|
||||
'trust_level' => $trustLevel,
|
||||
'is_identity_key' => $identityKey,
|
||||
]],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user