Skip to content

Commit fac2905

Browse files
Merge pull request #42 from niteeshkanna-sh/claude/stoic-rubin-fnglq7
Run pending migrations when the panel loads
2 parents 321ea6e + 40e7677 commit fac2905

5 files changed

Lines changed: 86 additions & 1 deletion

File tree

‎admin/admin.css‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ button { font-family: inherit; }
132132
.panel-header-link:hover { color: var(--primary); }
133133
.panel-header p { color: var(--ink-dim); font-size: 0.84rem; }
134134

135+
/* Shown only when a migration could not be applied, which is rare and worth
136+
interrupting for: the panel works, but something in it will not. */
137+
.migration-warning {
138+
background: #FFF4E0; border-bottom: 1px solid var(--warning, #E8A317);
139+
padding: 10px 16px; font-size: 0.9rem; color: var(--ink);
140+
}
141+
.migration-warning span { display: block; color: var(--ink-dim); font-size: 0.82rem; margin-top: 2px; }
142+
135143
/* ---------- Vehicle photograph ---------- */
136144
.photo-field { display: flex; flex-direction: column; gap: 8px; }
137145
.photo-preview { display: flex; align-items: center; gap: 10px; }

‎admin/api/public-vehicles.php‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,18 @@
5757
// edit in the panel take long to show up.
5858
header('Cache-Control: public, max-age=300');
5959

60+
// Named only when it exists. A deploy adds the column, but the migration that
61+
// creates it does not run until someone opens the panel -- and this endpoint
62+
// serves the public website in the meantime. Selecting it unconditionally
63+
// meant a change made inside the admin could empty the fleet on the live site.
64+
$hasPhoto = table_has_column('vehicles', 'photo_file');
65+
$photoColumn = $hasPhoto ? 'v.photo_file,' : "NULL AS photo_file,";
66+
6067
$rows = fetch_all(
6168
"SELECT v.id,
6269
v.name,
6370
v.brand,
64-
v.photo_file,
71+
$photoColumn
6572
v.body_type,
6673
v.fuel,
6774
v.transmission,

‎admin/api/vehicle-photo.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@
2323
json_error('Which vehicle this belongs to was not sent.', 422);
2424
}
2525

26+
// A clear sentence rather than a SQL error, for the window between a deploy
27+
// adding the column and the migration that creates it having run. Loading the
28+
// dashboard applies it, which is what the message says to do.
29+
if (!table_has_column('vehicles', 'photo_file')) {
30+
json_error('The database has not been updated for photographs yet. '
31+
. 'Open the Dashboard tab once and try again.', 503);
32+
}
33+
2634
$vehicle = fetch_one('SELECT id, name, photo_file FROM vehicles WHERE id = ?', [$vehicleId]);
2735
if ($vehicle === null) {
2836
json_error('That vehicle no longer exists.', 404);

‎admin/dashboard.php‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,33 @@
33

44
require_once __DIR__ . '/src/csrf.php';
55
require_once __DIR__ . '/src/assets.php';
6+
require_once __DIR__ . '/src/migrate.php';
67

78
// Anyone reaching this page must already be signed in; require_login sends
89
// them to the sign-in form otherwise.
910
$me = require_login();
1011

12+
// Apply any migration that has not run yet.
13+
//
14+
// They used to run only from install.php, which refuses once an account
15+
// exists, or from a button on the content page that nobody has a reason to
16+
// press. So a deploy could add a column and nothing would ever create it: the
17+
// code shipped, the database did not, and the first sign was a feature failing
18+
// with a SQL error. That happened -- the vehicle photograph column was added
19+
// and never existed.
20+
//
21+
// migrate() records what it has applied and skips those, so the cost here is
22+
// one small SELECT per dashboard load. A failure is reported rather than
23+
// thrown: a migration that cannot run is worth knowing about, and it is not a
24+
// reason to refuse to show a panel that otherwise works.
25+
$migrationError = null;
26+
try {
27+
migrate();
28+
} catch (Throwable $e) {
29+
error_log('migrate on dashboard load failed: ' . $e->getMessage());
30+
$migrationError = $e->getMessage();
31+
}
32+
1133
header('X-Frame-Options: DENY');
1234
header('X-Content-Type-Options: nosniff');
1335
header('Referrer-Policy: same-origin');
@@ -26,6 +48,13 @@
2648
<link rel="stylesheet" href="<?= asset('admin.css') ?>" />
2749
</head>
2850
<body>
51+
<?php if ($migrationError !== null): ?>
52+
<div class="migration-warning">
53+
<strong>The database is not fully up to date.</strong>
54+
Some newer features may fail until this is resolved.
55+
<span><?= e($migrationError) ?></span>
56+
</div>
57+
<?php endif; ?>
2958

3059
<!-- ===== Dashboard ===== -->
3160
<div class="dashboard" id="dashboard">

‎admin/src/db.php‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,36 @@ function next_number(string $prefix, ?int $year = null): string
295295

296296
return sprintf('%s-%d-%04d', $prefix, $year, $next);
297297
}
298+
299+
/**
300+
* Is this column on this table yet?
301+
*
302+
* Migrations only run when someone opens the panel, and the public website
303+
* asks for the fleet whether or not anyone has signed in today. So between a
304+
* deploy that adds a column and an admin next loading the dashboard, a query
305+
* naming that column would fail -- and the failure would land on the public
306+
* site's car listing, for a change made entirely inside the panel.
307+
*
308+
* Asked once per request and remembered, so a page reading several vehicles
309+
* does not ask several times.
310+
*/
311+
function table_has_column(string $table, string $column): bool
312+
{
313+
static $cache = [];
314+
$key = $table . '.' . $column;
315+
316+
if (!array_key_exists($key, $cache)) {
317+
// The table name cannot be a bound parameter, so it is checked against
318+
// a pattern rather than trusted -- these are always literals in this
319+
// codebase, and the day one is not is the day this matters.
320+
if (!preg_match('/^[a-z_]+$/', $table)) {
321+
return false;
322+
}
323+
try {
324+
$cache[$key] = fetch_one("SHOW COLUMNS FROM `$table` LIKE ?", [$column]) !== null;
325+
} catch (Throwable $e) {
326+
$cache[$key] = false;
327+
}
328+
}
329+
return $cache[$key];
330+
}

0 commit comments

Comments
 (0)