Files
crewli/api/app/Services/FormBuilder/FormSubmissionService.php
bert.hausmans 0b14416e28 fix(form-builder): fire FormSubmissionSubmitted AFTER transaction commit (WS-6)
Per RFC O2: pre-commit dispatch let queued listeners (tag sync, shifts,
webhooks, mailables) enqueue with state that might never persist on
rollback. Move dispatch to after DB::transaction returns.

This is semantically critical for the new ApplyBindings two-transaction
pattern (RFC Q4): the inner transaction must commit before sibling
listeners observe the submission.

Refs: RFC-WS-6.md §5 (O2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:22:58 +02:00

353 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
use App\Enums\FormBuilder\FormSubmissionReviewStatus;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Events\FormBuilder\FormSubmissionCreated;
use App\Events\FormBuilder\FormSubmissionDeleted;
use App\Events\FormBuilder\FormSubmissionDraftUpdated;
use App\Events\FormBuilder\FormSubmissionReviewed;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Exceptions\FormBuilder\FrozenSchemaException;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionDelegation;
use App\Models\FormBuilder\FormValue;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Submission lifecycle: draft → submitted → reviewed per ARCH §4.3, §15.
* Fires domain events so webhooks / listeners / activity attach without
* tight coupling (ARCH §18.5).
*/
final class FormSubmissionService
{
public function __construct(
private readonly FormLocaleResolver $localeResolver,
private readonly FormValueService $valueService,
private readonly FormFieldBindingService $bindingService,
private readonly FormFieldValidationRuleService $validationRuleService,
private readonly FormFieldConfigService $configService,
private readonly FormFieldConditionalLogicService $conditionalLogicService,
private readonly FormFieldOptionService $optionService,
) {}
/**
* @param array<string, mixed> $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->schema_version_at_open = (int) $schema->version;
$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<string, mixed> $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);
return $submission->refresh();
}
public function submit(FormSubmission $submission, ?User $actor): FormSubmission
{
$this->assertWritable($submission);
$result = 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) {
// abs + int cast: Carbon's diffInSeconds returns signed fractional
// seconds, and the column is unsignedInteger.
$submission->submission_duration_seconds = (int) abs(now()->diffInSeconds($submission->opened_at));
}
$submission->save();
// Compute SIGNATURE hashes on submit (ARCH §9). One query, scalar-safe.
$this->finaliseSignatureValues($submission);
return $submission;
});
// RFC-WS-6 §5 (O2) — fire AFTER commit. Pre-commit dispatch let
// queued listeners (tag sync, shifts, webhooks, mailables) enqueue
// with state that may never persist on rollback. The new
// ApplyBindings two-transaction pattern (RFC Q4) requires the
// outer commit to land before any listener observes the submission.
FormSubmissionSubmitted::dispatch($result->refresh());
return $result->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, $reviewer);
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<string, mixed>
*/
private function buildSnapshot(FormSchema $schema): array
{
$schema->loadMissing(['fields.bindings', 'fields.validationRules', 'fields.configs', 'fields.options', '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->isNotEmpty()
? $this->optionService->toJsonShape($f->options)
: null,
'validation_rules' => $this->validationRuleService->toJsonShape($f->validationRules),
'configs' => $this->configService->toJsonShape($f->configs),
'is_required' => (bool) $f->is_required,
'is_filterable' => (bool) $f->is_filterable,
'is_pii' => (bool) $f->is_pii,
// WS-6 RFC Q6 — singular 'binding' kept for legacy webhook /
// GDPR readers; plural 'bindings' carries every binding on
// the field with id, merge_strategy, trust_level,
// is_identity_key for PersonProvisioner / BindingConflictResolver
// / FormBindingApplicator. Single helper to avoid duplicated
// dynamic-property access inside this lambda.
...$this->bindingService->snapshotShapesFor($f->bindings),
'conditional_logic' => $this->conditionalLogicService->toJsonShape($f->rootConditionalLogicGroup()),
'translations' => $this->stripOptionsFromTranslations($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(),
];
}
/**
* Per-locale `options[]` parallel arrays moved onto each
* form_field_options.translations row in WS-5d. The field's own
* translations bag retains only label/help_text per locale; strip
* any residual options key defensively (commit 2 backfill should
* already have done so on existing rows).
*
* @param mixed $translations
* @return array<string, mixed>|null
*/
private function stripOptionsFromTranslations(mixed $translations): ?array
{
if (! is_array($translations) || $translations === []) {
return null;
}
$clean = [];
foreach ($translations as $locale => $bag) {
if (is_array($bag)) {
unset($bag['options']);
if ($bag !== []) {
$clean[$locale] = $bag;
}
} else {
$clean[$locale] = $bag;
}
}
return $clean === [] ? null : $clean;
}
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;
}
}