diff --git a/backend/src/lib/device-stream.js b/backend/src/lib/device-stream.js index e922d7b..aeb39d9 100644 --- a/backend/src/lib/device-stream.js +++ b/backend/src/lib/device-stream.js @@ -17,14 +17,26 @@ export function removeStream(deviceId, res) { if (streams.get(deviceId)?.size === 0) streams.delete(deviceId) } +// Returns true only if at least one write demonstrably succeeded (didn't +// throw) — NOT just "a stream entry exists for this device". A stale-but- +// not-yet-closed connection (tablet's WiFi silently drops mid-idle, common +// enough to have caused a real stuck-busy device 2026-09-01 — see +// project_checkin memory) can accept a write() without throwing even though +// nothing will ever actually reach the far end, so "an entry exists" was +// never a reliable signal on its own. This doesn't catch every dead-socket +// case (a true network black hole can still succeed the write locally), but +// it does catch the more common "connection already formally closed" +// case, which is worth surfacing honestly rather than reporting delivered +// regardless. function send(deviceId, event, data) { const set = streams.get(deviceId) - if (!set) return false + if (!set || !set.size) return false const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + let wroteAny = false for (const res of set) { - try { res.write(payload) } catch { /* dead connection, will be cleaned up on close */ } + try { res.write(payload); wroteAny = true } catch { /* dead connection, will be cleaned up on close */ } } - return true + return wroteAny } // Returns true if the device had a live connection to push to. staff.js still diff --git a/backend/src/lib/push.js b/backend/src/lib/push.js index ea2b28e..4a75189 100644 --- a/backend/src/lib/push.js +++ b/backend/src/lib/push.js @@ -16,10 +16,34 @@ export async function pushBookingToDevice(deviceId, bookingId) { // truth, not the SELECT above (which is only used for a friendly device // name in the 409 message), so two near-simultaneous pushes to the same // device can't both "win". - const { rows: claimed } = await pool.query( + let { rows: claimed } = await pool.query( `UPDATE devices SET status = 'busy' WHERE device_id = $1 AND status = 'idle' RETURNING *`, [deviceId] ) + + if (!claimed.length) { + // Self-healing fallback: a device can get stuck 'busy' with no live + // session behind it — e.g. the tablet's connection silently drops + // mid-push and the navigate event never actually reaches it (a real + // failure hit 2026-09-01, see project_checkin memory). Previously that + // needed an admin to manually click Reset before the device could be + // used again at all. Reclaim it here instead, still atomically (only + // succeeds if the device is 'busy' with no unexpired *unused* session + // at the exact moment of the UPDATE) — a genuinely in-progress guest + // check-in, still inside its session TTL, correctly keeps blocking a + // steal. + ;({ rows: claimed } = await pool.query( + `UPDATE devices SET status = 'busy' + WHERE device_id = $1 AND status = 'busy' + AND NOT EXISTS ( + SELECT 1 FROM checkin_sessions + WHERE device_id = $1 AND used_at IS NULL AND expires_at > now() + ) + RETURNING *`, + [deviceId] + )) + } + if (!claimed.length) { return { ok: false, status: 409, error: `${existing[0].name} is busy with another check-in` } } diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 08e4226..810f736 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -14,8 +14,20 @@ server { # SSE (devices/:id/stream) needs buffering off and no timeout — otherwise # nginx buffers the event-stream response and the tablet's idle page # never actually receives a push in real time. - location /tablet-checkin/api/devices/ { - proxy_pass http://backend:3001/api/devices/; + # + # No trailing slash on either the location pattern or the proxy_pass + # target — with one, nginx auto-redirects any request matching this + # prefix *without* the trailing slash (i.e. exactly + # /tablet-checkin/api/devices, the plain device-list endpoint) to add + # it. That redirect broke real usage 2026-09-01: this inner nginx sits + # behind NPM's TLS termination, forwarded as plain HTTP, so it built + # the redirect's Location header as http:// even though the original + # request was https:// — browsers block that as mixed content, which + # surfaced as a bare "Failed to fetch" with no other clue. Dropping the + # trailing slash keeps this a plain prefix match (still correctly + # covers every /devices/:id/... sub-route) with no auto-redirect at all. + location /tablet-checkin/api/devices { + proxy_pass http://backend:3001/api/devices; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;