From a71201f4d36152af198b626cbffdd51ef3c3180d Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 24 Apr 2026 14:35:56 +0200 Subject: [PATCH] feat(form-builder): add pre-publish binding check per purpose `FormSchemaService::publish()` now verifies that every binding path declared by the schema's PurposeDefinition::requiredBindings is present on at least one of the schema's `form_fields.binding` JSON entries. Missing bindings raise PurposeRequirementsNotMetException with a structured `purposeSlug` + `missingBindings[]` payload. v1.0 this is a trivial JSON scan; in WS-5a the check will switch to the relational `form_field_bindings` table. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PurposeRequirementsNotMetException.php | 31 +++++++++++ .../FormBuilder/FormSchemaService.php | 51 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 api/app/Exceptions/FormBuilder/PurposeRequirementsNotMetException.php diff --git a/api/app/Exceptions/FormBuilder/PurposeRequirementsNotMetException.php b/api/app/Exceptions/FormBuilder/PurposeRequirementsNotMetException.php new file mode 100644 index 00000000..b6b84272 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/PurposeRequirementsNotMetException.php @@ -0,0 +1,31 @@ + $missingBindings paths in "{entity}.{attribute}" form + */ + public function __construct( + public readonly string $purposeSlug, + public readonly array $missingBindings, + ) { + parent::__construct(sprintf( + "Purpose '%s' cannot be published: missing required binding(s): %s", + $purposeSlug, + implode(', ', $missingBindings), + )); + } +} diff --git a/api/app/Services/FormBuilder/FormSchemaService.php b/api/app/Services/FormBuilder/FormSchemaService.php index 95fbf9d6..47d7a520 100644 --- a/api/app/Services/FormBuilder/FormSchemaService.php +++ b/api/app/Services/FormBuilder/FormSchemaService.php @@ -8,6 +8,8 @@ use App\Enums\FormBuilder\FormPurpose; use App\Enums\FormBuilder\FormSubmissionStatus; use App\Exceptions\FormBuilder\DestructiveConfirmationRequiredException; use App\Exceptions\FormBuilder\EditLockConflictException; +use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException; +use App\FormBuilder\Purposes\PurposeRegistry; use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormSchema; use App\Models\FormBuilder\FormSchemaSection; @@ -23,6 +25,10 @@ use Illuminate\Support\Str; */ final class FormSchemaService { + public function __construct( + private readonly PurposeRegistry $purposeRegistry, + ) {} + public function create(Organisation $organisation, array $data, User $actor): FormSchema { return DB::transaction(function () use ($organisation, $data, $actor): FormSchema { @@ -112,6 +118,8 @@ final class FormSchemaService public function publish(FormSchema $schema, User $actor): FormSchema { + $this->assertRequiredBindingsPresent($schema); + $schema->is_published = true; $schema->last_updated_by_user_id = $actor->id; $schema->save(); @@ -120,6 +128,49 @@ final class FormSchemaService return $schema->refresh(); } + /** + * Verify that every `required_bindings` path declared by the schema's + * purpose is bound by at least one field on the schema. + * + * Binding paths follow Pattern A notation (`{entity}.{attribute}`). + * In v1.0 we read `form_fields.binding` JSON; WS-5a will switch this + * to the relational `form_field_bindings` table. + */ + private function assertRequiredBindingsPresent(FormSchema $schema): void + { + $purposeValue = $schema->purpose instanceof FormPurpose + ? $schema->purpose->value + : (string) $schema->purpose; + + if (! $this->purposeRegistry->has($purposeValue)) { + return; + } + + $required = $this->purposeRegistry->get($purposeValue)->requiredBindings; + if ($required === []) { + return; + } + + $bound = []; + foreach ($schema->fields as $field) { + $binding = $field->binding; + if (! is_array($binding)) { + continue; + } + $entity = (string) ($binding['entity'] ?? ''); + $column = (string) ($binding['column'] ?? ''); + if ($entity === '' || $column === '') { + continue; + } + $bound[] = $entity.'.'.$column; + } + + $missing = array_values(array_diff($required, $bound)); + if ($missing !== []) { + throw new PurposeRequirementsNotMetException($purposeValue, $missing); + } + } + public function unpublish(FormSchema $schema, User $actor): FormSchema { $schema->is_published = false;