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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\FormBuilder;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when FormSchemaService::publish() finds a purpose-declared
|
||||||
|
* binding path that is not bound by any field on the schema.
|
||||||
|
*
|
||||||
|
* v1.0: bindings are read from `form_fields.binding` JSON. In WS-5 the
|
||||||
|
* check will switch to the relational `form_field_bindings` table.
|
||||||
|
*/
|
||||||
|
final class PurposeRequirementsNotMetException extends RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use App\Enums\FormBuilder\FormPurpose;
|
|||||||
use App\Enums\FormBuilder\FormSubmissionStatus;
|
use App\Enums\FormBuilder\FormSubmissionStatus;
|
||||||
use App\Exceptions\FormBuilder\DestructiveConfirmationRequiredException;
|
use App\Exceptions\FormBuilder\DestructiveConfirmationRequiredException;
|
||||||
use App\Exceptions\FormBuilder\EditLockConflictException;
|
use App\Exceptions\FormBuilder\EditLockConflictException;
|
||||||
|
use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException;
|
||||||
|
use App\FormBuilder\Purposes\PurposeRegistry;
|
||||||
use App\Models\FormBuilder\FormField;
|
use App\Models\FormBuilder\FormField;
|
||||||
use App\Models\FormBuilder\FormSchema;
|
use App\Models\FormBuilder\FormSchema;
|
||||||
use App\Models\FormBuilder\FormSchemaSection;
|
use App\Models\FormBuilder\FormSchemaSection;
|
||||||
@@ -23,6 +25,10 @@ use Illuminate\Support\Str;
|
|||||||
*/
|
*/
|
||||||
final class FormSchemaService
|
final class FormSchemaService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PurposeRegistry $purposeRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function create(Organisation $organisation, array $data, User $actor): FormSchema
|
public function create(Organisation $organisation, array $data, User $actor): FormSchema
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($organisation, $data, $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
|
public function publish(FormSchema $schema, User $actor): FormSchema
|
||||||
{
|
{
|
||||||
|
$this->assertRequiredBindingsPresent($schema);
|
||||||
|
|
||||||
$schema->is_published = true;
|
$schema->is_published = true;
|
||||||
$schema->last_updated_by_user_id = $actor->id;
|
$schema->last_updated_by_user_id = $actor->id;
|
||||||
$schema->save();
|
$schema->save();
|
||||||
@@ -120,6 +128,49 @@ final class FormSchemaService
|
|||||||
return $schema->refresh();
|
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
|
public function unpublish(FormSchema $schema, User $actor): FormSchema
|
||||||
{
|
{
|
||||||
$schema->is_published = false;
|
$schema->is_published = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user