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>
353 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|