diff --git a/openapi.yaml b/openapi.yaml index 9db79e35..1a02883a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8843,6 +8843,47 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /modules/self-serve/lane/wash/my-active-wash: + get: + tags: + - Modules + 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. + operationId: getMyActiveSelfServeWash + responses: + '200': + description: Active self-serve wash details resolved + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + status: + type: string + in_progress: + type: boolean + elapsed_minutes: + type: integer + session: + type: object + nullable: true + customer: + type: object + nullable: true + vehicle: + type: object + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /modules/self-serve/sessions: get: tags: @@ -18688,4 +18729,3 @@ components: data: $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload' - diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index cc892f50..2b9d91d3 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -9208,6 +9208,47 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /modules/self-serve/lane/wash/my-active-wash: + get: + tags: + - Modules + 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. + operationId: getMyActiveSelfServeWash + responses: + '200': + description: Active self-serve wash details resolved + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + status: + type: string + in_progress: + type: boolean + elapsed_minutes: + type: integer + session: + type: object + nullable: true + customer: + type: object + nullable: true + vehicle: + type: object + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /modules/self-serve/sessions: get: tags: diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 9cd73fd7..3925a266 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -334,6 +334,23 @@ class moduleSelfServeRoute ] ); + /** Modules > Self Serve > Lane > Wash > My active wash */ + $this->get('/modules/self-serve/lane/wash/my-active-wash', function () { + global $response; + + $customer_number = $this->requireMyActiveWashCustomerNumber(); + $session = $this->findLatestActiveSelfServeSessionForCustomer($customer_number); + if (!$session->exists()) { + $response->error('No active self-serve wash found.', 404); + } + + $response->success($this->buildActiveSelfServeSessionResponse($session)); + }, + [ + 'list_own_department_selfserve_vehicle_conditions' => 'View the authenticated customer\'s active self-serve wash', + ] + ); + /** Modules > Self Serve > Sessions */ $this->get('/modules/self-serve/sessions', function () { global $response; @@ -1307,6 +1324,188 @@ class moduleSelfServeRoute return null; } + private function requireMyActiveWashCustomerNumber(): int + { + global $response; + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Authentication failed. Invalid or missing token.', 401); + } + + if (!self::hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) { + $this->emitForbidden([self::CUSTOMER_SELFSERVE_PERMISSION]); + } + + $customer_number = $this->resolveEffectiveCustomerNumber(); + if ($customer_number === null || $customer_number <= 0) { + $response->error('No customer number found for authenticated user.', 404); + } + + return (int)$customer_number; + } + + private function findLatestActiveSelfServeSessionForCustomer(int $customer_number): selfserve_wash_sessions_o + { + if ($customer_number <= 0) { + return new selfserve_wash_sessions_o(); + } + + $rows = (new selfserve_wash_sessions_o())->getFieldsWhere([ + 'customer_number' => $customer_number, + 'completed_at' => null, + 'deleted_at' => null, + ], ['id', 'status']); + + $active_statuses = $this->activeSelfServeWashSessionStatusValues(); + $rows = array_values(array_filter( + $rows, + static fn(array $row): bool => in_array((string)($row['status'] ?? ''), $active_statuses, true) + )); + if ($rows === []) { + return new selfserve_wash_sessions_o(); + } + + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + return (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']); + } + + /** + * @return array + */ + private function activeSelfServeWashSessionStatusValues(): array + { + return array_map( + static fn(selfserve_wash_session_status $status): string => $status->value, + [ + selfserve_wash_session_status::MACHINE_RELAY_ENABLED, + selfserve_wash_session_status::READY_FOR_MACHINE_START, + selfserve_wash_session_status::MACHINE_STARTED, + selfserve_wash_session_status::PENDING_QUESTIONS, + selfserve_wash_session_status::MACHINE_NOT_ALLOWED, + ] + ); + } + + /** + * @return array + */ + private function buildActiveSelfServeSessionResponse(selfserve_wash_sessions_o $session): array + { + $lane_id = (int)$session->lane_id->value(); + $customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value(); + $vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value(); + $session_reg = trim((string)$session->reg->value()); + if ($session_reg === '') { + $session_reg = null; + } + + $machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value(); + $wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value(); + if ($wash_started_at === null) { + $wash_started_at = $machine_start_triggered_at; + } + + $machine_relay_enabled = (bool)$session->machine_relay_enabled->value(); + $included_minutes = null; + if ($machine_relay_enabled) { + $included_minutes = (int)(new selfserve())->config->machine_wash_minutes_included->getVariableValue(); + if ($included_minutes < 0) { + $included_minutes = 0; + } + } + + return [ + 'lane_id' => $lane_id, + 'status' => (string)$session->status->value(), + 'in_progress' => true, + 'elapsed_minutes' => $session->getElapsedMinutes(), + 'session' => [ + 'id' => (int)$session->id, + 'status' => (string)$session->status->value(), + 'department_id' => $session->department_id->value() === null ? null : (int)$session->department_id->value(), + 'lane_id' => $lane_id, + 'reg' => $session_reg, + 'customer_number' => $customer_number, + '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, + 'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(), + 'machine_relay_enabled' => $machine_relay_enabled, + 'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(), + 'machine_start_triggered' => (bool)$session->machine_start_triggered->value(), + 'machine_start_triggered_at' => $machine_start_triggered_at, + 'wash_started_at' => $wash_started_at, + 'created_at' => (string)$session->created_at->value(), + 'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(), + ], + 'customer' => $this->buildInProgressWashCustomerPayload($customer_number), + 'vehicle' => $this->buildInProgressWashVehiclePayload($vehicle_id, $session_reg), + ]; + } + + /** + * @return array|null + */ + private function buildInProgressWashCustomerPayload(?int $customer_number): ?array + { + if ($customer_number === null || $customer_number <= 0) { + return null; + } + + $customer_obj = (new users_o())->getUserByCustomerNumber($customer_number); + if ($customer_obj->exists()) { + return [ + 'id' => (int)$customer_obj->id, + 'customer_number' => $customer_number, + 'display_name' => $customer_obj->display_name->value() === null ? null : (string)$customer_obj->display_name->value(), + 'email' => $customer_obj->email->value() === null ? null : (string)$customer_obj->email->value(), + 'phone_country_code' => $customer_obj->phone_country_code->value() === null ? null : (int)$customer_obj->phone_country_code->value(), + 'phone' => $customer_obj->phone->value() === null ? null : (string)$customer_obj->phone->value(), + ]; + } + + return [ + 'id' => null, + 'customer_number' => $customer_number, + 'display_name' => null, + 'email' => null, + 'phone_country_code' => null, + 'phone' => null, + ]; + } + + /** + * @return array|null + */ + private function buildInProgressWashVehiclePayload(?int $vehicle_id, ?string $reg): ?array + { + $vehicle_obj = null; + if ($vehicle_id !== null && $vehicle_id > 0) { + $tmp_vehicle = (new customer_vehicles_o())->select($vehicle_id); + if ($tmp_vehicle->exists()) { + $vehicle_obj = $tmp_vehicle; + } + } + if ($vehicle_obj === null && $reg !== null && trim($reg) !== '') { + $tmp_vehicle = (new customer_vehicles_o())->selectByPlate(trim($reg)); + if ($tmp_vehicle->exists()) { + $vehicle_obj = $tmp_vehicle; + } + } + if ($vehicle_obj === null) { + return null; + } + + return [ + 'id' => (int)$vehicle_obj->id, + 'customer_id' => (int)$vehicle_obj->customer_id->value(), + 'type' => (int)$vehicle_obj->type->value(), + 'reg' => (string)$vehicle_obj->reg->value(), + 'reference' => $vehicle_obj->reference->value() === null ? null : (string)$vehicle_obj->reference->value(), + ]; + } + /** * @param array $status * @param array $extra diff --git a/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php index 8e6cd284..3a647451 100644 --- a/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php +++ b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php @@ -10,6 +10,14 @@ it('requires authentication before checking in-progress wash permissions', funct ->assertMessage('Authentication failed. Invalid or missing token.'); }); +it('requires authentication before checking the current customers active self-serve wash', function (): void { + $response = api_client()->get('/modules/self-serve/lane/wash/my-active-wash'); + + $response + ->assertStatus(401) + ->assertMessage('Authentication failed. Invalid or missing token.'); +}); + it('reports both elevated and customer self-serve permissions when lane polling is not allowed', function (): void { $session = api_fixtures()->createUserSession([]); @@ -26,6 +34,18 @@ it('reports both elevated and customer self-serve permissions when lane polling ]); }); +it('requires customer self-serve permission before checking my active wash', function (): void { + $session = api_fixtures()->createUserSession([]); + + $response = api_client()->get('/modules/self-serve/lane/wash/my-active-wash', $session['headers']); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'list_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', @@ -51,6 +71,46 @@ it('allows customer self-serve permission to view their own in-progress wash det ->and($response->data()['vehicle']['reg'] ?? null)->toBe($scenario['vehicle']['reg']); }); +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', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => [ + 'group_id' => $group['id'], + ], + ]); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/my-active-wash', + api_fixtures()->bearerHeaders($token) + ); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data()['in_progress'] ?? null)->toBeTrue() + ->and($response->data()['lane_id'] ?? null)->toBe((int)$scenario['lane']['id']) + ->and($response->data()['session']['id'] ?? null)->toBe((int)$scenario['session']['id']) + ->and($response->data()['session']['lane_id'] ?? null)->toBe((int)$scenario['lane']['id']) + ->and($response->data()['session']['customer_number'] ?? null)->toBe((int)$scenario['customer']['customer_number']) + ->and($response->data()['vehicle']['reg'] ?? null)->toBe($scenario['vehicle']['reg']); +}); + +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', + ]); + + $response = api_client()->get('/modules/self-serve/lane/wash/my-active-wash', $session['headers']); + + $response + ->assertStatus(404) + ->assertMessage('No active self-serve wash found.'); +}); + it('redacts another customers in-progress wash from customer self-serve lane polling', function (): void { $scenario = api_fixtures()->createSelfServeScenario(); $otherSession = api_fixtures()->createUserSession([ diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index 4112dfd1..3d937789 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -52,6 +52,7 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin expect($content)->toContain('/relay/button/press/post:'); expect($content)->toContain('/relay/machine/on/post:'); expect($content)->toContain('/modules/self-serve/lane/wash/in-progress:'); + expect($content)->toContain('/modules/self-serve/lane/wash/my-active-wash:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:'); expect($content)->toContain('/modules/self-serve/lane/gate/open:'); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index b742cbea..a895f77f 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -286,9 +286,12 @@ it('wires in-progress self-serve wash details endpoint', function (): void { expect($moduleSelfServeRoute)->not->toBeFalse(); 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('requireInProgressWashDetailsAccess($lane_id)'); + expect($moduleSelfServeRoute)->toContain('findLatestActiveSelfServeSessionForCustomer($customer_number)'); + 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())'); expect($moduleSelfServeRoute)->toContain('scopeInProgressWashResponseForCustomer');