Cache-busting version stamps + client-side encounter version tracking

1. Build-ID cache busting (server.js):
   - Compute a BUILD_ID at boot: git HEAD short hash if available,
     else /app/BUILD_ID file, else random-on-boot.
   - On first request for /, rewrite every local /js/*.js and
     /css/*.css reference in index.html to include ?v=BUILD_ID.
     Cached once at startup so subsequent renders are free.
   - X-Build-Id response header + GET /api/build expose it for
     debugging.
   - Eliminates the "works after hard-refresh" class of bugs: every
     deploy gets a new build ID, so browsers fetch fresh JS/CSS on
     the very next page load.

2. Optimistic encounter locking wired into the client
   (public/js/encounters.js):
   - On resumeEncounter(): stash enc.version into
     window._encounterVersions[id]
   - On saveEncounter(): send expected_version in the POST body
     when we have one.
   - Server returns 409 if another tab/device wrote first → user
     sees "Someone else edited this encounter. Reload to see the
     latest version." instead of silently clobbering the prior save.
   - On success, remember the new server-assigned version for the
     next save.
This commit is contained in:
Daniel 2026-04-14 05:40:42 +02:00
parent 2f3e608c88
commit e32e9977f5
2 changed files with 71 additions and 2 deletions

View file

@ -117,16 +117,31 @@
// Prevent duplicate saves from double-click
if (_savingInProgress[type]) { showToast('Save in progress…', 'info'); return; }
_savingInProgress[type] = true;
// Optimistic locking: send the version we last saw. Server returns 409
// if the encounter was updated elsewhere (another tab, another device).
var body = Object.assign({}, opts);
if (opts.id && window._encounterVersions && window._encounterVersions[opts.id] != null) {
body.expected_version = window._encounterVersions[opts.id];
}
fetch('/api/encounters/saved', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(opts)
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(function(r) { return r.json().then(function(d) { d._status = r.status; return d; }); })
.then(function(data) {
_savingInProgress[type] = false;
if (data._status === 409) {
showToast('Someone else edited this encounter. Reload to see the latest version.', 'error');
return;
}
if (data.success) {
showToast('Encounter saved (' + (opts.label || '') + ')', 'success');
// Track the new version the server assigned
if (data.id != null && data.version != null) {
window._encounterVersions = window._encounterVersions || {};
window._encounterVersions[data.id] = data.version;
}
// Persist ID so refreshing the page won't create a duplicate
if (data.id) {
if (opts.onSaved) opts.onSaved(data.id);
@ -275,6 +290,11 @@
.then(function(data) {
if (!data.success) { showToast(data.error || 'Failed', 'error'); return; }
var enc = data.encounter;
// Remember the loaded version so future saves can detect drift
if (enc && enc.id != null && enc.version != null) {
window._encounterVersions = window._encounterVersions || {};
window._encounterVersions[enc.id] = enc.version;
}
// Close all popovers
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
var pop = document.getElementById(p + '-load-popover');

View file

@ -130,6 +130,55 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
res.sendFile(path.join(__dirname, 'public', '.well-known', 'assetlinks.json'));
});
// ============================================================
// CACHE-BUSTING VERSION STAMP
// ============================================================
// Compute a per-boot BUILD_ID (short hex). Inject it as ?v=BUILD_ID
// on every local /js/*.js and /css/*.css reference in index.html so
// browsers always fetch fresh JS/CSS after a deploy instead of
// serving from the 1-hour cache.
var fs = require('fs');
var crypto = require('crypto');
var BUILD_ID = crypto.randomBytes(4).toString('hex');
try {
var gitHead = fs.readFileSync(path.join(__dirname, '.git/HEAD'), 'utf8').trim();
if (gitHead.indexOf('ref:') === 0) {
var refPath = gitHead.split(' ')[1];
BUILD_ID = fs.readFileSync(path.join(__dirname, '.git', refPath), 'utf8').trim().slice(0, 7);
} else {
BUILD_ID = gitHead.slice(0, 7);
}
} catch (e) {
// Non-git environment (Docker image) — use /app/BUILD_ID file if present,
// otherwise stick with the random-on-boot value generated above.
try {
BUILD_ID = fs.readFileSync(path.join(__dirname, 'BUILD_ID'), 'utf8').trim() || BUILD_ID;
} catch (_) {}
}
console.log('🔖 Build ID:', BUILD_ID);
// Template index.html once at boot with the version stamp. Serves fast
// because the rewrite happens one time and the string is reused.
var INDEX_HTML_TEMPLATED = null;
try {
var raw = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
INDEX_HTML_TEMPLATED = raw.replace(
/(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g,
'$1$2?v=' + BUILD_ID + '$3'
);
} catch (e) { console.warn('[build-id] index.html template skipped:', e.message); }
app.get(['/', '/index.html'], function(req, res) {
if (!INDEX_HTML_TEMPLATED) return res.sendFile(path.join(__dirname, 'public', 'index.html'));
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('X-Build-Id', BUILD_ID);
res.send(INDEX_HTML_TEMPLATED);
});
// Public endpoint for cache-bust debugging + build-info
app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); });
app.use(loggingMiddleware);
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, filePath) => {