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:
@@ -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'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
135
api/app/Services/FormBuilder/FormFailureRetryService.php
Normal file
135
api/app/Services/FormBuilder/FormFailureRetryService.php
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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']]],
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user