Allow customer self-serve lane commands
This commit is contained in:
@@ -458,23 +458,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);
|
||||
// Require access to the lane's department to prevent cross-department command execution
|
||||
if (empty($lane->department_lane) || empty($lane->department_lane->department)) {
|
||||
$response->error('Lane department not found', 404);
|
||||
}
|
||||
self::requireDepartmentAccess((string)$lane->department_lane->department->value());
|
||||
// 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:
|
||||
@@ -482,7 +493,7 @@ class moduleSelfServeRoute
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_start',
|
||||
false
|
||||
true
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::STOP:
|
||||
@@ -491,7 +502,8 @@ class moduleSelfServeRoute
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_stop',
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::RESERVE:
|
||||
@@ -519,10 +531,18 @@ class moduleSelfServeRoute
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_open_property_access_gate');
|
||||
$this->requirePropertyGateCommandPermission(
|
||||
'modules_selfserve_lane_command_execute_open_property_access_gate',
|
||||
$lane,
|
||||
$customer_number
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_open_property_exit_gate');
|
||||
$this->requirePropertyGateCommandPermission(
|
||||
'modules_selfserve_lane_command_execute_open_property_exit_gate',
|
||||
$lane,
|
||||
$customer_number
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Execute the command
|
||||
@@ -1361,6 +1381,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<int,string> $permissions
|
||||
*/
|
||||
@@ -1408,7 +1473,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',
|
||||
@@ -1420,7 +1486,7 @@ class moduleSelfServeRoute
|
||||
|
||||
if ($allow_customer_self_serve) {
|
||||
$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) {
|
||||
@@ -1480,7 +1546,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
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function selfserve_lane_command_ensure_legacy_redis_constant(): void
|
||||
{
|
||||
if (defined('redis')) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $REDIS_CONFIG;
|
||||
|
||||
$REDIS_CONFIG = [
|
||||
'host' => 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'],
|
||||
], 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'],
|
||||
], $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'],
|
||||
]);
|
||||
});
|
||||
@@ -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<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user