## Problem
Production was returning:
```json
{"success":false,"data":{"message":"Internal server error: Unknown column 'invoice_email' in 'SELECT'"}}
```
when authenticating as a superuser. The migration that adds
`users.invoice_email` was merged to master in api#381 but never applied
to the production database.
## Fix
- New `GET /api/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
## What this prevents
- Future migrations being merged without being applied to production
- Silent failures (generic 500) when a column is missing
- Repeated manual investigation of the same root cause
Refs: TRU-77 (the original bug), api#381 (the original PR)
---------
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: bugfix-subagent <bugfix-subagent@truckwash.local>
Co-authored-by: OpenClaw Bugfix Agent <bugfix@openclaw.local>
65 lines
1.8 KiB
PHP
65 lines
1.8 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
/**
|
|
* Pre-deploy schema bootstrap runner.
|
|
*
|
|
* Loads and runs every `*_schema_bootstrap` class so the production
|
|
* database has all the columns the current code expects. Each
|
|
* bootstrap is additive and idempotent — safe to run on every deploy.
|
|
*
|
|
* Run via:
|
|
* php scripts/run-schema-bootstraps.php
|
|
*
|
|
* Used in .github/workflows/deploy.yml as a pre-deploy step.
|
|
*
|
|
* When you add a new *_schema_bootstrap class, you don't need to
|
|
* edit this file — the runner auto-discovers any class whose name
|
|
* ends in `_schema_bootstrap`.
|
|
*/
|
|
|
|
namespace scripts;
|
|
|
|
// Load the app entry point so $db is wired up the same way as in
|
|
// normal request handling.
|
|
$index = __DIR__ . '/../services/nginx/app/index.php';
|
|
if (!file_exists($index)) {
|
|
fwrite(STDERR, "Cannot find app entry point at {$index}\n");
|
|
exit(2);
|
|
}
|
|
require_once $index;
|
|
|
|
$classesDir = __DIR__ . '/../services/nginx/app/classes';
|
|
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
|
|
if (!$bootstraps) {
|
|
fwrite(STDERR, "No *_schema_bootstrap.php files found in {$classesDir}\n");
|
|
exit(0);
|
|
}
|
|
|
|
$ran = 0;
|
|
$skipped = 0;
|
|
foreach ($bootstraps as $file) {
|
|
require_once $file;
|
|
$base = basename($file, '.php');
|
|
$class = "classes\\{$base}";
|
|
if (!class_exists($class)) {
|
|
fwrite(STDERR, " [skip] {$base}: class not found\n");
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
if (!method_exists($class, 'ensureSchema')) {
|
|
fwrite(STDERR, " [skip] {$base}: no ensureSchema() method\n");
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
try {
|
|
$class::ensureSchema();
|
|
echo " [ok] {$base}\n";
|
|
$ran++;
|
|
} catch (\Throwable $e) {
|
|
fwrite(STDERR, " [FAIL] {$base}: " . $e->getMessage() . "\n");
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
echo "Schema bootstraps complete: {$ran} ran, {$skipped} skipped.\n";
|