Fix customer wash certificate access and emails

This commit is contained in:
Jeppe Bundgaard
2026-07-07 12:37:19 +02:00
parent 79185a3c76
commit 08ac16e665
7 changed files with 227 additions and 12 deletions
@@ -131,7 +131,7 @@
"post": {
"tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate",
"description": "Requires permission `download_own_wash_certificate`. Returns a presigned download link if certificate exists and user has access.",
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Returns a presigned download link if certificate exists and user has access.",
"parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -146,7 +146,7 @@
"get": {
"tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate PDF",
"description": "Requires permission `download_own_wash_certificate`. Checks both legacy and current storage buckets.",
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Checks both legacy and current storage buckets.",
"parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -381,7 +381,7 @@ class order_bookings_o extends db
}
$this->attachWashCertificate($user_id, $order->getSafetySealValue());
if ($order->hasWashCertificateAttached()) {
if ($this->getOrder()->hasWashCertificateAttached()) {
$this->sendWashCertificateToCustomer();
}
}
@@ -585,7 +585,7 @@ class order_bookings_o extends db
return !empty($this->order_id->value());
}
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, string $date = null): int
public function getDailyUnfulfilledBookingsCountForDepartment(int $department_id, ?string $date = null): int
{
global /** @var db $db */
$db;
+17 -8
View File
@@ -291,12 +291,11 @@ class bookingsRoute
// Require the user to be logged in
global /** @var response $response */
$response;
// Check if the user has access to the department
$this->requirePermission('download_own_wash_certificate');
$this->requireOwnWashCertificateDownloadAccess();
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if (!$user->exists()) {
if ($user === false || !$user->exists()) {
$response->error('User not found', 400);
}
// Check if the required fields are set
@@ -340,7 +339,7 @@ class bookingsRoute
);
},
[
'download_own_wash_certificate' => 'Download the wash certificate for a booking'
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
]
);
@@ -348,12 +347,11 @@ class bookingsRoute
// Require the user to be logged in
global /** @var response $response */
$response;
// Check if the user has access to the department
$this->requirePermission('download_own_wash_certificate');
$this->requireOwnWashCertificateDownloadAccess();
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if (!$user->exists()) {
if ($user === false || !$user->exists()) {
$response->error('User not found', 400);
}
self::requireParameters(['id']);
@@ -401,7 +399,7 @@ class bookingsRoute
}
},
[
'download_own_wash_certificate' => 'Download the wash certificate for a booking'
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
]
);
@@ -530,4 +528,15 @@ class bookingsRoute
]
);
}
private function requireOwnWashCertificateDownloadAccess(): void
{
$auth = new authentication();
$user = $auth->get_user();
if ($user !== false && $user->exists() && $this->hasPermission('user')) {
return;
}
$this->requirePermission('download_own_wash_certificate');
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
usesApiSuite();
function wash_certificate_download_legacy_booking(int $customerNumber, int $departmentId, array $attributes = []): array
{
return api_fixtures()->createLegacyBooking(array_merge([
'customer_number' => $customerNumber,
'department' => $departmentId,
'washCertificateStatus' => 'pending',
'status' => 'pending',
], $attributes));
}
it('lets customer accounts reach their own wash certificate download without the download permission', function (): void {
api_test_covers('POST /user/bookings/washcertificate/download', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Customer Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$session['user']['customer_number'],
(int)$department['id']
);
$response = api_client()->post('/user/bookings/washcertificate/download', [
'id' => (int)$booking['id'],
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Wash certificate has not been issued yet');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
it('keeps customer wash certificate downloads scoped to their own bookings', function (): void {
api_test_covers('POST /user/bookings/washcertificate/download', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Wash Certificate Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Other Wash Certificate Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$otherCustomer['customer_number'],
(int)$department['id']
);
$response = api_client()->post('/user/bookings/washcertificate/download', [
'id' => (int)$booking['id'],
], $session['headers']);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('You are not allowed to download this wash certificate');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
it('lets customer accounts reach the legacy wash certificate pdf download gate', function (): void {
api_test_covers('GET /bookings/download_pdf', 'customer-access');
$session = api_fixtures()->createUserSession(['user']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Legacy PDF Other Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Legacy PDF Department']);
$booking = wash_certificate_download_legacy_booking(
(int)$otherCustomer['customer_number'],
(int)$department['id']
);
$response = api_client()->get(
'/bookings/download_pdf?id=' . (int)$booking['id'],
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('You are not allowed to download this wash certificate');
expect($response->body)->not->toContain('download_own_wash_certificate');
});
@@ -838,6 +838,54 @@ final class ApiFixtures
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createLegacyBooking(array $attributes): array
{
$customerNumber = (int)($attributes['customer_number'] ?? 0);
$departmentId = (int)($attributes['department'] ?? $attributes['department_id'] ?? 0);
if ($customerNumber <= 0 || $departmentId <= 0) {
throw new RuntimeException('Legacy bookings require customer_number and department.');
}
$bookingId = $this->insertRowWithExistingColumns('bookings', [
'customer_number' => $customerNumber,
'wash_type' => (string)($attributes['wash_type'] ?? 'API wash'),
'contact_email' => (string)($attributes['contact_email'] ?? 'customer@example.test'),
'reference_number' => (string)($attributes['reference_number'] ?? 'API-LEGACY-BOOKING'),
'regNrTraekker' => (string)($attributes['regNrTraekker'] ?? 'LEG123'),
'regNrTrailer' => (string)($attributes['regNrTrailer'] ?? ''),
'washCertificateEmail' => (string)($attributes['washCertificateEmail'] ?? ''),
'date' => $attributes['date'] ?? $this->now(),
'department' => $departmentId,
'pickup_bool' => (int)($attributes['pickup_bool'] ?? 0),
'notes' => (string)($attributes['notes'] ?? ''),
'washCertificateStatus' => (string)($attributes['washCertificateStatus'] ?? 'pending'),
'washCertificateUrl' => (string)($attributes['washCertificateUrl'] ?? ''),
'wash_certificate_pdf' => $attributes['wash_certificate_pdf'] ?? null,
'status' => (string)($attributes['status'] ?? 'pending'),
'data' => json_encode($attributes['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'virtual_cart' => (int)($attributes['virtual_cart'] ?? 0),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(function () use ($bookingId): void {
$this->deleteById('bookings', $bookingId);
$this->deleteRedisKey('bookings_' . $bookingId . '_asArray');
$this->deleteRedisPattern('bookings:*');
});
return [
'id' => $bookingId,
'customer_number' => $customerNumber,
'department' => $departmentId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
@@ -1803,6 +1851,7 @@ final class ApiFixtures
'object_type' => 'users',
'object_id' => $userId,
]);
$this->deleteWhereIfPossible('bookings', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $customerNumber]);
@@ -536,6 +536,35 @@ CREATE TABLE IF NOT EXISTS `orders` (
KEY `idx_orders_period_customer_created_deleted` (`customer_id`, `created_at`, `deleted_at`),
KEY `idx_orders_period_created_deleted_customer` (`created_at`, `deleted_at`, `customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'bookings' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `bookings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`customer_number` INT NOT NULL,
`wash_type` VARCHAR(255) NULL,
`contact_email` VARCHAR(255) NULL,
`reference_number` VARCHAR(255) NULL,
`regNrTraekker` VARCHAR(32) NULL,
`regNrTrailer` VARCHAR(32) NULL,
`washCertificateEmail` VARCHAR(255) NULL,
`date` DATETIME NULL,
`department` INT NOT NULL DEFAULT 0,
`pickup_bool` TINYINT(1) NOT NULL DEFAULT 0,
`notes` TEXT NULL,
`washCertificateStatus` VARCHAR(32) NULL,
`washCertificateUrl` TEXT NULL,
`wash_certificate_pdf` VARCHAR(255) NULL,
`status` VARCHAR(32) NULL,
`data` LONGTEXT NULL,
`virtual_cart` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_bookings_customer_number` (`customer_number`),
KEY `idx_bookings_department` (`department`),
KEY `idx_bookings_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'order_bookings' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `order_bookings` (
@@ -99,6 +99,31 @@ if (!class_exists('OrderBookingsCompletionDouble')) {
}
}
if (!class_exists('OrderBookingsCompletionRefreshDouble')) {
class OrderBookingsCompletionRefreshDouble extends OrderBookingsCompletionDouble
{
public orders_o $refreshedOrder;
public function __construct(orders_o $initialOrder, orders_o $refreshedOrder)
{
parent::__construct($initialOrder);
$this->refreshedOrder = $refreshedOrder;
}
public function getOrder(): orders_o
{
return $this->attachCalls > 0 ? $this->refreshedOrder : $this->linkedOrder;
}
protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void
{
$this->attachCalls++;
$this->refreshedOrder->washCertificateAttached = true;
$this->refreshedOrder->safety_seal->set($safety_seal);
}
}
}
it('attaches and emails a wash certificate when a booking is already linked to a matching pos order without one', function (): void {
$order = new OrderBookingsCompletionOrderDouble();
$booking = new OrderBookingsCompletionDouble($order);
@@ -112,6 +137,22 @@ it('attaches and emails a wash certificate when a booking is already linked to a
expect($booking->sendCalls)->toBe(1);
});
it('reloads the linked order before deciding whether to email a newly attached wash certificate', function (): void {
$initialOrder = new OrderBookingsCompletionOrderDouble();
$refreshedOrder = new OrderBookingsCompletionOrderDouble();
$booking = new OrderBookingsCompletionRefreshDouble($initialOrder, $refreshedOrder);
$booking->order_id->set(321);
$booking->containsWashCertificate = true;
$booking->completeBooking(77, 'REFRESH-SEAL');
expect($initialOrder->washCertificateAttached)->toBeFalse();
expect($refreshedOrder->washCertificateAttached)->toBeTrue();
expect($refreshedOrder->getSafetySealValue())->toBe('REFRESH-SEAL');
expect($booking->attachCalls)->toBe(1);
expect($booking->sendCalls)->toBe(1);
});
it('rejects wash certificate completion when the linked pos order belongs to another booking context', function (): void {
$order = new OrderBookingsCompletionOrderDouble();
$order->customer_id->set(222222);