diff --git a/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php b/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php new file mode 100644 index 00000000..8cfd9f42 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php @@ -0,0 +1,23 @@ +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'); + } + } +} diff --git a/api/app/FormBuilder/Bindings/FormBindingApplicator.php b/api/app/FormBuilder/Bindings/FormBindingApplicator.php new file mode 100644 index 00000000..a8c42045 --- /dev/null +++ b/api/app/FormBuilder/Bindings/FormBindingApplicator.php @@ -0,0 +1,207 @@ +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(); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 12dd8cea..4f34331f 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -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` diff --git a/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php new file mode 100644 index 00000000..866dc508 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php @@ -0,0 +1,187 @@ +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 + */ + 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, + ]], + ]; + } +}