Merge remote-tracking branch 'origin/master'

This commit is contained in:
Jeppe Bundgaard
2026-06-02 10:29:25 +02:00
8 changed files with 553 additions and 11 deletions
+26
View File
@@ -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:
@@ -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<int,string> $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
@@ -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;
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
putenv('EMAIL_FAKE_MODE=1');
usesApiSuite();
it('allows department admins to resend order booking confirmations', function (): void {
$customer = api_fixtures()->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']]);
});
@@ -0,0 +1,205 @@
<?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'],
'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'],
]);
});
@@ -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),