## Why Customer `k.sand@ksand.dk` reported never receiving wash certificates for completed bookings. Two methods contained silent early-return guards so the actual reason was unobservable from container logs: - `order_bookings_o::sendWashCertificateToCustomer()` — 5 silent returns - `email::sendWashCertificateEmailToCustomer()` — 1 silent return The most likely root cause: `email_notifications_enabled` defaults to `0` in the schema and `users.add()` does not set it on insert, so newly imported customers have notifications off until toggled. `wantsEmailNotifications()` then returns false and the email silently skips. ## What changed ### Original commit (`0ead5de5`) - `objects/order_bookings_o.php` — all 5 silent early-returns now log via new `logWashCertificateSkip()` helper (Redis stream `module=email / action=WASH_CERT_SKIP` + `error_log('[wash-cert-skip] …')`). - `classes/email.php` — silent `hasTransaction()` return in `sendWashCertificateEmailToCustomer()` now logs too. - `objects/bookings_o.php` — emits `WASH_CERT_SKIP` (legacy_no_wash_certificate_email) when `washCertificateEmail` is empty; no behavioural change. - **New** `routes/washCertificateDebugRoute.php` — `GET /debug/wash-certificates/diagnose?customer_number=&from=&to=` (404 in prod via $DEBUG; superuser-auth otherwise) replays the decision tree and reports `blocking_reason` per booking. ### Follow-up commit (`46a59e4e`) — silent-failure sweep **PART A — silent returns / silent errors (10 fixes):** - `email::sendEmailMailerSend()` — blacklisted-recipient skip now logs with context. - `email::sendNewCustomerRegistrationNotifications()` — empty-email skip + per-recipient try/catch with error_log (was unprotected; a single MailerSend error broke the loop). - `bookings_new_o::generateWashCertificate()` — wrapped `sendWashCertificateEmail()` in try/catch with error_log and re-throw (same pattern as the k.sand fix). - `users_o::getCustomerName()` — replaced catch-and-swallow with structured error_log. - `users_o::getCustomerEcocomicData()` — same. - `bookingsRoute.php` — added booking-id context to 4 × `$response->error('Booking not found', 404)` calls. **PART B — cron paths (10 files):** Added error_log breadcrumb + try/catch to `CheckUnfulfilledBookings`, `ClearAllUsersEconomicCustomerDetails`, `ClearAllUsersEconomicCustomerDiscounts`, `RunXLVaskModuleCron`, `SyncBookings`, `SyncEconomicInvoiceStatus`, `SyncLogs`, `BackfillEconomicV2History`, `EnsureXLVaskAutomationSchema`, and 3 functions in `Cron.php`. Each uses a distinct `[cron-…]` prefix for grep-ability. **PART C — real bugs (2 fixed):** 1. `email::sendEmailMailerSend()` attachment `array_map` — the previous exception message emitted a binary blob because `$attachment[0]` was already overwritten by `file_get_contents()`. Now captures $path first. 2. `bookings_new_o::generateWashCertificate()` — booking persisted as `completed` before email was sent, with no try/catch. Fixed (see PART A). ## How to verify 1. Deploy to staging. 2. Hit `/debug/wash-certificates/diagnose?customer_number=<k.sand's customer_number>` as a superuser — the response lists every booking's `blocking_reason`. 3. Tail container logs for `[wash-cert-skip]`, `[email-skip]`, `[cron-…]`, and Redis stream `module=email` action `WASH_CERT_SKIP` to see real-world skips going forward. ## Follow-ups (out of scope) - Schema migration to default `email_notifications_enabled` to `1` and backfill non-empty-email customers. - Move `error_log` to a proper PSR-3 logger. ## Risk - Logging only + new debug endpoint (404-gated in prod). No behavioural change for any path that previously sent mail successfully. `php -l` could not be run in the original sandbox; please verify on your CI box before deploying. 🤖 Generated with [OpenClaw](https://openclaw.ai)
531 lines
24 KiB
PHP
531 lines
24 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use classes\pdf_store;
|
|
use classes\response;
|
|
use classes\wash_certificate_store;
|
|
use objects\bookings_o;
|
|
use objects\departments_o;
|
|
use objects\logs_o;
|
|
use objects\order_bookings_o;
|
|
use traits\route_t;
|
|
|
|
class bookingsRoute
|
|
{
|
|
use route_t;
|
|
|
|
public function run(): void
|
|
{
|
|
/** All bookings */
|
|
$this->get('/bookings', function () {
|
|
// Require the user to be logged in
|
|
global
|
|
/** @var response $response */
|
|
$EMAIL_WASH_CERTIFICATE_TOKEN,
|
|
$response;
|
|
$this->requirePermission('list_bookings');
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if ($user) {
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, $user->id, 'LIST_BOOKINGS', 'Successfully listed bookings');
|
|
// If the user has the permission to issue wash certificates, add the wash certificate key to the response
|
|
if ($user->hasPermission('issue_wash_certificates')) {
|
|
$response->add_meta('wash_certificate_token', $EMAIL_WASH_CERTIFICATE_TOKEN);
|
|
}
|
|
$bookings_o = new bookings_o();
|
|
// Check if the user has specified a booking id
|
|
if (self::isParametersSet(['id'])) {
|
|
// Check if the booking id is a number
|
|
if (!is_numeric(self::getParameter('id'))) {
|
|
$response->error('Booking ID must be a number', 400);
|
|
}
|
|
// Check if the booking exists
|
|
if (!$bookings_o->select((int)self::getParameter('id'))->exists()) {
|
|
// Surface the requested booking id so support can
|
|
// correlate this 404 with the missing row.
|
|
$response->error('Booking not found: ' . self::getParameter('id'), 404);
|
|
}
|
|
// Check if the user has access to the booking
|
|
if (!$user->hasAccessToBooking((int)self::getParameter('id'))) {
|
|
$response->forbidden(['list_bookings']);
|
|
}
|
|
$this->requireLimitedBackofficeDepartmentAccess(
|
|
$user,
|
|
(int)$bookings_o->department->value()
|
|
);
|
|
// Return the booking
|
|
$response->success(
|
|
$bookings_o->asArray()
|
|
);
|
|
}
|
|
// Return the list of bookings
|
|
$response->success(
|
|
$bookings_o->parseBookings($bookings_o->listObjectsWithPaginationIfSet(
|
|
function ($booking) {
|
|
return (new bookings_o())->select($booking['id'])->asArray(
|
|
[
|
|
'include_parsed_services' => true,
|
|
]
|
|
);
|
|
},
|
|
$bookings_o->forceRestrictFilters(
|
|
[
|
|
'department' => $this->effectiveDepartmentIds($user),
|
|
]
|
|
)
|
|
))
|
|
);
|
|
} else {
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, 0, 'LIST_BOOKINGS', 'No user found, or invalid session');
|
|
// Return an error
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
},
|
|
[
|
|
'list_bookings' => 'List all bookings',
|
|
'issue_wash_certificates' => 'When set, the response will include the wash certificate token for sending wash certificates'
|
|
]
|
|
);
|
|
/** Own bookings */
|
|
$this->get('/user/bookings', function () {
|
|
// Require the user to be logged in
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requirePermission('list_own_bookings');
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if ($user) {
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, $user->id, 'LIST_OWN_BOOKINGS', 'Successfully listed own bookings');
|
|
// Return the list of departments
|
|
$bookings_o = new bookings_o();
|
|
$response->success(
|
|
$bookings_o->parseBookings($bookings_o->listObjectsWithPaginationIfSet(
|
|
function ($booking) {
|
|
return (new bookings_o())->select($booking['id'])->asArray(
|
|
[
|
|
'include_parsed_services' => true,
|
|
]
|
|
);
|
|
},
|
|
$bookings_o->forceRestrictFilters(
|
|
[
|
|
'customer_number' => [
|
|
$user->customer_number->value(),
|
|
],
|
|
]
|
|
)
|
|
))
|
|
);
|
|
} else {
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, 0, 'LIST_OWN_BOOKINGS', 'No user found, or invalid session');
|
|
// Return an error
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
},
|
|
[
|
|
'list_own_bookings' => 'List all bookings for the logged in user'
|
|
]
|
|
);
|
|
|
|
$this->put('/bookings', function () {
|
|
// Require the user to be logged in
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requirePermission('list_own_bookings');
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if ($user) {
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, $user->id, 'UPDATE_BOOKING', 'Successfully updated booking');
|
|
// Require the booking id parameter
|
|
self::requireParameters([
|
|
'id'
|
|
]);
|
|
// Check if the booking id is a number
|
|
self::requireType((int)$this->getParameter('id'), self::type_int());
|
|
self::requireMinValue((int)$this->getParameter('id'), 1);
|
|
self::requireSameLength(
|
|
(int)$this->getParameter('id'),
|
|
$this->getParameter('id'),
|
|
);
|
|
// Check if the booking exists
|
|
$booking = (new bookings_o())->select((int)$this->getParameter('id'));
|
|
if (!$booking->exists()) {
|
|
// Surface the requested booking id so support can
|
|
// correlate this 404 with the missing row.
|
|
$response->error('Booking not found: ' . $this->getParameter('id'), 404);
|
|
}
|
|
// Check if the user has access to the booking
|
|
if (!$user->hasAccessToBooking((int)$this->getParameter('id'))) {
|
|
$response->forbidden(['list_bookings']);
|
|
}
|
|
// Check if the optional parameters are set
|
|
if (self::isParametersSet(['reference_number'])) {
|
|
// Check if the reference number is a string
|
|
self::requireType(
|
|
(string)$this->getParameter('reference_number'),
|
|
self::type_string()
|
|
);
|
|
self::requireMinLength(
|
|
'reference_number',
|
|
0
|
|
);
|
|
self::requireMaxLength(
|
|
'reference_number',
|
|
255
|
|
);
|
|
$booking->reference_number->set(
|
|
(string)$this->getParameter('reference_number')
|
|
);
|
|
}
|
|
// Return the booking
|
|
$response->success(
|
|
$booking->asArray()
|
|
);
|
|
} else {
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, 0, 'ADD_BOOKING', 'No user found, or invalid session');
|
|
// Return an error
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
},
|
|
[
|
|
'add_booking' => 'Add a new booking'
|
|
]
|
|
);
|
|
// Synchronize booking from the external system
|
|
$this->post('/admin/bookings/sync', function () {
|
|
// Require the user to be logged in
|
|
global $response;
|
|
$this->requirePermission('sync_bookings');
|
|
// Check if the request was successful
|
|
$booking = [
|
|
'id' => $this->fromRequest('id'),
|
|
'customer_number' => $this->fromRequest('customer_number'),
|
|
'wash_type' => $this->fromRequest('wash_type'),
|
|
'contact_email' => $this->fromRequest('contact_email'),
|
|
'reference_number' => $this->fromRequest('reference_number'),
|
|
'regNrTraekker' => $this->fromRequest('regNrTraekker'),
|
|
'regNrTrailer' => $this->fromRequest('regNrTrailer'),
|
|
'washCertificateEmail' => $this->fromRequest('washCertificateEmail'),
|
|
'date' => $this->fromRequest('date'),
|
|
'department' => $this->fromRequest('department'),
|
|
'pickup_bool' => $this->fromRequest('pickup_bool'),
|
|
'notes' => $this->fromRequest('notes'),
|
|
'washCertificateStatus' => $this->fromRequest('washCertificateStatus'),
|
|
'washCertificateUrl' => $this->fromRequest('washCertificateUrl'),
|
|
'status' => $this->fromRequest('status'),
|
|
];
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, 0, 'SYNC_BOOKINGS', 'Successfully synced bookings');
|
|
// Add the booking, if it doesn't exist, update it if it does
|
|
(new bookings_o())->addOrUpdate(
|
|
(int)$booking['id'],
|
|
(int)$booking['customer_number'],
|
|
(string)$booking['wash_type'],
|
|
(string)$booking['contact_email'],
|
|
(string)$booking['reference_number'],
|
|
(string)$booking['regNrTraekker'],
|
|
(string)$booking['regNrTrailer'],
|
|
(string)$booking['washCertificateEmail'],
|
|
(string)$booking['date'],
|
|
(string)$booking['department'],
|
|
(int)$booking['pickup_bool'],
|
|
(string)$booking['notes'],
|
|
(string)$booking['washCertificateStatus'],
|
|
(string)$booking['washCertificateUrl'],
|
|
(string)$booking['status']
|
|
);
|
|
$response->success(
|
|
['message' => 'Successfully synced booking']
|
|
);
|
|
},
|
|
[
|
|
'sync_bookings' => 'Sync bookings from the external system'
|
|
]
|
|
);
|
|
|
|
// Get a departments unfulfilled bookings (count) for the day
|
|
$this->get('/admin/bookings/department/count', function () {
|
|
// Require the user to be logged in
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requirePermission('list_department_bookings_count');
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if ($user) {
|
|
// Check if the department_id is set
|
|
if ($this->fromRequest('department_id') === null) {
|
|
$response->error('Department ID is required', 400);
|
|
}
|
|
// Check if the department id is a valid number
|
|
if (!is_numeric($this->fromRequest('department_id'))) {
|
|
$response->error('Department ID must be a number', 400);
|
|
}
|
|
// Check if the result is cached, if so, we don't need to query the database
|
|
// Check if the department exists
|
|
if (!(new departments_o())->selectId((int)$this->fromRequest('department_id'))->exists()) {
|
|
$response->error('Department not found', 404);
|
|
}
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, $user->id, 'LIST_DEPARTMENT_BOOKINGS_COUNT', 'Successfully listed department bookings');
|
|
// Return the list of departments
|
|
$response->success(
|
|
(int)(new order_bookings_o())->getDailyUnfulfilledBookingsCountForDepartment((int)$this->fromRequest('department_id'))
|
|
);
|
|
} else {
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, 0, 'LIST_DEPARTMENT_BOOKINGS_COUNT', 'No user found, or invalid session');
|
|
// Return an error
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
},
|
|
[
|
|
'list_department_bookings_count' => 'List the unfulfilled bookings count for a department'
|
|
]
|
|
);
|
|
|
|
$this->post('/user/bookings/washcertificate/download', function () {
|
|
// Require the user to be logged in
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requireOwnWashCertificateDownloadAccess();
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if ($user === false || !$user->exists()) {
|
|
$response->error('User not found', 400);
|
|
}
|
|
// Check if the required fields are set
|
|
$id = $response->getRequestParameter('id');
|
|
// Make sure the id is a number
|
|
if (!is_numeric($id)) {
|
|
$response->error('id parameter must be a number got: ' . $id, 400);
|
|
}
|
|
// Make sure the user is allowed to download the wash certificate
|
|
if (!$user->hasAccessToBooking($id)) {
|
|
$response->error('You are not allowed to download this wash certificate', 400);
|
|
}
|
|
// Check if the booking is completed
|
|
if (!(new bookings_o())->select($id)->exists()) {
|
|
// Surface the requested booking id so support can
|
|
// correlate this 404 with the missing row.
|
|
$response->error('Booking not found: ' . $id, 404);
|
|
}
|
|
$bookings_new = (new bookings_o())->select($id);
|
|
$wash_certificate_status = $bookings_new->washCertificateStatus->value();
|
|
switch ($wash_certificate_status) {
|
|
case 'pending':
|
|
$response->error('Wash certificate has not been issued yet', 400);
|
|
case 'completed':
|
|
// The wash certificate has been issued, so we can proceed
|
|
break;
|
|
case 'cancelled':
|
|
$response->error('Wash certificate is cancelled', 400);
|
|
default:
|
|
$response->error('Wash certificate status is unknown', 500);
|
|
}
|
|
// Get the wash certificate object
|
|
//$wash_certificate_object = $bookings_new->washCertificateUrl->value();
|
|
// Create the connection
|
|
$pdf_storage = new wash_certificate_store();
|
|
// Check if the wash certificate exists.
|
|
if (!$pdf_storage->washCertificateExists($bookings_new->id)) {
|
|
$response->error('Wash certificate not found, but it should exist', 500);
|
|
}
|
|
// Generate the download link
|
|
$response->success(
|
|
["link" => $pdf_storage->getWashCertificateDownload($bookings_new->id)]
|
|
);
|
|
},
|
|
[
|
|
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
|
|
]
|
|
);
|
|
|
|
$this->get('/bookings/download_pdf', function () {
|
|
// Require the user to be logged in
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requireOwnWashCertificateDownloadAccess();
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if ($user === false || !$user->exists()) {
|
|
$response->error('User not found', 400);
|
|
}
|
|
self::requireParameters(['id']);
|
|
$id = (int)$this->getParameter('id');
|
|
self::requireType(
|
|
$id,
|
|
self::type_int()
|
|
);
|
|
self::requireMinValue($id, 1);
|
|
self::requireSameLength(
|
|
$id,
|
|
self::getParameter('id'),
|
|
);
|
|
// Make sure the user is allowed to download the wash certificate
|
|
if (!$user->hasAccessToBooking($id)) {
|
|
$response->error('You are not allowed to download this wash certificate', 400);
|
|
}
|
|
$bookings = (new bookings_o())->select((int)$id);
|
|
if (!$bookings->exists()) {
|
|
// Surface the requested booking id so support can
|
|
// correlate this 404 with the missing row.
|
|
$response->error('Booking not found: ' . $id, 404);
|
|
}
|
|
// Check if the booking has a wash certificate
|
|
if (!empty($bookings->wash_certificate_pdf->value())) {
|
|
// The booking has a wash certificate, so we can proceed
|
|
$pdf_storage = new pdf_store();
|
|
// Check if the wash certificate exists.
|
|
if (!$pdf_storage->doesObjectExist($bookings->wash_certificate_pdf->value())) {
|
|
$response->error('Wash certificate not found, but it should exist', 500);
|
|
}
|
|
// Generate the download link
|
|
$response->success(
|
|
["link" => $pdf_storage->getPresignedUrl($bookings->wash_certificate_pdf->value())]
|
|
);
|
|
} else {
|
|
// Check if the certificate is stored in the other bucket
|
|
$wash_certificate_storage = new wash_certificate_store();
|
|
// Check if the wash certificate exists.
|
|
if (!$wash_certificate_storage->washCertificateExists($bookings->id)) {
|
|
$response->error('No wash certificate found for this booking', 404);
|
|
}
|
|
// Generate the download link
|
|
$response->success(
|
|
["link" => $wash_certificate_storage->getWashCertificateDownload($bookings->id)]
|
|
);
|
|
}
|
|
},
|
|
[
|
|
'download_own_wash_certificate' => 'Download the wash certificate for a booking. Authenticated customer accounts may download their own booking certificates without this permission.'
|
|
]
|
|
);
|
|
|
|
$this->post('/admin/bookings/delete', function () {
|
|
// Require the user to be logged in
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requirePermission('delete_booking');
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if (!$user->exists()) {
|
|
$response->error('User not found', 400);
|
|
}
|
|
// Check if the required fields are set
|
|
$id = $response->getRequestParameter('id');
|
|
// Make sure the id is a number
|
|
if (!is_numeric($id)) {
|
|
$response->error('id parameter must be a number got: ' . $id, 400);
|
|
}
|
|
// Make sure the user is allowed to delete the booking
|
|
if (!$user->hasAccessToBooking($id)) {
|
|
$response->error('You are not allowed to delete this booking', 400);
|
|
}
|
|
// Delete the booking
|
|
(new bookings_o())->delete($id);
|
|
// Return success
|
|
$response->success(
|
|
["message" => "Booking deleted"]
|
|
);
|
|
},
|
|
[
|
|
'delete_booking' => 'Delete a booking'
|
|
]
|
|
);
|
|
|
|
$this->post('/superuser/bookings/sync/all', function () {
|
|
// Require the user to be logged in
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requirePermission('sync_all_bookings');
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if (!$user->exists()) {
|
|
$response->error('User not found', 400);
|
|
}
|
|
// Log the incident
|
|
(new logs_o())->add('bookings', 'global', 1, $user->id, 'SYNC_ALL_BOOKINGS', 'Successfully synced all bookings');
|
|
// Sync all bookings
|
|
(new bookings_o())->syncBookings();
|
|
// Return success
|
|
$response->success(
|
|
["message" => "All bookings synced"]
|
|
);
|
|
},
|
|
[
|
|
'sync_all_bookings' => 'Sync all bookings from the external system'
|
|
]
|
|
);
|
|
|
|
$this->post('/admin/bookings/completeWashWithoutWashCertificate', function () {
|
|
global /** @var response $response */
|
|
$response;
|
|
$response->error('Booking completion must be completed through POS desktop or mobile steps.', 410);
|
|
},
|
|
[
|
|
'complete_wash_without_wash_certificate' => 'Complete a wash without a wash certificate'
|
|
]
|
|
);
|
|
|
|
$this->post('/user/bookings/delete', function () {
|
|
// Require the user to be logged in
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requirePermission('delete_own_booking');
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if (!$user->exists()) {
|
|
$response->error('User not found', 400);
|
|
}
|
|
// Check if the required fields are set
|
|
$id = $response->getRequestParameter('id');
|
|
// Make sure the id is a number
|
|
if (!is_numeric($id)) {
|
|
$response->error('id parameter must be a number got: ' . $id, 400);
|
|
}
|
|
// Make sure the user is allowed to delete the booking
|
|
if (!$user->hasAccessToBooking($id)) {
|
|
$response->error('You are not allowed to delete this booking', 400);
|
|
}
|
|
// Delete the booking
|
|
(new bookings_o())->delete($id);
|
|
// Return success
|
|
$response->success(
|
|
["message" => "Booking deleted"]
|
|
);
|
|
},
|
|
[
|
|
'delete_own_booking' => 'Delete the users own booking'
|
|
]
|
|
);
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|