$context opened_at / public_submitter_* / is_test / idempotency_key / event_id * * `event_id` may be supplied for flows where the schema is org-owned (not * event-owned) and the route has no `{event}` parameter for the * FormSubmissionObserver fallback to pick up — e.g. the artist-advance * portal where the engagement is the source of truth per WS-4 * (ARCH-FORM-BUILDER §17.3 footnote). */ 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(); } if (isset($context['event_id']) && is_string($context['event_id']) && $context['event_id'] !== '') { $submission->event_id = $context['event_id']; } $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 $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): 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) { // RFC-WS-6 session 2.7: schema_snapshot is audit-immutable; // canonicalize before storage so MySQL JSON-column round-trip // can never corrupt audit-replay diffs or webhook signing. $submission->schema_snapshot = JsonCanonicalizer::canonicalize( $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 */ 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). * * @return array|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; } }