fix(api): allow operators to review XL Vask automation decisions in the Selvvask view (#365)

## Why

Operators on the Superuser → Fakturaer → Periode → Selvvask view were
unable to Accept / Reject / Ignore / Link XL Vask washes even though the
UI claimed the buttons should be there. The previous capability contract
only lit `can_review` for users with `manage_xlvask_usage_automation`, a
small admin group, so the FE never rendered any review actions for the
rest of the superuser staff. The same contract also blocked the
corresponding `/decisions/preview` and `/decisions/apply` calls, so even
if the buttons were forced on, the API would 403.

## What changed

- `xlvaskUsageLogsRoute.php`:
  - New permission string `review_xlvask_usage_order` for operators.
  - `/modules/xlvask/services/usage/automation/capabilities`:
- `can_review` now returns `true` when the user has
`review_xlvask_usage_order` or the existing `list_xlvask_usage_orders_*`
(so existing operator groups keep working without an extra grant), or
`manage_xlvask_usage_automation` (the AI admin path).
- `can_dry_run` / `can_execute` / `can_manage_policy` / `can_halt`
remain gated on the AI-admin permissions to keep the autopilot lifecycle
fail-closed.
- The new permission is registered in the route's permission manifest.
- `/modules/xlvask/services/usage/automation/decisions/preview` and
`/apply` now accept either `manage_xlvask_usage_automation` or
`review_xlvask_usage_order`. The existing `force_manual` branch in
`xlvask_autopilot_service` lights up automatically for these operators,
so the existing manual-suggestion path drives them.
- The AI autopilot run lifecycle (`/autopilot-runs`,
`/autopilot-runs/{id}`, `/autopilot-runs/active`,
`/automation/admin/...`) still requires `manage_xlvask_usage_automation`
/ `superuser_xlvask_automation_activate`.

## Tests

- New contract in `XLVaskUsageRouteContractTest`:
- "exposes a review_xlvask_usage_order permission on decision endpoints
for the selvvask operator flow" — locks the new permission string, the
new `can_review` flag, and the manage-only `can_dry_run` / `can_execute`
flags.
- "still requires manage_xlvask_usage_automation for the AI autopilot
run lifecycle" — regression guard for the admin path.
- All 87 XLVask unit tests pass. The wider 1334 unit tests also pass;
the only pre-existing failure is the unrelated
`BirdControlPlaneActivationTest` which requires
`PLENO_REPO_ROOT_FOR_TESTS` and is broken on master.

## Companion frontend PR

`copenhagentruckwash/pleno-vue` → `fix/xlvask-selvvask-review-actions`
(the FE was already wired correctly: `allow-review-actions =
automationWorkspace && capabilities.can_review`. With the API change
above, `can_review` now lights up for operators so the buttons surface.
A new source-inspection regression test pins the contract so future
edits cannot re-tighten the gating.)

Co-authored-by: Hermes Agent <agent@truckwash.io>
This commit is contained in:
Jeppe B
2026-08-11 23:58:21 +02:00
committed by GitHub
co-authored by Hermes Agent
parent d81634033e
commit 3e683c8559
2 changed files with 56 additions and 5 deletions
@@ -262,10 +262,14 @@ class xlvaskUsageLogsRoute
$service = new xlvask_automation_policy_service();
$capabilities = $service->capabilitiesReadOnly($dateFrom, $dateTo, $this->allowedHallIdsForUser($user));
$canManage = $this->hasPermission('manage_xlvask_usage_automation');
$canReview = $canManage
|| $this->hasPermission('review_xlvask_usage_order')
|| $this->hasPermission('list_xlvask_usage_orders_all')
|| $this->hasPermission('list_xlvask_usage_orders_own');
$canManagePolicy = $this->hasPermission('superuser_xlvask_automation_activate');
$response->success([
'can_view' => true,
'can_review' => $canManage,
'can_review' => $canReview,
'can_dry_run' => $canManage,
'can_execute' => $canManage && in_array('execute', $capabilities['allowed_modes'], true),
'can_manage_policy' => $canManagePolicy,
@@ -274,6 +278,7 @@ class xlvaskUsageLogsRoute
]);
}, [
'list_xlvask_usage_orders_own' => 'Inspect XL Vask automation capabilities',
'review_xlvask_usage_order' => 'Inspect XL Vask automation capabilities as a reviewer',
]);
$this->get('/modules/xlvask/services/usage/autopilot-runs/active', function () {
@@ -426,7 +431,10 @@ class xlvaskUsageLogsRoute
$this->post('/modules/xlvask/services/usage/automation/decisions/preview', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
if (!$this->hasPermission('manage_xlvask_usage_automation')
&& !$this->hasPermission('review_xlvask_usage_order')) {
$this->requirePermission('manage_xlvask_usage_automation');
}
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
@@ -444,11 +452,17 @@ class xlvaskUsageLogsRoute
$this->allowedHallIdsForUser($user)
),
]);
}, ['manage_xlvask_usage_automation' => 'Preview an XL Vask automation decision']);
}, [
'manage_xlvask_usage_automation' => 'Preview an XL Vask automation decision',
'review_xlvask_usage_order' => 'Preview an XL Vask automation decision as a reviewer',
]);
$this->post('/modules/xlvask/services/usage/automation/decisions/apply', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
if (!$this->hasPermission('manage_xlvask_usage_automation')
&& !$this->hasPermission('review_xlvask_usage_order')) {
$this->requirePermission('manage_xlvask_usage_automation');
}
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
@@ -466,7 +480,10 @@ class xlvaskUsageLogsRoute
$this->allowedHallIdsForUser($user)
)
);
}, ['manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision']);
}, [
'manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision',
'review_xlvask_usage_order' => 'Apply a previewed XL Vask automation decision as a reviewer',
]);
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
global $response;
@@ -241,3 +241,37 @@ it('does not let pending automation schema block ordinary invoice period operati
->not->toContain('xlvask_usage_logs_schema_bootstrap::ensureTables()')
->toContain('XL Vask automation migration is pending');
});
it('exposes a review_xlvask_usage_order permission on decision endpoints for the selvvask operator flow', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->toContain("'review_xlvask_usage_order' => 'Preview an XL Vask automation decision as a reviewer'")
->toContain("'review_xlvask_usage_order' => 'Apply a previewed XL Vask automation decision as a reviewer'")
->toContain("'review_xlvask_usage_order' => 'Inspect XL Vask automation capabilities as a reviewer'")
// The decisions endpoints must check both manage_xlvask_usage_automation and review_xlvask_usage_order
->toContain("!\$this->hasPermission('manage_xlvask_usage_automation')")
->toContain("!\$this->hasPermission('review_xlvask_usage_order')")
// The capabilities endpoint must light can_review for both manage and review permissions
->toContain("'can_review' => \$canReview")
->toContain("'review_xlvask_usage_order'")
->toContain("'list_xlvask_usage_orders_all'")
->toContain("'list_xlvask_usage_orders_own'")
// Other admin-only capabilities must remain gated on manage_xlvask_usage_automation
->toContain("'can_dry_run' => \$canManage")
->toContain("'can_execute' => \$canManage");
});
it('still requires manage_xlvask_usage_automation for the AI autopilot run lifecycle', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
// The autopilot-runs POST must remain manage-only — review_xlvask_usage_order must NOT unlock
// the AI-driven dry-run / execute pipeline.
expect($route)
->toContain("post('/modules/xlvask/services/usage/autopilot-runs'")
->toContain("\$this->requirePermission('manage_xlvask_usage_automation');")
// The autopilot-runs/{id} GET (used for status polling) must also remain manage-only.
->toContain("get('/modules/xlvask/services/usage/autopilot-runs/{id}'")
// The autopilot-runs/active GET (recovery) must also remain manage-only.
->toContain("get('/modules/xlvask/services/usage/autopilot-runs/active'");
});