Closes the 'Unknown column invoice_email in SELECT' production failure mode (TRU-77) by: - New GET /admin/schema-check endpoint — returns 503 with explicit list of missing columns if any are absent (instead of a generic 500) - scripts/run-schema-bootstraps.php — auto-discovers and runs every *_schema_bootstrap class on the live database (additive, idempotent) - scripts/schema-health-check.php — CLI tool for the same check, used by deploy pipelines - New Pest contract test SchemaHealthCheckTest — verifies the test DB has every required users column and the schema-check endpoint works - deploy.yml: pre-deploy step runs the bootstrap runner, smoke test also runs the schema check, Slack alert on failure
109 lines
3.7 KiB
PHP
109 lines
3.7 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use classes\response;
|
|
use classes\customer_invoice_email_schema_bootstrap;
|
|
use traits\route_t;
|
|
|
|
/**
|
|
* Admin / ops endpoints. Currently exposes the schema health check.
|
|
*
|
|
* The schema health check verifies that all required DB columns exist
|
|
* for the routes the code references. If a column is missing (e.g. a
|
|
* migration wasn't run on production), the endpoint returns 503 with
|
|
* a clear list of missing columns — much more useful than a generic
|
|
* 500 with "Unknown column" hidden in the stack trace.
|
|
*/
|
|
class adminRoute
|
|
{
|
|
use route_t;
|
|
|
|
public function run(): void
|
|
{
|
|
// Schema health check — used by deploy pipelines, monitoring,
|
|
// and the cron job. Anonymous (no auth) so it can be hit
|
|
// before user login; returns only structural info, no data.
|
|
$this->get('/admin/schema-check', function () {
|
|
global /** @var response $response */ $response;
|
|
// Self-heal: run all schema bootstraps first
|
|
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
|
|
try {
|
|
customer_invoice_email_schema_bootstrap::ensureSchema();
|
|
} catch (\Throwable $e) {
|
|
// Bootstrap may fail in environments where $db is
|
|
// not yet wired up; report and continue with check
|
|
}
|
|
}
|
|
$report = $this->runSchemaCheck();
|
|
$response->setStatus($report['ok'] ? 200 : 503);
|
|
$response->setBody(json_encode($report, JSON_PRETTY_PRINT));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns ['ok' => bool, 'missing' => array, ...].
|
|
* If ok=false, the deploy should be blocked.
|
|
*/
|
|
private function runSchemaCheck(): array
|
|
{
|
|
global $db;
|
|
$report = [
|
|
'ok' => true,
|
|
'missing' => [],
|
|
'tables_checked' => 0,
|
|
'columns_checked' => 0,
|
|
'timestamp' => date('c'),
|
|
'note' => 'If "missing" is non-empty, the migration that adds these columns was not run on the database.',
|
|
];
|
|
|
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
|
$report['ok'] = false;
|
|
$report['error'] = 'no_db_connection';
|
|
return $report;
|
|
}
|
|
|
|
$requirements = [
|
|
'users' => [
|
|
'invoice_email', // TRU-77 (added 2026-08-16, was missing on production)
|
|
'wash_certificate_email',
|
|
'email',
|
|
'customer_number',
|
|
],
|
|
'invoices' => [
|
|
'po_number',
|
|
'closed_at',
|
|
'customer_number',
|
|
],
|
|
'bookings' => [
|
|
'id',
|
|
'customer_number',
|
|
'department',
|
|
],
|
|
];
|
|
|
|
foreach ($requirements as $table => $columns) {
|
|
$report['tables_checked']++;
|
|
$tableSafe = str_replace('`', '', $table);
|
|
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
|
|
if (!$result || (int)$result->num_rows === 0) {
|
|
$report['ok'] = false;
|
|
$report['missing'][] = "table `{$table}` does not exist";
|
|
continue;
|
|
}
|
|
foreach ($columns as $column) {
|
|
$report['columns_checked']++;
|
|
$colSafe = str_replace("'", '', $column);
|
|
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
|
|
if (!$r || (int)$r->num_rows === 0) {
|
|
$report['ok'] = false;
|
|
$report['missing'][] = "{$table}.{$column}";
|
|
}
|
|
}
|
|
}
|
|
|
|
return $report;
|
|
}
|
|
}
|