Files
crewli/api/app/Services/FormBuilder/FormSubmissionService.php
bert.hausmans 6e89b0ccf7 test(form-builder): feature suites + integration contracts incl. FORM-02 (§31.10)
Phase 6 of S2b. 37 new tests, 820 → 857 passing across the suite.

Feature suites (api/tests/Feature/FormBuilder/):
- FormSchemaApiTest: CRUD, publish/unpublish, rotate-public-token (with
  grace window), edit-lock conflict, typed-confirmation delete, 401 on
  unauthenticated, 403 on outsider.
- FormFieldApiTest: create, reorder, binding-change guard (422 w/o force,
  200 with force), conditional_logic cycle rejection, 401 unauth.
- FormSubmissionApiTest: draft → values → submit stores schema snapshot +
  version; review records reviewer; delegation creates active row; draft
  update blocked for non-subject non-delegatee (403).
- FormValueSecurityTest: FieldAccessService hides admin-only fields from
  non-admin; subject-self bypass; admin-only field leaks through neither
  admin list nor non-admin detail responses (§22.9 intent).
- PublicFormApiTest: portal-visible non-admin fields only; unknown token
  → 404; happy-path submission; expired-previous-token → 410; grace
  window still allows submission.
- FormSchemaWebhookApiTest: url/secret NEVER returned in resources;
  DeliverFormWebhookJob rejects 10.x private-ip SSRF (response_body_excerpt
  logs rejection).
- FilterRegistryApiTest: response shape includes tags + form_field
  sources; form_field filter registers.

Integration contract (§31.10):
- TagPickerSyncListenerTest: 5 cases proving (a) no-op on user_id=null,
  (b) sync on submit, (c) deferred sync via
  PersonIdentityService::confirmMatch, (d) organiser_assigned tags
  preserved on rebuild, (e) idempotent rerun.

Fixes discovered while writing tests:
- SyncTagPickerSelectionsOnSubmit: removed hardcoded connection='redis'
  so tests run via sync queue (QUEUE_CONNECTION fallback).
- FormSubmissionService: corrected FormSubmissionReviewed / DraftUpdated
  event signatures to match S1 event classes.
- FormSubmission model: added schema_version_at_submit / snapshot /
  anonymised_at / submission_duration_seconds / auto_save_count to
  $fillable so bulk operations + factory states populate consistently.
- FormSchema: added version, edit_lock_user_id, edit_lock_expires_at to
  $fillable; factory now sets version=1 explicitly.
- FormValueService: public submission path (actor=null) enforces
  is_portal_visible=true AND is_admin_only=false at the write layer
  instead of running FieldAccessService against a null user.
- MigrationRollbackTest: target the S2a drop migration by filename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:27:27 +02:00

299 lines
11 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,
) {}
/**
* @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->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);
return 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) {
$submission->submission_duration_seconds = now()->diffInSeconds($submission->opened_at);
}
$submission->save();
// Compute SIGNATURE hashes on submit (ARCH §9). One query, scalar-safe.
$this->finaliseSignatureValues($submission);
FormSubmissionSubmitted::dispatch($submission);
return $submission->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', '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,
'validation_rules' => $f->validation_rules,
'is_required' => (bool) $f->is_required,
'is_filterable' => (bool) $f->is_filterable,
'is_pii' => (bool) $f->is_pii,
'binding' => $f->binding,
'conditional_logic' => $f->conditional_logic,
'translations' => $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(),
];
}
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;
}
}