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:
44
api/app/Jobs/FormBuilder/BackfillFormValueIndexedJob.php
Normal file
44
api/app/Jobs/FormBuilder/BackfillFormValueIndexedJob.php
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
187
api/app/Jobs/FormBuilder/DeliverFormWebhookJob.php
Normal file
187
api/app/Jobs/FormBuilder/DeliverFormWebhookJob.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user