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:
2026-04-17 20:47:39 +02:00
parent a3ca596362
commit b3eab6e0c8
19 changed files with 2006 additions and 3 deletions

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Jobs\FormBuilder;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormValue;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Re-saves every FormValue for a field so the observer re-computes typed
* columns (ARCH §7.2, §22.10). Idempotent handles toggle in either direction.
*/
final class BackfillFormValueIndexedJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(public readonly string $fieldId) {}
public function handle(): void
{
$field = FormField::query()->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();
}
});
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Jobs\FormBuilder;
use App\Enums\FormBuilder\FormWebhookDeliveryStatus;
use App\Models\FormBuilder\FormWebhookDelivery;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* HTTP delivery with HMAC signing, SSRF protection, exponential backoff
* retries, and dead-letter on exhaustion (ARCH §17.5.3§17.5.4, §22.10).
*/
final class DeliverFormWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var array<int, int> 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);
}
}