diff --git a/api/app/Console/Commands/DismissFormSubmissionActionFailures.php b/api/app/Console/Commands/DismissFormSubmissionActionFailures.php new file mode 100644 index 00000000..248cdc8e --- /dev/null +++ b/api/app/Console/Commands/DismissFormSubmissionActionFailures.php @@ -0,0 +1,77 @@ +argument('id'); + $reasonInput = $this->option('reason'); + $note = $this->option('note'); + + if ($reasonInput === null) { + $this->error('--reason is required. Allowed: ' . implode(', ', array_map(static fn (DismissalReasonType $r): string => $r->value, DismissalReasonType::cases()))); + + return self::FAILURE; + } + + $reason = DismissalReasonType::tryFrom((string) $reasonInput); + if ($reason === null) { + $this->error("Unknown reason '{$reasonInput}'."); + + return self::FAILURE; + } + + if ($reason->requiresNote() && empty($note)) { + $this->error("--note is required when --reason={$reason->value}."); + + return self::FAILURE; + } + + $failure = FormSubmissionActionFailure::query()->withoutGlobalScopes()->find($id); + if ($failure === null) { + $this->error("Failure {$id} not found."); + + return self::FAILURE; + } + + if ($failure->resolved_at !== null) { + $this->warn("Failure {$id} already resolved; cannot dismiss."); + + return self::SUCCESS; + } + + if ($failure->dismissed_at !== null) { + $this->warn("Failure {$id} already dismissed; no-op."); + + return self::SUCCESS; + } + + $failure->dismissed_at = now(); + $failure->dismissed_reason_type = $reason; + $failure->dismissed_reason_note = $note !== null ? (string) $note : null; + $failure->save(); + + $this->info("Dismissed failure {$id} ({$reason->value})."); + + return self::SUCCESS; + } +} diff --git a/api/app/Console/Commands/ResolveFormSubmissionActionFailures.php b/api/app/Console/Commands/ResolveFormSubmissionActionFailures.php new file mode 100644 index 00000000..deb5c550 --- /dev/null +++ b/api/app/Console/Commands/ResolveFormSubmissionActionFailures.php @@ -0,0 +1,58 @@ +argument('id'); + $note = $this->option('note'); + + $failure = FormSubmissionActionFailure::query()->withoutGlobalScopes()->find($id); + if ($failure === null) { + $this->error("Failure {$id} not found."); + + return self::FAILURE; + } + + if ($failure->resolved_at !== null) { + $this->warn("Failure {$id} already resolved at {$failure->resolved_at}; no-op."); + + return self::SUCCESS; + } + + if ($failure->dismissed_at !== null) { + $this->warn("Failure {$id} already dismissed at {$failure->dismissed_at}; cannot resolve."); + + return self::SUCCESS; + } + + if (! $this->confirm("Resolve failure {$id}?", true)) { + return self::SUCCESS; + } + + $failure->resolved_at = now(); + $failure->resolved_note = $note !== null ? (string) $note : null; + $failure->save(); + + $this->info("Resolved failure {$id}."); + + return self::SUCCESS; + } +} diff --git a/api/app/Console/Commands/RetryFormSubmissionActionFailures.php b/api/app/Console/Commands/RetryFormSubmissionActionFailures.php new file mode 100644 index 00000000..8c2773b9 --- /dev/null +++ b/api/app/Console/Commands/RetryFormSubmissionActionFailures.php @@ -0,0 +1,142 @@ +option('id') === null + && $this->option('submission') === null + && $this->option('org') === null + ) { + $this->error('At least one of --id, --submission, or --org is required.'); + + return self::FAILURE; + } + + $query = $this->buildQuery(); + $failures = $query->get(); + + if ($failures->isEmpty()) { + $this->info('No matching open failures.'); + + return self::SUCCESS; + } + + $rows = []; + 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); + } + + $this->table(['id', 'submission', 'result'], $rows); + + return self::SUCCESS; + } + + /** + * @return Builder + */ + private function buildQuery(): Builder + { + $query = FormSubmissionActionFailure::query()->open(); + + if (($id = $this->option('id')) !== null) { + $query->where('id', $id); + } + if (($submissionId = $this->option('submission')) !== null) { + $query->where('form_submission_id', $submissionId); + } + if (($listener = $this->option('listener')) !== null) { + $query->where('listener_class', $listener); + } + if (($org = $this->option('org')) !== null) { + // FK chain: failure → submission → organisation. + $query->whereHas('submission', function (Builder $q) use ($org): void { + /** @var Builder<\App\Models\FormBuilder\FormSubmission> $q */ + $q->where('organisation_id', $org); + }); + } + + return $query; + } + + /** + * @return array{id:string, submission:string, result:string} + */ + private function retryOne(FormSubmissionActionFailure $failure, FormBindingApplicator $applicator): 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(); + + 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']; + } + } +} diff --git a/api/tests/Feature/FormBuilder/Commands/FormFailuresCommandsTest.php b/api/tests/Feature/FormBuilder/Commands/FormFailuresCommandsTest.php new file mode 100644 index 00000000..b79edcf4 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Commands/FormFailuresCommandsTest.php @@ -0,0 +1,128 @@ +create(); + + $this->artisan('form-failures:resolve', ['id' => $failure->id, '--note' => 'fixed via console']) + ->expectsConfirmation("Resolve failure {$failure->id}?", 'yes') + ->expectsOutput("Resolved failure {$failure->id}.") + ->assertSuccessful(); + + $reloaded = FormSubmissionActionFailure::query()->find($failure->id); + $this->assertNotNull($reloaded->resolved_at); + $this->assertSame('fixed via console', $reloaded->resolved_note); + } + + public function test_resolve_no_op_on_already_resolved(): void + { + $failure = FormSubmissionActionFailure::factory()->resolved()->create(); + + $this->artisan('form-failures:resolve', ['id' => $failure->id]) + ->expectsOutput("Failure {$failure->id} already resolved at {$failure->resolved_at}; no-op.") + ->assertSuccessful(); + } + + public function test_resolve_skips_already_dismissed(): void + { + $failure = FormSubmissionActionFailure::factory()->dismissed()->create(); + + $this->artisan('form-failures:resolve', ['id' => $failure->id]) + ->expectsOutput("Failure {$failure->id} already dismissed at {$failure->dismissed_at}; cannot resolve.") + ->assertSuccessful(); + } + + public function test_dismiss_with_enum_reason(): void + { + $failure = FormSubmissionActionFailure::factory()->create(); + + $this->artisan('form-failures:dismiss', [ + 'id' => $failure->id, + '--reason' => DismissalReasonType::SCHEMA_DELETED->value, + ])->assertSuccessful(); + + $reloaded = FormSubmissionActionFailure::query()->find($failure->id); + $this->assertNotNull($reloaded->dismissed_at); + $this->assertSame(DismissalReasonType::SCHEMA_DELETED, $reloaded->dismissed_reason_type); + } + + public function test_dismiss_other_without_note_fails(): void + { + $failure = FormSubmissionActionFailure::factory()->create(); + + $this->artisan('form-failures:dismiss', [ + 'id' => $failure->id, + '--reason' => DismissalReasonType::OTHER->value, + ])->assertFailed(); + } + + public function test_dismiss_other_with_note_succeeds(): void + { + $failure = FormSubmissionActionFailure::factory()->create(); + + $this->artisan('form-failures:dismiss', [ + 'id' => $failure->id, + '--reason' => DismissalReasonType::OTHER->value, + '--note' => 'manual investigation closed it', + ])->assertSuccessful(); + + $reloaded = FormSubmissionActionFailure::query()->find($failure->id); + $this->assertSame('manual investigation closed it', $reloaded->dismissed_reason_note); + } + + public function test_retry_dry_run_does_not_modify_state(): void + { + $failure = FormSubmissionActionFailure::factory()->create(); + + $this->artisan('form-failures:retry', ['--id' => $failure->id, '--dry-run' => true]) + ->assertSuccessful(); + + $reloaded = FormSubmissionActionFailure::query()->find($failure->id); + $this->assertNull($reloaded->resolved_at); + $this->assertSame(0, $reloaded->retry_count); + } + + public function test_retry_requires_at_least_one_filter(): void + { + $this->artisan('form-failures:retry') + ->expectsOutput('At least one of --id, --submission, or --org is required.') + ->assertFailed(); + } + + public function test_retry_by_org_isolates_to_that_organisation(): void + { + $orgA = Organisation::factory()->create(); + $orgB = Organisation::factory()->create(); + + $submissionA = FormSubmission::factory()->forOrganisation($orgA)->create(); + $submissionB = FormSubmission::factory()->forOrganisation($orgB)->create(); + + $failureA = FormSubmissionActionFailure::factory() + ->for($submissionA, 'submission') + ->create(); + $failureB = FormSubmissionActionFailure::factory() + ->for($submissionB, 'submission') + ->create(); + + // Dry-run isolates risk; we just verify the filter works. + $this->artisan('form-failures:retry', ['--org' => $orgA->id, '--dry-run' => true]) + ->expectsOutputToContain((string) $failureA->id) + ->doesntExpectOutputToContain((string) $failureB->id) + ->assertSuccessful(); + } +}