feat(form-builder): retry history table + integration (WS-6)

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:53:36 +02:00
parent acd7cf5ec8
commit b47e096a55
20 changed files with 767 additions and 123 deletions

View File

@@ -4,14 +4,12 @@ declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Enums\FormBuilder\ApplyStatus; use App\Exceptions\FormBuilder\FailureNotRetriableException;
use App\FormBuilder\Bindings\FormBindingApplicator; use App\Exceptions\FormBuilder\ParentSubmissionGoneException;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure; use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Services\FormBuilder\FormFailureRetryService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Throwable;
/** /**
* RFC-WS-6 §3 (Q5) replay open failures via the applicator. * 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'; protected $description = 'Replay open FormSubmissionActionFailure rows via the applicator';
public function handle(FormBindingApplicator $applicator): int public function handle(FormFailureRetryService $retryService): int
{ {
if ( if (
$this->option('id') === null $this->option('id') === null
@@ -54,10 +52,11 @@ final class RetryFormSubmissionActionFailures extends Command
foreach ($failures as $failure) { foreach ($failures as $failure) {
if ($this->option('dry-run')) { if ($this->option('dry-run')) {
$rows[] = ['id' => (string) $failure->id, 'submission' => (string) $failure->form_submission_id, 'result' => 'would-retry']; $rows[] = ['id' => (string) $failure->id, 'submission' => (string) $failure->form_submission_id, 'result' => 'would-retry'];
continue; continue;
} }
$rows[] = $this->retryOne($failure, $applicator); $rows[] = $this->retryOne($failure, $retryService);
} }
$this->table(['id', 'submission', 'result'], $rows); $this->table(['id', 'submission', 'result'], $rows);
@@ -95,48 +94,28 @@ final class RetryFormSubmissionActionFailures extends Command
/** /**
* @return array{id:string, submission:string, result:string} * @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 { try {
DB::transaction(function () use ($applicator, $submission): void { $result = $retryService->retry($failure);
$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']; return [
} catch (Throwable $e) { 'id' => (string) $failure->id,
// Append a NEW row preserving history, increment retry_count on original. 'submission' => (string) $failure->form_submission_id,
DB::transaction(function () use ($failure, $submission, $e): void { 'result' => $result['outcome'] === 'succeeded' ? 'succeeded' : 'failed-again',
FormSubmissionActionFailure::query()->create([ ];
'form_submission_id' => $submission->id, } catch (FailureNotRetriableException $e) {
'listener_class' => $failure->listener_class, return [
'failed_at' => now(), 'id' => (string) $failure->id,
'exception_class' => $e::class, 'submission' => (string) $failure->form_submission_id,
'exception_message' => $e->getMessage(), 'result' => "skipped-{$e->reason}",
'context' => ['retry_of' => (string) $failure->id], ];
]); } catch (ParentSubmissionGoneException) {
FormSubmissionActionFailure::query() return [
->whereKey($failure->id) 'id' => (string) $failure->id,
->update(['retry_count' => (int) $failure->retry_count + 1]); 'submission' => (string) $failure->form_submission_id,
FormSubmission::query() 'result' => 'submission-gone',
->whereKey($submission->id) ];
->update(['apply_status' => ApplyStatus::FAILED->value]);
});
return ['id' => (string) $failure->id, 'submission' => (string) $submission->id, 'result' => 'failed-again'];
} }
} }
} }

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
use RuntimeException;
/**
* Thrown by FormFailureRetryService when canBeRetried() returns false.
* Controller maps to 422; artisan command displays the reason and skips.
*/
final class FailureNotRetriableException extends RuntimeException
{
public function __construct(public readonly string $reason)
{
parent::__construct("Failure is {$reason}; cannot retry.");
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
use RuntimeException;
/**
* Thrown by FormFailureRetryService when the failure's parent
* submission has been deleted. Controller maps to 410 Gone.
*/
final class ParentSubmissionGoneException extends RuntimeException
{
public function __construct()
{
parent::__construct('Parent submission has been deleted.');
}
}

View File

@@ -4,22 +4,21 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder; namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\DismissalReasonType; use App\Enums\FormBuilder\DismissalReasonType;
use App\FormBuilder\Bindings\FormBindingApplicator; use App\Exceptions\FormBuilder\FailureNotRetriableException;
use App\Exceptions\FormBuilder\ParentSubmissionGoneException;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\FormBuilder\DismissFailureRequest; use App\Http\Requests\FormBuilder\DismissFailureRequest;
use App\Http\Requests\FormBuilder\ResolveFailureRequest; use App\Http\Requests\FormBuilder\ResolveFailureRequest;
use App\Http\Resources\FormBuilder\FormSubmissionActionFailureResource; use App\Http\Resources\FormBuilder\FormSubmissionActionFailureResource;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure; use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\Organisation; use App\Models\Organisation;
use App\Services\FormBuilder\FormFailureRetryService;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Throwable;
/** /**
* RFC-WS-6 §3 (Q5) + §4 (V3) admin endpoints for the binding-pipeline * RFC-WS-6 §3 (Q5) + §4 (V3) admin endpoints for the binding-pipeline
@@ -41,7 +40,7 @@ final class FormSubmissionActionFailureController extends Controller
$failures = FormSubmissionActionFailure::query() $failures = FormSubmissionActionFailure::query()
->whereHas('submission', function ($q) use ($organisation): void { ->whereHas('submission', function ($q) use ($organisation): void {
/** @var \Illuminate\Database\Eloquent\Builder<FormSubmission> $q */ /** @var \Illuminate\Database\Eloquent\Builder<\App\Models\FormBuilder\FormSubmission> $q */
$q->where('organisation_id', $organisation->id); $q->where('organisation_id', $organisation->id);
}) })
->latest('failed_at') ->latest('failed_at')
@@ -74,59 +73,30 @@ final class FormSubmissionActionFailureController extends Controller
return new FormSubmissionActionFailureResource($formSubmissionActionFailure); 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); unset($organisation);
$failure = $formSubmissionActionFailure; $failure = $formSubmissionActionFailure;
$this->authorizeOrNotFound('retry', $failure); $this->authorizeOrNotFound('retry', $failure);
if (! $failure->canBeRetried()) { try {
$retryService->retry($failure, $request->user());
} catch (FailureNotRetriableException $e) {
return response()->json([ return response()->json([
'error' => 'cannot_retry', 'error' => 'cannot_retry',
'message' => 'Failure is dismissed; cannot retry.', 'message' => $e->getMessage(),
], 422); ], 422);
} } catch (ParentSubmissionGoneException $e) {
$submission = $failure->submission;
if ($submission === null) {
return response()->json([ return response()->json([
'error' => 'submission_gone', 'error' => 'submission_gone',
'message' => 'Parent submission has been deleted.', 'message' => $e->getMessage(),
], 410); ], 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()); return new FormSubmissionActionFailureResource($failure->refresh());
} }

