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:
86
api/app/Services/FormBuilder/FormWebhookDispatcher.php
Normal file
86
api/app/Services/FormBuilder/FormWebhookDispatcher.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormWebhookDeliveryStatus;
|
||||
use App\Jobs\FormBuilder\DeliverFormWebhookJob;
|
||||
use App\Models\FormBuilder\FormSchemaWebhook;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormWebhookDelivery;
|
||||
|
||||
/**
|
||||
* Finds active webhooks for a submission's schema + trigger and queues a
|
||||
* DeliverFormWebhookJob per delivery row (ARCH §17.5.2).
|
||||
*/
|
||||
final class FormWebhookDispatcher
|
||||
{
|
||||
public function dispatchForSubmission(FormSubmission $submission, string $triggerEvent): void
|
||||
{
|
||||
if ($submission->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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user