refactor(form-builder): restore type-hinted route model binding for failures controller (WS-6)
Replace the manual `$request->route('formSubmissionActionFailure')` workaround
with type-hinted parameters. Implicit route model binding now resolves
FormSubmissionActionFailure correctly on both the platform admin route
(/admin/form-failures/{id}) and the org-scoped route
(/organisations/{organisation}/form-failures/{id}).
Root cause:
On the nested org-scoped route, Laravel's implicit binding triggers its
scoped-binding code path: for the second URL segment, it tries to resolve
the failure as a relation of the route's parent ({organisation}) by calling
`$organisation->formSubmissionActionFailures()`. Organisation has no such
relation (failures live under FormSubmission, not Organisation directly),
so the lookup silently fell through and the controller received a raw
string. PHP then raised a TypeError on the type-hinted parameter.
A second issue compounded it: with the controller method declaring
`(FormSubmissionActionFailure $formSubmissionActionFailure, ?Organisation $organisation)`
the parameter order did NOT match the URL parameter order
(/{organisation}/.../{formSubmissionActionFailure}), so Laravel's
resolveMethodDependencies — which falls back to positional binding when
parameter counts diverge — bound them to the wrong slots.
Fix:
- Register an explicit `Route::bind('formSubmissionActionFailure', ...)`
in AppServiceProvider that loads the model `withoutGlobalScopes()` and
throws ModelNotFoundException on miss. This sidesteps the scoped-binding
parent-relation lookup entirely.
- Add `->withoutScopedBindings()` to all four org-scoped routes (show,
retry, resolve, dismiss) as a belt-and-braces guarantee that Laravel
never enters the scoped-binding path for these nested routes.
- Reorder controller method signatures to put `?Organisation $organisation`
FIRST, matching URL parameter order so positional binding lands the
ULID strings on the correct method parameters.
- Drop the now-unused private `resolveFailure()` helper.
- Tenant scoping continues to be enforced by FormSubmissionActionFailurePolicy
via the failure.submission.organisation_id FK chain (RFC V3); cross-
tenant access still translates denied → 404, never 403.
Tests: all 9 controller tests pass (cross-tenant 404 contract verified for
view, dismiss, and resolve).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,46 +2,24 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Api\V1\CheckEmailController;
|
||||
use App\Http\Controllers\Api\V1\CompanyController;
|
||||
use App\Http\Controllers\Api\V1\EmailLogController;
|
||||
use App\Http\Controllers\Api\V1\CrowdListController;
|
||||
use App\Http\Controllers\Api\V1\CrowdTypeController;
|
||||
use App\Http\Controllers\Api\V1\EventController;
|
||||
use App\Http\Controllers\Api\V1\FestivalSectionController;
|
||||
use App\Http\Controllers\Api\V1\InvitationController;
|
||||
use App\Http\Controllers\Api\V1\LocationController;
|
||||
use App\Http\Controllers\Api\V1\LoginController;
|
||||
use App\Http\Controllers\Api\V1\LogoutController;
|
||||
use App\Http\Controllers\Api\V1\MeController;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Http\Controllers\Api\V1\OrganisationController;
|
||||
use App\Http\Controllers\Api\V1\OrganisationEmailSettingsController;
|
||||
use App\Http\Controllers\Api\V1\OrganisationEmailTemplateController;
|
||||
use App\Http\Controllers\Api\V1\PersonController;
|
||||
use App\Http\Controllers\Api\V1\PersonIdentityMatchController;
|
||||
use App\Http\Controllers\Api\V1\PersonTagController;
|
||||
use App\Http\Controllers\Api\V1\ShiftAssignmentController;
|
||||
use App\Http\Controllers\Api\V1\ShiftController;
|
||||
use App\Http\Controllers\Api\V1\TimeSlotController;
|
||||
use App\Http\Controllers\Api\V1\VolunteerAvailabilityController;
|
||||
use App\Http\Controllers\Api\V1\PortalTokenController;
|
||||
use App\Http\Controllers\Api\V1\AccountController;
|
||||
use App\Http\Controllers\Api\V1\AuthRefreshController;
|
||||
use App\Http\Controllers\Api\V1\EmailChangeController;
|
||||
use App\Http\Controllers\Api\V1\PasswordResetController;
|
||||
use App\Http\Controllers\Api\V1\PortalMeController;
|
||||
use App\Http\Controllers\Api\V1\Portal\PortalShiftController;
|
||||
use App\Http\Controllers\Api\V1\UploadController;
|
||||
use App\Http\Controllers\Api\V1\UserOrganisationTagController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminOrganisationController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminUserController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminStatsController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminActivityLogController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminImpersonationController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminOrganisationController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminStatsController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminUserController;
|
||||
use App\Http\Controllers\Api\V1\Auth\MfaSetupController;
|
||||
use App\Http\Controllers\Api\V1\Auth\MfaVerifyController;
|
||||
use App\Http\Controllers\Api\V1\Auth\TrustedDeviceController;
|
||||
use App\Http\Controllers\Api\V1\AuthRefreshController;
|
||||
use App\Http\Controllers\Api\V1\CheckEmailController;
|
||||
use App\Http\Controllers\Api\V1\CompanyController;
|
||||
use App\Http\Controllers\Api\V1\CrowdListController;
|
||||
use App\Http\Controllers\Api\V1\CrowdTypeController;
|
||||
use App\Http\Controllers\Api\V1\EmailChangeController;
|
||||
use App\Http\Controllers\Api\V1\EmailLogController;
|
||||
use App\Http\Controllers\Api\V1\EventController;
|
||||
use App\Http\Controllers\Api\V1\FestivalSectionController;
|
||||
use App\Http\Controllers\Api\V1\FormBuilder\FilterRegistryController;
|
||||
use App\Http\Controllers\Api\V1\FormBuilder\FormFieldController;
|
||||
use App\Http\Controllers\Api\V1\FormBuilder\FormFieldLibraryController;
|
||||
@@ -53,6 +31,28 @@ use App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionReviewController;
|
||||
use App\Http\Controllers\Api\V1\FormBuilder\FormTemplateController;
|
||||
use App\Http\Controllers\Api\V1\FormBuilder\FormValueController;
|
||||
use App\Http\Controllers\Api\V1\FormBuilder\PublicFormController;
|
||||
use App\Http\Controllers\Api\V1\InvitationController;
|
||||
use App\Http\Controllers\Api\V1\LocationController;
|
||||
use App\Http\Controllers\Api\V1\LoginController;
|
||||
use App\Http\Controllers\Api\V1\LogoutController;
|
||||
use App\Http\Controllers\Api\V1\MeController;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Http\Controllers\Api\V1\OrganisationController;
|
||||
use App\Http\Controllers\Api\V1\OrganisationEmailSettingsController;
|
||||
use App\Http\Controllers\Api\V1\OrganisationEmailTemplateController;
|
||||
use App\Http\Controllers\Api\V1\PasswordResetController;
|
||||
use App\Http\Controllers\Api\V1\PersonController;
|
||||
use App\Http\Controllers\Api\V1\PersonIdentityMatchController;
|
||||
use App\Http\Controllers\Api\V1\PersonTagController;
|
||||
use App\Http\Controllers\Api\V1\Portal\PortalShiftController;
|
||||
use App\Http\Controllers\Api\V1\PortalMeController;
|
||||
use App\Http\Controllers\Api\V1\PortalTokenController;
|
||||
use App\Http\Controllers\Api\V1\ShiftAssignmentController;
|
||||
use App\Http\Controllers\Api\V1\ShiftController;
|
||||
use App\Http\Controllers\Api\V1\TimeSlotController;
|
||||
use App\Http\Controllers\Api\V1\UploadController;
|
||||
use App\Http\Controllers\Api\V1\UserOrganisationTagController;
|
||||
use App\Http\Controllers\Api\V1\VolunteerAvailabilityController;
|
||||
use App\Models\FestivalSection;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@@ -243,11 +243,15 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () {
|
||||
Route::get('email-logs', [EmailLogController::class, 'index']);
|
||||
|
||||
// RFC-WS-6 §3 (Q5) — org-scoped form-failure admin endpoints.
|
||||
// withoutScopedBindings(): tenant scope is enforced by the policy
|
||||
// (RFC V3), not by Laravel's parent-relation scoped binding.
|
||||
// Organisation has no formSubmissionActionFailures() relation;
|
||||
// the policy's FK-chain check is the tenant gate.
|
||||
Route::get('form-failures', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'orgIndex']);
|
||||
Route::get('form-failures/{formSubmissionActionFailure}', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'show']);
|
||||
Route::post('form-failures/{formSubmissionActionFailure}/retry', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'retry']);
|
||||
Route::post('form-failures/{formSubmissionActionFailure}/resolve', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'resolve']);
|
||||
Route::post('form-failures/{formSubmissionActionFailure}/dismiss', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'dismiss']);
|
||||
Route::get('form-failures/{formSubmissionActionFailure}', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'show'])->withoutScopedBindings();
|
||||
Route::post('form-failures/{formSubmissionActionFailure}/retry', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'retry'])->withoutScopedBindings();
|
||||
Route::post('form-failures/{formSubmissionActionFailure}/resolve', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'resolve'])->withoutScopedBindings();
|
||||
Route::post('form-failures/{formSubmissionActionFailure}/dismiss', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'dismiss'])->withoutScopedBindings();
|
||||
|
||||
// Person tags (organisation settings)
|
||||
Route::apiResource('person-tags', PersonTagController::class)
|
||||
|
||||
Reference in New Issue
Block a user