feat(form-builder): add core services (schema, field, submission, value, field-access, locale, tag-sync, filter, webhook, anonymisation)
S2b Phase 1 per ARCH-FORM-BUILDER.md §20.2. Ten services + supporting
exceptions, jobs, and the organisations.default_locale column needed by
FormLocaleResolver. All services log via spatie/laravel-activitylog, write
operations are transactional, queued jobs are idempotent.
- FormSchemaService: CRUD, slug, version bump, duplicate, edit-lock,
public_token rotation (7-day grace window), typed-confirmation delete.
- FormFieldService: CRUD, reorder, insertFromLibrary, binding-change guard
(§6.5), conditional_logic + section cycle detection (§8, §4.8.1),
is_filterable toggle triggers BackfillFormValueIndexedJob (§7.2, §22.10).
- FormSubmissionService: createDraft with idempotency, saveDraft (auto-save),
submit with schema snapshot + signature hash computation (§9), review,
delegate/revoke, soft delete. Fires S1 domain events (§17.1).
- FormValueService: bulk upsert with FieldAccessService RBAC (§24.2),
Pattern A/C entity mirror writes (§6.1, §6.6) with cross-entity graceful
skip for person.user_id=null.
- FieldAccessService: canRead/canWrite/filterVisibleFields honouring
role_restrictions + subject-self (§18.3, §24.1).
- FormLocaleResolver: submitter → schema → org.default_locale → 'nl' (§16.2).
- FormTagSyncService: rebuildForPerson — replaces legacy TagSyncService
deleted in S2a (§31.10).
- FilterQueryBuilder: generic filter applier for entity_column / tags /
form_field sources (§7.4–§7.5).
- FormWebhookDispatcher + DeliverFormWebhookJob: HMAC-signed delivery with
SSRF protection, exponential backoff {1m,5m,30m,2h,8h}, max 5 attempts,
dead-letter on exhaustion (§17.5).
- FormSubmissionAnonymisationService: per-field anonymisation with separate
activity log entries (§13.3, §23.4).
MigrationRollbackTest: pin the S2a drop migration by filename so future
migrations don't shift the step offset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
334
api/app/Services/FormBuilder/FormFieldService.php
Normal file
334
api/app/Services/FormBuilder/FormFieldService.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Exceptions\FormBuilder\BindingChangeBlockedException;
|
||||
use App\Exceptions\FormBuilder\CyclicDependencyException;
|
||||
use App\Exceptions\FormBuilder\DestructiveConfirmationRequiredException;
|
||||
use App\Exceptions\FormBuilder\FrozenSchemaException;
|
||||
use App\Jobs\FormBuilder\BackfillFormValueIndexedJob;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormFieldLibrary;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSchemaSection;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* FormField CRUD + reorder + library insertion + binding safety
|
||||
* (ARCH §4.2, §6.5, §8, §4.8.1, §7.2).
|
||||
*/
|
||||
final class FormFieldService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FormSchemaService $schemaService,
|
||||
) {}
|
||||
|
||||
public function create(FormSchema $schema, array $data): FormField
|
||||
{
|
||||
$this->assertNotFrozen($schema);
|
||||
|
||||
$data['form_schema_id'] = $schema->id;
|
||||
$data['sort_order'] ??= $this->nextSortOrder($schema);
|
||||
|
||||
$this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null);
|
||||
|
||||
/** @var FormField $field */
|
||||
$field = FormField::create($data);
|
||||
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
$field->logFieldChange('field.created');
|
||||
|
||||
if ($field->is_filterable) {
|
||||
BackfillFormValueIndexedJob::dispatch($field->id)->onQueue('default');
|
||||
}
|
||||
|
||||
return $field->refresh();
|
||||
}
|
||||
|
||||
public function update(FormField $field, array $data, bool $forceBindingChange = false): FormField
|
||||
{
|
||||
$schema = $field->schema;
|
||||
$this->assertNotFrozenForStructural($schema, $data);
|
||||
|
||||
if (array_key_exists('binding', $data) && $data['binding'] !== $field->binding) {
|
||||
$this->assertBindingChangeAllowed($field, $forceBindingChange);
|
||||
}
|
||||
|
||||
if (array_key_exists('conditional_logic', $data)) {
|
||||
$this->assertNoConditionalCycle($schema, $field, $data['conditional_logic'], $data['slug'] ?? $field->slug);
|
||||
}
|
||||
|
||||
$before = [
|
||||
'binding' => $field->binding,
|
||||
'is_filterable' => $field->is_filterable,
|
||||
'is_pii' => $field->is_pii,
|
||||
'field_type' => $field->field_type,
|
||||
];
|
||||
|
||||
$field->fill($data);
|
||||
$field->save();
|
||||
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
|
||||
$field->logFieldChange('field.updated', [
|
||||
'old' => $before,
|
||||
'new' => [
|
||||
'binding' => $field->binding,
|
||||
'is_filterable' => $field->is_filterable,
|
||||
'is_pii' => $field->is_pii,
|
||||
'field_type' => $field->field_type,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($before['is_filterable'] !== $field->is_filterable) {
|
||||
BackfillFormValueIndexedJob::dispatch($field->id)->onQueue('default');
|
||||
}
|
||||
|
||||
return $field->refresh();
|
||||
}
|
||||
|
||||
public function delete(FormField $field, ?string $confirmedName = null): void
|
||||
{
|
||||
$schema = $field->schema;
|
||||
$this->assertNotFrozen($schema);
|
||||
|
||||
$hasValues = FormValue::query()->where('form_field_id', $field->id)->exists();
|
||||
if ($hasValues && $confirmedName !== $field->label) {
|
||||
throw DestructiveConfirmationRequiredException::forName($field->label);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($field, $schema): void {
|
||||
$field->logFieldChange('field.deleted');
|
||||
$field->delete();
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $orderedFieldIds
|
||||
*/
|
||||
public function reorder(FormSchema $schema, array $orderedFieldIds): void
|
||||
{
|
||||
DB::transaction(function () use ($schema, $orderedFieldIds): void {
|
||||
foreach ($orderedFieldIds as $index => $fieldId) {
|
||||
FormField::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->whereKey($fieldId)
|
||||
->update(['sort_order' => $index]);
|
||||
}
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
});
|
||||
}
|
||||
|
||||
public function insertFromLibrary(FormSchema $schema, FormFieldLibrary $library, array $overrides = []): FormField
|
||||
{
|
||||
$this->assertNotFrozen($schema);
|
||||
|
||||
$data = array_merge([
|
||||
'form_schema_id' => $schema->id,
|
||||
'library_field_id' => $library->id,
|
||||
'field_type' => $library->field_type,
|
||||
'slug' => $this->ensureUniqueSlug($schema, $library->slug),
|
||||
'label' => $library->label,
|
||||
'help_text' => $library->help_text,
|
||||
'options' => $library->options,
|
||||
'validation_rules' => $library->validation_rules,
|
||||
'is_required' => (bool) $library->default_is_required,
|
||||
'is_filterable' => (bool) $library->default_is_filterable,
|
||||
'binding' => $library->default_binding,
|
||||
'translations' => $library->translations,
|
||||
'sort_order' => $this->nextSortOrder($schema),
|
||||
], $overrides);
|
||||
|
||||
if (! isset($data['slug']) || $data['slug'] === '') {
|
||||
$data['slug'] = $this->ensureUniqueSlug($schema, $library->slug);
|
||||
} else {
|
||||
$data['slug'] = $this->ensureUniqueSlug($schema, $data['slug']);
|
||||
}
|
||||
|
||||
/** @var FormField $field */
|
||||
$field = FormField::create($data);
|
||||
|
||||
FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count');
|
||||
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
$field->logFieldChange('field.inserted_from_library', ['library_field_id' => $library->id]);
|
||||
|
||||
if ($field->is_filterable) {
|
||||
BackfillFormValueIndexedJob::dispatch($field->id)->onQueue('default');
|
||||
}
|
||||
|
||||
return $field->refresh();
|
||||
}
|
||||
|
||||
private function assertBindingChangeAllowed(FormField $field, bool $forceBindingChange): void
|
||||
{
|
||||
$submittedCount = FormSubmission::query()
|
||||
->where('form_schema_id', $field->form_schema_id)
|
||||
->where('status', 'submitted')
|
||||
->count();
|
||||
|
||||
if ($submittedCount > 0 && ! $forceBindingChange) {
|
||||
throw BindingChangeBlockedException::forField($field->id, $submittedCount);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotFrozen(FormSchema $schema): void
|
||||
{
|
||||
if ($schema->freeze_on_submit && $this->schemaService->hasSubmittedSubmissions($schema)) {
|
||||
throw FrozenSchemaException::forSchema($schema->id);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotFrozenForStructural(FormSchema $schema, array $data): void
|
||||
{
|
||||
$structuralKeys = ['field_type', 'binding', 'options', 'validation_rules', 'is_required', 'slug'];
|
||||
foreach ($structuralKeys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$this->assertNotFrozen($schema);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNoConditionalCycle(FormSchema $schema, ?FormField $subject, mixed $conditionalLogic, ?string $subjectSlug): void
|
||||
{
|
||||
if ($conditionalLogic === null || $subjectSlug === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dependsOn = $this->extractConditionSlugs($conditionalLogic);
|
||||
if ($dependsOn === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$adjacency = $this->buildConditionalAdjacency($schema, $subject, $subjectSlug, $dependsOn);
|
||||
|
||||
$visiting = [];
|
||||
$visited = [];
|
||||
$walk = function (string $node) use (&$walk, &$adjacency, &$visiting, &$visited, $subjectSlug): void {
|
||||
if (isset($visited[$node])) {
|
||||
return;
|
||||
}
|
||||
if (isset($visiting[$node])) {
|
||||
throw CyclicDependencyException::forField($subjectSlug);
|
||||
}
|
||||
$visiting[$node] = true;
|
||||
foreach ($adjacency[$node] ?? [] as $next) {
|
||||
$walk($next);
|
||||
}
|
||||
unset($visiting[$node]);
|
||||
$visited[$node] = true;
|
||||
};
|
||||
|
||||
$walk($subjectSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractConditionSlugs(mixed $logic): array
|
||||
{
|
||||
if (! is_array($logic)) {
|
||||
return [];
|
||||
}
|
||||
$slugs = [];
|
||||
$walk = function ($node) use (&$walk, &$slugs): void {
|
||||
if (! is_array($node)) {
|
||||
return;
|
||||
}
|
||||
if (isset($node['field_slug'])) {
|
||||
$slugs[] = (string) $node['field_slug'];
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
if (is_array($child)) {
|
||||
$walk($child);
|
||||
}
|
||||
}
|
||||
};
|
||||
$walk($logic);
|
||||
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $seedDeps
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private function buildConditionalAdjacency(FormSchema $schema, ?FormField $subject, string $subjectSlug, array $seedDeps): array
|
||||
{
|
||||
$fields = FormField::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->get(['id', 'slug', 'conditional_logic']);
|
||||
|
||||
$adjacency = [];
|
||||
foreach ($fields as $f) {
|
||||
if ($subject !== null && $f->id === $subject->id) {
|
||||
continue;
|
||||
}
|
||||
$deps = $this->extractConditionSlugs($f->conditional_logic);
|
||||
if ($deps !== []) {
|
||||
$adjacency[$f->slug] = $deps;
|
||||
}
|
||||
}
|
||||
$adjacency[$subjectSlug] = $seedDeps;
|
||||
|
||||
return $adjacency;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function detectSectionCycle(FormSchema $schema, FormSchemaSection $section, ?string $dependsOnId): void
|
||||
{
|
||||
if ($dependsOnId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chain = [];
|
||||
$current = $dependsOnId;
|
||||
$safety = 100;
|
||||
while ($current !== null && $safety-- > 0) {
|
||||
if ($current === $section->id) {
|
||||
throw CyclicDependencyException::forSection($section->id);
|
||||
}
|
||||
$chain[] = $current;
|
||||
$parent = FormSchemaSection::query()
|
||||
->whereKey($current)
|
||||
->value('depends_on_section_id');
|
||||
$current = $parent !== null ? (string) $parent : null;
|
||||
}
|
||||
}
|
||||
|
||||
private function nextSortOrder(FormSchema $schema): int
|
||||
{
|
||||
$max = (int) FormField::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->max('sort_order');
|
||||
|
||||
return $max + 1;
|
||||
}
|
||||
|
||||
private function ensureUniqueSlug(FormSchema $schema, string $slug): string
|
||||
{
|
||||
$base = \Illuminate\Support\Str::slug($slug) ?: 'veld';
|
||||
$candidate = $base;
|
||||
$i = 2;
|
||||
while (FormField::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->where('slug', $candidate)
|
||||
->exists()
|
||||
) {
|
||||
$candidate = $base.'-'.$i;
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user