diff --git a/openapi.yaml b/openapi.yaml index 1a02883a..7b935287 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5874,6 +5874,32 @@ paths: '200': description: Success + /order-bookings/booking-confirmation/resend: + post: + tags: + - Bookings + summary: Resend order booking confirmation + description: Resends the customer booking confirmation email for an order booking. Requires `resend_booking_confirmations` and access to the booking's department. + operationId: resendOrderBookingConfirmation + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Booking confirmation resent successfully + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + /order-bookings/complete: post: tags: diff --git a/scripts/php-ci-test.sh b/scripts/php-ci-test.sh index 63158f36..18ba43d7 100644 --- a/scripts/php-ci-test.sh +++ b/scripts/php-ci-test.sh @@ -51,6 +51,23 @@ collect_logs() { docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true } +retry_command() { + max_attempts="$1" + shift + attempt=1 + while :; do + "$@" && return 0 + status="$?" + if [ "$attempt" -ge "$max_attempts" ]; then + return "$status" + fi + sleep_seconds=$((attempt * 5)) + echo "Command failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts): $*" >&2 + sleep "$sleep_seconds" + attempt=$((attempt + 1)) + done +} + cleanup() { status="$?" collect_logs "$status" @@ -70,7 +87,7 @@ cleanup() { } trap cleanup EXIT INT TERM -docker compose $compose_files up -d redis mysql-debug php1 +retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1 docker compose $compose_files exec -T php1 sh -lc ' set -eu diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 2b9d91d3..1a675cbe 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -6239,6 +6239,32 @@ paths: '200': description: Success + /order-bookings/booking-confirmation/resend: + post: + tags: + - Bookings + summary: Resend order booking confirmation + description: Resends the customer booking confirmation email for an order booking. Requires `resend_booking_confirmations` and access to the booking's department. + operationId: resendOrderBookingConfirmation + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Booking confirmation resent successfully + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + /order-bookings/complete: post: tags: diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 3925a266..ec7a51e6 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -475,18 +475,34 @@ class moduleSelfServeRoute self::requireMinValue($lane_id, 1); $commandParam = (string)$this->getParameter($param_command); self::requireType($commandParam, self::type_string()); - // Get the lane and command - $lane = $selfserve->lane($lane_id); - // If the user has the bypass permission, set the lane to bypass customer number validation - if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) { - $lane->setBypassCustomerNumberValidation(true); - } $command = selfserve_lane_command::tryFrom($commandParam); if ($command === null) { $response->error("Invalid command: " . $commandParam); } + // Get the lane and command + $lane = $selfserve->lane($lane_id); + // Preserve not-found behavior before evaluating elevated/customer alternatives. + if (empty($lane->department_lane) || empty($lane->department_lane->department)) { + $response->error('Lane department not found', 404); + } $customer_number = $this->resolveEffectiveCustomerNumber(); $customer_number = $customer_number === null ? 0 : (int)$customer_number; + [ + $allow_customer_self_serve, + $requires_active_wash, + $allow_department_active_wash + ] = $this->customerSelfServeCommandAccessRequirements($command); + $this->requireSelfServeLaneDepartmentOrCustomerAccess( + $lane, + $customer_number, + $allow_customer_self_serve, + $requires_active_wash, + $allow_department_active_wash + ); + // If the user has the bypass permission, set the lane to bypass customer number validation + if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) { + $lane->setBypassCustomerNumberValidation(true); + } // Require permissions for specific commands switch ($command) { case selfserve_lane_command::START: @@ -503,7 +519,8 @@ class moduleSelfServeRoute $customer_number, 'modules_selfserve_lane_command_execute_stop', true, - true + true, + false ); break; case selfserve_lane_command::RESERVE: @@ -1557,6 +1574,51 @@ class moduleSelfServeRoute $lane->setShellyTransportOverride($transport); } + /** + * @return array{0:bool,1:bool,2:bool} [customer self-serve allowed, active wash required, department active wash fallback allowed] + */ + private function customerSelfServeCommandAccessRequirements(selfserve_lane_command $command): array + { + return match ($command) { + selfserve_lane_command::START => [true, false, false], + selfserve_lane_command::STOP => [true, true, false], + selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, + selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE => [true, true, true], + selfserve_lane_command::RESERVE, + selfserve_lane_command::RELEASE, + selfserve_lane_command::RESET => [false, false, false], + }; + } + + private function requireSelfServeLaneDepartmentOrCustomerAccess( + selfserve_lane $lane, + int $customer_number, + bool $allow_customer_self_serve, + bool $requires_active_wash = false, + bool $allow_department_active_wash = false + ): void { + $department_id = $this->departmentIdForLane($lane); + if ($department_id > 0 && $this->hasDepartmentAccess((string)$department_id)) { + return; + } + + if ($allow_customer_self_serve) { + $customer_allowed = $requires_active_wash + ? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash) + : $this->canCustomerUseSelfServeLane($lane, $customer_number); + + if ($customer_allowed) { + return; + } + } + + $missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : []; + if ($allow_customer_self_serve) { + $missing_permissions[] = self::CUSTOMER_SELFSERVE_PERMISSION; + } + $this->emitForbidden($missing_permissions); + } + /** * @param array $permissions */ @@ -1604,7 +1666,8 @@ class moduleSelfServeRoute int $customer_number, string $command_permission, bool $allow_customer_self_serve, - bool $requires_active_wash = false + bool $requires_active_wash = false, + bool $allow_department_active_wash = false ): void { $elevated_permissions = [ 'modules_selfserve_lane_command_execute', @@ -1616,7 +1679,7 @@ class moduleSelfServeRoute if ($allow_customer_self_serve && $this->isSelfServeModuleEnabled()) { $customer_allowed = $requires_active_wash - ? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number) + ? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash) : $this->canCustomerUseSelfServeLane($lane, $customer_number); if ($customer_allowed) { @@ -1697,7 +1760,61 @@ class moduleSelfServeRoute protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool { - return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number); + return $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true); + } + + protected function canCustomerUseActiveOperationalSelfServeLane( + selfserve_lane $lane, + int $customer_number, + bool $allow_department_active_wash = false + ): bool { + return $this->isLaneSelfServeOperationallyEnabled($lane) + && ( + $allow_department_active_wash + ? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number) + : $this->canCustomerUseActiveSelfServeLaneSession($lane, $customer_number) + ); + } + + protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool + { + if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) { + return false; + } + + try { + if ((int)$lane->getCustomerNumber() === $customer_number) { + return true; + } + } catch (\Throwable) { + // Fall back to the persisted lane session lookup below. + } + + $active_statuses = 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, + ] + ); + + $sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([ + 'lane_id' => (int)$lane->id, + 'customer_number' => $customer_number, + 'completed_at' => null, + 'deleted_at' => null, + ], ['id', 'status']); + + foreach ($sessions as $session) { + if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) { + return true; + } + } + + return false; } protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index e2e784f0..a23dca75 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\email; use classes\order_bookings_counts_cache; use classes\order_bookings_list_cache; use classes\redis; @@ -368,6 +369,29 @@ class orderBookingRoute ] ); + $this->post('/order-bookings/booking-confirmation/resend', function () { + global $response; + + $object = self::getTargetObject(); + if (!$object || !$object->exists()) { + $response->error('Order booking does not exist.', 400); + } + + self::requirePermission('resend_booking_confirmations'); + self::requireDepartmentAccess((int)$object->department->value()); + + (new email())->sendOrderBookingConfirmationEmail($object); + + $response->success([ + 'message' => 'Booking confirmation resent successfully.', + 'booking' => $object->asArray(), + ]); + }, + [ + 'resend_booking_confirmations' => 'Permission for department admins to resend order booking confirmation emails.' + ] + ); + $this->post('/order-bookings/complete', function () { // Require the user to be logged in global $response; diff --git a/services/nginx/app/tests/Api/OrderBookingConfirmationResendApiTest.php b/services/nginx/app/tests/Api/OrderBookingConfirmationResendApiTest.php new file mode 100644 index 00000000..91b2b026 --- /dev/null +++ b/services/nginx/app/tests/Api/OrderBookingConfirmationResendApiTest.php @@ -0,0 +1,78 @@ +createUser([ + 'display_name' => 'Resend Booking Confirmation Customer', + 'email' => 'resend-booking-confirmation@example.test', + ]); + $branding = api_fixtures()->createBranding([ + 'name' => 'Resend Booking Confirmation Brand', + 'address' => 'Resend Booking Confirmation Address 1', + ]); + $department = api_fixtures()->createDepartment([ + 'name' => 'Resend Booking Confirmation Department', + 'branding' => $branding['id'], + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'RESEND-CONFIRMATION', + 'reg_1' => 'RESEND1', + ]); + $session = api_fixtures()->createUserSession([ + 'resend_booking_confirmations', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post('/order-bookings/booking-confirmation/resend', [ + 'id' => $booking['id'], + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('message', 'Booking confirmation resent successfully.') + ->toHaveKey('booking') + ->and($response->data()['booking']) + ->toBeArray() + ->and($response->data()['booking']['id'] ?? null) + ->toBe($booking['id']) + ->and($response->data()['booking']['reference'] ?? null) + ->toBe('RESEND-CONFIRMATION'); +}); + +it('requires department access when resending order booking confirmations', function (): void { + $customer = api_fixtures()->createUser([ + 'display_name' => 'Resend Booking Confirmation Foreign Customer', + ]); + $department = api_fixtures()->createDepartment([ + 'name' => 'Resend Booking Confirmation Foreign Department', + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + ]); + $session = api_fixtures()->createUserSession([ + 'resend_booking_confirmations', + ]); + + $response = api_client()->post('/order-bookings/booking-confirmation/resend', [ + 'id' => $booking['id'], + ], $session['headers']); + + $response + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['department_access_' . $department['id']]); +}); diff --git a/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php b/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php new file mode 100644 index 00000000..4ffdd7d4 --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php @@ -0,0 +1,205 @@ + getenv('REDIS_CONFIG_HOST') ?: getenv('REDIS_CONFIG_DEBUG_HOST') ?: 'redis', + 'user' => getenv('REDIS_CONFIG_USER') ?: getenv('REDIS_CONFIG_DEBUG_USER') ?: 'default', + 'database' => getenv('REDIS_CONFIG_DATABASE') ?: getenv('REDIS_CONFIG_DEBUG_DATABASE') ?: '0', + 'password' => getenv('REDIS_CONFIG_PASSWORD') ?: getenv('REDIS_CONFIG_DEBUG_PASSWORD') ?: '', + 'port' => getenv('REDIS_CONFIG_PORT') ?: getenv('REDIS_CONFIG_DEBUG_PORT') ?: '6379', + ]; + + define('redis', (new \classes\redis())->connect()); +} + +function selfserve_lane_command_lane(int $laneId): \modules\selfserve\classes\selfserve_lane +{ + selfserve_lane_command_ensure_legacy_redis_constant(); + + return (new \classes\selfserve())->lane($laneId); +} + +function selfserve_lane_command_make_available(int $laneId): void +{ + $lane = selfserve_lane_command_lane($laneId); + $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE); + $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IDLE); + $lane->setCustomerNumber(0); + $lane->setLicensePlate(''); + $lane->setWashStartTime(0); +} + +function selfserve_lane_command_make_occupied(int $laneId, int $customerNumber, string $licensePlate): void +{ + $lane = selfserve_lane_command_lane($laneId); + $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED); + $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); + $lane->setCustomerNumber($customerNumber); + $lane->setLicensePlate($licensePlate); + $lane->setWashStartTime(time() - 60); +} + +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', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => ['group_id' => $group['id']], + 'department_selfserve_enabled' => true, + 'lane_selfserve_enabled' => true, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'START', + 'license_plate' => (string)$scenario['vehicle']['reg'], + 'defer_relay_side_effects' => true, + ], api_fixtures()->bearerHeaders($token)); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data()['status'] ?? null)->toBe('OCCUPIED') + ->and($response->data()['customer_number'] ?? null)->toBe((int)$scenario['customer']['customer_number']); +}); + +it('denies customer START when department self-serve is disabled', function (): void { + $group = api_fixtures()->createGroup([], [ + 'list_own_department_selfserve_vehicle_conditions', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => ['group_id' => $group['id']], + 'department_selfserve_enabled' => false, + 'lane_selfserve_enabled' => true, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'START', + 'license_plate' => (string)$scenario['vehicle']['reg'], + ], api_fixtures()->bearerHeaders($token)); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'department_access_' . (int)$scenario['department']['id'], + 'list_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', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => ['group_id' => $group['id']], + 'department_selfserve_enabled' => true, + 'lane_selfserve_enabled' => false, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'START', + 'license_plate' => (string)$scenario['vehicle']['reg'], + ], api_fixtures()->bearerHeaders($token)); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'department_access_' . (int)$scenario['department']['id'], + 'list_own_department_selfserve_vehicle_conditions', + ]); +}); + +it('denies a customer STOP for another customers active lane', function (): void { + $scenario = api_fixtures()->createSelfServeScenario([ + 'department_selfserve_enabled' => true, + 'lane_selfserve_enabled' => true, + ]); + selfserve_lane_command_make_occupied( + (int)$scenario['lane']['id'], + (int)$scenario['customer']['customer_number'], + (string)$scenario['vehicle']['reg'] + ); + $otherSession = api_fixtures()->createUserSession([ + 'list_own_department_selfserve_vehicle_conditions', + ]); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'STOP', + ], $otherSession['headers']); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'department_access_' . (int)$scenario['department']['id'], + 'list_own_department_selfserve_vehicle_conditions', + ]); +}); + +it('still allows elevated operators with department access to execute lane commands', function (): void { + $scenario = api_fixtures()->createSelfServeScenario([ + 'department_selfserve_enabled' => false, + 'lane_selfserve_enabled' => false, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $session = api_fixtures()->createUserSession([ + 'department_access_' . (int)$scenario['department']['id'], + 'modules_selfserve_lane_command_execute', + 'modules_selfserve_lane_command_execute_start', + ]); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'START', + 'license_plate' => 'OP' . (int)$scenario['lane']['id'], + 'defer_relay_side_effects' => true, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data()['status'] ?? null)->toBe('OCCUPIED'); +}); + +it('keeps operator-only commands elevated-only for customers and reports command permissions', function (): void { + $group = api_fixtures()->createGroup([], [ + 'list_own_department_selfserve_vehicle_conditions', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => ['group_id' => $group['id']], + 'department_selfserve_enabled' => true, + 'lane_selfserve_enabled' => true, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'RESET', + ], api_fixtures()->bearerHeaders($token)); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'department_access_' . (int)$scenario['department']['id'], + ]); +}); diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 0e25bfbb..b4649240 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -145,6 +145,47 @@ final class ApiFixtures return ['id' => $departmentId]; } + public function setDepartmentSelfServeEnabled(int $departmentId, bool $enabled): void + { + if ($departmentId <= 0) { + throw new RuntimeException('Department self-serve fixtures require a positive department id.'); + } + + $this->cleanupDeleteWhere('department_variables', [ + 'department_id' => $departmentId, + 'variable' => 'selfserve_enabled', + ]); + + $existingIds = $this->fetchIntColumnWhere('department_variables', 'id', [ + 'department_id' => $departmentId, + 'variable' => 'selfserve_enabled', + ]); + if ($existingIds !== []) { + $this->updateById('department_variables', (int)$existingIds[0], [ + 'value' => $enabled ? 'true' : 'false', + ]); + return; + } + + $variableId = $this->insertRowWithExistingColumns('department_variables', [ + 'department_id' => $departmentId, + 'variable' => 'selfserve_enabled', + 'value' => $enabled ? 'true' : 'false', + ]); + $this->cleanup->add(fn() => $this->deleteById('department_variables', $variableId)); + } + + public function setLaneSelfServeEnabled(int $laneId, bool $enabled): void + { + if ($laneId <= 0) { + throw new RuntimeException('Lane self-serve fixtures require a positive lane id.'); + } + + $this->updateById('department_lanes', $laneId, [ + 'selfserve_enabled' => $enabled ? 1 : 0, + ]); + } + /** * @param array $attributes * @return array @@ -295,6 +336,14 @@ final class ApiFixtures $laneId = $this->insertRowWithExistingColumns('department_lanes', $laneData); $this->cleanup->add(fn() => $this->deleteById('department_lanes', $laneId)); + $this->setDepartmentSelfServeEnabled( + (int)$department['id'], + (bool)($overrides['department_selfserve_enabled'] ?? true) + ); + if (array_key_exists('lane_selfserve_enabled', $overrides)) { + $this->setLaneSelfServeEnabled($laneId, (bool)$overrides['lane_selfserve_enabled']); + } + $customer = $this->createUser(array_merge([ 'display_name' => 'API Self-Serve Customer ' . strtoupper($suffix), 'economic_customer_name' => 'API Self-Serve Customer ' . strtoupper($suffix),