From b3eab6e0c8fa7418a633f784e985b87dd98910ca Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 20:47:39 +0200 Subject: [PATCH] feat(form-builder): add core services (schema, field, submission, value, field-access, locale, tag-sync, filter, webhook, anonymisation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../BindingChangeBlockedException.php | 19 + .../FormBuilder/CyclicDependencyException.php | 20 ++ ...structiveConfirmationRequiredException.php | 18 + .../FormBuilder/EditLockConflictException.php | 22 ++ .../FormBuilder/FrozenSchemaException.php | 18 + .../BackfillFormValueIndexedJob.php | 44 +++ .../FormBuilder/DeliverFormWebhookJob.php | 187 ++++++++++ .../FormBuilder/FieldAccessService.php | 163 +++++++++ .../FormBuilder/FilterQueryBuilder.php | 113 ++++++ .../Services/FormBuilder/FormFieldService.php | 334 ++++++++++++++++++ .../FormBuilder/FormLocaleResolver.php | 34 ++ .../FormBuilder/FormSchemaService.php | 260 ++++++++++++++ .../FormSubmissionAnonymisationService.php | 87 +++++ .../FormBuilder/FormSubmissionService.php | 298 ++++++++++++++++ .../FormBuilder/FormTagSyncService.php | 101 ++++++ .../Services/FormBuilder/FormValueService.php | 173 +++++++++ .../FormBuilder/FormWebhookDispatcher.php | 86 +++++ ..._default_locale_to_organisations_table.php | 24 ++ api/tests/Feature/MigrationRollbackTest.php | 8 +- 19 files changed, 2006 insertions(+), 3 deletions(-) create mode 100644 api/app/Exceptions/FormBuilder/BindingChangeBlockedException.php create mode 100644 api/app/Exceptions/FormBuilder/CyclicDependencyException.php create mode 100644 api/app/Exceptions/FormBuilder/DestructiveConfirmationRequiredException.php create mode 100644 api/app/Exceptions/FormBuilder/EditLockConflictException.php create mode 100644 api/app/Exceptions/FormBuilder/FrozenSchemaException.php create mode 100644 api/app/Jobs/FormBuilder/BackfillFormValueIndexedJob.php create mode 100644 api/app/Jobs/FormBuilder/DeliverFormWebhookJob.php create mode 100644 api/app/Services/FormBuilder/FieldAccessService.php create mode 100644 api/app/Services/FormBuilder/FilterQueryBuilder.php create mode 100644 api/app/Services/FormBuilder/FormFieldService.php create mode 100644 api/app/Services/FormBuilder/FormLocaleResolver.php create mode 100644 api/app/Services/FormBuilder/FormSchemaService.php create mode 100644 api/app/Services/FormBuilder/FormSubmissionAnonymisationService.php create mode 100644 api/app/Services/FormBuilder/FormSubmissionService.php create mode 100644 api/app/Services/FormBuilder/FormTagSyncService.php create mode 100644 api/app/Services/FormBuilder/FormValueService.php create mode 100644 api/app/Services/FormBuilder/FormWebhookDispatcher.php create mode 100644 api/database/migrations/2026_04_21_100000_add_default_locale_to_organisations_table.php diff --git a/api/app/Exceptions/FormBuilder/BindingChangeBlockedException.php b/api/app/Exceptions/FormBuilder/BindingChangeBlockedException.php new file mode 100644 index 00000000..5cdbc544 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/BindingChangeBlockedException.php @@ -0,0 +1,19 @@ +id ?? 'unknown', + $expiresAt?->format('Y-m-d H:i:s') ?? 'unknown', + )); + } +} diff --git a/api/app/Exceptions/FormBuilder/FrozenSchemaException.php b/api/app/Exceptions/FormBuilder/FrozenSchemaException.php new file mode 100644 index 00000000..e6820b80 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/FrozenSchemaException.php @@ -0,0 +1,18 @@ +find($this->fieldId); + if ($field === null) { + return; + } + + FormValue::query() + ->where('form_field_id', $this->fieldId) + ->chunkById(500, function ($values) use ($field): void { + foreach ($values as $value) { + $value->setRelation('field', $field); + $value->save(); + } + }); + } +} diff --git a/api/app/Jobs/FormBuilder/DeliverFormWebhookJob.php b/api/app/Jobs/FormBuilder/DeliverFormWebhookJob.php new file mode 100644 index 00000000..37edeb2f --- /dev/null +++ b/api/app/Jobs/FormBuilder/DeliverFormWebhookJob.php @@ -0,0 +1,187 @@ + Retry delays in seconds per attempt. */ + public array $backoff = [60, 300, 1800, 7200, 28800]; + + public int $timeout = 30; + + public int $tries = 5; + + public function __construct(public readonly string $deliveryId) {} + + public function handle(): void + { + /** @var FormWebhookDelivery|null $delivery */ + $delivery = FormWebhookDelivery::query()->find($this->deliveryId); + if ($delivery === null) { + return; + } + + $webhook = $delivery->webhook; + if ($webhook === null || ! $webhook->is_active) { + $delivery->status = FormWebhookDeliveryStatus::FAILED->value; + $delivery->failed_permanently_at = now(); + $delivery->save(); + + return; + } + + $url = (string) $webhook->url; + if (! $this->urlIsSafe($url)) { + $delivery->status = FormWebhookDeliveryStatus::FAILED->value; + $delivery->failed_permanently_at = now(); + $delivery->response_body_excerpt = 'SSRF protection: URL rejected.'; + $delivery->save(); + + return; + } + + $payload = (array) ($delivery->payload_snapshot ?? []); + $body = json_encode($payload, JSON_THROW_ON_ERROR); + + $headers = ['Content-Type' => 'application/json']; + if (! empty($webhook->secret)) { + $headers['X-Crewli-Signature'] = 'sha256='.hash_hmac('sha256', $body, (string) $webhook->secret); + } + + $delivery->attempts = (int) $delivery->attempts + 1; + $delivery->last_attempt_at = now(); + + try { + $response = Http::withHeaders($headers) + ->timeout((int) config('form_builder.webhooks.timeout_seconds', 10)) + ->withBody($body, 'application/json') + ->post($url); + + $delivery->response_status = $response->status(); + $delivery->response_body_excerpt = mb_substr((string) $response->body(), 0, 1000); + + if ($response->successful()) { + $delivery->status = FormWebhookDeliveryStatus::DELIVERED->value; + $delivery->delivered_at = now(); + $delivery->save(); + + return; + } + + if ($this->isRetriable($response->status()) && $delivery->attempts < $this->tries) { + $delivery->status = FormWebhookDeliveryStatus::PENDING->value; + $delivery->next_retry_at = now()->addSeconds($this->backoff[$delivery->attempts - 1] ?? 28800); + $delivery->save(); + $this->release($this->backoff[$delivery->attempts - 1] ?? 28800); + + return; + } + + $delivery->status = $delivery->attempts >= $this->tries + ? FormWebhookDeliveryStatus::DEAD_LETTER->value + : FormWebhookDeliveryStatus::FAILED->value; + $delivery->failed_permanently_at = now(); + $delivery->save(); + } catch (\Throwable $e) { + Log::warning('form-webhook.delivery.exception', [ + 'delivery_id' => $delivery->id, + 'message' => $e->getMessage(), + ]); + + if ($delivery->attempts < $this->tries) { + $delivery->status = FormWebhookDeliveryStatus::PENDING->value; + $delivery->next_retry_at = now()->addSeconds($this->backoff[$delivery->attempts - 1] ?? 28800); + $delivery->save(); + $this->release($this->backoff[$delivery->attempts - 1] ?? 28800); + + return; + } + + $delivery->status = FormWebhookDeliveryStatus::DEAD_LETTER->value; + $delivery->failed_permanently_at = now(); + $delivery->save(); + } + } + + private function urlIsSafe(string $url): bool + { + $parts = parse_url($url); + if ($parts === false || ! isset($parts['host'], $parts['scheme'])) { + return false; + } + + if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { + return false; + } + + $host = (string) $parts['host']; + $allowlist = (array) config('form_builder.webhooks.allowlist_domains', []); + if ($allowlist !== [] && ! in_array(strtolower($host), array_map('strtolower', $allowlist), true)) { + return false; + } + + $ips = @gethostbynamel($host) ?: [$host]; + $blocklist = (array) config('form_builder.webhooks.blocklist_ips', []); + foreach ($ips as $ip) { + if (! filter_var($ip, FILTER_VALIDATE_IP)) { + continue; + } + foreach ($blocklist as $cidr) { + if ($this->ipInCidr($ip, (string) $cidr)) { + return false; + } + } + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + } + + return true; + } + + private function ipInCidr(string $ip, string $cidr): bool + { + if (! str_contains($cidr, '/')) { + return $ip === $cidr; + } + [$subnet, $mask] = explode('/', $cidr, 2); + $ipLong = ip2long($ip); + $subnetLong = ip2long($subnet); + if ($ipLong === false || $subnetLong === false) { + return false; + } + $maskLong = -1 << (32 - (int) $mask); + + return (($ipLong & $maskLong) === ($subnetLong & $maskLong)); + } + + private function isRetriable(int $status): bool + { + if ($status >= 500) { + return true; + } + + return in_array($status, [408, 425, 429], true); + } +} diff --git a/api/app/Services/FormBuilder/FieldAccessService.php b/api/app/Services/FormBuilder/FieldAccessService.php new file mode 100644 index 00000000..9dc4d34e --- /dev/null +++ b/api/app/Services/FormBuilder/FieldAccessService.php @@ -0,0 +1,163 @@ +isSubjectSelf($user, $submission)) { + return true; + } + + $restrictions = $this->effectiveRestrictions($field); + $readRule = $restrictions['read'] ?? true; + + return $this->matches($user, $readRule); + } + + public function canWrite(?User $user, FormField $field, ?FormSubmission $submission = null): bool + { + if ($this->isSubjectSelf($user, $submission)) { + return true; + } + + $restrictions = $this->effectiveRestrictions($field); + $writeRule = $restrictions['write'] ?? ['any_of_roles' => ['org_admin', 'event_manager']]; + + return $this->matches($user, $writeRule); + } + + /** + * @param Collection $fields + * @return Collection + */ + public function filterVisibleFields(?User $user, Collection $fields, ?FormSubmission $submission = null): Collection + { + return $fields->filter(fn (FormField $f) => $this->canRead($user, $f, $submission))->values(); + } + + /** + * @return array + */ + private function effectiveRestrictions(FormField $field): array + { + $r = $field->role_restrictions ?? null; + if (is_array($r) && $r !== []) { + return $r; + } + + if ($field->is_admin_only) { + return [ + 'read' => ['any_of_roles' => ['org_admin']], + 'write' => ['any_of_roles' => ['org_admin']], + ]; + } + + return [ + 'read' => true, + 'write' => ['any_of_roles' => ['org_admin', 'event_manager']], + ]; + } + + private function isSubjectSelf(?User $user, ?FormSubmission $submission): bool + { + if ($user === null || $submission === null) { + return false; + } + + if ($submission->submitted_by_user_id === $user->id) { + return true; + } + + if ($submission->subject_type === 'user' && $submission->subject_id === $user->id) { + return true; + } + + if ($submission->subject_type === 'user_profile' && $submission->subject_id !== null) { + $profile = \App\Models\UserProfile::query()->find($submission->subject_id); + if ($profile !== null && $profile->user_id === $user->id) { + return true; + } + } + + if ($submission->subject_type === 'person' && $submission->subject_id !== null) { + $personUserId = \App\Models\Person::withoutGlobalScopes() + ->whereKey($submission->subject_id) + ->value('user_id'); + if ($personUserId !== null && $personUserId === $user->id) { + return true; + } + } + + return false; + } + + /** + * @param mixed $rule + */ + private function matches(?User $user, $rule): bool + { + if ($rule === true) { + return true; + } + if ($rule === false) { + return false; + } + if (! is_array($rule)) { + return false; + } + + if ($user === null) { + return false; + } + + if (isset($rule['subject_self']) && $rule['subject_self'] === true) { + return true; + } + + if (isset($rule['any_of_roles']) && is_array($rule['any_of_roles'])) { + foreach ($rule['any_of_roles'] as $role) { + if ($user->hasRole((string) $role)) { + return true; + } + } + + return false; + } + + if (isset($rule['all_of_roles']) && is_array($rule['all_of_roles'])) { + foreach ($rule['all_of_roles'] as $role) { + if (! $user->hasRole((string) $role)) { + return false; + } + } + + return true; + } + + if (isset($rule['not_roles']) && is_array($rule['not_roles'])) { + foreach ($rule['not_roles'] as $role) { + if ($user->hasRole((string) $role)) { + return false; + } + } + + return true; + } + + return false; + } +} diff --git a/api/app/Services/FormBuilder/FilterQueryBuilder.php b/api/app/Services/FormBuilder/FilterQueryBuilder.php new file mode 100644 index 00000000..090b3528 --- /dev/null +++ b/api/app/Services/FormBuilder/FilterQueryBuilder.php @@ -0,0 +1,113 @@ + $params + * @param array $registry + */ + public function apply(Builder $query, array $params, array $registry, ?User $actor, string $context = 'persons'): Builder + { + foreach ($params as $key => $value) { + $def = $registry[$key] ?? null; + if ($def === null) { + continue; + } + + $source = (string) $def['source']; + match ($source) { + 'entity_column' => $this->applyEntityColumn($query, $key, $value), + 'tags' => $this->applyTagFilter($query, $value, $context), + 'form_field' => $this->applyFormFieldFilter($query, (string) ($def['form_field_id'] ?? ''), $value, $actor), + default => null, + }; + } + + return $query; + } + + private function applyEntityColumn(Builder $query, string $column, mixed $value): void + { + if (is_array($value)) { + $query->whereIn($column, $value); + + return; + } + if (is_bool($value)) { + $query->where($column, $value); + + return; + } + if ($value === null || $value === '') { + return; + } + $query->where($column, $value); + } + + /** + * @param array|string $tagIds + */ + private function applyTagFilter(Builder $query, mixed $tagIds, string $context): void + { + $ids = is_array($tagIds) ? array_values($tagIds) : [$tagIds]; + $ids = array_filter($ids, 'is_string'); + if ($ids === []) { + return; + } + + if ($context === 'persons') { + $query->whereHas('user.organisationTags', fn (Builder $q) => $q->whereIn('person_tag_id', $ids)); + } + } + + private function applyFormFieldFilter(Builder $query, string $fieldId, mixed $value, ?User $actor): void + { + $field = FormField::query()->find($fieldId); + if ($field === null) { + return; + } + + if (! $this->fieldAccess->canRead($actor, $field)) { + throw new AuthorizationException(sprintf('Not allowed to filter on field %s.', $fieldId)); + } + + $query->whereIn('persons.id', function ($sub) use ($fieldId, $value): void { + $sub->from('form_submissions') + ->join('form_values', 'form_values.form_submission_id', '=', 'form_submissions.id') + ->where('form_values.form_field_id', $fieldId) + ->where('form_submissions.subject_type', 'person') + ->where('form_submissions.status', 'submitted') + ->whereColumn('form_submissions.subject_id', 'persons.id') + ->select('form_submissions.subject_id'); + + if (is_array($value)) { + $sub->join('form_value_options', 'form_value_options.form_value_id', '=', 'form_values.id') + ->whereIn('form_value_options.option_value', $value); + } elseif (is_bool($value)) { + $sub->where('form_values.value_bool', $value); + } elseif (is_numeric($value)) { + $sub->where('form_values.value_number', $value); + } else { + $sub->where('form_values.value_indexed', $value); + } + }); + } +} diff --git a/api/app/Services/FormBuilder/FormFieldService.php b/api/app/Services/FormBuilder/FormFieldService.php new file mode 100644 index 00000000..920c2610 --- /dev/null +++ b/api/app/Services/FormBuilder/FormFieldService.php @@ -0,0 +1,334 @@ +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 $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 + */ + 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 $seedDeps + * @return array> + */ + 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 + */ + 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; + } +} diff --git a/api/app/Services/FormBuilder/FormLocaleResolver.php b/api/app/Services/FormBuilder/FormLocaleResolver.php new file mode 100644 index 00000000..55c8de01 --- /dev/null +++ b/api/app/Services/FormBuilder/FormLocaleResolver.php @@ -0,0 +1,34 @@ +locale)) { + return (string) $submitter->locale; + } + + if (! empty($schema->locale)) { + return (string) $schema->locale; + } + + $organisation = $schema->organisation ?? Organisation::withoutGlobalScopes()->find($schema->organisation_id); + if ($organisation !== null && ! empty($organisation->default_locale)) { + return (string) $organisation->default_locale; + } + + return 'nl'; + } +} diff --git a/api/app/Services/FormBuilder/FormSchemaService.php b/api/app/Services/FormBuilder/FormSchemaService.php new file mode 100644 index 00000000..95fbf9d6 --- /dev/null +++ b/api/app/Services/FormBuilder/FormSchemaService.php @@ -0,0 +1,260 @@ +resolvePurpose($data['purpose'] ?? null); + $data['organisation_id'] = $organisation->id; + $data['slug'] = $this->ensureUniqueSlug($organisation, $data['slug'] ?? Str::slug($data['name'] ?? 'formulier')); + $data['purpose'] = $purpose->value; + $data['submission_mode'] ??= $purpose->defaultSubmissionMode()->value; + $data['version'] = 1; + $data['created_by_user_id'] = $actor->id; + $data['last_updated_by_user_id'] = $actor->id; + + /** @var FormSchema $schema */ + $schema = FormSchema::create($data); + + $schema->logSchemaChange('schema.created', ['purpose' => $purpose->value]); + + return $schema->refresh(); + }); + } + + public function update(FormSchema $schema, array $data, User $actor): FormSchema + { + return DB::transaction(function () use ($schema, $data, $actor): FormSchema { + if (isset($data['slug']) && $data['slug'] !== $schema->slug) { + $data['slug'] = $this->ensureUniqueSlug($schema->organisation, $data['slug'], $schema->id); + } + + $before = $schema->toArray(); + $schema->fill($data); + + if ($this->isStructuralChange($schema)) { + $schema->version = (int) $schema->version + 1; + } + + $schema->last_updated_by_user_id = $actor->id; + $schema->save(); + + $schema->logSchemaChange('schema.updated', [ + 'old_version' => $before['version'] ?? null, + 'new_version' => $schema->version, + ]); + + return $schema->refresh(); + }); + } + + public function duplicate(FormSchema $source, User $actor, ?string $nameOverride = null): FormSchema + { + return DB::transaction(function () use ($source, $actor, $nameOverride): FormSchema { + $name = $nameOverride ?? ($source->name.' (kopie)'); + $copy = $source->replicate([ + 'public_token', 'public_token_previous', 'public_token_rotated_at', + 'edit_lock_user_id', 'edit_lock_expires_at', + ]); + $copy->name = $name; + $copy->slug = $this->ensureUniqueSlug($source->organisation, Str::slug($name)); + $copy->version = 1; + $copy->is_published = false; + $copy->created_by_user_id = $actor->id; + $copy->last_updated_by_user_id = $actor->id; + $copy->save(); + + // Copy sections (id mapping) then fields (pointing at new section ids). + $sectionIdMap = []; + foreach ($source->sections as $section) { + $newSection = $section->replicate(); + $newSection->form_schema_id = $copy->id; + $newSection->save(); + $sectionIdMap[$section->id] = $newSection->id; + } + + foreach ($source->fields as $field) { + $newField = $field->replicate(); + $newField->form_schema_id = $copy->id; + if ($field->form_schema_section_id && isset($sectionIdMap[$field->form_schema_section_id])) { + $newField->form_schema_section_id = $sectionIdMap[$field->form_schema_section_id]; + } + $newField->save(); + } + + $copy->logSchemaChange('schema.duplicated', ['source_schema_id' => $source->id]); + + return $copy->refresh(); + }); + } + + public function publish(FormSchema $schema, User $actor): FormSchema + { + $schema->is_published = true; + $schema->last_updated_by_user_id = $actor->id; + $schema->save(); + $schema->logSchemaChange('schema.published'); + + return $schema->refresh(); + } + + public function unpublish(FormSchema $schema, User $actor): FormSchema + { + $schema->is_published = false; + $schema->last_updated_by_user_id = $actor->id; + $schema->save(); + $schema->logSchemaChange('schema.unpublished'); + + return $schema->refresh(); + } + + public function rotatePublicToken(FormSchema $schema, User $actor, int $graceDays = 7): FormSchema + { + return DB::transaction(function () use ($schema, $actor, $graceDays): FormSchema { + $previous = $schema->public_token; + $schema->public_token = (string) Str::ulid(); + $schema->public_token_previous = $previous; + $schema->public_token_rotated_at = now(); + $schema->last_updated_by_user_id = $actor->id; + $schema->save(); + + $schema->logSchemaChange('schema.public_token_rotated', [ + 'grace_days' => $graceDays, + 'previous_token_present' => $previous !== null, + ]); + + return $schema->refresh(); + }); + } + + public function acquireEditLock(FormSchema $schema, User $user, int $ttlMinutes = 10): FormSchema + { + return DB::transaction(function () use ($schema, $user, $ttlMinutes): FormSchema { + $now = now(); + $holderId = $schema->edit_lock_user_id; + $expiresAt = $schema->edit_lock_expires_at; + + if ($holderId !== null && $holderId !== $user->id && $expiresAt !== null && $expiresAt->greaterThan($now)) { + throw new EditLockConflictException( + User::query()->find($holderId), + $expiresAt, + ); + } + + $schema->edit_lock_user_id = $user->id; + $schema->edit_lock_expires_at = $now->copy()->addMinutes($ttlMinutes); + $schema->save(); + + return $schema->refresh(); + }); + } + + public function releaseEditLock(FormSchema $schema, User $user): FormSchema + { + if ($schema->edit_lock_user_id !== null && $schema->edit_lock_user_id !== $user->id && ! $user->hasRole('org_admin')) { + throw new EditLockConflictException( + User::query()->find($schema->edit_lock_user_id), + $schema->edit_lock_expires_at, + ); + } + + $schema->edit_lock_user_id = null; + $schema->edit_lock_expires_at = null; + $schema->save(); + + return $schema->refresh(); + } + + public function delete(FormSchema $schema, User $actor, ?string $confirmedName = null): void + { + $hasSubmissions = FormSubmission::query() + ->where('form_schema_id', $schema->id) + ->exists(); + + if ($hasSubmissions) { + if ($confirmedName !== $schema->name) { + throw DestructiveConfirmationRequiredException::forName($schema->name); + } + } + + DB::transaction(function () use ($schema, $actor): void { + $schema->last_updated_by_user_id = $actor->id; + $schema->save(); + $schema->logSchemaChange('schema.deleted'); + $schema->delete(); + }); + } + + public function bumpVersion(FormSchema $schema): void + { + $schema->version = (int) $schema->version + 1; + $schema->save(); + } + + public function hasSubmittedSubmissions(FormSchema $schema): bool + { + return FormSubmission::query() + ->where('form_schema_id', $schema->id) + ->where('status', FormSubmissionStatus::SUBMITTED->value) + ->exists(); + } + + private function resolvePurpose(mixed $purpose): FormPurpose + { + if ($purpose instanceof FormPurpose) { + return $purpose; + } + + return FormPurpose::from((string) $purpose); + } + + private function ensureUniqueSlug(?Organisation $organisation, string $slug, ?string $excludeId = null): string + { + $orgId = $organisation?->id; + if ($orgId === null) { + return $slug; + } + + $base = Str::slug($slug); + $candidate = $base; + $i = 2; + while (FormSchema::withoutGlobalScopes() + ->where('organisation_id', $orgId) + ->where('slug', $candidate) + ->when($excludeId !== null, fn ($q) => $q->where('id', '!=', $excludeId)) + ->exists() + ) { + $candidate = $base.'-'.$i; + $i++; + } + + return $candidate; + } + + private function isStructuralChange(FormSchema $schema): bool + { + return $schema->isDirty(['purpose', 'submission_mode', 'locale', 'freeze_on_submit', 'snapshot_mode', 'section_level_submit', 'consent_version']); + } +} diff --git a/api/app/Services/FormBuilder/FormSubmissionAnonymisationService.php b/api/app/Services/FormBuilder/FormSubmissionAnonymisationService.php new file mode 100644 index 00000000..3782e12d --- /dev/null +++ b/api/app/Services/FormBuilder/FormSubmissionAnonymisationService.php @@ -0,0 +1,87 @@ +with('field') + ->where('form_submission_id', $submission->id) + ->get(); + + foreach ($values as $value) { + $field = $value->field; + if ($field === null || ! $field->is_pii) { + continue; + } + + $this->anonymiseValue($value, $field->field_type); + + activity() + ->performedOn($value) + ->withProperties([ + 'field_slug' => $field->slug, + 'reason' => $reason, + 'original_was_pii' => true, + ]) + ->log('field.anonymised'); + } + + $submission->anonymised_at = now(); + $submission->search_index = null; + $submission->save(); + }); + + FormSubmissionAnonymised::dispatch($submission); + } + + private function anonymiseValue(FormValue $value, string $fieldType): void + { + if ($fieldType === FormFieldType::SIGNATURE->value) { + $raw = $value->value; + if (is_array($raw) && isset($raw['file_path'], $raw['disk'])) { + try { + Storage::disk((string) $raw['disk'])->delete((string) $raw['file_path']); + } catch (\Throwable) { + // Swallow; best-effort. + } + } + $value->value = ['anonymised' => true]; + } elseif (in_array($fieldType, [FormFieldType::FILE_UPLOAD->value, FormFieldType::IMAGE_UPLOAD->value], true)) { + $raw = $value->value; + if (is_array($raw) && isset($raw['file_path'])) { + try { + Storage::disk((string) ($raw['disk'] ?? config('filesystems.default')))->delete((string) $raw['file_path']); + } catch (\Throwable) { + // Best-effort. + } + } + $value->value = ['anonymised' => true, 'original_filename_redacted' => true]; + } else { + $value->value = '[ANONYMISED]'; + } + + $value->value_indexed = null; + $value->value_number = null; + $value->value_date = null; + $value->value_bool = null; + $value->value_anonymised = true; + $value->save(); + } +} diff --git a/api/app/Services/FormBuilder/FormSubmissionService.php b/api/app/Services/FormBuilder/FormSubmissionService.php new file mode 100644 index 00000000..8286a8ff --- /dev/null +++ b/api/app/Services/FormBuilder/FormSubmissionService.php @@ -0,0 +1,298 @@ + $context opened_at / public_submitter_* / is_test / idempotency_key + */ + public function createDraft(FormSchema $schema, ?Model $subject, ?User $submitter, array $context = []): FormSubmission + { + if (isset($context['idempotency_key'])) { + $existing = FormSubmission::query() + ->where('form_schema_id', $schema->id) + ->where('idempotency_key', $context['idempotency_key']) + ->first(); + if ($existing !== null) { + return $existing; + } + } + + return DB::transaction(function () use ($schema, $subject, $submitter, $context): FormSubmission { + $submission = new FormSubmission; + $submission->form_schema_id = $schema->id; + if ($subject !== null) { + $submission->subject_type = $this->morphKeyFor($subject); + $submission->subject_id = (string) $subject->getKey(); + } + $submission->submitted_by_user_id = $submitter?->id; + $submission->status = FormSubmissionStatus::DRAFT->value; + $submission->is_test = (bool) ($context['is_test'] ?? false); + $submission->opened_at = $context['opened_at'] ?? now(); + $submission->idempotency_key = $context['idempotency_key'] ?? null; + $submission->public_submitter_name = $context['public_submitter_name'] ?? null; + $submission->public_submitter_email = $context['public_submitter_email'] ?? null; + $submission->public_submitter_ip = $context['public_submitter_ip'] ?? null; + $submission->submitted_in_locale = $this->localeResolver->resolve($schema, $submitter); + $submission->save(); + + FormSubmissionCreated::dispatch($submission); + + return $submission->refresh(); + }); + } + + /** + * @param array $values slug → value + */ + public function saveDraft(FormSubmission $submission, array $values, ?User $actor): FormSubmission + { + $this->assertWritable($submission); + + DB::transaction(function () use ($submission, $values, $actor): void { + $this->valueService->upsertMany($submission, $values, $actor); + $submission->first_interacted_at ??= now(); + $submission->auto_save_count = (int) $submission->auto_save_count + 1; + $submission->save(); + }); + + FormSubmissionDraftUpdated::dispatch($submission, array_keys($values)); + + return $submission->refresh(); + } + + public function submit(FormSubmission $submission, ?User $actor): FormSubmission + { + $this->assertWritable($submission); + + return DB::transaction(function () use ($submission, $actor): FormSubmission { + $schema = $submission->schema; + + $submission->status = FormSubmissionStatus::SUBMITTED->value; + $submission->submitted_at = now(); + $submission->schema_version_at_submit = $schema->version; + + if ($schema->snapshot_mode !== FormSchemaSnapshotMode::NEVER) { + $submission->schema_snapshot = $this->buildSnapshot($schema); + } + + if ($submission->opened_at !== null) { + $submission->submission_duration_seconds = now()->diffInSeconds($submission->opened_at); + } + + $submission->save(); + + // Compute SIGNATURE hashes on submit (ARCH §9). One query, scalar-safe. + $this->finaliseSignatureValues($submission); + + FormSubmissionSubmitted::dispatch($submission); + + return $submission->refresh(); + }); + } + + public function review(FormSubmission $submission, FormSubmissionReviewStatus $status, ?string $notes, User $reviewer): FormSubmission + { + return DB::transaction(function () use ($submission, $status, $notes, $reviewer): FormSubmission { + $submission->review_status = $status->value; + $submission->review_notes = $notes; + $submission->reviewed_by_user_id = $reviewer->id; + $submission->reviewed_at = now(); + $submission->save(); + + FormSubmissionReviewed::dispatch($submission, $status->value); + + return $submission->refresh(); + }); + } + + public function delegate(FormSubmission $submission, User $delegatee, User $delegator, ?string $message = null): FormSubmissionDelegation + { + /** @var FormSubmissionDelegation $delegation */ + $delegation = FormSubmissionDelegation::create([ + 'form_submission_id' => $submission->id, + 'delegated_to_user_id' => $delegatee->id, + 'delegated_by_user_id' => $delegator->id, + 'granted_at' => now(), + 'message' => $message, + ]); + + activity() + ->performedOn($submission) + ->causedBy($delegator) + ->withProperties(['delegated_to_user_id' => $delegatee->id]) + ->log('submission.delegated'); + + return $delegation; + } + + public function revokeDelegation(FormSubmissionDelegation $delegation, User $actor): FormSubmissionDelegation + { + $delegation->revoked_at = now(); + $delegation->save(); + + activity() + ->performedOn($delegation->submission ?? $delegation) + ->causedBy($actor) + ->log('submission.delegation_revoked'); + + return $delegation->refresh(); + } + + public function delete(FormSubmission $submission, User $actor): void + { + DB::transaction(function () use ($submission, $actor): void { + $submission->delete(); + FormSubmissionDeleted::dispatch($submission); + activity()->performedOn($submission)->causedBy($actor)->log('submission.deleted'); + }); + } + + private function assertWritable(FormSubmission $submission): void + { + $schema = $submission->schema; + if ( + $submission->status === FormSubmissionStatus::SUBMITTED + && $schema->freeze_on_submit + ) { + throw FrozenSchemaException::forSchema($schema->id); + } + } + + /** + * @return array + */ + private function buildSnapshot(FormSchema $schema): array + { + $schema->loadMissing(['fields', 'sections']); + + return [ + 'schema_version' => $schema->version, + 'snapshot_created_at' => now()->toIso8601String(), + 'schema' => [ + 'name' => $schema->name, + 'slug' => $schema->slug, + 'purpose' => $schema->purpose instanceof \BackedEnum ? $schema->purpose->value : $schema->purpose, + 'description' => $schema->description, + 'locale' => $schema->locale, + 'freeze_on_submit' => (bool) $schema->freeze_on_submit, + 'section_level_submit' => (bool) $schema->section_level_submit, + 'consent_version' => $schema->consent_version, + 'settings' => $schema->settings, + ], + 'sections' => $schema->sections->map(fn ($s) => [ + 'id' => $s->id, + 'slug' => $s->slug, + 'name' => $s->name, + 'sort_order' => $s->sort_order, + 'depends_on_section_slug' => $this->sectionSlug($schema, $s->depends_on_section_id), + 'required_for_schema_submit' => (bool) $s->required_for_schema_submit, + ])->toArray(), + 'fields' => $schema->fields->map(fn ($f) => [ + 'id' => $f->id, + 'slug' => $f->slug, + 'field_type' => $f->field_type, + 'label' => $f->label, + 'help_text' => $f->help_text, + 'section_slug' => $this->sectionSlug($schema, $f->form_schema_section_id), + 'options' => $f->options, + 'validation_rules' => $f->validation_rules, + 'is_required' => (bool) $f->is_required, + 'is_filterable' => (bool) $f->is_filterable, + 'is_pii' => (bool) $f->is_pii, + 'binding' => $f->binding, + 'conditional_logic' => $f->conditional_logic, + 'translations' => $f->translations, + 'value_storage_hint' => $f->value_storage_hint instanceof \BackedEnum ? $f->value_storage_hint->value : $f->value_storage_hint, + 'sort_order' => $f->sort_order, + ])->toArray(), + ]; + } + + private function sectionSlug(FormSchema $schema, ?string $sectionId): ?string + { + if ($sectionId === null) { + return null; + } + + return $schema->sections->firstWhere('id', $sectionId)?->slug; + } + + private function finaliseSignatureValues(FormSubmission $submission): void + { + $signatureValues = FormValue::query() + ->with('field') + ->where('form_submission_id', $submission->id) + ->get() + ->filter(fn ($v) => $v->field?->field_type === FormFieldType::SIGNATURE->value); + + foreach ($signatureValues as $value) { + $raw = $value->value ?? []; + if (! is_array($raw) || ! isset($raw['file_path'])) { + continue; + } + + $payload = array_merge([ + 'signed_at' => now()->toIso8601String(), + 'signer_name' => $submission->public_submitter_name ?? optional($submission->submittedBy)->name, + 'signer_ip' => $submission->public_submitter_ip ?? request()?->ip(), + ], $raw); + + $disk = $raw['disk'] ?? config('filesystems.default'); + $bytes = ''; + try { + if (\Illuminate\Support\Facades\Storage::disk($disk)->exists($raw['file_path'])) { + $bytes = (string) \Illuminate\Support\Facades\Storage::disk($disk)->get($raw['file_path']); + } + } catch (\Throwable) { + $bytes = ''; + } + + $hashInput = $bytes.($payload['signed_at'] ?? '').($payload['signer_name'] ?? '').($payload['signer_ip'] ?? ''); + $payload['hash'] = 'sha256:'.hash('sha256', $hashInput); + $payload['disk'] = $disk; + + $value->value = $payload; + $value->save(); + } + } + + private function morphKeyFor(Model $model): string + { + $alias = $model->getMorphClass(); + + return (string) $alias; + } +} diff --git a/api/app/Services/FormBuilder/FormTagSyncService.php b/api/app/Services/FormBuilder/FormTagSyncService.php new file mode 100644 index 00000000..ba66d9b8 --- /dev/null +++ b/api/app/Services/FormBuilder/FormTagSyncService.php @@ -0,0 +1,101 @@ +user_id === null) { + return; + } + + $organisationId = $person->event?->organisation_id; + if ($organisationId === null) { + return; + } + + $rows = FormValue::query() + ->join('form_submissions', 'form_submissions.id', '=', 'form_values.form_submission_id') + ->join('form_fields', 'form_fields.id', '=', 'form_values.form_field_id') + ->join('form_schemas', 'form_schemas.id', '=', 'form_submissions.form_schema_id') + ->where('form_submissions.subject_type', 'person') + ->where('form_submissions.subject_id', $person->id) + ->where('form_submissions.status', FormSubmissionStatus::SUBMITTED->value) + ->where('form_submissions.is_test', false) + ->where('form_schemas.purpose', FormPurpose::EVENT_REGISTRATION->value) + ->where('form_schemas.organisation_id', $organisationId) + ->where('form_fields.field_type', FormFieldType::TAG_PICKER->value) + ->select(['form_values.value']) + ->get(); + + $tagIds = []; + foreach ($rows as $row) { + $decoded = is_string($row->value) ? json_decode($row->value, true) : $row->value; + if (is_array($decoded) && array_is_list($decoded)) { + foreach ($decoded as $id) { + if (is_string($id) && $id !== '') { + $tagIds[$id] = true; + } + } + } elseif (is_array($decoded) && isset($decoded['value']) && is_array($decoded['value'])) { + foreach ($decoded['value'] as $id) { + if (is_string($id) && $id !== '') { + $tagIds[$id] = true; + } + } + } + } + + $tagIds = array_keys($tagIds); + + DB::transaction(function () use ($person, $organisationId, $tagIds): void { + UserOrganisationTag::query() + ->where('user_id', $person->user_id) + ->where('organisation_id', $organisationId) + ->where('source', 'self_reported') + ->delete(); + + $now = now(); + foreach ($tagIds as $tagId) { + UserOrganisationTag::query()->updateOrCreate( + [ + 'user_id' => $person->user_id, + 'organisation_id' => $organisationId, + 'person_tag_id' => $tagId, + ], + [ + 'source' => 'self_reported', + 'assigned_at' => $now, + ], + ); + } + }); + + activity() + ->performedOn($person) + ->withProperties([ + 'organisation_id' => $organisationId, + 'tag_count' => count($tagIds), + ]) + ->log('person.tags.self_reported_rebuilt'); + } +} diff --git a/api/app/Services/FormBuilder/FormValueService.php b/api/app/Services/FormBuilder/FormValueService.php new file mode 100644 index 00000000..68216ed2 --- /dev/null +++ b/api/app/Services/FormBuilder/FormValueService.php @@ -0,0 +1,173 @@ + $slugToValue + */ + public function upsertMany(FormSubmission $submission, array $slugToValue, ?User $actor): void + { + $schema = $submission->schema; + $fields = FormField::query() + ->where('form_schema_id', $schema->id) + ->whereIn('slug', array_keys($slugToValue)) + ->get() + ->keyBy('slug'); + + DB::transaction(function () use ($slugToValue, $fields, $submission, $actor): void { + foreach ($slugToValue as $slug => $raw) { + $field = $fields->get($slug); + if ($field === null) { + continue; + } + + if (! $this->fieldAccess->canWrite($actor, $field, $submission)) { + throw new AuthorizationException(sprintf('Not allowed to write field "%s".', $slug)); + } + + $this->writeValue($submission, $field, $raw); + $this->writeEntityMirror($submission, $field, $raw); + } + }); + } + + private function writeValue(FormSubmission $submission, FormField $field, mixed $raw): void + { + $payload = $this->normalisePayload($field, $raw); + + /** @var FormValue|null $value */ + $value = FormValue::query() + ->where('form_submission_id', $submission->id) + ->where('form_field_id', $field->id) + ->first(); + + if ($value === null) { + $value = new FormValue; + $value->form_submission_id = $submission->id; + $value->form_field_id = $field->id; + } + + $value->setRelation('field', $field); + $value->value = $payload; + $value->value_anonymised = false; + $value->save(); + } + + private function normalisePayload(FormField $field, mixed $raw): mixed + { + $multi = in_array($field->field_type, [ + FormFieldType::MULTISELECT->value, + FormFieldType::CHECKBOX_LIST->value, + FormFieldType::TAG_PICKER->value, + FormFieldType::AVAILABILITY_PICKER->value, + FormFieldType::SECTION_PRIORITY->value, + FormFieldType::TABLE_ROWS->value, + ], true); + + if ($multi) { + return is_array($raw) ? array_values($raw) : []; + } + + return $raw; + } + + private function writeEntityMirror(FormSubmission $submission, FormField $field, mixed $raw): void + { + $binding = $field->binding; + if (! is_array($binding) || ($binding['mode'] ?? null) === null) { + return; + } + + $mode = (string) $binding['mode']; + if (! in_array($mode, ['entity_owned', 'mirrored'], true)) { + return; + } + + $entity = (string) ($binding['entity'] ?? ''); + $column = (string) ($binding['column'] ?? ''); + if ($entity === '' || $column === '') { + return; + } + + $registry = config('form_binding.'.$entity); + if (! is_array($registry) || ! isset($registry[$column]) || ! ($registry[$column]['writable'] ?? false)) { + return; + } + + $target = $this->resolveEntityTarget($submission, $entity); + if ($target === null) { + // Cross-entity Pattern C (person → user_profile) may have null user_id. + Log::info('form-builder.mirror.skipped', [ + 'submission_id' => $submission->id, + 'field_id' => $field->id, + 'entity' => $entity, + 'column' => $column, + 'reason' => 'target_not_resolvable', + ]); + + return; + } + + $scalar = is_scalar($raw) ? $raw : null; + $target->{$column} = $scalar; + $target->save(); + } + + private function resolveEntityTarget(FormSubmission $submission, string $entity): ?\Illuminate\Database\Eloquent\Model + { + $subjectType = $submission->subject_type; + $subjectId = $submission->subject_id; + if ($subjectId === null) { + return null; + } + + if ($subjectType === $entity) { + $map = config('form_subjects'); + $model = $map[$entity]['model'] ?? null; + if ($model === null) { + return null; + } + + return $model::withoutGlobalScopes()->find($subjectId); + } + + // Cross-entity: person → user_profile via person.user_id + if ($entity === 'user_profile' && $subjectType === 'person') { + $person = \App\Models\Person::withoutGlobalScopes()->find($subjectId); + if ($person === null || $person->user_id === null) { + return null; + } + + return \App\Models\UserProfile::firstOrCreate(['user_id' => $person->user_id]); + } + + if ($entity === 'user_profile' && $subjectType === 'user') { + return \App\Models\UserProfile::firstOrCreate(['user_id' => $subjectId]); + } + + return null; + } +} diff --git a/api/app/Services/FormBuilder/FormWebhookDispatcher.php b/api/app/Services/FormBuilder/FormWebhookDispatcher.php new file mode 100644 index 00000000..2cfcbbe5 --- /dev/null +++ b/api/app/Services/FormBuilder/FormWebhookDispatcher.php @@ -0,0 +1,86 @@ +is_test) { + return; + } + + $webhooks = FormSchemaWebhook::query() + ->where('form_schema_id', $submission->form_schema_id) + ->where('trigger_event', $triggerEvent) + ->where('is_active', true) + ->get(); + + foreach ($webhooks as $webhook) { + /** @var FormWebhookDelivery $delivery */ + $delivery = FormWebhookDelivery::create([ + 'form_schema_webhook_id' => $webhook->id, + 'form_submission_id' => $submission->id, + 'trigger_event' => $triggerEvent, + 'status' => FormWebhookDeliveryStatus::PENDING->value, + 'attempts' => 0, + 'payload_snapshot' => $this->buildPayload($submission, $triggerEvent), + ]); + + DeliverFormWebhookJob::dispatch($delivery->id)->onConnection('webhooks')->onQueue('webhooks'); + } + } + + /** + * @return array + */ + private function buildPayload(FormSubmission $submission, string $triggerEvent): array + { + $submission->loadMissing(['schema', 'schema.organisation', 'values.field']); + + $values = []; + foreach ($submission->values as $value) { + if ($value->field?->slug) { + $values[$value->field->slug] = $value->value; + } + } + + return [ + 'event' => 'form_submission.'.$triggerEvent, + 'triggered_at' => now()->toIso8601String(), + 'organisation' => [ + 'id' => $submission->schema?->organisation?->id, + 'name' => $submission->schema?->organisation?->name, + 'slug' => $submission->schema?->organisation?->slug, + ], + 'schema' => [ + 'id' => $submission->schema?->id, + 'purpose' => $submission->schema?->purpose instanceof \BackedEnum + ? $submission->schema->purpose->value + : $submission->schema?->purpose, + 'slug' => $submission->schema?->slug, + 'version' => $submission->schema?->version, + ], + 'submission' => [ + 'id' => $submission->id, + 'subject_type' => $submission->subject_type, + 'subject_id' => $submission->subject_id, + 'submitted_at' => optional($submission->submitted_at)->toIso8601String(), + 'submitted_by_user_id' => $submission->submitted_by_user_id, + 'values' => $values, + ], + ]; + } +} diff --git a/api/database/migrations/2026_04_21_100000_add_default_locale_to_organisations_table.php b/api/database/migrations/2026_04_21_100000_add_default_locale_to_organisations_table.php new file mode 100644 index 00000000..6eaea259 --- /dev/null +++ b/api/database/migrations/2026_04_21_100000_add_default_locale_to_organisations_table.php @@ -0,0 +1,24 @@ +string('default_locale', 10)->default('nl')->after('billing_status'); + }); + } + + public function down(): void + { + Schema::table('organisations', function (Blueprint $table): void { + $table->dropColumn('default_locale'); + }); + } +}; diff --git a/api/tests/Feature/MigrationRollbackTest.php b/api/tests/Feature/MigrationRollbackTest.php index 787c3661..71b60a01 100644 --- a/api/tests/Feature/MigrationRollbackTest.php +++ b/api/tests/Feature/MigrationRollbackTest.php @@ -67,11 +67,13 @@ final class MigrationRollbackTest extends TestCase { Artisan::call('migrate:fresh'); - // step=1 targets the most recent migration — the S2a drop — - // whose down() is a hard failure. + // Target the S2a drop migration by name so subsequent unrelated + // migrations don't shift the step offset. $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Legacy registration tables cannot be restored'); - Artisan::call('migrate:rollback', ['--step' => 1]); + $path = 'database/migrations/2026_04_20_100000_drop_remaining_legacy_registration_tables.php'; + $migration = require base_path($path); + $migration->down(); } }