From c5b0210ae749923b5b81bcae5f9290aeb562e6db Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 25 Apr 2026 22:47:06 +0200 Subject: [PATCH] feat(form-builder): add FormSubmissionActionFailure model + apply_status casts (WS-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormSubmissionActionFailure: audit model, no organisation_id (FK-chain tenancy per RFC V3), open/resolved/dismissed scopes, canBeRetried() helper. Morph alias 'form_submission_action_failure' registered for future activity-log subject references. - FormSubmission: apply_status (ApplyStatus enum cast), apply_completed_at (datetime), actionFailures() HasMany, scopePendingApply(). Refs: RFC-WS-6.md §3 (Q5), §4 (V3) Co-Authored-By: Claude Opus 4.7 (1M context) --- api/app/Models/FormBuilder/FormSubmission.php | 22 ++++ .../FormSubmissionActionFailure.php | 124 ++++++++++++++++++ api/app/Providers/AppServiceProvider.php | 1 + .../FormSubmissionActionFailureFactory.php | 56 ++++++++ .../FormSubmissionApplyStatusCastTest.php | 74 +++++++++++ .../FormSubmissionActionFailureTest.php | 95 ++++++++++++++ 6 files changed, 372 insertions(+) create mode 100644 api/app/Models/FormBuilder/FormSubmissionActionFailure.php create mode 100644 api/database/factories/FormBuilder/FormSubmissionActionFailureFactory.php create mode 100644 api/tests/Feature/FormBuilder/FormSubmissionApplyStatusCastTest.php create mode 100644 api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureTest.php diff --git a/api/app/Models/FormBuilder/FormSubmission.php b/api/app/Models/FormBuilder/FormSubmission.php index e934cf41..d871a23a 100644 --- a/api/app/Models/FormBuilder/FormSubmission.php +++ b/api/app/Models/FormBuilder/FormSubmission.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\FormBuilder; +use App\Enums\FormBuilder\ApplyStatus; use App\Enums\FormBuilder\FormSubmissionReviewStatus; use App\Enums\FormBuilder\FormSubmissionStatus; use App\Models\Event; @@ -12,6 +13,7 @@ use App\Models\Scopes\OrganisationScope; use App\Models\User; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -76,6 +78,8 @@ final class FormSubmission extends Model protected $casts = [ 'status' => FormSubmissionStatus::class, 'review_status' => FormSubmissionReviewStatus::class, + 'apply_status' => ApplyStatus::class, + 'apply_completed_at' => 'datetime', 'schema_snapshot' => 'array', 'is_test' => 'bool', 'submitted_at' => 'datetime', @@ -134,4 +138,22 @@ final class FormSubmission extends Model { return $this->hasMany(FormSubmissionDelegation::class); } + + /** @return HasMany */ + public function actionFailures(): HasMany + { + return $this->hasMany(FormSubmissionActionFailure::class); + } + + /** + * RFC-WS-6 §3 (Q4) — submissions awaiting an applicator pass. Excludes + * NULL apply_status legacy rows by design (RFC O1). + * + * @param Builder $query + * @return Builder + */ + protected function scopePendingApply(Builder $query): Builder + { + return $query->where('apply_status', ApplyStatus::PENDING->value); + } } diff --git a/api/app/Models/FormBuilder/FormSubmissionActionFailure.php b/api/app/Models/FormBuilder/FormSubmissionActionFailure.php new file mode 100644 index 00000000..ff2aedec --- /dev/null +++ b/api/app/Models/FormBuilder/FormSubmissionActionFailure.php @@ -0,0 +1,124 @@ + */ + use HasFactory; + use HasUlids; + + protected $table = 'form_submission_action_failures'; + + protected $fillable = [ + 'form_submission_id', + 'listener_class', + 'binding_id', + 'failed_at', + 'exception_class', + 'exception_message', + 'context', + 'retry_count', + 'resolved_at', + 'resolved_by_user_id', + 'resolved_note', + 'dismissed_at', + 'dismissed_by_user_id', + 'dismissed_reason_type', + 'dismissed_reason_note', + ]; + + /** @var array */ + protected $casts = [ + 'failed_at' => 'datetime', + 'resolved_at' => 'datetime', + 'dismissed_at' => 'datetime', + 'context' => 'array', + 'retry_count' => 'int', + 'dismissed_reason_type' => DismissalReasonType::class, + ]; + + /** @return BelongsTo */ + public function submission(): BelongsTo + { + return $this->belongsTo(FormSubmission::class, 'form_submission_id'); + } + + /** @return BelongsTo */ + public function binding(): BelongsTo + { + return $this->belongsTo(FormFieldBinding::class, 'binding_id'); + } + + /** @return BelongsTo */ + public function resolvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'resolved_by_user_id'); + } + + /** @return BelongsTo */ + public function dismissedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'dismissed_by_user_id'); + } + + /** + * @param Builder $query + * @return Builder + */ + protected function scopeOpen(Builder $query): Builder + { + return $query->whereNull('resolved_at')->whereNull('dismissed_at'); + } + + /** + * @param Builder $query + * @return Builder + */ + protected function scopeResolved(Builder $query): Builder + { + return $query->whereNotNull('resolved_at'); + } + + /** + * @param Builder $query + * @return Builder + */ + protected function scopeDismissed(Builder $query): Builder + { + return $query->whereNotNull('dismissed_at'); + } + + public function isOpen(): bool + { + return $this->resolved_at === null && $this->dismissed_at === null; + } + + public function canBeRetried(): bool + { + return $this->dismissed_at === null; + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index d32df645..54e45753 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -230,6 +230,7 @@ class AppServiceProvider extends ServiceProvider 'form_submission' => FormSubmission::class, 'form_submission_section_status' => FormSubmissionSectionStatus::class, 'form_submission_delegation' => FormSubmissionDelegation::class, + 'form_submission_action_failure' => \App\Models\FormBuilder\FormSubmissionActionFailure::class, 'form_value' => FormValue::class, 'form_value_option' => FormValueOption::class, 'form_template' => FormTemplate::class, diff --git a/api/database/factories/FormBuilder/FormSubmissionActionFailureFactory.php b/api/database/factories/FormBuilder/FormSubmissionActionFailureFactory.php new file mode 100644 index 00000000..bedd9439 --- /dev/null +++ b/api/database/factories/FormBuilder/FormSubmissionActionFailureFactory.php @@ -0,0 +1,56 @@ + */ +final class FormSubmissionActionFailureFactory extends Factory +{ + protected $model = FormSubmissionActionFailure::class; + + /** @return array, mixed> */ + public function definition(): array + { + return [ + 'form_submission_id' => FormSubmission::factory(), + 'listener_class' => 'App\\Listeners\\FormBuilder\\ApplyBindingsOnFormSubmit', + 'binding_id' => null, + 'failed_at' => now(), + 'exception_class' => \RuntimeException::class, + 'exception_message' => 'Simulated apply failure', + 'context' => [ + 'target_entity' => 'person', + 'target_attribute' => 'email', + ], + 'retry_count' => 0, + 'resolved_at' => null, + 'resolved_by_user_id' => null, + 'resolved_note' => null, + 'dismissed_at' => null, + 'dismissed_by_user_id' => null, + 'dismissed_reason_type' => null, + 'dismissed_reason_note' => null, + ]; + } + + public function resolved(): static + { + return $this->state(fn (): array => [ + 'resolved_at' => now(), + 'resolved_note' => 'Resolved via direct edit', + ]); + } + + public function dismissed(): static + { + return $this->state(fn (): array => [ + 'dismissed_at' => now(), + 'dismissed_reason_type' => \App\Enums\FormBuilder\DismissalReasonType::SCHEMA_DELETED, + ]); + } +} diff --git a/api/tests/Feature/FormBuilder/FormSubmissionApplyStatusCastTest.php b/api/tests/Feature/FormBuilder/FormSubmissionApplyStatusCastTest.php new file mode 100644 index 00000000..bf9f6d6c --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormSubmissionApplyStatusCastTest.php @@ -0,0 +1,74 @@ +create(); + $submission->apply_status = ApplyStatus::COMPLETED; + $submission->save(); + + $reloaded = FormSubmission::query()->find($submission->id); + $this->assertSame(ApplyStatus::COMPLETED, $reloaded->apply_status); + } + + public function test_apply_status_null_round_trip(): void + { + $submission = FormSubmission::factory()->create(); + $this->assertNull($submission->apply_status); + + $submission->apply_status = null; + $submission->save(); + + $reloaded = FormSubmission::query()->find($submission->id); + $this->assertNull($reloaded->apply_status); + } + + public function test_legacy_seed_row_without_apply_status_remains_null(): void + { + $organisation = Organisation::factory()->create(); + $schema = FormSchema::factory()->for($organisation)->create(); + $id = (string) Str::ulid(); + + DB::table('form_submissions')->insert([ + 'id' => $id, + 'form_schema_id' => $schema->id, + 'organisation_id' => $organisation->id, + 'status' => 'submitted', + 'is_test' => false, + 'auto_save_count' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $reloaded = FormSubmission::query()->find($id); + $this->assertNull($reloaded->apply_status); + } + + public function test_pending_apply_scope_filters_correctly(): void + { + FormSubmission::factory()->create(['apply_status' => ApplyStatus::PENDING]); + FormSubmission::factory()->create(['apply_status' => ApplyStatus::COMPLETED]); + FormSubmission::factory()->create(['apply_status' => ApplyStatus::FAILED]); + FormSubmission::factory()->create(); // null + + $pending = FormSubmission::query()->pendingApply()->get(); + $this->assertCount(1, $pending); + $this->assertSame(ApplyStatus::PENDING, $pending->first()->apply_status); + } +} diff --git a/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureTest.php b/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureTest.php new file mode 100644 index 00000000..4342eff6 --- /dev/null +++ b/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureTest.php @@ -0,0 +1,95 @@ +create(); + + $this->assertNotEmpty($failure->id); + $this->assertSame(0, $failure->retry_count); + $this->assertNull($failure->resolved_at); + $this->assertNull($failure->dismissed_at); + $this->assertNotEmpty($failure->context); + } + + public function test_submission_relation_returns_parent(): void + { + $submission = FormSubmission::factory()->create(); + $failure = FormSubmissionActionFailure::factory() + ->for($submission, 'submission') + ->create(); + + $this->assertSame($submission->id, $failure->submission->id); + } + + public function test_binding_relation_is_nullable(): void + { + $failure = FormSubmissionActionFailure::factory()->create(); + $this->assertNull($failure->binding); + } + + public function test_open_scope_excludes_resolved_and_dismissed(): void + { + FormSubmissionActionFailure::factory()->create(); + FormSubmissionActionFailure::factory()->resolved()->create(); + FormSubmissionActionFailure::factory()->dismissed()->create(); + + $this->assertSame(1, FormSubmissionActionFailure::query()->open()->count()); + $this->assertSame(1, FormSubmissionActionFailure::query()->resolved()->count()); + $this->assertSame(1, FormSubmissionActionFailure::query()->dismissed()->count()); + } + + public function test_dismissed_reason_type_round_trips_as_enum(): void + { + $failure = FormSubmissionActionFailure::factory()->create([ + 'dismissed_reason_type' => DismissalReasonType::DATA_QUALITY_ISSUE, + 'dismissed_at' => now(), + ]); + + $reloaded = FormSubmissionActionFailure::query()->find($failure->id); + $this->assertSame(DismissalReasonType::DATA_QUALITY_ISSUE, $reloaded->dismissed_reason_type); + } + + public function test_context_round_trips_as_array(): void + { + $failure = FormSubmissionActionFailure::factory()->create([ + 'context' => ['target_entity' => 'company', 'target_attribute' => 'kvk_number'], + ]); + + $reloaded = FormSubmissionActionFailure::query()->find($failure->id); + $this->assertSame('company', $reloaded->context['target_entity']); + } + + public function test_can_be_retried_false_when_dismissed(): void + { + $open = FormSubmissionActionFailure::factory()->create(); + $resolved = FormSubmissionActionFailure::factory()->resolved()->create(); + $dismissed = FormSubmissionActionFailure::factory()->dismissed()->create(); + + $this->assertTrue($open->canBeRetried()); + $this->assertTrue($resolved->canBeRetried()); + $this->assertFalse($dismissed->canBeRetried()); + } + + public function test_table_has_no_organisation_id_column_per_rfc_v3(): void + { + $this->assertFalse( + Schema::hasColumn('form_submission_action_failures', 'organisation_id'), + 'Tenant scope must flow via FK chain to form_submissions.organisation_id (RFC V3)', + ); + } +}