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();
|
||||
}
|
||||
}
|
||||
}
|
||||
45
api/app/Http/Requests/FormBuilder/DismissFailureRequest.php
Normal file
45
api/app/Http/Requests/FormBuilder/DismissFailureRequest.php
Normal 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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
25
api/app/Http/Requests/FormBuilder/ResolveFailureRequest.php
Normal file
25
api/app/Http/Requests/FormBuilder/ResolveFailureRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user