Commit Graph

4 Commits

Author SHA1 Message Date
192353f4bc feat(form-builder): admin UI completion — server filters, KPIs, resource expansion (WS-6 sessie 3c)
Closes the four production gaps that emerged from sessie 3b's admin UI.
What we ship here is final: no further rework planned before production.

Backend
- IndexFailuresRequest validates state/search/failed_at_from/failed_at_to/
  listener_class. orgIndex + platformIndex apply them via a single
  applyIndexFilters() helper. Search runs case-insensitive `LIKE` on
  exception_message; SQL wildcards in user input are escaped.
- New /kpis aggregate endpoint per scope (orgKpis, platformKpis) returns
  open / resolved_30d / dismissed_30d / total_submissions in O(1) COUNTs.
  Replaces sessie 3b's client-side bucketing of an oversized list.
- Resource expansion: organisation_name, form_schema_label,
  resolved_by_user_name, dismissed_by_user_name, exception_trace,
  retry_history[]. Eager-loading via indexEagerLoads()/detailEagerLoads()
  prevents N+1 (verified by query-count assertion in test).
- New 2026_04_28_181000 migration adds exception_trace (longtext nullable)
  to form_submission_action_failures. ApplyBindingsOnFormSubmit listener
  now captures $e->getTraceAsString() at failure time.
- New FormSubmissionActionFailureRetryAttemptResource exposes per-attempt
  data (timestamp, actor name, outcome, exception details) inside
  retry_history[]. Index payloads omit the field via whenLoaded() to keep
  list responses lean.

Frontend (apps/app)
- Types updated to mirror the expanded resource shape and the new KPI
  endpoint contract. FormFailuresKpis is now { open, resolved_30d,
  dismissed_30d, total_submissions } (server-aggregate).
- useFormFailures composable forwards all 5 server filters via
  buildIndexParams() (strips empty/whitespace). useFormFailuresKpis hits
  the dedicated /kpis endpoint per scope.
- FormFailuresTable replaces client-side bucketing with server-side
  filtering, adds listener_class + date-range filter inputs, and renames
  the 4th KPI tile to "Submissions" (was "Totaal").
- FormFailureDetail renders organisation_name + form_schema_label in the
  header, surfaces an expandable stack-trace card, names the resolved/
  dismissed actor in the timeline, and replaces the "v1 placeholder"
  retry-history card with a full per-attempt timeline.

ESLint config gap (apps/app)
- New .eslintrc.cjs adapted from the Vuexy reference, minus Vuexy-internal
  rules. `pnpm lint` now runs successfully (was previously broken — the
  package.json script referenced a missing config). The 80 baseline
  violations across the codebase are pre-existing and out of scope for
  this session.

Tests + gates
- 24 new backend tests across filter, kpis, and resource-shape suites.
  Backend: 1462 → 1486 passing, 0 → 0 failing. Larastan clean. Rector
  dry-run unchanged at 354 (pre-Task-1 baseline from f18b55b).
- 3 new vitest tests in apps/app (filter wiring, KPI endpoint, KPI tile
  values from /kpis). Vitest: 38 → 41 passing. tsc clean. Portal
  unchanged (113 vitest, tsc clean).
- 5 backfill rollback tests bumped --step counts +1 for the new migration.
- Ws6FoundationMigrationTest down/up chain now includes exception_trace
  before the parent table is restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:20 +02:00
21c042d93f test(form-builder): IDOR-class route-level security for form-failures admin (WS-6)
RFC §4 V3 compliance — cross-tenant access to FormSubmissionActionFailure
endpoints returns 404, not 403, to prevent resource-existence
enumeration. The FormSubmissionActionFailurePolicy is the single tenant
gate; these tests assert the route-level integration end-to-end.

Production-code finding (in scope per "security gaps zijn altijd urgent"):
the orgIndex endpoint had a real IDOR gap. Original implementation called
`Gate::authorize('viewAny', ...)` which permits any org_admin in any org,
then filtered the result set by the URL's `{organisation}` param. orgB's
admin hitting `/organisations/{orgA}/form-failures` would get back orgA's
failures — leakage.

Fix:
- New policy method `viewAnyInOrganisation(User, Organisation)` that
  requires super_admin OR org_admin on THIS specific organisation.
- Controller `orgIndex` calls `authorizeViewAnyInOrgOrNotFound()` which
  translates a denied policy → 404 (matches the show/retry/resolve/dismiss
  pattern).
- viewAny on the class level stays as the platformIndex gate (super_admin
  + any-org_admin enumeration is acceptable on the platform endpoint
  because the role middleware already restricts to super_admin).

Test coverage (24 tests, all passing):
- 5 org-scoped endpoints × cross-tenant scenarios (all return 404)
- 5 platform endpoints × role-class scenarios (org_admin gets 403, never 404)
- Edge cases: soft-deleted parent submission, invalid ULID format,
  non-existent ID, unauthenticated, authenticated-without-role on org

The 403 vs 404 distinction matters: role-gated endpoints return 403
(auth-class — "not allowed in this room"); ownership-gated endpoints
return 404 (IDOR-class — "this room doesn't exist for you").

Refs: RFC-WS-6.md §4 V3, ARCH-BINDINGS.md §8.2 (Task 3 of this session)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:11 +02:00
6399bacdb6 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>
2026-04-28 08:57:06 +02:00
d0e17f2824 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>
2026-04-26 15:34:23 +02:00