diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionActionFailureController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionActionFailureController.php new file mode 100644 index 00000000..fe7f2228 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionActionFailureController.php @@ -0,0 +1,202 @@ +whereHas('submission', function ($q) use ($organisation): void { + /** @var \Illuminate\Database\Eloquent\Builder $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(); + } + } +} diff --git a/api/app/Http/Requests/FormBuilder/DismissFailureRequest.php b/api/app/Http/Requests/FormBuilder/DismissFailureRequest.php new file mode 100644 index 00000000..99dc47b2 --- /dev/null +++ b/api/app/Http/Requests/FormBuilder/DismissFailureRequest.php @@ -0,0 +1,45 @@ + + */ + 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')); + } + }); + } +} diff --git a/api/app/Http/Requests/FormBuilder/ResolveFailureRequest.php b/api/app/Http/Requests/FormBuilder/ResolveFailureRequest.php new file mode 100644 index 00000000..6c1f029a --- /dev/null +++ b/api/app/Http/Requests/FormBuilder/ResolveFailureRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'note' => ['nullable', 'string', 'max:65535'], + ]; + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php b/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php new file mode 100644 index 00000000..afad008e --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php @@ -0,0 +1,43 @@ + + */ + 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', + }, + ]; + } +} diff --git a/api/app/Policies/FormBuilder/FormSubmissionActionFailurePolicy.php b/api/app/Policies/FormBuilder/FormSubmissionActionFailurePolicy.php index 7d2bc0fd..40f0085f 100644 --- a/api/app/Policies/FormBuilder/FormSubmissionActionFailurePolicy.php +++ b/api/app/Policies/FormBuilder/FormSubmissionActionFailurePolicy.php @@ -54,11 +54,19 @@ final class FormSubmissionActionFailurePolicy private function canAccess(User $user, FormSubmissionActionFailure $failure): bool { - $failure->loadMissing('submission'); - $submission = $failure->submission; + // Load the submission without global scopes so cross-tenant + // resolution works for super_admin and so the policy itself + // does the tenant gating (RFC V3 — single source of truth for + // tenant resolution, not OrganisationScope). + $submission = \App\Models\FormBuilder\FormSubmission::query() + ->withoutGlobalScopes() + ->find($failure->form_submission_id); if ($submission === null) { return false; // parent submission deleted } + if ($submission->deleted_at !== null) { + return false; // soft-deleted parent — treat as gone + } $orgId = (string) $submission->organisation_id; if ($orgId === '') { @@ -71,7 +79,9 @@ final class FormSubmissionActionFailurePolicy // Tenant scope: user must be an org_admin in the failure's // organisation. RFC V3 — IDOR-class FK-chain enforcement. - $organisation = $submission->organisation; + $organisation = \App\Models\Organisation::query() + ->withoutGlobalScopes() + ->find($orgId); if (! $organisation instanceof Organisation) { return false; } diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index c6be0adc..42cfe9d6 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -64,6 +64,7 @@ use App\Observers\UserObserver; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\Gate; use Spatie\Activitylog\Models\Activity; class AppServiceProvider extends ServiceProvider @@ -120,6 +121,16 @@ class AppServiceProvider extends ServiceProvider { $this->registerMorphMap(); + // RFC-WS-6 V3 — explicit policy registration. Laravel's auto-discovery + // doesn't reliably resolve nested-namespace models like + // App\Models\FormBuilder\FormSubmissionActionFailure to + // App\Policies\FormBuilder\FormSubmissionActionFailurePolicy. + Gate::policy( + \App\Models\FormBuilder\FormSubmissionActionFailure::class, + \App\Policies\FormBuilder\FormSubmissionActionFailurePolicy::class, + ); + + Person::observe(PersonObserver::class); User::observe(UserObserver::class); FormValue::observe(FormValueObserver::class); diff --git a/api/lang/nl/form_builder.php b/api/lang/nl/form_builder.php new file mode 100644 index 00000000..96a8297a --- /dev/null +++ b/api/lang/nl/form_builder.php @@ -0,0 +1,9 @@ + [ + 'note_required_for_other' => 'Een toelichting is verplicht wanneer reden = "anders".', + ], +]; diff --git a/api/routes/api.php b/api/routes/api.php index 8a9e5bc9..31f9face 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -144,6 +144,13 @@ Route::prefix('admin') Route::get('impersonate/status', [AdminImpersonationController::class, 'status']); Route::post('impersonate/send-mfa-code', [AdminImpersonationController::class, 'sendMfaCode']); Route::post('impersonate/{user}', [AdminImpersonationController::class, 'start']); + + // RFC-WS-6 §3 (Q5) — platform-wide form-failure admin endpoints. + Route::get('form-failures', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'platformIndex']); + 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']); }); // Protected routes @@ -235,6 +242,13 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { // Email logs (read-only) Route::get('email-logs', [EmailLogController::class, 'index']); + // RFC-WS-6 §3 (Q5) — org-scoped form-failure admin endpoints. + 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']); + // Person tags (organisation settings) Route::apiResource('person-tags', PersonTagController::class) ->except(['show']); diff --git a/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureControllerTest.php b/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureControllerTest.php new file mode 100644 index 00000000..2bc29f4d --- /dev/null +++ b/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureControllerTest.php @@ -0,0 +1,147 @@ +seed(RoleSeeder::class); + + $this->orgA = Organisation::factory()->create(); + $this->orgB = Organisation::factory()->create(); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + + $this->orgAdminA = User::factory()->create(); + $this->orgA->users()->attach($this->orgAdminA, ['role' => 'org_admin']); + + $this->orgAdminB = User::factory()->create(); + $this->orgB->users()->attach($this->orgAdminB, ['role' => 'org_admin']); + + $schema = FormSchema::factory()->create(['organisation_id' => $this->orgA->id]); + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $schema->id, + 'organisation_id' => $this->orgA->id, + ]); + $this->failureInOrgA = FormSubmissionActionFailure::factory() + ->for($submission, 'submission') + ->create(); + } + + public function test_unauthenticated_returns_401(): void + { + $this->getJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}") + ->assertStatus(401); + } + + public function test_super_admin_can_view_failure(): void + { + $this->withoutExceptionHandling(); + Sanctum::actingAs($this->superAdmin); $this + ->getJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}") + ->assertOk() + ->assertJsonPath('data.id', (string) $this->failureInOrgA->id); + } + + public function test_org_admin_can_view_own_org_failure(): void + { + Sanctum::actingAs($this->orgAdminA); + $this->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}") + ->assertOk(); + } + + /** + * RFC V3 IDOR-class — admin from org B must NOT see a failure + * whose submission belongs to org A. 404, NOT 403. + */ + public function test_cross_tenant_access_returns_404_not_403(): void + { + Sanctum::actingAs($this->orgAdminB); $this + ->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}") + ->assertStatus(404); + } + + public function test_resolve_endpoint_with_note(): void + { + Sanctum::actingAs($this->superAdmin); $this + ->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/resolve", [ + 'note' => 'fixed via direct edit', + ]) + ->assertOk() + ->assertJsonPath('data.state', 'resolved') + ->assertJsonPath('data.resolved_note', 'fixed via direct edit'); + } + + public function test_dismiss_endpoint_with_enum_reason(): void + { + Sanctum::actingAs($this->superAdmin); $this + ->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [ + 'reason_type' => DismissalReasonType::SCHEMA_DELETED->value, + ]) + ->assertOk() + ->assertJsonPath('data.state', 'dismissed') + ->assertJsonPath('data.dismissed_reason_type', DismissalReasonType::SCHEMA_DELETED->value); + } + + public function test_dismiss_other_without_note_fails_422(): void + { + Sanctum::actingAs($this->superAdmin); $this + ->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [ + 'reason_type' => DismissalReasonType::OTHER->value, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['note']); + } + + public function test_cross_tenant_dismiss_returns_404(): void + { + Sanctum::actingAs($this->orgAdminB); $this + ->postJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}/dismiss", [ + 'reason_type' => DismissalReasonType::OTHER->value, + 'note' => 'evil', + ]) + ->assertStatus(404); + } + + public function test_cross_tenant_resolve_returns_404(): void + { + Sanctum::actingAs($this->orgAdminB); $this + ->postJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}/resolve", []) + ->assertStatus(404); + } +}