View File

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* RFC-WS-6 §3 (Q5) audit table for binding-pipeline failures. * 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<\Database\Factories\FormBuilder\FormSubmissionActionFailureFactory> */
use HasFactory; use HasFactory;
use HasUlids; use HasUlids;
protected $table = 'form_submission_action_failures'; protected $table = 'form_submission_action_failures';
@@ -85,6 +87,19 @@ final class FormSubmissionActionFailure extends Model
return $this->belongsTo(User::class, 'dismissed_by_user_id'); 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<FormSubmissionActionFailureRetryAttempt, $this>
*/
public function retryAttempts(): HasMany
{
return $this->hasMany(FormSubmissionActionFailureRetryAttempt::class, 'form_submission_action_failure_id')
->latest('attempted_at');
}
/** /**
* @param Builder<FormSubmissionActionFailure> $query * @param Builder<FormSubmissionActionFailure> $query
* @return Builder<FormSubmissionActionFailure> * @return Builder<FormSubmissionActionFailure>
@@ -117,8 +132,14 @@ final class FormSubmissionActionFailure extends Model
return $this->resolved_at === null && $this->dismissed_at === null; 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 public function canBeRetried(): bool
{ {
return $this->dismissed_at === null; return $this->resolved_at === null && $this->dismissed_at === null;
} }
} }

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Models\FormBuilder;
use App\Models\User;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* RFC-WS-6.md §3 Q5 addendum per-attempt retry history.
*
* Each retry of a FormSubmissionActionFailure produces a row here.
* Outcome is 'succeeded' (parent gets resolved_at + system note) or
* 'failed' (parent remains open with retry_count incremented; if the
* exception details differ from the original, they're captured
* per-attempt). Parent's own `exception_class` / `exception_message`
* stay audit-immutable they represent the FIRST failure, not the
* latest retry.
*/
final class FormSubmissionActionFailureRetryAttempt extends Model
{
/** @use HasFactory<\Database\Factories\FormBuilder\FormSubmissionActionFailureRetryAttemptFactory> */
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<string, string> */
protected $casts = [
'attempted_at' => 'datetime',
];
/** @return BelongsTo<FormSubmissionActionFailure, $this> */
public function failure(): BelongsTo
{
return $this->belongsTo(FormSubmissionActionFailure::class, 'form_submission_action_failure_id');
}
/** @return BelongsTo<User, $this> */
public function attemptedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'attempted_by_user_id');
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Services\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Exceptions\FormBuilder\FailureNotRetriableException;
use App\Exceptions\FormBuilder\ParentSubmissionGoneException;
use App\FormBuilder\Bindings\FormBindingApplicator;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\FormBuilder\FormSubmissionActionFailureRetryAttempt;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* RFC-WS-6 §3 (Q5) sessie 3c centralised retry-flow logic.
*
* The controller's `retry` action AND the artisan command both delegate
* here so the per-attempt record write stays consistent across paths.
*
* Flow:
* 1. canBeRetried() guard (open = resolved_at IS NULL AND dismissed_at IS NULL)
* 2. Run the applicator inside a transaction
* 3. On success: write retry_attempt(outcome=succeeded), increment
* retry_count, set resolved_at + system note + resolved_by_user_id
* 4. On failure: write retry_attempt(outcome=failed) with the NEW
* exception details, increment retry_count. Parent's own
* exception_class / exception_message stay audit-immutable
* they represent the FIRST failure.
*/
final readonly class FormFailureRetryService
{
public function __construct(private FormBindingApplicator $applicator) {}
/**
* @return array{outcome: 'succeeded'|'failed', attempt: FormSubmissionActionFailureRetryAttempt}
*
* @throws FailureNotRetriableException
* @throws ParentSubmissionGoneException
*/
public function retry(FormSubmissionActionFailure $failure, ?User $actor = null): array
{
if (! $failure->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;
});
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Database\Factories\FormBuilder;
use App\Exceptions\FormBuilder\PersonProvisioningException;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\FormBuilder\FormSubmissionActionFailureRetryAttempt;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<FormSubmissionActionFailureRetryAttempt> */
final class FormSubmissionActionFailureRetryAttemptFactory extends Factory
{
protected $model = FormSubmissionActionFailureRetryAttempt::class;
/** @return array<model-property<FormSubmissionActionFailureRetryAttempt>, 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,
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* RFC-WS-6.md §3 (Q5) addendum per-attempt retry history.
*
* Sessie 1's form_submission_action_failures.retry_count is a counter
* only. Sessie 3c adds per-attempt records (timestamp, user, outcome,
* exception details if failed) so the admin UI can show retry history.
*
* retry_count on the parent stays as denormalized cache for index-view
* performance. Service layer keeps both in sync per retry.
*
* No backfill: pre-launch the table is empty and dev seeders re-seed
* every iteration.
*
* Note on constraint names: the table name is 43 chars long; auto-generated
* FK constraint names (`{table}_{column}_foreign`) exceed MySQL's 64-char
* identifier limit. Each FK uses an explicit short name below.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('form_submission_action_failure_retry_attempts', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -111,7 +111,7 @@ CREATE TABLE `companies` (
`organisation_id` char(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `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, `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, `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_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_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, `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 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!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`; DROP TABLE IF EXISTS `form_submission_action_failures`;
/*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */; /*!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 (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 (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 (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);

View File

@@ -57,7 +57,7 @@ final class FormFieldBindingMigrationTest extends TestCase
// validation-rules-backfill, create-validation-rules) + // validation-rules-backfill, create-validation-rules) +
// 2 WS-6 migrations (action-failures, apply-status) + // 2 WS-6 migrations (action-failures, apply-status) +
// 2 WS-5a migrations (drop-binding-cols, create-bindings) = 16. // 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->assertFalse(Schema::hasTable('form_field_bindings'));
$this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_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 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). // 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(); [$fieldAId] = $this->seedFieldsWithBindingJson();
[$libAId] = $this->seedLibraryWithBindingJson(); [$libAId] = $this->seedLibraryWithBindingJson();
@@ -134,7 +134,7 @@ final class FormFieldBindingMigrationTest extends TestCase
// the pre-WS-5b state (conditional-logic, validation-rules, configs // the pre-WS-5b state (conditional-logic, validation-rules, configs
// and options tables gone, validation_rules + options JSON columns // and options tables gone, validation_rules + options JSON columns
// reappear on source tables; binding contract intact). // 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_options'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions'));

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Bindings;
use App\Enums\FormBuilder\FormPurpose;
use App\Exceptions\FormBuilder\FailureNotRetriableException;
use App\FormBuilder\Bindings\BindingPassResult;
use App\FormBuilder\Bindings\FormBindingApplicator;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\FormBuilder\FormSubmissionActionFailureRetryAttempt;
use App\Models\Organisation;
use App\Models\User;
use App\Services\FormBuilder\FormFailureRetryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use RuntimeException;
use Tests\TestCase;
/**
* Sessie 3c covers the integration between FormFailureRetryService,
* the parent FormSubmissionActionFailure model, and the new
* FormSubmissionActionFailureRetryAttempt records.
*/
final class RetryFlowProducesRetryAttemptsTest extends TestCase
{
use RefreshDatabase;
private function makeFailure(): FormSubmissionActionFailure
{
$org = Organisation::factory()->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());
}
}

View File

@@ -49,7 +49,7 @@ final class ConditionalLogicBackfillTest extends TestCase
// create-options + WS-5c drop-cl-col + WS-5c backfill-cl // create-options + WS-5c drop-cl-col + WS-5c backfill-cl
// migrations to land in the conditional-logic JSON-era state with // migrations to land in the conditional-logic JSON-era state with
// no relational form_field_options table yet. // 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')); $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic'));
$fieldId = $this->seedFieldWithJson([ $fieldId = $this->seedFieldWithJson([
@@ -170,7 +170,7 @@ final class ConditionalLogicBackfillTest extends TestCase
]); ]);
// Roll back only the backfill migration — writes the JSON back. // 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') $reconstructed = DB::table('form_fields')
->where('id', $fieldId) ->where('id', $fieldId)
@@ -203,7 +203,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_top_level_key_fails_migration(): void 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([ $this->seedFieldWithJson([
'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], '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 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([ $this->seedFieldWithJson([
'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]], 'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]],

View File

@@ -30,7 +30,7 @@ final class FormFieldConfigBackfillAndDropTest extends TestCase
// Roll back 4 WS-5c migrations + 2 WS-6 migrations + 5 WS-5b // 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 // migrations = 11, to get the pre-WS-5b state where the JSON column
// still exists on form_fields / form_field_library. // 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')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
$fieldId = $this->seedField([ $fieldId = $this->seedField([

View File

@@ -47,7 +47,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
// Roll back only the backfill migration (latest WS-5d step). // Roll back only the backfill migration (latest WS-5d step).
// Leaves the form_field_options table in place, JSON columns // Leaves the form_field_options table in place, JSON columns
// present on the source tables, and snapshots in pre-WS-5d shape. // 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::hasTable('form_field_options'));
$this->assertTrue(Schema::hasColumn('form_fields', '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 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(); [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson();
$submissionId = $this->seedSubmissionWithSnapshot($selectId); $submissionId = $this->seedSubmissionWithSnapshot($selectId);
@@ -149,7 +149,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
// Step back over only the backfill migration → JSON columns repopulate // Step back over only the backfill migration → JSON columns repopulate
// and snapshots revert to flat-string-array shape. // 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()); $this->assertSame(0, DB::table('form_field_options')->count());
$select = DB::table('form_fields')->where('id', $selectId)->first(); $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 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->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']);
$this->expectException(\RuntimeException::class); $this->expectException(\RuntimeException::class);
@@ -178,7 +178,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_contains_non_string_entry(): void 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([ $this->seedFieldWithOptionsRaw('SELECT', json_encode([
['label' => 'A'], ['label' => 'A'],
@@ -192,7 +192,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_is_object_shape(): void 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([ $this->seedFieldWithOptionsRaw('SELECT', json_encode([
'XS' => 'Extra small', 'XS' => 'Extra small',
@@ -206,7 +206,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_translations_length_mismatch(): void 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([ $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([
'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3 '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 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([ $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([
'de' => ['options' => ['Klein', 42]], 'de' => ['options' => ['Klein', 42]],
])); ]));
@@ -230,7 +230,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_oversized_translation(): void 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([ $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([
'de' => ['options' => [str_repeat('x', 256)]], '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 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([ $this->seedTemplateWithSnapshotRaw([
'fields' => [[ 'fields' => [[
'id' => (string) Str::ulid(), 'id' => (string) Str::ulid(),

View File

@@ -110,7 +110,13 @@ final class Ws6FoundationMigrationTest extends TestCase
$applyStatus = require database_path( $applyStatus = require database_path(
'migrations/2026_04_25_140000_extend_form_submissions_with_apply_status.php', '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(); $createFailures->down();
$applyStatus->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_status'));
$this->assertFalse(Schema::hasColumn('form_submissions', 'apply_completed_at')); $this->assertFalse(Schema::hasColumn('form_submissions', 'apply_completed_at'));
$this->assertFalse(Schema::hasTable('form_submission_action_failures')); $this->assertFalse(Schema::hasTable('form_submission_action_failures'));
$this->assertFalse(Schema::hasTable('form_submission_action_failure_retry_attempts'));
$indexes = $this->indexNamesFor('form_submissions'); $indexes = $this->indexNamesFor('form_submissions');
$this->assertNotContains('fs_schema_apply_status_idx', $indexes); $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. // Restore state for any subsequent tests in this class.
$applyStatus->up(); $applyStatus->up();
$createFailures->up(); $createFailures->up();
$retryAttempts->up();
} }
} }

View File

@@ -53,7 +53,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// validation-rules-backfill + create-validation-rules) = 14. // validation-rules-backfill + create-validation-rules) = 14.
// Brings us to the pre-WS-5b state: validation_rules JSON column // Brings us to the pre-WS-5b state: validation_rules JSON column
// present, no relational tables for WS-5b/c/d. // 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->assertFalse(Schema::hasTable('form_field_validation_rules'));
$this->assertTrue(Schema::hasColumn('form_fields', '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 // (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs // WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5. // + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([ $fieldId = $this->seedFieldWithJson([
'field_type' => 'TAG_PICKER', 'field_type' => 'TAG_PICKER',
@@ -138,7 +138,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for // (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs // WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5. // + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([ $fieldId = $this->seedFieldWithJson([
'field_type' => 'TEXT', 'field_type' => 'TEXT',
@@ -165,7 +165,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for // (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs // WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5. // + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful();
$this->seedFieldWithJson([ $this->seedFieldWithJson([
'field_type' => 'TEXT', 'field_type' => 'TEXT',
@@ -182,7 +182,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for // (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs // WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5. // + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful();
$this->seedFieldWithJson([ $this->seedFieldWithJson([
'field_type' => 'BOOLEAN', 'field_type' => 'BOOLEAN',
@@ -201,7 +201,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// full-back-then-full-forward cycle — rolling back all WS-5b // full-back-then-full-forward cycle — rolling back all WS-5b
// migrations restores the pre-WS-5b state (columns present on // migrations restores the pre-WS-5b state (columns present on
// source tables; validation rules relational table gone). // source tables; validation rules relational table gone).
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful();
[$numberId] = $this->seedFields(); [$numberId] = $this->seedFields();
$this->artisan('migrate')->assertSuccessful(); $this->artisan('migrate')->assertSuccessful();
@@ -216,7 +216,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// Roll back WS-5b fully → column reappears and carries canonical JSON // Roll back WS-5b fully → column reappears and carries canonical JSON
// reconstructed from the relational rows. // 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')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
$field = DB::table('form_fields')->where('id', $numberId)->first(); $field = DB::table('form_fields')->where('id', $numberId)->first();

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Models\FormBuilder;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\FormBuilder\FormSubmissionActionFailureRetryAttempt;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class FormSubmissionActionFailureRetryAttemptTest extends TestCase
{
use RefreshDatabase;
public function test_factory_creates_a_valid_record(): void
{
$attempt = FormSubmissionActionFailureRetryAttempt::factory()->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);
}
}

View File

@@ -74,14 +74,16 @@ final class FormSubmissionActionFailureTest extends TestCase
$this->assertSame('company', $reloaded->context['target_entity']); $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(); $open = FormSubmissionActionFailure::factory()->create();
$resolved = FormSubmissionActionFailure::factory()->resolved()->create(); $resolved = FormSubmissionActionFailure::factory()->resolved()->create();
$dismissed = FormSubmissionActionFailure::factory()->dismissed()->create(); $dismissed = FormSubmissionActionFailure::factory()->dismissed()->create();
$this->assertTrue($open->canBeRetried()); $this->assertTrue($open->canBeRetried());
$this->assertTrue($resolved->canBeRetried()); $this->assertFalse($resolved->canBeRetried());
$this->assertFalse($dismissed->canBeRetried()); $this->assertFalse($dismissed->canBeRetried());
} }

View File

@@ -1,10 +1,19 @@
# Crewli — Core Database Schema # Crewli — Core Database Schema
> Source: Design Document v1.3 — Section 3.5 > Source: Design Document v1.3 — Section 3.5
> **Version: 2.8** — Updated April 2026 > **Version: 2.9** — Updated April 2026
> >
> **Changelog:** > **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 > - v2.8: WS-6 session 3a.5 — `companies.kvk_number` column added
> (nullable, indexed). Aligns with the binding-target registry's > (nullable, indexed). Aligns with the binding-target registry's
> B2B identity-key candidate. Registry entries renamed/removed in > 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 **Activity log strategy:** explicit calls via
`FormSchema::logSchemaChange()` and `FormField::logFieldChange()` — no `FormSchema::logSchemaChange()` and `FormField::logFieldChange()` — no
`LogsActivity` trait (would produce noise). Only impactful events `LogsActivity` trait (would produce noise). Only impactful events