## 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)
239 lines
10 KiB
PHP
239 lines
10 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use Exception;
|
|
use objects\logs_o;
|
|
use objects\order_bookings_o;
|
|
use objects\users_o;
|
|
use traits\route_t;
|
|
|
|
/**
|
|
* Debug route for diagnosing wash certificate delivery failures.
|
|
*
|
|
* Example failure: k.sand@ksand.dk reported never receiving wash certificates.
|
|
* Each silent early-return in sendWashCertificateToCustomer() previously made
|
|
* this kind of issue very hard to triage without DB access. This endpoint
|
|
* simulates the same decision tree for a given customer so we can pinpoint
|
|
* exactly which condition would have caused a skip in production.
|
|
*
|
|
* Access is intentionally restricted:
|
|
* - Only available when $DEBUG is true (no route behaviour in prod).
|
|
* - Additionally requires an authenticated superuser.
|
|
*
|
|
* GET /debug/wash-certificates/diagnose?customer_number=12345&from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
*/
|
|
class washCertificateDebugRoute
|
|
{
|
|
use route_t;
|
|
|
|
public function run(): void
|
|
{
|
|
$this->get('/debug/wash-certificates/diagnose', function () {
|
|
global $response, $DEBUG;
|
|
|
|
if (empty($DEBUG)) {
|
|
// 404 hides the existence of this dev tool in production.
|
|
$response->error('Not found', 404);
|
|
}
|
|
|
|
$auth = new authentication();
|
|
$user = $auth->get_user();
|
|
if ($user === false) {
|
|
$response->error('Unauthorized', 401);
|
|
}
|
|
if (method_exists($user, 'hasPermission') && !$user->hasPermission('superuser')) {
|
|
$response->error('Superuser permission required', 403);
|
|
}
|
|
|
|
self::requireParameters(['customer_number']);
|
|
$customer_number = (int)self::getParameter('customer_number');
|
|
if ($customer_number < 1) {
|
|
$response->error('Invalid customer_number', 400);
|
|
}
|
|
|
|
$from = self::isParametersSet(['from']) ? (string)self::getParameter('from') : null;
|
|
$to = self::isParametersSet(['to']) ? (string)self::getParameter('to') : null;
|
|
if ($from !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
|
|
$response->error('Invalid from (expected YYYY-MM-DD)', 400);
|
|
}
|
|
if ($to !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
|
|
$response->error('Invalid to (expected YYYY-MM-DD)', 400);
|
|
}
|
|
|
|
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
|
|
|
// Use the freshly-loaded object to inspect properties for the
|
|
// blocking-reason simulation. Note: getUserByCustomerNumber may
|
|
// trigger importCustomerFromExternalSource for missing rows. That
|
|
// matches production behaviour but means this debug endpoint can
|
|
// create users as a side effect; callers should treat it as a
|
|
// read-mostly diagnostic.
|
|
$customer_summary = [
|
|
'customer_number' => $customer_number,
|
|
'exists' => $customer->exists(),
|
|
'email' => $customer->exists() ? $customer->email->value() : null,
|
|
'wash_certificate_email' => $customer->exists() ? $customer->wash_certificate_email->value() : null,
|
|
'email_notifications_enabled' => $customer->exists()
|
|
? (bool)$customer->email_notifications_enabled->value()
|
|
: null,
|
|
'sms_notifications_enabled' => $customer->exists()
|
|
? (bool)$customer->sms_notifications_enabled->value()
|
|
: null,
|
|
'display_name' => $customer->exists() ? $customer->display_name->value() : null,
|
|
];
|
|
|
|
// getFieldsWhere doesn't support comparison operators, so fall back
|
|
// to a small raw SQL with proper escaping for the date range.
|
|
global $db;
|
|
$bookingTable = (new order_bookings_o())->getTable();
|
|
$clauses = ["customer_number = " . (int)$customer_number];
|
|
if ($from !== null) {
|
|
$clauses[] = "datetime >= '" . $db->escape_string($from . ' 00:00:00') . "'";
|
|
}
|
|
if ($to !== null) {
|
|
$clauses[] = "datetime <= '" . $db->escape_string($to . ' 23:59:59') . "'";
|
|
}
|
|
$sql = "SELECT id, datetime, order_id, department, reference FROM $bookingTable WHERE " . implode(' AND ', $clauses);
|
|
$result = $db->query($sql);
|
|
$booking_rows = $result ? $db->fetch_all($result) : [];
|
|
usort($booking_rows, static function (array $a, array $b): int {
|
|
return (int)$b['id'] <=> (int)$a['id'];
|
|
});
|
|
|
|
$diagnostics = [];
|
|
foreach ($booking_rows as $row) {
|
|
$booking_id = (int)$row['id'];
|
|
$entry = [
|
|
'booking_id' => $booking_id,
|
|
'datetime' => $row['datetime'] ?? null,
|
|
'department' => isset($row['department']) ? (int)$row['department'] : null,
|
|
'reference' => $row['reference'] ?? null,
|
|
'has_transaction' => false,
|
|
'has_wash_certificate_attached' => false,
|
|
'would_send_email' => false,
|
|
'blocking_reason' => null,
|
|
'recipient_email' => null,
|
|
];
|
|
|
|
$booking = (new order_bookings_o())->select($booking_id);
|
|
if (!$booking->exists()) {
|
|
$entry['blocking_reason'] = 'booking_not_found';
|
|
$diagnostics[] = $entry;
|
|
continue;
|
|
}
|
|
|
|
$entry['has_transaction'] = $booking->hasTransaction();
|
|
if (!$entry['has_transaction']) {
|
|
$entry['blocking_reason'] = 'no_transaction';
|
|
$diagnostics[] = $entry;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$order = $booking->getOrder();
|
|
$entry['has_wash_certificate_attached'] = $order->hasWashCertificateAttached();
|
|
} catch (Exception $e) {
|
|
$entry['blocking_reason'] = 'order_lookup_failed: ' . $e->getMessage();
|
|
$diagnostics[] = $entry;
|
|
continue;
|
|
}
|
|
if (!$entry['has_wash_certificate_attached']) {
|
|
$entry['blocking_reason'] = 'no_wash_certificate_attached';
|
|
$diagnostics[] = $entry;
|
|
continue;
|
|
}
|
|
|
|
if (!$customer->exists()) {
|
|
$entry['blocking_reason'] = 'customer_not_found';
|
|
$diagnostics[] = $entry;
|
|
continue;
|
|
}
|
|
|
|
if (!$customer->wantsEmailNotifications()) {
|
|
$entry['blocking_reason'] = 'email_notifications_disabled';
|
|
$diagnostics[] = $entry;
|
|
continue;
|
|
}
|
|
|
|
$recipient = !empty($customer->wash_certificate_email->value())
|
|
? $customer->wash_certificate_email->value()
|
|
: $customer->email->value();
|
|
$entry['recipient_email'] = $recipient;
|
|
if (empty($recipient)) {
|
|
$entry['blocking_reason'] = 'no_recipient_email';
|
|
$diagnostics[] = $entry;
|
|
continue;
|
|
}
|
|
|
|
$entry['would_send_email'] = true;
|
|
$diagnostics[] = $entry;
|
|
}
|
|
|
|
try {
|
|
(new logs_o())->add(
|
|
'email',
|
|
'global',
|
|
0,
|
|
(int)$user->id,
|
|
'WASH_CERT_DIAGNOSE',
|
|
json_encode([
|
|
'actor_user_id' => (int)$user->id,
|
|
'customer_number' => $customer_number,
|
|
'booking_count' => count($diagnostics),
|
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
} catch (\Throwable) {
|
|
// best-effort audit log only
|
|
}
|
|
|
|
$response->success([
|
|
'customer' => $customer_summary,
|
|
'filter' => [
|
|
'from' => $from,
|
|
'to' => $to,
|
|
],
|
|
'booking_count' => count($diagnostics),
|
|
'bookings' => $diagnostics,
|
|
'interpretation' => $this->buildInterpretation($customer_summary, $diagnostics),
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $customer
|
|
* @param array<int, array<string, mixed>> $diagnostics
|
|
* @return array<int, string>
|
|
*/
|
|
private function buildInterpretation(array $customer, array $diagnostics): array
|
|
{
|
|
$notes = [];
|
|
if (!$customer['exists']) {
|
|
$notes[] = 'Customer does not exist. importCustomerFromExternalSource will be triggered when the booking flow runs.';
|
|
return $notes;
|
|
}
|
|
if ($customer['email_notifications_enabled'] === false) {
|
|
$notes[] = 'email_notifications_enabled is false. New customers default to false (see department_daily_report_complaints_schema_bootstrap.php:114). Toggle it on via PATCH /users/notifications to enable wash certificate delivery.';
|
|
}
|
|
if (empty($customer['email']) && empty($customer['wash_certificate_email'])) {
|
|
$notes[] = 'Both email and wash_certificate_email are empty. sendWashCertificateToCustomer() will silently return.';
|
|
}
|
|
|
|
$blocking = [];
|
|
foreach ($diagnostics as $entry) {
|
|
if (!empty($entry['would_send_email'])) {
|
|
continue;
|
|
}
|
|
$reason = (string)($entry['blocking_reason'] ?? 'unknown');
|
|
$blocking[$reason] = ($blocking[$reason] ?? 0) + 1;
|
|
}
|
|
if ($blocking !== []) {
|
|
$notes[] = 'Booking-blocking reasons: ' . json_encode($blocking, JSON_UNESCAPED_SLASHES);
|
|
} else {
|
|
$notes[] = 'No bookings in the selected range were blocked by the silent-return guard.';
|
|
}
|
|
|
|
return $notes;
|
|
}
|
|
} |