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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user