diff --git a/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php index d4021f22..fd62d696 100644 --- a/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php +++ b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php @@ -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; } diff --git a/services/nginx/app/modules/selfserve/selfserve.md b/services/nginx/app/modules/selfserve/selfserve.md index 442a3822..649fbd8c 100644 --- a/services/nginx/app/modules/selfserve/selfserve.md +++ b/services/nginx/app/modules/selfserve/selfserve.md @@ -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: diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php index eca32e5b..bd1161bc 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php @@ -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; diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php index 28f0b7b1..3d004a84 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php @@ -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) { diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 6db7b732..051ab108 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -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: diff --git a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php index 8797bcc9..59ad408e 100644 --- a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php @@ -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); diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index e010d37d..4fd4c113 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -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 $payload * @return array */ - 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 $payload */ @@ -1502,7 +1543,31 @@ class moduleSelfServeRoute return null; } - private function requireMyActiveWashCustomerNumber(): int + /** + * @param array $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|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|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) { diff --git a/services/nginx/app/tests/Api/SelfserveCustomerStartApiTest.php b/services/nginx/app/tests/Api/SelfserveCustomerStartApiTest.php index 97f989f7..692a0d21 100644 --- a/services/nginx/app/tests/Api/SelfserveCustomerStartApiTest.php +++ b/services/nginx/app/tests/Api/SelfserveCustomerStartApiTest.php @@ -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']], diff --git a/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php b/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php index 65f0cfb0..7eef058a 100644 --- a/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php +++ b/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php @@ -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', ]); }); diff --git a/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php index 3a647451..1a3a879e 100644 --- a/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php +++ b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php @@ -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( diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 14dec108..355b088b 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -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 diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php index 084de273..9a96ee97 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php @@ -15,6 +15,7 @@ class SelfserveCustomerLaneAccessRouteHarness extends moduleSelfServeRoute /** @var array */ 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, ], ]); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php index 6f150e7b..b1911758 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php @@ -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, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index 5bcfbd51..668b0435 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -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\'');