feat(form-builder): retry/resolve/dismiss API endpoints + dual-route auth (WS-6)

Two route groups: /api/v1/admin/form-failures (super_admin platform) and
/api/v1/organisations/{organisation}/form-failures (org_admin scoped).
Same controller, policy authorises via FK chain (RFC V3). Cross-tenant
access returns 404 not 403 to prevent enumeration.

Resolve takes optional note; Dismiss requires DismissalReasonType
enum with conditional note (mandatory for 'other'). Both via
FormRequest validation with explicit i18n message keys.

Implementation note: Laravel implicit model binding for nested-namespace
ULID models doesn't pick up reliably across nested route groups. Using
manual resolveFailure() helper that loads withoutGlobalScopes() (so
cross-tenant access still reaches the policy, which translates denied →
404 per V3). Policy explicitly checks soft-delete via deleted_at since
withoutGlobalScopes bypasses SoftDeletes too. Policy registered
explicitly in AppServiceProvider — auto-discovery doesn't reliably
resolve App\Models\FormBuilder\* → App\Policies\FormBuilder\*.

NOT: admin UI (session 3). Not: public form routes (no API contract
notification needed).

Refs: RFC-WS-6.md §3 (Q5), §4 (V2, V3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 15:34:23 +02:00
parent 84d57c5bbc
commit d0e17f2824
9 changed files with 509 additions and 3 deletions

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\DismissalReasonType;
use App\FormBuilder\Bindings\FormBindingApplicator;
use App\Http\Controllers\Controller;
use App\Http\Requests\FormBuilder\DismissFailureRequest;
use App\Http\Requests\FormBuilder\ResolveFailureRequest;
use App\Http\Resources\FormBuilder\FormSubmissionActionFailureResource;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\Organisation;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Throwable;
/**
* RFC-WS-6 §3 (Q5) + §4 (V3) admin endpoints for the binding-pipeline
* failure workflow. Two route groups in api.php (org-scoped + super_admin
* platform). Cross-tenant access returns 404, never 403, to prevent
* resource-existence enumeration.
*/
final class FormSubmissionActionFailureController extends Controller
{
public function orgIndex(Organisation $organisation): AnonymousResourceCollection
{
Gate::authorize('viewAny', FormSubmissionActionFailure::class);
$failures = FormSubmissionActionFailure::query()
->whereHas('submission', function ($q) use ($organisation): void {
/** @var \Illuminate\Database\Eloquent\Builder<FormSubmission> $q */
$q->where('organisation_id', $organisation->id);
})
->latest('failed_at')
->paginate(50);
return FormSubmissionActionFailureResource::collection($failures);
}
public function platformIndex(): AnonymousResourceCollection
{
Gate::authorize('viewAny', FormSubmissionActionFailure::class);
$failures = FormSubmissionActionFailure::query()
->latest('failed_at')
->paginate(50);
return FormSubmissionActionFailureResource::collection($failures);
}
public function show(\Illuminate\Http\Request $request): FormSubmissionActionFailureResource
{
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
$this->authorizeOrNotFound('view', $failure);
return new FormSubmissionActionFailureResource($failure);
}
public function retry(\Illuminate\Http\Request $request, FormBindingApplicator $applicator): FormSubmissionActionFailureResource|JsonResponse
{
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
$this->authorizeOrNotFound('retry', $failure);
if (! $failure->canBeRetried()) {
return response()->json([
'error' => 'cannot_retry',
'message' => 'Failure is dismissed; cannot retry.',
], 422);
}
$submission = $failure->submission;
if ($submission === null) {
return response()->json([
'error' => 'submission_gone',
'message' => 'Parent submission has been deleted.',
], 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());
}
public function resolve(
ResolveFailureRequest $request,
): FormSubmissionActionFailureResource|JsonResponse {
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
$this->authorizeOrNotFound('resolve', $failure);
if ($failure->resolved_at !== null) {
return response()->json([
'error' => 'already_resolved',
], 422);
}
if ($failure->dismissed_at !== null) {
return response()->json([
'error' => 'already_dismissed',
], 422);
}
$failure->resolved_at = now();
$failure->resolved_note = $request->input('note');
$failure->resolved_by_user_id = $request->user()?->id;
$failure->save();
return new FormSubmissionActionFailureResource($failure->refresh());
}
public function dismiss(
DismissFailureRequest $request,
): FormSubmissionActionFailureResource|JsonResponse {
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
$this->authorizeOrNotFound('dismiss', $failure);
if ($failure->resolved_at !== null) {
return response()->json([
'error' => 'already_resolved',
], 422);
}
if ($failure->dismissed_at !== null) {
return response()->json([
'error' => 'already_dismissed',
], 422);
}
$failure->dismissed_at = now();
$failure->dismissed_reason_type = DismissalReasonType::from((string) $request->input('reason_type'));
$failure->dismissed_reason_note = $request->input('note');
$failure->dismissed_by_user_id = $request->user()?->id;
$failure->save();
return new FormSubmissionActionFailureResource($failure->refresh());
}
/**
* Manual model resolution. Implicit binding doesn't pick up nested-
* namespace ULID models reliably across nested route groups, so we
* load explicitly without global scopes (cross-tenant access reaches
* the policy, which then translates denied 404 per RFC V3).
*/
private function resolveFailure(string $id): FormSubmissionActionFailure
{
$failure = FormSubmissionActionFailure::query()
->withoutGlobalScopes()
->find($id);
if ($failure === null) {
throw new ModelNotFoundException();
}
return $failure;
}
/**
* Translate a denied policy to 404 (RFC V3) so cross-tenant access
* does NOT confirm resource existence.
*/
private function authorizeOrNotFound(string $ability, FormSubmissionActionFailure $failure): void
{
if (Gate::denies($ability, $failure)) {
throw new ModelNotFoundException();
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\FormBuilder;
use App\Enums\FormBuilder\DismissalReasonType;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class DismissFailureRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'reason_type' => ['required', Rule::enum(DismissalReasonType::class)],
'note' => ['nullable', 'string', 'max:500'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $v): void {
if ($v->errors()->isNotEmpty()) {
return;
}
$reason = DismissalReasonType::tryFrom((string) $this->input('reason_type'));
if ($reason === null) {
return;
}
if ($reason->requiresNote() && $this->input('note') === null) {
$v->errors()->add('note', __('form_builder.dismiss.note_required_for_other'));
}
});
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
final class ResolveFailureRequest extends FormRequest
{
public function authorize(): bool
{
return true; // policy enforced in controller via Gate::authorize
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'note' => ['nullable', 'string', 'max:65535'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\FormBuilder;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin FormSubmissionActionFailure
*/
final class FormSubmissionActionFailureResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'form_submission_id' => $this->form_submission_id,
'binding_id' => $this->binding_id,
'listener_class' => $this->listener_class,
'failed_at' => $this->failed_at->toIso8601String(),
'exception_class' => $this->exception_class,
'exception_message' => $this->exception_message,
'context' => $this->context,
'retry_count' => $this->retry_count,
'resolved_at' => $this->resolved_at?->toIso8601String(),
'resolved_note' => $this->resolved_note,
'dismissed_at' => $this->dismissed_at?->toIso8601String(),
'dismissed_reason_type' => $this->dismissed_reason_type?->value,
'dismissed_reason_note' => $this->dismissed_reason_note,
'state' => match (true) {
$this->resolved_at !== null => 'resolved',
$this->dismissed_at !== null => 'dismissed',
default => 'open',
},
];
}
}