From b47e096a5573c7e742b1478b19835c522d6a9d46 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 28 Apr 2026 22:53:36 +0200 Subject: [PATCH] feat(form-builder): retry history table + integration (WS-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-attempt retry history (timestamp, user, outcome, exception detail if failed) replaces the counter-only retry_count tracking. Changes: - New `form_submission_action_failure_retry_attempts` table (cascade on parent delete, nullOnDelete on user). Explicit short FK names (`fsafra_failure_fk`, `fsafra_user_fk`) — auto-generated names exceed MySQL's 64-char identifier limit. - New FormSubmissionActionFailureRetryAttempt model + factory + succeeded() state. - Parent FormSubmissionActionFailure gets retryAttempts() HasMany relation (latest('attempted_at')). - New FormFailureRetryService centralises the retry-flow logic. Both the API controller and the artisan command delegate to it. Service writes a retry_attempt record per attempt; parent's retry_count stays as denormalised cache for index-view performance. - Successful retry: attempt(succeeded) + parent.retry_count++ + parent.resolved_at + parent.resolved_by_user_id + parent.resolved_note ("Geslaagde retry door {actor.name}" or "Geslaagde retry (geautomatiseerd)" for command-line invocation without an actor). - Failed retry: attempt(failed) with NEW exception details + parent.retry_count++. Parent's exception_class/_message stay audit-immutable — they represent the FIRST failure. - canBeRetried() now correctly checks both resolved_at AND dismissed_at (sessie 2's open question Q2 closure). - New FailureNotRetriableException (controller → 422) and ParentSubmissionGoneException (controller → 410) for cleaner flow control. 12 new tests: - FormSubmissionActionFailureRetryAttemptTest (5 unit tests) - RetryFlowProducesRetryAttemptsTest (7 integration tests covering succeeded path, failed path, resolved/dismissed blocking, multiple-retries chronological ordering, canBeRetried truth tables) Pre-existing tests touched: - FormSubmissionActionFailureTest::test_can_be_retried_only_for_open_state — updated to reflect Q2 closure (resolved now blocks too). - Ws6FoundationMigrationTest::test_down_methods_clean_up_columns_and_table — child table must drop before parent (FK constraint). - 5 backfill test step-counts bumped +1 (new migration sits at top). SCHEMA.md → v2.9. Schema dump regenerated. Refs: RFC-WS-6.md §3 Q5 addendum, sessie 2 Q2 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../RetryFormSubmissionActionFailures.php | 71 ++---- .../FailureNotRetriableException.php | 19 ++ .../ParentSubmissionGoneException.php | 19 ++ .../FormSubmissionActionFailureController.php | 64 ++--- .../FormSubmissionActionFailure.php | 23 +- ...ormSubmissionActionFailureRetryAttempt.php | 58 +++++ .../FormBuilder/FormFailureRetryService.php | 135 +++++++++++ ...issionActionFailureRetryAttemptFactory.php | 39 ++++ ...on_action_failure_retry_attempts_table.php | 58 +++++ api/database/schema/mysql-schema.sql | 23 +- .../FormFieldBindingMigrationTest.php | 6 +- .../RetryFlowProducesRetryAttemptsTest.php | 218 ++++++++++++++++++ .../ConditionalLogicBackfillTest.php | 8 +- .../FormFieldConfigBackfillAndDropTest.php | 2 +- .../Options/FormFieldOptionsBackfillTest.php | 20 +- .../Schema/Ws6FoundationMigrationTest.php | 8 + .../FormFieldValidationRuleBackfillTest.php | 14 +- ...ubmissionActionFailureRetryAttemptTest.php | 61 +++++ .../FormSubmissionActionFailureTest.php | 6 +- dev-docs/SCHEMA.md | 38 ++- 20 files changed, 767 insertions(+), 123 deletions(-) create mode 100644 api/app/Exceptions/FormBuilder/FailureNotRetriableException.php create mode 100644 api/app/Exceptions/FormBuilder/ParentSubmissionGoneException.php create mode 100644 api/app/Models/FormBuilder/FormSubmissionActionFailureRetryAttempt.php create mode 100644 api/app/Services/FormBuilder/FormFailureRetryService.php create mode 100644 api/database/factories/FormBuilder/FormSubmissionActionFailureRetryAttemptFactory.php create mode 100644 api/database/migrations/2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table.php create mode 100644 api/tests/Feature/FormBuilder/Bindings/RetryFlowProducesRetryAttemptsTest.php create mode 100644 api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureRetryAttemptTest.php diff --git a/api/app/Console/Commands/RetryFormSubmissionActionFailures.php b/api/app/Console/Commands/RetryFormSubmissionActionFailures.php index 8c2773b9..e014497f 100644 --- a/api/app/Console/Commands/RetryFormSubmissionActionFailures.php +++ b/api/app/Console/Commands/RetryFormSubmissionActionFailures.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace App\Console\Commands; -use App\Enums\FormBuilder\ApplyStatus; -use App\FormBuilder\Bindings\FormBindingApplicator; -use App\Models\FormBuilder\FormSubmission; +use App\Exceptions\FormBuilder\FailureNotRetriableException; +use App\Exceptions\FormBuilder\ParentSubmissionGoneException; use App\Models\FormBuilder\FormSubmissionActionFailure; +use App\Services\FormBuilder\FormFailureRetryService; use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\DB; -use Throwable; /** * RFC-WS-6 §3 (Q5) — replay open failures via the applicator. @@ -29,7 +27,7 @@ final class RetryFormSubmissionActionFailures extends Command protected $description = 'Replay open FormSubmissionActionFailure rows via the applicator'; - public function handle(FormBindingApplicator $applicator): int + public function handle(FormFailureRetryService $retryService): int { if ( $this->option('id') === null @@ -54,10 +52,11 @@ final class RetryFormSubmissionActionFailures extends Command foreach ($failures as $failure) { if ($this->option('dry-run')) { $rows[] = ['id' => (string) $failure->id, 'submission' => (string) $failure->form_submission_id, 'result' => 'would-retry']; + continue; } - $rows[] = $this->retryOne($failure, $applicator); + $rows[] = $this->retryOne($failure, $retryService); } $this->table(['id', 'submission', 'result'], $rows); @@ -95,48 +94,28 @@ final class RetryFormSubmissionActionFailures extends Command /** * @return array{id:string, submission:string, result:string} */ - private function retryOne(FormSubmissionActionFailure $failure, FormBindingApplicator $applicator): array + private function retryOne(FormSubmissionActionFailure $failure, FormFailureRetryService $retryService): array { - $submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id); - if ($submission === null) { - return ['id' => (string) $failure->id, 'submission' => (string) $failure->form_submission_id, 'result' => 'submission-gone']; - } - try { - DB::transaction(function () use ($applicator, $submission): void { - $result = $applicator->apply($submission); - FormSubmission::query() - ->whereKey($submission->id) - ->update([ - 'apply_status' => $result->applyStatus()->value, - 'apply_completed_at' => now(), - ]); - }); - $failure->retry_count = (int) $failure->retry_count + 1; - $failure->resolved_at = now(); - $failure->save(); + $result = $retryService->retry($failure); - return ['id' => (string) $failure->id, 'submission' => (string) $submission->id, 'result' => 'succeeded']; - } catch (Throwable $e) { - // Append a NEW row preserving history, increment retry_count on original. - DB::transaction(function () use ($failure, $submission, $e): void { - FormSubmissionActionFailure::query()->create([ - 'form_submission_id' => $submission->id, - 'listener_class' => $failure->listener_class, - 'failed_at' => now(), - 'exception_class' => $e::class, - 'exception_message' => $e->getMessage(), - 'context' => ['retry_of' => (string) $failure->id], - ]); - FormSubmissionActionFailure::query() - ->whereKey($failure->id) - ->update(['retry_count' => (int) $failure->retry_count + 1]); - FormSubmission::query() - ->whereKey($submission->id) - ->update(['apply_status' => ApplyStatus::FAILED->value]); - }); - - return ['id' => (string) $failure->id, 'submission' => (string) $submission->id, 'result' => 'failed-again']; + return [ + 'id' => (string) $failure->id, + 'submission' => (string) $failure->form_submission_id, + 'result' => $result['outcome'] === 'succeeded' ? 'succeeded' : 'failed-again', + ]; + } catch (FailureNotRetriableException $e) { + return [ + 'id' => (string) $failure->id, + 'submission' => (string) $failure->form_submission_id, + 'result' => "skipped-{$e->reason}", + ]; + } catch (ParentSubmissionGoneException) { + return [ + 'id' => (string) $failure->id, + 'submission' => (string) $failure->form_submission_id, + 'result' => 'submission-gone', + ]; } } } diff --git a/api/app/Exceptions/FormBuilder/FailureNotRetriableException.php b/api/app/Exceptions/FormBuilder/FailureNotRetriableException.php new file mode 100644 index 00000000..d4e8968d --- /dev/null +++ b/api/app/Exceptions/FormBuilder/FailureNotRetriableException.php @@ -0,0 +1,19 @@ +whereHas('submission', function ($q) use ($organisation): void { - /** @var \Illuminate\Database\Eloquent\Builder $q */ + /** @var \Illuminate\Database\Eloquent\Builder<\App\Models\FormBuilder\FormSubmission> $q */ $q->where('organisation_id', $organisation->id); }) ->latest('failed_at') @@ -74,59 +73,30 @@ final class FormSubmissionActionFailureController extends Controller return new FormSubmissionActionFailureResource($formSubmissionActionFailure); } - public function retry(?Organisation $organisation, FormSubmissionActionFailure $formSubmissionActionFailure, FormBindingApplicator $applicator): FormSubmissionActionFailureResource|JsonResponse - { + public function retry( + ?Organisation $organisation, + FormSubmissionActionFailure $formSubmissionActionFailure, + Request $request, + FormFailureRetryService $retryService, + ): FormSubmissionActionFailureResource|JsonResponse { unset($organisation); $failure = $formSubmissionActionFailure; $this->authorizeOrNotFound('retry', $failure); - if (! $failure->canBeRetried()) { + try { + $retryService->retry($failure, $request->user()); + } catch (FailureNotRetriableException $e) { return response()->json([ 'error' => 'cannot_retry', - 'message' => 'Failure is dismissed; cannot retry.', + 'message' => $e->getMessage(), ], 422); - } - - $submission = $failure->submission; - if ($submission === null) { + } catch (ParentSubmissionGoneException $e) { return response()->json([ 'error' => 'submission_gone', - 'message' => 'Parent submission has been deleted.', + 'message' => $e->getMessage(), ], 410); } - try { - DB::transaction(function () use ($applicator, $submission): void { - $result = $applicator->apply($submission); - FormSubmission::query() - ->whereKey($submission->id) - ->update([ - 'apply_status' => $result->applyStatus()->value, - 'apply_completed_at' => now(), - ]); - }); - $failure->retry_count = (int) $failure->retry_count + 1; - $failure->resolved_at = now(); - $failure->save(); - } catch (Throwable $e) { - DB::transaction(function () use ($failure, $submission, $e): void { - FormSubmissionActionFailure::query()->create([ - 'form_submission_id' => $submission->id, - 'listener_class' => $failure->listener_class, - 'failed_at' => now(), - 'exception_class' => $e::class, - 'exception_message' => $e->getMessage(), - 'context' => ['retry_of' => (string) $failure->id], - ]); - FormSubmissionActionFailure::query() - ->whereKey($failure->id) - ->update(['retry_count' => (int) $failure->retry_count + 1]); - FormSubmission::query() - ->whereKey($submission->id) - ->update(['apply_status' => ApplyStatus::FAILED->value]); - }); - } - return new FormSubmissionActionFailureResource($failure->refresh()); } diff --git a/api/app/Models/FormBuilder/FormSubmissionActionFailure.php b/api/app/Models/FormBuilder/FormSubmissionActionFailure.php index ff2aedec..9febdc57 100644 --- a/api/app/Models/FormBuilder/FormSubmissionActionFailure.php +++ b/api/app/Models/FormBuilder/FormSubmissionActionFailure.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * RFC-WS-6 §3 (Q5) — audit table for binding-pipeline failures. @@ -29,6 +30,7 @@ final class FormSubmissionActionFailure extends Model { /** @use HasFactory<\Database\Factories\FormBuilder\FormSubmissionActionFailureFactory> */ use HasFactory; + use HasUlids; protected $table = 'form_submission_action_failures'; @@ -85,6 +87,19 @@ final class FormSubmissionActionFailure extends Model return $this->belongsTo(User::class, 'dismissed_by_user_id'); } + /** + * RFC-WS-6 Q5 addendum (sessie 3c) — per-attempt retry history. + * `retry_count` on this model stays as denormalized cache; the + * detail UI consumes this relation for per-attempt timeline. + * + * @return HasMany + */ + public function retryAttempts(): HasMany + { + return $this->hasMany(FormSubmissionActionFailureRetryAttempt::class, 'form_submission_action_failure_id') + ->latest('attempted_at'); + } + /** * @param Builder $query * @return Builder @@ -117,8 +132,14 @@ final class FormSubmissionActionFailure extends Model return $this->resolved_at === null && $this->dismissed_at === null; } + /** + * Sessie 3c (Q2 closure): a resolved failure also blocks retry — + * retrying a closed failure would either no-op or trigger a + * spurious state transition. Both are unwanted. Open is the only + * retriable state. + */ public function canBeRetried(): bool { - return $this->dismissed_at === null; + return $this->resolved_at === null && $this->dismissed_at === null; } } diff --git a/api/app/Models/FormBuilder/FormSubmissionActionFailureRetryAttempt.php b/api/app/Models/FormBuilder/FormSubmissionActionFailureRetryAttempt.php new file mode 100644 index 00000000..f2157c08 --- /dev/null +++ b/api/app/Models/FormBuilder/FormSubmissionActionFailureRetryAttempt.php @@ -0,0 +1,58 @@ + */ + use HasFactory; + + use HasUlids; + + protected $table = 'form_submission_action_failure_retry_attempts'; + + protected $fillable = [ + 'form_submission_action_failure_id', + 'attempted_at', + 'attempted_by_user_id', + 'outcome', + 'exception_class', + 'exception_message', + ]; + + /** @var array */ + protected $casts = [ + 'attempted_at' => 'datetime', + ]; + + /** @return BelongsTo */ + public function failure(): BelongsTo + { + return $this->belongsTo(FormSubmissionActionFailure::class, 'form_submission_action_failure_id'); + } + + /** @return BelongsTo */ + public function attemptedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'attempted_by_user_id'); + } +} diff --git a/api/app/Services/FormBuilder/FormFailureRetryService.php b/api/app/Services/FormBuilder/FormFailureRetryService.php new file mode 100644 index 00000000..7361bf83 --- /dev/null +++ b/api/app/Services/FormBuilder/FormFailureRetryService.php @@ -0,0 +1,135 @@ +canBeRetried()) { + throw new FailureNotRetriableException($failure->resolved_at !== null ? 'resolved' : 'dismissed'); + } + + /** @var FormSubmission|null $submission */ + $submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id); + if ($submission === null) { + throw new ParentSubmissionGoneException; + } + + try { + DB::transaction(function () use ($submission): void { + $result = $this->applicator->apply($submission); + FormSubmission::query() + ->whereKey($submission->id) + ->update([ + 'apply_status' => $result->applyStatus()->value, + 'apply_completed_at' => now(), + ]); + }); + + $attempt = $this->recordSuccess($failure, $actor); + + return ['outcome' => 'succeeded', 'attempt' => $attempt]; + } catch (Throwable $e) { + $attempt = $this->recordFailure($failure, $submission, $actor, $e); + + return ['outcome' => 'failed', 'attempt' => $attempt]; + } + } + + private function recordSuccess(FormSubmissionActionFailure $failure, ?User $actor): FormSubmissionActionFailureRetryAttempt + { + return DB::transaction(function () use ($failure, $actor): FormSubmissionActionFailureRetryAttempt { + /** @var FormSubmissionActionFailureRetryAttempt $attempt */ + $attempt = FormSubmissionActionFailureRetryAttempt::query()->create([ + 'form_submission_action_failure_id' => $failure->id, + 'attempted_at' => now(), + 'attempted_by_user_id' => $actor?->id, + 'outcome' => 'succeeded', + 'exception_class' => null, + 'exception_message' => null, + ]); + + $note = $actor instanceof User + ? "Geslaagde retry door {$actor->name}" + : 'Geslaagde retry (geautomatiseerd)'; + + FormSubmissionActionFailure::query() + ->whereKey($failure->id) + ->update([ + 'retry_count' => DB::raw('retry_count + 1'), + 'resolved_at' => now(), + 'resolved_by_user_id' => $actor?->id, + 'resolved_note' => $note, + ]); + + return $attempt; + }); + } + + private function recordFailure( + FormSubmissionActionFailure $failure, + FormSubmission $submission, + ?User $actor, + Throwable $e, + ): FormSubmissionActionFailureRetryAttempt { + return DB::transaction(function () use ($failure, $submission, $actor, $e): FormSubmissionActionFailureRetryAttempt { + /** @var FormSubmissionActionFailureRetryAttempt $attempt */ + $attempt = FormSubmissionActionFailureRetryAttempt::query()->create([ + 'form_submission_action_failure_id' => $failure->id, + 'attempted_at' => now(), + 'attempted_by_user_id' => $actor?->id, + 'outcome' => 'failed', + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + ]); + + FormSubmissionActionFailure::query() + ->whereKey($failure->id) + ->update(['retry_count' => DB::raw('retry_count + 1')]); + + FormSubmission::query() + ->whereKey($submission->id) + ->update(['apply_status' => ApplyStatus::FAILED->value]); + + return $attempt; + }); + } +} diff --git a/api/database/factories/FormBuilder/FormSubmissionActionFailureRetryAttemptFactory.php b/api/database/factories/FormBuilder/FormSubmissionActionFailureRetryAttemptFactory.php new file mode 100644 index 00000000..650df473 --- /dev/null +++ b/api/database/factories/FormBuilder/FormSubmissionActionFailureRetryAttemptFactory.php @@ -0,0 +1,39 @@ + */ +final class FormSubmissionActionFailureRetryAttemptFactory extends Factory +{ + protected $model = FormSubmissionActionFailureRetryAttempt::class; + + /** @return array, mixed> */ + public function definition(): array + { + return [ + 'form_submission_action_failure_id' => FormSubmissionActionFailure::factory(), + 'attempted_at' => fake()->dateTimeBetween('-7 days', 'now'), + 'attempted_by_user_id' => User::factory(), + 'outcome' => 'failed', + 'exception_class' => PersonProvisioningException::class, + 'exception_message' => 'Person provisioning failed: no_default_crowd_type', + ]; + } + + public function succeeded(): static + { + return $this->state(fn (): array => [ + 'outcome' => 'succeeded', + 'exception_class' => null, + 'exception_message' => null, + ]); + } +} diff --git a/api/database/migrations/2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table.php b/api/database/migrations/2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table.php new file mode 100644 index 00000000..c0aceab0 --- /dev/null +++ b/api/database/migrations/2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table.php @@ -0,0 +1,58 @@ +ulid('id')->primary(); + $table->ulid('form_submission_action_failure_id'); + $table->timestamp('attempted_at'); + $table->ulid('attempted_by_user_id')->nullable(); + $table->enum('outcome', ['succeeded', 'failed']); + $table->string('exception_class', 255)->nullable(); + $table->text('exception_message')->nullable(); + $table->timestamps(); + + $table->foreign('form_submission_action_failure_id', 'fsafra_failure_fk') + ->references('id') + ->on('form_submission_action_failures') + ->cascadeOnDelete(); + + $table->foreign('attempted_by_user_id', 'fsafra_user_fk') + ->references('id') + ->on('users') + ->nullOnDelete(); + + $table->index(['form_submission_action_failure_id', 'attempted_at'], 'fsafra_failure_attempt_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('form_submission_action_failure_retry_attempts'); + } +}; diff --git a/api/database/schema/mysql-schema.sql b/api/database/schema/mysql-schema.sql index 120140fc..4efae0f6 100644 --- a/api/database/schema/mysql-schema.sql +++ b/api/database/schema/mysql-schema.sql @@ -111,7 +111,7 @@ CREATE TABLE `companies` ( `organisation_id` char(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `type` enum('supplier','partner','agency','venue','other') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - `kvk_number` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `kvk_number` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `contact_first_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `contact_last_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `contact_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, @@ -624,6 +624,26 @@ CREATE TABLE `form_schemas` ( CONSTRAINT `form_schemas_organisation_id_foreign` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `form_submission_action_failure_retry_attempts`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `form_submission_action_failure_retry_attempts` ( + `id` char(26) COLLATE utf8mb4_unicode_ci NOT NULL, + `form_submission_action_failure_id` char(26) COLLATE utf8mb4_unicode_ci NOT NULL, + `attempted_at` timestamp NOT NULL, + `attempted_by_user_id` char(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `outcome` enum('succeeded','failed') COLLATE utf8mb4_unicode_ci NOT NULL, + `exception_class` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `exception_message` text COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fsafra_user_fk` (`attempted_by_user_id`), + KEY `fsafra_failure_attempt_idx` (`form_submission_action_failure_id`,`attempted_at`), + CONSTRAINT `fsafra_failure_fk` FOREIGN KEY (`form_submission_action_failure_id`) REFERENCES `form_submission_action_failures` (`id`) ON DELETE CASCADE, + CONSTRAINT `fsafra_user_fk` FOREIGN KEY (`attempted_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; DROP TABLE IF EXISTS `form_submission_action_failures`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -1750,3 +1770,4 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (153,'2026_04_27_10 INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (154,'2026_04_27_100002_drop_form_field_options_json_columns',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (155,'2026_04_28_100000_restore_default_crowd_type_id_foreign_key',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (156,'2026_04_28_140000_add_kvk_number_to_companies_table',3); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (157,'2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table',4); diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index f4ad47a5..7781388d 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -57,7 +57,7 @@ final class FormFieldBindingMigrationTest extends TestCase // validation-rules-backfill, create-validation-rules) + // 2 WS-6 migrations (action-failures, apply-status) + // 2 WS-5a migrations (drop-binding-cols, create-bindings) = 16. - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -119,7 +119,7 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { // Walk back the full WS-5d + WS-5c + WS-6 + WS-5b + WS-5a stack (16 migrations). - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); [$fieldAId] = $this->seedFieldsWithBindingJson(); [$libAId] = $this->seedLibraryWithBindingJson(); @@ -134,7 +134,7 @@ final class FormFieldBindingMigrationTest extends TestCase // the pre-WS-5b state (conditional-logic, validation-rules, configs // and options tables gone, validation_rules + options JSON columns // reappear on source tables; binding contract intact). - $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_options')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions')); diff --git a/api/tests/Feature/FormBuilder/Bindings/RetryFlowProducesRetryAttemptsTest.php b/api/tests/Feature/FormBuilder/Bindings/RetryFlowProducesRetryAttemptsTest.php new file mode 100644 index 00000000..4ba7b24b --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/RetryFlowProducesRetryAttemptsTest.php @@ -0,0 +1,218 @@ +create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + ]); + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $schema->id, + 'organisation_id' => $org->id, + ]); + + return FormSubmissionActionFailure::factory() + ->for($submission, 'submission') + ->create([ + 'exception_class' => 'OriginalException', + 'exception_message' => 'first failure message', + ]); + } + + /** + * @param callable(FormSubmission, ?string): BindingPassResult $apply + */ + private function bindApplicator(callable $apply): void + { + // The applicator is `class` (not final/not readonly) specifically so + // listener tests can extend and override apply(). We use the same + // override mechanism here. Properties on the parent are readonly + // promoted constructor params; we skip parent::__construct because + // we override the ONLY method (apply) that touches them. + $stub = new class($apply) extends FormBindingApplicator { + /** @var callable(FormSubmission, ?string): BindingPassResult */ + private $apply; + + /** @param callable(FormSubmission, ?string): BindingPassResult $apply */ + public function __construct(callable $apply) + { + $this->apply = $apply; + } + + public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult + { + return ($this->apply)($submission, $sectionId); + } + }; + $this->app->instance(FormBindingApplicator::class, $stub); + } + + public function test_successful_retry_creates_succeeded_attempt_and_resolves_parent(): void + { + $failure = $this->makeFailure(); + $actor = User::factory()->create(['first_name' => 'Maud', 'last_name' => 'Admin']); + + // Applicator returns a real BindingPassResult that maps to COMPLETED. + $this->bindApplicator(fn (): BindingPassResult => new BindingPassResult( + formSubmissionId: (string) $failure->form_submission_id, + provisionedSubjectType: 'person', + provisionedSubjectId: (string) \Illuminate\Support\Str::ulid(), + applications: [], + )); + + $service = $this->app->make(FormFailureRetryService::class); + $result = $service->retry($failure, $actor); + + $this->assertSame('succeeded', $result['outcome']); + + $failure->refresh(); + $this->assertNotNull($failure->resolved_at); + $this->assertSame((string) $actor->id, (string) $failure->resolved_by_user_id); + $this->assertSame('Geslaagde retry door Maud Admin', $failure->resolved_note); + $this->assertSame(1, $failure->retry_count); + + $attempts = FormSubmissionActionFailureRetryAttempt::query() + ->where('form_submission_action_failure_id', $failure->id) + ->get(); + $this->assertCount(1, $attempts); + $this->assertSame('succeeded', $attempts[0]->outcome); + $this->assertNull($attempts[0]->exception_class); + + // Parent's original exception fields stay audit-immutable. + $this->assertSame('OriginalException', $failure->exception_class); + $this->assertSame('first failure message', $failure->exception_message); + } + + public function test_failed_retry_creates_failed_attempt_and_keeps_parent_open(): void + { + $failure = $this->makeFailure(); + + $this->bindApplicator(function (): never { + throw new RuntimeException('NEW exception on retry'); + }); + + $service = $this->app->make(FormFailureRetryService::class); + $result = $service->retry($failure); + + $this->assertSame('failed', $result['outcome']); + + $failure->refresh(); + $this->assertNull($failure->resolved_at); + $this->assertNull($failure->dismissed_at); + $this->assertSame(1, $failure->retry_count); + // Parent's original exception fields untouched (audit-immutable). + $this->assertSame('OriginalException', $failure->exception_class); + $this->assertSame('first failure message', $failure->exception_message); + + $attempts = FormSubmissionActionFailureRetryAttempt::query() + ->where('form_submission_action_failure_id', $failure->id) + ->get(); + $this->assertCount(1, $attempts); + $this->assertSame('failed', $attempts[0]->outcome); + $this->assertSame(RuntimeException::class, $attempts[0]->exception_class); + $this->assertSame('NEW exception on retry', $attempts[0]->exception_message); + } + + public function test_retry_on_resolved_failure_throws(): void + { + $failure = $this->makeFailure(); + $failure->update(['resolved_at' => now(), 'resolved_note' => 'manual fix']); + + $service = $this->app->make(FormFailureRetryService::class); + + $this->expectException(FailureNotRetriableException::class); + $service->retry($failure); + } + + public function test_retry_on_dismissed_failure_throws(): void + { + $failure = $this->makeFailure(); + $failure->update([ + 'dismissed_at' => now(), + 'dismissed_reason_type' => 'schema_deleted', + ]); + + $service = $this->app->make(FormFailureRetryService::class); + + $this->expectException(FailureNotRetriableException::class); + $service->retry($failure); + } + + public function test_multiple_retries_produce_chronologically_ordered_attempts(): void + { + $failure = $this->makeFailure(); + + $callCount = 0; + $this->bindApplicator(function () use (&$callCount): never { + $callCount++; + throw new RuntimeException("attempt #{$callCount}"); + }); + + $service = $this->app->make(FormFailureRetryService::class); + $service->retry($failure); + $failure->refresh(); + $service->retry($failure); + $failure->refresh(); + $service->retry($failure); + + $failure->refresh(); + $this->assertSame(3, $failure->retry_count); + $this->assertNull($failure->resolved_at); + + $attempts = FormSubmissionActionFailureRetryAttempt::query() + ->where('form_submission_action_failure_id', $failure->id) + ->oldest('attempted_at') + ->get(); + $this->assertCount(3, $attempts); + $this->assertSame('attempt #1', $attempts[0]->exception_message); + $this->assertSame('attempt #2', $attempts[1]->exception_message); + $this->assertSame('attempt #3', $attempts[2]->exception_message); + } + + public function test_can_be_retried_blocks_resolved_state(): void + { + $failure = $this->makeFailure(); + $this->assertTrue($failure->canBeRetried()); + + $failure->update(['resolved_at' => now()]); + $this->assertFalse($failure->fresh()->canBeRetried()); + } + + public function test_can_be_retried_blocks_dismissed_state(): void + { + $failure = $this->makeFailure(); + $failure->update(['dismissed_at' => now(), 'dismissed_reason_type' => 'schema_deleted']); + + $this->assertFalse($failure->fresh()->canBeRetried()); + } + +} diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index 908d6700..3dfc2b95 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -49,7 +49,7 @@ final class ConditionalLogicBackfillTest extends TestCase // create-options + WS-5c drop-cl-col + WS-5c backfill-cl // migrations to land in the conditional-logic JSON-era state with // no relational form_field_options table yet. - $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic')); $fieldId = $this->seedFieldWithJson([ @@ -170,7 +170,7 @@ final class ConditionalLogicBackfillTest extends TestCase ]); // Roll back only the backfill migration — writes the JSON back. - $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); $reconstructed = DB::table('form_fields') ->where('id', $fieldId) @@ -203,7 +203,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_top_level_key_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); $this->seedFieldWithJson([ 'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], @@ -216,7 +216,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_comparison_operator_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); $this->seedFieldWithJson([ 'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]], diff --git a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php index 9792bace..a8beb727 100644 --- a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php +++ b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php @@ -30,7 +30,7 @@ final class FormFieldConfigBackfillAndDropTest extends TestCase // Roll back 4 WS-5c migrations + 2 WS-6 migrations + 5 WS-5b // migrations = 11, to get the pre-WS-5b state where the JSON column // still exists on form_fields / form_field_library. - $this->artisan('migrate:rollback', ['--step' => 14])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $fieldId = $this->seedField([ diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php index 22920583..3d3bc176 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php @@ -47,7 +47,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Roll back only the backfill migration (latest WS-5d step). // Leaves the form_field_options table in place, JSON columns // present on the source tables, and snapshots in pre-WS-5d shape. - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->assertTrue(Schema::hasTable('form_field_options')); $this->assertTrue(Schema::hasColumn('form_fields', 'options')); @@ -136,7 +136,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_rollback_reconstructs_json_columns_and_snapshots(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson(); $submissionId = $this->seedSubmissionWithSnapshot($selectId); @@ -149,7 +149,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Step back over only the backfill migration → JSON columns repopulate // and snapshots revert to flat-string-array shape. - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->assertSame(0, DB::table('form_field_options')->count()); $select = DB::table('form_fields')->where('id', $selectId)->first(); @@ -168,7 +168,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']); $this->expectException(\RuntimeException::class); @@ -178,7 +178,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_contains_non_string_entry(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ ['label' => 'A'], @@ -192,7 +192,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_is_object_shape(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ 'XS' => 'Extra small', @@ -206,7 +206,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_translations_length_mismatch(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([ 'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3 ])); @@ -218,7 +218,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_non_string_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([ 'de' => ['options' => ['Klein', 42]], ])); @@ -230,7 +230,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_oversized_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([ 'de' => ['options' => [str_repeat('x', 256)]], ])); @@ -242,7 +242,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->seedTemplateWithSnapshotRaw([ 'fields' => [[ 'id' => (string) Str::ulid(), diff --git a/api/tests/Feature/FormBuilder/Schema/Ws6FoundationMigrationTest.php b/api/tests/Feature/FormBuilder/Schema/Ws6FoundationMigrationTest.php index 0c7fb5ab..ede0082d 100644 --- a/api/tests/Feature/FormBuilder/Schema/Ws6FoundationMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Schema/Ws6FoundationMigrationTest.php @@ -110,7 +110,13 @@ final class Ws6FoundationMigrationTest extends TestCase $applyStatus = require database_path( 'migrations/2026_04_25_140000_extend_form_submissions_with_apply_status.php', ); + // Sessie 3c added a child table referencing form_submission_action_failures + // via FK; we must drop the child before downing the parent. + $retryAttempts = require database_path( + 'migrations/2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table.php', + ); + $retryAttempts->down(); $createFailures->down(); $applyStatus->down(); @@ -118,6 +124,7 @@ final class Ws6FoundationMigrationTest extends TestCase $this->assertFalse(Schema::hasColumn('form_submissions', 'apply_status')); $this->assertFalse(Schema::hasColumn('form_submissions', 'apply_completed_at')); $this->assertFalse(Schema::hasTable('form_submission_action_failures')); + $this->assertFalse(Schema::hasTable('form_submission_action_failure_retry_attempts')); $indexes = $this->indexNamesFor('form_submissions'); $this->assertNotContains('fs_schema_apply_status_idx', $indexes); @@ -126,6 +133,7 @@ final class Ws6FoundationMigrationTest extends TestCase // Restore state for any subsequent tests in this class. $applyStatus->up(); $createFailures->up(); + $retryAttempts->up(); } } diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index 33ba0996..002d6f85 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -53,7 +53,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // validation-rules-backfill + create-validation-rules) = 14. // Brings us to the pre-WS-5b state: validation_rules JSON column // present, no relational tables for WS-5b/c/d. - $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -114,7 +114,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -138,7 +138,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -165,7 +165,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -182,7 +182,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -201,7 +201,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // full-back-then-full-forward cycle — rolling back all WS-5b // migrations restores the pre-WS-5b state (columns present on // source tables; validation rules relational table gone). - $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -216,7 +216,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // Roll back WS-5b fully → column reappears and carries canonical JSON // reconstructed from the relational rows. - $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first(); diff --git a/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureRetryAttemptTest.php b/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureRetryAttemptTest.php new file mode 100644 index 00000000..7159c8ac --- /dev/null +++ b/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureRetryAttemptTest.php @@ -0,0 +1,61 @@ +create(); + + $this->assertSame('failed', $attempt->outcome); + $this->assertNotEmpty($attempt->exception_class); + $this->assertNotEmpty($attempt->exception_message); + } + + public function test_failure_relation_resolves_to_the_parent(): void + { + $failure = FormSubmissionActionFailure::factory()->create(); + $attempt = FormSubmissionActionFailureRetryAttempt::factory() + ->for($failure, 'failure') + ->create(); + + $this->assertSame((string) $failure->id, (string) $attempt->failure->id); + } + + public function test_attempted_by_relation_resolves_to_the_user(): void + { + $user = User::factory()->create(); + $attempt = FormSubmissionActionFailureRetryAttempt::factory() + ->state(['attempted_by_user_id' => $user->id]) + ->create(); + + $this->assertSame((string) $user->id, (string) $attempt->attemptedBy->id); + } + + public function test_succeeded_state_produces_null_exception_fields(): void + { + $attempt = FormSubmissionActionFailureRetryAttempt::factory()->succeeded()->create(); + + $this->assertSame('succeeded', $attempt->outcome); + $this->assertNull($attempt->exception_class); + $this->assertNull($attempt->exception_message); + } + + public function test_attempted_at_is_cast_to_datetime(): void + { + $attempt = FormSubmissionActionFailureRetryAttempt::factory()->create(); + + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $attempt->attempted_at); + } +} diff --git a/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureTest.php b/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureTest.php index 4342eff6..13ee94fe 100644 --- a/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureTest.php +++ b/api/tests/Unit/Models/FormBuilder/FormSubmissionActionFailureTest.php @@ -74,14 +74,16 @@ final class FormSubmissionActionFailureTest extends TestCase $this->assertSame('company', $reloaded->context['target_entity']); } - public function test_can_be_retried_false_when_dismissed(): void + public function test_can_be_retried_only_for_open_state(): void { + // Sessie 3c (Q2 closure): both resolved AND dismissed block retry. + // Open is the only retriable state. $open = FormSubmissionActionFailure::factory()->create(); $resolved = FormSubmissionActionFailure::factory()->resolved()->create(); $dismissed = FormSubmissionActionFailure::factory()->dismissed()->create(); $this->assertTrue($open->canBeRetried()); - $this->assertTrue($resolved->canBeRetried()); + $this->assertFalse($resolved->canBeRetried()); $this->assertFalse($dismissed->canBeRetried()); } diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index d8f3bc65..cd3c0e19 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1,10 +1,19 @@ # Crewli — Core Database Schema > Source: Design Document v1.3 — Section 3.5 -> **Version: 2.8** — Updated April 2026 +> **Version: 2.9** — Updated April 2026 > > **Changelog:** > +> - v2.9: WS-6 session 3c — `form_submission_action_failure_retry_attempts` +> table added. Per-attempt retry history (timestamp, user, outcome, +> exception details if failed) replaces the counter-only `retry_count` +> tracking on the parent. Parent's `retry_count` stays as denormalised +> cache; service layer (`FormFailureRetryService`) keeps both in sync. +> `canBeRetried()` now correctly checks both `resolved_at` AND +> `dismissed_at` (sessie 2 Q2 closure). +> RFC-WS-6.md §3 Q5 addendum. +> > - v2.8: WS-6 session 3a.5 — `companies.kvk_number` column added > (nullable, indexed). Aligns with the binding-target registry's > B2B identity-key candidate. Registry entries renamed/removed in @@ -2601,6 +2610,33 @@ that aggregates the user's submitted, non-test `form_submissions`. --- +### `form_submission_action_failure_retry_attempts` + +> **v2.9 — WS-6 sessie 3c (RFC-WS-6.md §3 Q5 addendum)** Per-attempt retry +> history. Sessie 1's `form_submission_action_failures.retry_count` is a +> counter only; this table adds per-attempt records (timestamp, user, +> outcome, exception details if failed) so the admin UI can show retry +> history with full context. Parent's `retry_count` stays as denormalised +> cache for index-view performance; the service layer (`FormFailureRetryService`) +> keeps both in sync per retry. + +| Column | Type | Notes | +| ------------------------------------- | ----------------------------- | ------------------------------------------------------------------ | +| `id` | ULID | PK | +| `form_submission_action_failure_id` | ULID FK | → form_submission_action_failures, cascade delete (FK name `fsafra_failure_fk` to fit MySQL's 64-char identifier limit) | +| `attempted_at` | timestamp | When the retry was invoked | +| `attempted_by_user_id` | ULID FK nullable | → users, null on delete (FK name `fsafra_user_fk`) | +| `outcome` | enum | `succeeded` \| `failed` | +| `exception_class` | string(255) nullable | Captured per-attempt — parent's `exception_class` stays audit-immutable (represents FIRST failure) | +| `exception_message` | text nullable | Captured per-attempt | +| `created_at`, `updated_at` | timestamps | | + +**Relations:** `belongsTo` failure, attemptedBy (User) +**Indexes:** `fsafra_failure_attempt_idx` on `(form_submission_action_failure_id, attempted_at)` +**Soft delete:** no — audit table; retention via parent failure cascade-delete + +--- + **Activity log strategy:** explicit calls via `FormSchema::logSchemaChange()` and `FormField::logFieldChange()` — no `LogsActivity` trait (would produce noise). Only impactful events