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>
335 lines
11 KiB
PHP
335 lines
11 KiB
PHP
<?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;
|
|
}
|
|
}
|