Files
api/services/nginx/app/routes/washCertificateDebugRoute.php
T
Bugfix Subagent 9025a8af6e fix(auth): add scope checks to remaining protected routes and fix scope test contract
Three fixes for the failing CI checks (PHP api, PHP integration):

1. RouteScopeTest.php: Pest's toContain() is variadic, so both arguments
   are treated as needles. The second 'description' argument was
   being treated as a needle, causing every file to fail. Removed the
   misleading second argument.

2. Added ScopeMiddleware::requireScope() calls and the matching
   Scope/ScopeMiddleware imports to 15 protected route files that
   the integration test contract requires.

3. documentation/auth/route-scope-audit.md: added the missing
   Scope::SUPERUSER_WRITE reference and a constants reference table.

Also registered tests/auth/StripeInvoiceEmailTemplateTest.php in the
legacy test manifest.
2026-08-17 13:22:09 +00:00

242 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;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
/**
* 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 () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/debug/wash-certificates/diagnose');
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;
}
}