Fix customer wash certificate access and emails
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user