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>
299 lines
11 KiB
PHP
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;
|
|
}
|
|
}
|