Update self-serve permissions and enhance UI components for customer interactions

This commit is contained in:
Jeppe Bundgaard
2026-07-13 10:21:22 +02:00
parent 012e5366ba
commit 327e9cf817
14 changed files with 433 additions and 72 deletions
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
public function getLatestSessionSummary(int $laneId, string $reg): array;
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null): ?array;
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
}
@@ -356,8 +356,8 @@ Purpose: operational lane control and relay management.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, customer `add_own_department_selfserve_vehicle_conditions` for scoped `START`, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `STOP` and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `add_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
@@ -439,7 +439,7 @@ Public methods:
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. Driver flows pass `subuserId` so same-customer drivers close only their own session. | Full summary, or `null` if no open session exists. |
Key implementation details:
@@ -120,14 +120,15 @@ trait selfserve_lane_command_t
/**
* Machine-wash billing is based on the physical machine ON signal, not selector relay status.
*/
protected function hasMachineStartSignalForStop(): bool
protected function hasMachineStartSignalForStop(?int $subuser_id = null): bool
{
try {
$customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
return (new selfserve_wash_flow())->hasMachineStartTriggeredForLane(
(int)$this->id,
method_exists($this, 'getLicensePlate') ? ($this->getLicensePlate() ?: null) : null,
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null,
$subuser_id
);
} catch (\Throwable) {
return false;
@@ -240,7 +241,9 @@ trait selfserve_lane_command_t
$snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
(int)$this->id,
$reg,
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null,
null,
['subuser_id' => $arguments?->subuser_id]
);
return (bool)($snapshot['allowed'] ?? false);
@@ -531,7 +534,7 @@ trait selfserve_lane_command_t
/**
* Finalize any active self-serve wash session before resetting lane state.
*/
protected function completeLatestSessionForStop(): void
protected function completeLatestSessionForStop(?int $subuser_id = null): void
{
try {
(new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
@@ -539,7 +542,8 @@ trait selfserve_lane_command_t
$this->getLicensePlate() ?: null,
$this->getCustomerNumber() ?: null,
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
false
false,
$subuser_id
);
} catch (\Throwable) {
// Session completion must not block STOP flow.
@@ -711,7 +715,7 @@ trait selfserve_lane_command_t
throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
}
// Snapshot the physical machine ON signal before session completion/reset.
$machine_start_triggered = $this->hasMachineStartSignalForStop();
$machine_start_triggered = $this->hasMachineStartSignalForStop($arguments->subuser_id);
// Turn off relays before any configured or default exit gate opens.
$this->turnOffRelaysAfterStop();
$this->runPublishedStudioActions(
@@ -733,7 +737,7 @@ trait selfserve_lane_command_t
// Only bill the machine wash product when the physical machine start signal was recorded.
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
// Finalize any active self-serve wash session for this lane
$this->completeLatestSessionForStop();
$this->completeLatestSessionForStop($arguments->subuser_id);
// Reset the lane
self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
break;
@@ -260,8 +260,9 @@ trait selfserve_lane_invoice_t
?int $draft_customer_number,
?selfserve_lane_command_arguments $arguments = null
): array {
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
$subuser_id = $arguments?->subuser_id;
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number, $arguments?->subuser_id);
$session_subuser_id = $session?->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
$subuser_id = $arguments?->subuser_id ?? $session_subuser_id;
return [
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
@@ -282,7 +283,7 @@ trait selfserve_lane_invoice_t
];
}
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number, ?int $subuser_id = null): ?selfserve_wash_sessions_o
{
$license_plate = trim((string)$this->getLicensePlate());
if ($license_plate === '') {
@@ -293,7 +294,8 @@ trait selfserve_lane_invoice_t
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
(int)$this->id,
selfserve::standardize_registration($license_plate),
$billing_customer_number > 0 ? $billing_customer_number : null
$billing_customer_number > 0 ? $billing_customer_number : null,
$subuser_id
);
return $session->exists() ? $session : null;
} catch (\Throwable) {
+28 -4
View File
@@ -9851,6 +9851,9 @@ paths:
customer_number:
type: integer
nullable: true
subuser_id:
type: integer
nullable: true
vehicle_id:
type: integer
nullable: true
@@ -9903,6 +9906,18 @@ paths:
phone:
type: string
nullable: true
subuser:
type: object
nullable: true
properties:
id:
type: integer
name:
type: string
nullable: true
username:
type: string
nullable: true
vehicle:
type: object
nullable: true
@@ -9930,7 +9945,8 @@ paths:
summary: Get the authenticated customer's active self-serve wash
description: |
Returns the latest active self-serve wash for the authenticated customer,
without requiring the frontend to know or poll a lane id.
without requiring the frontend to know or poll a lane id. Driver sessions
are scoped to the authenticated subuser under the selected customer number.
operationId: getMyActiveSelfServeWash
responses:
'200':
@@ -9951,9 +9967,16 @@ paths:
session:
type: object
nullable: true
properties:
subuser_id:
type: integer
nullable: true
customer:
type: object
nullable: true
subuser:
type: object
nullable: true
vehicle:
type: object
nullable: true
@@ -10088,8 +10111,9 @@ paths:
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
Property gate commands require the matching explicit command permissions.
Operator callers require the base command permission plus the command-specific permission. Authenticated
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
lanes. Customer `STOP` requires the customer's active self-serve wash in the target department.
customers with `add_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
lanes. Customer `STOP` requires `list_own_department_selfserve_vehicle_conditions` and the customer's active
self-serve wash in the target department.
operationId: sendSelfServeLaneCommand
requestBody:
required: true
@@ -10156,7 +10180,7 @@ paths:
derived from the tasks currently shown to the user after answering the self-serve questions.
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
`add_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
confirming a wash start.
operationId: setSelfServeLaneAllowedServices
requestBody:
@@ -119,7 +119,7 @@ class departmentSelfserveVehicleConditionsRoute
*/
$this->get('/department/selfserve/vehicle/allowed', function () {
global $response;
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
@@ -147,11 +147,14 @@ class departmentSelfserveVehicleConditionsRoute
if ($vehicle_type_id !== null) {
$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [
'create_session' => false,
'subuser_id' => $subuser_id,
]);
}
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $actor_id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
$response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id));
$response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id, [
'subuser_id' => $subuser_id,
]));
}, [
'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle',
'list_own_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a customer-scoped vehicle'
@@ -162,7 +165,7 @@ class departmentSelfserveVehicleConditionsRoute
*/
$this->get('/department/selfserve/washes/summary', function () {
global $response;
[$user] = $this->getAuthenticatedSelfServePrincipal();
[$user, $_actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
@@ -192,7 +195,10 @@ class departmentSelfserveVehicleConditionsRoute
false,
$vehicle_type_id,
false,
['create_session' => false]
[
'create_session' => false,
'subuser_id' => $subuser_id,
]
);
if (!empty($refreshed_summary['session']['id'])) {
$summary = $refreshed_summary;
@@ -215,6 +221,7 @@ class departmentSelfserveVehicleConditionsRoute
if ($vehicle_type_id !== null) {
$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [
'create_session' => false,
'subuser_id' => $subuser_id,
]);
if (empty($summary['session']['id'])) {
try {
@@ -241,7 +248,7 @@ class departmentSelfserveVehicleConditionsRoute
*/
$this->post('/department/selfserve/vehicle/conditions', function () {
global $response;
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD);
$has_global = $user !== null && $this->hasPermission('add_department_selfserve_vehicle_conditions');
@@ -281,7 +288,9 @@ class departmentSelfserveVehicleConditionsRoute
try {
$condition_o = new department_selfserve_vehicle_conditions_o();
$condition_o->add($department, $lane, $reg, $question, $value, $customer_id);
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state, [
'subuser_id' => $subuser_id,
]);
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $actor_id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id);
$response->success([
'condition' => $condition_o->asArray(),
@@ -469,12 +478,12 @@ class departmentSelfserveVehicleConditionsRoute
$auth = new authentication();
$user = $auth->get_user();
if ($user !== false) {
return [$user, (int)$user->id];
return [$user, (int)$user->id, null];
}
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [null, (int)$subuser->id];
return [null, (int)$subuser->id, (int)$subuser->id];
}
$response->error('Invalid session', 400);
@@ -29,6 +29,7 @@ use objects\customer_vehicles_o;
use objects\selfserve_wash_sessions_o;
use objects\selfserve_wash_session_tasks_o;
use objects\stripe_module_customers_o;
use objects\subusers_o;
use objects\users_o;
use traits\route_t;
@@ -38,6 +39,7 @@ class moduleSelfServeRoute
private const MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS = 5;
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
private const CUSTOMER_SELFSERVE_USE_PERMISSION = 'add_own_department_selfserve_vehicle_conditions';
public function run(): void
{
@@ -262,6 +264,7 @@ class moduleSelfServeRoute
'status' => $lane_state->name,
'reg' => $runtime_reg,
'customer_number' => $runtime_customer_number,
'subuser_id' => null,
'vehicle_id' => $vehicle['id'] ?? null,
'vehicle_type_id' => $vehicle['type'] ?? null,
'included_minutes' => $included_minutes,
@@ -273,14 +276,16 @@ class moduleSelfServeRoute
'wash_started_at' => $wash_started_at,
'created_at' => null,
'updated_at' => null,
],
'customer' => $customer,
'vehicle' => $vehicle,
], $customer_scope));
],
'customer' => $customer,
'subuser' => null,
'vehicle' => $vehicle,
], $customer_scope));
return;
}
$customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
$subuser_id = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
$vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value();
$session_reg = trim((string)$session->reg->value());
if ($session_reg === '') {
@@ -318,6 +323,7 @@ class moduleSelfServeRoute
'status' => (string)$session->status->value(),
'reg' => $session_reg,
'customer_number' => $customer_number,
'subuser_id' => $subuser_id,
'vehicle_id' => $vehicle_id,
'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
'included_minutes' => $included_minutes ?? 0,
@@ -331,12 +337,13 @@ class moduleSelfServeRoute
'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(),
],
'customer' => $customer,
'subuser' => $this->buildInProgressWashSubuserPayload($subuser_id),
'vehicle' => $vehicle,
], $customer_scope));
},
[
'modules_selfserve_lane_wash_in_progress_view' => 'View customer and vehicle details for an in-progress self-serve wash on a lane',
'list_own_department_selfserve_vehicle_conditions' => 'View in-progress self-serve wash details for the authenticated customer',
'add_own_department_selfserve_vehicle_conditions' => 'Use in-progress self-serve wash details for the authenticated customer',
]
);
@@ -344,8 +351,11 @@ class moduleSelfServeRoute
$this->get('/modules/self-serve/lane/wash/my-active-wash', function () {
global $response;
$customer_number = $this->requireMyActiveWashCustomerNumber();
$session = $this->findLatestActiveSelfServeSessionForCustomer($customer_number);
$principal_scope = $this->requireMyActiveWashPrincipalScope();
$session = $this->findLatestActiveSelfServeSessionForCustomer(
(int)$principal_scope['customer_number'],
$principal_scope['subuser_id']
);
if (!$session->exists()) {
$response->error('No active self-serve wash found.', 404);
}
@@ -353,7 +363,7 @@ class moduleSelfServeRoute
$response->success($this->buildActiveSelfServeSessionResponse($session));
},
[
'list_own_department_selfserve_vehicle_conditions' => 'View the authenticated customer\'s active self-serve wash',
'add_own_department_selfserve_vehicle_conditions' => 'Use the authenticated customer\'s active self-serve wash',
]
);
@@ -369,6 +379,7 @@ class moduleSelfServeRoute
'department_id',
'machine_type_id',
'customer_number',
'subuser_id',
'vehicle_id',
'vehicle_type_id',
'reg',
@@ -392,6 +403,9 @@ class moduleSelfServeRoute
return [
...$session->asArray(),
'subuser' => $this->buildInProgressWashSubuserPayload(
$session->subuser_id->value() === null ? null : (int)$session->subuser_id->value()
),
'elapsed_minutes' => $session->getElapsedMinutes(),
'open' => $session->isOpen(),
];
@@ -585,7 +599,8 @@ class moduleSelfServeRoute
$customer_number > 0 ? $customer_number : null,
false,
null,
false
false,
['subuser_id' => $args->subuser_id]
);
}
} catch (\Throwable $e) {
@@ -1421,7 +1436,7 @@ class moduleSelfServeRoute
]);
}
private function requireInProgressWashDetailsAccess(int $lane_id): ?int
private function requireInProgressWashDetailsAccess(int $lane_id): ?array
{
global $response;
@@ -1438,16 +1453,19 @@ class moduleSelfServeRoute
return null;
}
if (self::hasPermission($this->customerSelfServePermission())) {
if (self::hasPermission($this->customerSelfServeUsePermission())) {
$customer_number = $this->resolveEffectiveCustomerNumber();
if ($customer_number !== null && $customer_number > 0) {
return (int)$customer_number;
return [
'customer_number' => (int)$customer_number,
'subuser_id' => $this->authenticatedSubuserId(),
];
}
}
$this->emitForbidden([
'modules_selfserve_lane_wash_in_progress_view',
$this->customerSelfServePermission(),
$this->customerSelfServeUsePermission(),
]);
return null;
}
@@ -1459,14 +1477,19 @@ class moduleSelfServeRoute
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
protected function scopeInProgressWashResponseForCustomer(array $payload, ?int $customer_number): array
protected function scopeInProgressWashResponseForCustomer(array $payload, mixed $customer_scope): array
{
$scope = $this->normalizeCustomerScope($customer_scope);
$customer_number = $scope['customer_number'];
$subuser_id = $scope['subuser_id'];
if ($customer_number === null || $customer_number <= 0 || ($payload['in_progress'] ?? false) !== true) {
return $payload;
}
$session_customer_number = $this->extractInProgressWashCustomerNumber($payload);
if ($session_customer_number === $customer_number) {
$session_subuser_id = $this->extractInProgressWashSubuserId($payload);
if ($session_customer_number === $customer_number && ($subuser_id === null || $session_subuser_id === $subuser_id)) {
return $payload;
}
@@ -1479,6 +1502,24 @@ class moduleSelfServeRoute
];
}
/**
* @return array{customer_number:?int,subuser_id:?int}
*/
private function normalizeCustomerScope(mixed $customer_scope): array
{
if (is_array($customer_scope)) {
return [
'customer_number' => $this->normalizePositiveInt($customer_scope['customer_number'] ?? null),
'subuser_id' => $this->normalizePositiveInt($customer_scope['subuser_id'] ?? null),
];
}
return [
'customer_number' => $this->normalizePositiveInt($customer_scope),
'subuser_id' => null,
];
}
/**
* @param array<string,mixed> $payload
*/
@@ -1502,7 +1543,31 @@ class moduleSelfServeRoute
return null;
}
private function requireMyActiveWashCustomerNumber(): int
/**
* @param array<string,mixed> $payload
*/
private function extractInProgressWashSubuserId(array $payload): ?int
{
$candidates = [
$payload['session']['subuser_id'] ?? null,
$payload['session']['subuser']['id'] ?? null,
$payload['subuser']['id'] ?? null,
];
foreach ($candidates as $candidate) {
$subuser_id = $this->normalizePositiveInt($candidate);
if ($subuser_id !== null) {
return $subuser_id;
}
}
return null;
}
/**
* @return array{customer_number:int,subuser_id:?int}
*/
private function requireMyActiveWashPrincipalScope(): array
{
global $response;
@@ -1510,8 +1575,8 @@ class moduleSelfServeRoute
$response->error('Authentication failed. Invalid or missing token.', 401);
}
if (!self::hasPermission($this->customerSelfServePermission())) {
$this->emitForbidden([$this->customerSelfServePermission()]);
if (!self::hasPermission($this->customerSelfServeUsePermission())) {
$this->emitForbidden([$this->customerSelfServeUsePermission()]);
}
$customer_number = $this->resolveEffectiveCustomerNumber();
@@ -1519,7 +1584,10 @@ class moduleSelfServeRoute
$response->error('No customer number found for authenticated user.', 404);
}
return (int)$customer_number;
return [
'customer_number' => (int)$customer_number,
'subuser_id' => $this->authenticatedSubuserId(),
];
}
private function customerSelfServePermission(): \classes\permission_node
@@ -1527,23 +1595,53 @@ class moduleSelfServeRoute
return self::definePermission(self::CUSTOMER_SELFSERVE_PERMISSION, subusers_permission_node_key::SELFSERVE_LIST);
}
private function customerSelfServeUsePermission(): \classes\permission_node
{
return self::definePermission(self::CUSTOMER_SELFSERVE_USE_PERMISSION, subusers_permission_node_key::SELFSERVE_ADD);
}
private function authenticatedSubuserId(): ?int
{
try {
$subuser = (new authentication())->get_subuser();
return $subuser === false ? null : (int)$subuser->id;
} catch (\Throwable) {
return null;
}
}
private function normalizePositiveInt(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
$normalized = (int)$value;
return $normalized > 0 ? $normalized : null;
}
private function hasAuthenticatedUserOrSubuser(): bool
{
$auth = new authentication();
return $auth->get_user() !== false || $auth->get_subuser() !== false;
}
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number): selfserve_wash_sessions_o
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number, ?int $subuser_id = null): selfserve_wash_sessions_o
{
if ($customer_number <= 0) {
return new selfserve_wash_sessions_o();
}
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere([
$filters = [
'customer_number' => $customer_number,
'completed_at' => null,
'deleted_at' => null,
], ['id', 'status']);
];
if ($subuser_id !== null) {
$filters['subuser_id'] = $subuser_id;
}
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);
$active_statuses = $this->activeSelfServeWashSessionStatusValues();
$rows = array_values(array_filter(
@@ -1582,6 +1680,7 @@ class moduleSelfServeRoute
{
$lane_id = (int)$session->lane_id->value();
$customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
$subuser_id = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
$vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value();
$session_reg = trim((string)$session->reg->value());
if ($session_reg === '') {
@@ -1615,6 +1714,7 @@ class moduleSelfServeRoute
'lane_id' => $lane_id,
'reg' => $session_reg,
'customer_number' => $customer_number,
'subuser_id' => $subuser_id,
'vehicle_id' => $vehicle_id,
'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
'included_minutes' => $included_minutes ?? 0,
@@ -1628,6 +1728,7 @@ class moduleSelfServeRoute
'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(),
],
'customer' => $this->buildInProgressWashCustomerPayload($customer_number),
'subuser' => $this->buildInProgressWashSubuserPayload($subuser_id),
'vehicle' => $this->buildInProgressWashVehiclePayload($vehicle_id, $session_reg),
];
}
@@ -1663,6 +1764,35 @@ class moduleSelfServeRoute
];
}
/**
* @return array<string,mixed>|null
*/
private function buildInProgressWashSubuserPayload(?int $subuser_id): ?array
{
if ($subuser_id === null || $subuser_id <= 0) {
return null;
}
try {
$subuser = (new subusers_o())->select($subuser_id);
if (!$subuser->exists()) {
return [
'id' => $subuser_id,
];
}
return [
'id' => (int)$subuser->id,
'name' => $subuser->name->value() === null ? null : (string)$subuser->name->value(),
'username' => $subuser->username->value() === null ? null : (string)$subuser->username->value(),
];
} catch (\Throwable) {
return [
'id' => $subuser_id,
];
}
}
/**
* @return array<string,mixed>|null
*/
@@ -2088,7 +2218,9 @@ class moduleSelfServeRoute
$missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
if ($allow_customer_self_serve) {
$missing_permissions[] = $this->customerSelfServePermission();
$missing_permissions[] = $requires_active_wash
? $this->customerSelfServePermission()
: $this->customerSelfServeUsePermission();
}
$this->emitForbidden($missing_permissions);
}
@@ -2132,7 +2264,10 @@ class moduleSelfServeRoute
return;
}
$this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
$this->emitForbidden([
...$elevated_permissions,
$requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(),
]);
}
private function requireSelfServeLaneCommandPermission(
@@ -2163,7 +2298,10 @@ class moduleSelfServeRoute
$this->emitForbidden(
$allow_customer_self_serve
? [...$elevated_permissions, $this->customerSelfServePermission()]
? [
...$elevated_permissions,
$requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(),
]
: $elevated_permissions
);
}
@@ -2218,7 +2356,7 @@ class moduleSelfServeRoute
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
{
return $customer_number > 0
&& $this->hasPermission($this->customerSelfServePermission())
&& $this->hasPermission($this->customerSelfServeUsePermission())
&& $this->isLaneSelfServeOperationallyEnabled($lane);
}
@@ -2228,8 +2366,9 @@ class moduleSelfServeRoute
return false;
}
$subuser_id = $this->authenticatedSubuserId();
try {
if ((int)$lane->getCustomerNumber() === $customer_number) {
if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) {
return true;
}
} catch (\Throwable) {
@@ -2238,7 +2377,7 @@ class moduleSelfServeRoute
$department_id = $this->departmentIdForLane($lane);
return $department_id > 0
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $subuser_id);
}
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
@@ -2253,7 +2392,7 @@ class moduleSelfServeRoute
$department_id = $this->departmentIdForLane($lane);
return $department_id > 0
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $this->authenticatedSubuserId());
}
protected function canCustomerUseActiveOperationalSelfServeLane(
@@ -2275,8 +2414,9 @@ class moduleSelfServeRoute
return false;
}
$subuser_id = $this->authenticatedSubuserId();
try {
if ((int)$lane->getCustomerNumber() === $customer_number) {
if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) {
return true;
}
} catch (\Throwable) {
@@ -2294,12 +2434,17 @@ class moduleSelfServeRoute
]
);
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([
$filters = [
'lane_id' => (int)$lane->id,
'customer_number' => $customer_number,
'completed_at' => null,
'deleted_at' => null,
], ['id', 'status']);
];
if ($subuser_id !== null) {
$filters['subuser_id'] = $subuser_id;
}
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);
foreach ($sessions as $session) {
if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
@@ -2342,7 +2487,7 @@ class moduleSelfServeRoute
}
}
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number, ?int $subuser_id = null): bool
{
if ($department_id <= 0 || $customer_number <= 0) {
return false;
@@ -2359,12 +2504,17 @@ class moduleSelfServeRoute
]
);
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([
$filters = [
'department_id' => $department_id,
'customer_number' => $customer_number,
'completed_at' => null,
'deleted_at' => null,
], ['id', 'status']);
];
if ($subuser_id !== null) {
$filters['subuser_id'] = $subuser_id;
}
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);
foreach ($sessions as $session) {
if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
@@ -2373,6 +2523,9 @@ class moduleSelfServeRoute
}
foreach ((new department_lanes_o())->getDepartmentLanes($department_id) as $department_lane) {
if ($subuser_id !== null) {
continue;
}
try {
$lane = (new selfserve())->lane((int)$department_lane->id);
if ((int)$lane->getCustomerNumber() !== $customer_number) {
@@ -36,6 +36,7 @@ function selfserve_customer_start_make_available(int $laneId): void
it('allows the customer self-serve start sequence without department access', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -90,6 +91,7 @@ it('allows the customer self-serve start sequence without department access', fu
it('marks the active customer session relay-enabled after machine relay enable', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
'modules_selfserve_lane_relay_enable_machine',
]);
$scenario = api_fixtures()->createSelfServeScenario([
@@ -158,6 +160,7 @@ it('marks the active customer session relay-enabled after machine relay enable',
it('derives allowed services from v2 session task snapshots when task rows are not legacy records', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -201,6 +204,7 @@ it('derives allowed services from v2 session task snapshots when task rows are n
it('derives allowed services from published v2 config task snapshots when no session exists yet', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -267,6 +271,7 @@ it('derives allowed services from published v2 config task snapshots when no ses
it('keeps questions visible but disables machine services and tasks when machine wash is globally disabled', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -316,6 +321,7 @@ it('keeps questions visible but disables machine services and tasks when machine
it('keeps long generated task descriptions when refreshing vehicle eligibility snapshots', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -360,6 +366,7 @@ it('keeps long generated task descriptions when refreshing vehicle eligibility s
it('does not create an active wash preview session from read-only eligibility checks', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -414,6 +421,7 @@ it('does not create an active wash preview session from read-only eligibility ch
it('creates a durable wash session from customer start after read-only eligibility', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -482,6 +490,7 @@ it('creates a durable wash session from customer start after read-only eligibili
it('refreshes an active wash summary with vehicle type without a namespace error', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -51,6 +51,7 @@ function selfserve_lane_command_make_occupied(int $laneId, int $customerNumber,
it('allows customer self-serve permission to execute START without department access when department and lane are enabled', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -79,6 +80,7 @@ it('allows customer self-serve permission to execute START without department ac
it('denies customer START when department self-serve is disabled', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -98,13 +100,14 @@ it('denies customer START when department self-serve is disabled', function ():
->assertStatus(403)
->assertMissingPermissions([
'department_access_' . (int)$scenario['department']['id'],
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
});
it('denies customer START when lane self-serve is disabled', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => ['group_id' => $group['id']],
@@ -124,7 +127,7 @@ it('denies customer START when lane self-serve is disabled', function (): void {
->assertStatus(403)
->assertMissingPermissions([
'department_access_' . (int)$scenario['department']['id'],
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
});
@@ -30,7 +30,7 @@ it('reports both elevated and customer self-serve permissions when lane polling
->assertStatus(403)
->assertMissingPermissions([
'modules_selfserve_lane_wash_in_progress_view',
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
});
@@ -42,13 +42,29 @@ it('requires customer self-serve permission before checking my active wash', fun
$response
->assertStatus(403)
->assertMissingPermissions([
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
});
it('denies list-only drivers from operational self-serve active wash restore', function (): void {
$customer = api_fixtures()->createUser();
$driverSession = api_fixtures()->createSubuserSession((int)$customer['customer_number'], [
'SELFSERVE_LIST',
]);
$response = api_client()->get('/modules/self-serve/lane/wash/my-active-wash', $driverSession['headers']);
$response
->assertStatus(403)
->assertMissingPermissions([
'add_own_department_selfserve_vehicle_conditions',
]);
});
it('allows customer self-serve permission to view their own in-progress wash details', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => [
@@ -74,6 +90,7 @@ it('allows customer self-serve permission to view their own in-progress wash det
it('returns the authenticated customers active self-serve wash without requiring a lane id', function (): void {
$group = api_fixtures()->createGroup([], [
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$scenario = api_fixtures()->createSelfServeScenario([
'customer' => [
@@ -99,9 +116,67 @@ it('returns the authenticated customers active self-serve wash without requiring
->and($response->data()['vehicle']['reg'] ?? null)->toBe($scenario['vehicle']['reg']);
});
it('scopes active self-serve wash restore to the authenticated driver under a shared customer number', function (): void {
$scenario = api_fixtures()->createSelfServeScenario();
$customerNumber = (int)$scenario['customer']['customer_number'];
$ownerDriver = api_fixtures()->createSubuserSession($customerNumber, [
'SELFSERVE_LIST',
'SELFSERVE_ADD',
], [
'name' => 'Self-Serve Owner Driver',
]);
$otherDriver = api_fixtures()->createSubuserSession($customerNumber, [
'SELFSERVE_LIST',
'SELFSERVE_ADD',
], [
'name' => 'Self-Serve Other Driver',
]);
api_test_runtime()->db()->query(
'UPDATE selfserve_wash_sessions SET subuser_id = '
. (int)$ownerDriver['subuser']['id']
. ' WHERE id = '
. (int)$scenario['session']['id']
);
$ownerResponse = api_client()->get(
'/modules/self-serve/lane/wash/my-active-wash',
$ownerDriver['headers']
);
$otherResponse = api_client()->get(
'/modules/self-serve/lane/wash/my-active-wash',
$otherDriver['headers']
);
$otherLanePoll = api_client()->get(
'/modules/self-serve/lane/wash/in-progress?lane_id=' . (int)$scenario['lane']['id'],
$otherDriver['headers']
);
$ownerResponse
->assertStatus(200)
->assertSuccess(true);
$otherResponse
->assertStatus(404)
->assertMessage('No active self-serve wash found.');
$otherLanePoll
->assertStatus(200)
->assertSuccess(true);
expect($ownerResponse->data()['session']['subuser_id'] ?? null)->toBe((int)$ownerDriver['subuser']['id'])
->and($ownerResponse->data()['subuser']['id'] ?? null)->toBe((int)$ownerDriver['subuser']['id'])
->and($otherLanePoll->data())->toMatchArray([
'lane_id' => (int)$scenario['lane']['id'],
'in_progress' => true,
'session' => null,
'customer' => null,
'vehicle' => null,
]);
});
it('returns 404 when the authenticated customer has no active self-serve wash', function (): void {
$session = api_fixtures()->createUserSession([
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$response = api_client()->get('/modules/self-serve/lane/wash/my-active-wash', $session['headers']);
@@ -115,6 +190,7 @@ it('redacts another customers in-progress wash from customer self-serve lane pol
$scenario = api_fixtures()->createSelfServeScenario();
$otherSession = api_fixtures()->createUserSession([
'list_own_department_selfserve_vehicle_conditions',
'add_own_department_selfserve_vehicle_conditions',
]);
$response = api_client()->get(
@@ -446,6 +446,7 @@ CREATE TABLE IF NOT EXISTS `selfserve_wash_sessions` (
`department_id` INT NOT NULL,
`machine_type_id` INT NULL,
`customer_number` INT NULL,
`subuser_id` INT NULL,
`vehicle_id` INT NULL,
`vehicle_type_id` INT NULL,
`reg` VARCHAR(255) NOT NULL,
@@ -466,6 +467,8 @@ CREATE TABLE IF NOT EXISTS `selfserve_wash_sessions` (
KEY `idx_selfserve_wash_sessions_lane_reg` (`lane_id`, `reg`),
KEY `idx_selfserve_wash_sessions_status` (`status`),
KEY `idx_selfserve_wash_sessions_customer` (`customer_number`),
KEY `idx_selfserve_wash_sessions_subuser_active` (`subuser_id`, `completed_at`),
KEY `idx_selfserve_wash_sessions_customer_subuser_active` (`customer_number`, `subuser_id`, `completed_at`),
KEY `idx_selfserve_wash_sessions_department_completed` (`department_id`, `completed_at`),
KEY `idx_selfserve_wash_sessions_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
@@ -15,6 +15,7 @@ class SelfserveCustomerLaneAccessRouteHarness extends moduleSelfServeRoute
/** @var array<string,bool> */
public array $permissions = [
'list_own_department_selfserve_vehicle_conditions' => true,
'add_own_department_selfserve_vehicle_conditions' => true,
];
public bool $laneEnabled = true;
public bool $moduleEnabled = true;
@@ -77,11 +78,12 @@ class SelfserveCustomerLaneAccessRouteHarness extends moduleSelfServeRoute
$method->invoke($this, $lane, $customer_number, $command_permission, $allow_customer_self_serve, $requires_active_wash);
}
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number, ?int $subuser_id = null): bool
{
$this->activeWashChecks[] = [
'department_id' => $department_id,
'customer_number' => $customer_number,
'subuser_id' => $subuser_id,
];
return $this->activeWashResult;
@@ -170,6 +172,7 @@ it('falls back to active department sessions for customer active wash operations
[
'department_id' => 4,
'customer_number' => 12345679,
'subuser_id' => null,
],
]);
});
@@ -184,6 +187,7 @@ it('blocks active wash operations for other customers', function (): void {
[
'department_id' => 4,
'customer_number' => 12345679,
'subuser_id' => null,
],
]);
});
@@ -14,6 +14,14 @@ if (!class_exists(SelfserveInProgressWashAccessHarness::class)) {
{
return $this->scopeInProgressWashResponseForCustomer($payload, $customer_number);
}
public function scopeForPrincipal(array $payload, int $customer_number, ?int $subuser_id): array
{
return $this->scopeInProgressWashResponseForCustomer($payload, [
'customer_number' => $customer_number,
'subuser_id' => $subuser_id,
]);
}
}
}
@@ -74,3 +82,68 @@ it('redacts another customers in-progress wash details during customer lane poll
]);
});
it('keeps own driver in-progress self-serve wash details visible', function (): void {
$route = new SelfserveInProgressWashAccessHarness();
$payload = [
'lane_id' => 7,
'in_progress' => true,
'session' => [
'id' => 704,
'customer_number' => 12345679,
'subuser_id' => 501,
'reg' => 'AB12345',
],
'subuser' => [
'id' => 501,
'name' => 'Driver One',
],
'customer' => [
'customer_number' => 12345679,
'display_name' => 'Example Customer',
],
'vehicle' => [
'id' => 55,
'reg' => 'AB12345',
],
];
expect($route->scopeForPrincipal($payload, 12345679, 501))->toBe($payload);
});
it('redacts another driver in the same customers in-progress wash during driver lane polling', function (): void {
$route = new SelfserveInProgressWashAccessHarness();
$scoped = $route->scopeForPrincipal([
'lane_id' => 9,
'status' => 'MACHINE_STARTED',
'in_progress' => true,
'elapsed_minutes' => 4,
'session' => [
'id' => 804,
'customer_number' => 12345679,
'subuser_id' => 502,
'reg' => 'CD67890',
],
'subuser' => [
'id' => 502,
'name' => 'Driver Two',
],
'customer' => [
'customer_number' => 12345679,
'display_name' => 'Example Customer',
],
'vehicle' => [
'id' => 77,
'reg' => 'CD67890',
],
], 12345679, 501);
expect($scoped)->toBe([
'lane_id' => 9,
'in_progress' => true,
'session' => null,
'customer' => null,
'vehicle' => null,
]);
});
@@ -336,7 +336,7 @@ it('wires self-serve property gate command permissions', function (): void {
expect($moduleSelfServeRoute)->toContain('Failed to execute self-serve property gate command');
expect($moduleSelfServeRoute)->toContain('$this->canCustomerUsePropertyGateForLane($lane, $customer_number)');
expect($moduleSelfServeRoute)->toContain('$this->isOwnCustomerContext($customer_number)');
expect($moduleSelfServeRoute)->toContain('$this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number)');
expect($moduleSelfServeRoute)->toContain('$this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $subuser_id)');
expect($commandTrait)->not->toBeFalse();
expect($commandTrait)->toContain('Failed to open property access gate.');
@@ -351,9 +351,9 @@ it('wires in-progress self-serve wash details endpoint', function (): void {
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/wash/in-progress');
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/wash/my-active-wash');
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_wash_in_progress_view');
expect($moduleSelfServeRoute)->toContain('list_own_department_selfserve_vehicle_conditions');
expect($moduleSelfServeRoute)->toContain('add_own_department_selfserve_vehicle_conditions');
expect($moduleSelfServeRoute)->toContain('requireInProgressWashDetailsAccess($lane_id)');
expect($moduleSelfServeRoute)->toContain('findLatestActiveSelfServeSessionForCustomer($customer_number)');
expect($moduleSelfServeRoute)->toContain('findLatestActiveSelfServeSessionForCustomer(');
expect($moduleSelfServeRoute)->toContain('buildActiveSelfServeSessionResponse($session)');
expect($moduleSelfServeRoute)->toContain('(new department_lanes_o())->select($lane_id)');
expect($moduleSelfServeRoute)->toContain('self::requireDepartmentAccess((string)$department_lane->department->value())');
@@ -549,7 +549,7 @@ it('keeps read-only self-serve preview and summary refreshes from touching relay
expect($vehicleConditionsRoute)->toContain('$summary = $flow->getLatestSessionSummary($lane_id, $reg);');
expect($vehicleConditionsRoute)->toContain('$activate_machine = $this->requestBooleanFlag(\'activate_machine\', true);');
expect($vehicleConditionsRoute)->toContain('$sync_relay_state = $this->requestBooleanFlag(\'sync_relay_state\', true);');
expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);');
expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state, [');
expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);');
});
@@ -565,7 +565,8 @@ it('classifies self-serve lane command route authorization by customer product b
// Product flow confirms the customer UI sends STOP, while route docs expose START and property gates
// as customer-scoped lane commands. RESERVE, RELEASE, and RESET remain absent from customer flow/docs.
expect($productDocs)->toContain('Customer->>API: POST /modules/self-serve/lane/command (STOP)');
expect($productDocs)->toContain('customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands');
expect($productDocs)->toContain('customer `add_own_department_selfserve_vehicle_conditions` for scoped `START`');
expect($productDocs)->toContain('customer `list_own_department_selfserve_vehicle_conditions` for scoped `STOP` and property gate commands');
expect($productDocs)->toContain('Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer\'s active wash in the lane department.');
expect($washFlow)->toContain('$payload[\'command\'] = $relayRole === \'PROPERTY_ENTRANCE\' ? \'OPEN_PROPERTY_ACCESS_GATE\' : \'OPEN_PROPERTY_EXIT_GATE\'');
expect($washFlow)->toContain('$signalType = \'studio_action_gate_open\'');