import { randomUUID } from 'crypto' import { requireAuth, requireCap, hasCap } from '../auth.js' import { pool } from '../db.js' import { addStream, removeStream, pushIdle } from '../lib/device-stream.js' import { pushBookingToDevice } from '../lib/push.js' // 'checkin' is a deliberately unofficial capability slug — it is never seeded // into app_capabilities (see seed-app.js, which only defines push/admin/ // settings) and so can never be granted to a human staff role via the portal. // It only ever appears inside a JWT minted by routes/kiosk.js's own login — // i.e. "this request came from a tablet that knows the kiosk password", not // "this staff member has a capability". Scoping the tablet's self-service // routes (register/heartbeat/stream/reset) to it keeps them unreachable by a // normal staff login and unreachable by a stale/guessed cookie. const KIOSK_CAP = 'checkin' export async function deviceRoutes(app) { app.addHook('preHandler', requireAuth) // ── Kiosk-side self-service (tablet_checkin_kiosk_session only) ───────── // POST /api/devices/register — { device_id?, name? }. If device_id is // omitted or unknown (fresh tablet / localStorage cleared), the server // mints a new one and a placeholder name — staff rename it from the // Devices admin page afterwards. If device_id is known, this doubles as a // heartbeat + is idempotent to call repeatedly on every idle-page load. app.post('/api/devices/register', { preHandler: requireCap(KIOSK_CAP) }, async (req) => { const { device_id, name } = req.body || {} if (device_id) { const { rows } = await pool.query('SELECT * FROM devices WHERE device_id = $1', [device_id]) if (rows.length) { await pool.query('UPDATE devices SET last_seen = now() WHERE device_id = $1', [device_id]) return rows[0] } } const newId = device_id || randomUUID() const { rows } = await pool.query( `INSERT INTO devices (device_id, name, status, last_seen) VALUES ($1, $2, 'idle', now()) RETURNING *`, [newId, name || 'New Tablet'] ) return rows[0] }) app.post('/api/devices/:id/heartbeat', { preHandler: requireCap(KIOSK_CAP) }, async (req, reply) => { const { rows } = await pool.query( 'UPDATE devices SET last_seen = now() WHERE device_id = $1 RETURNING *', [req.params.id] ) if (!rows.length) return reply.status(404).send({ error: 'Device not found — re-register' }) return rows[0] }) // GET /api/devices/:id/stream — SSE held open by the tablet's idle page. // Emits 'navigate' (staff.js push) and 'idle' (reset). A comment ping every // 20s keeps intermediate proxies (nginx) from timing the connection out. app.get('/api/devices/:id/stream', { preHandler: requireCap(KIOSK_CAP) }, async (req, reply) => { const deviceId = req.params.id // Takes the response out of Fastify's normal send lifecycle — without // this, Fastify tries to send its own reply once this async handler // resolves (which happens immediately after registering listeners // below, long before the SSE connection actually closes), colliding // with the raw writes already made to reply.raw. reply.hijack() reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }) reply.raw.write(': connected\n\n') addStream(deviceId, reply.raw) await pool.query('UPDATE devices SET last_seen = now() WHERE device_id = $1', [deviceId]) const ping = setInterval(() => { try { reply.raw.write(': ping\n\n') } catch { /* connection already closed */ } }, 20000) req.raw.on('close', () => { clearInterval(ping) removeStream(deviceId, reply.raw) }) }) // POST /api/devices/:id/reset — the "safety net" reset/cancel button // reachable from mid-flow on the tablet itself, plus an admin-usable // equivalent for an operator who spots a stuck device from the Devices // page. Expires any live session for the device and flips it back to idle. app.post('/api/devices/:id/reset', { preHandler: async (req, reply) => { if (!hasCap(req, KIOSK_CAP) && !hasCap(req, 'admin')) { return reply.status(403).send({ error: 'Missing capability: checkin or admin' }) } }, }, async (req, reply) => { const deviceId = req.params.id const { rows } = await pool.query('SELECT * FROM devices WHERE device_id = $1', [deviceId]) if (!rows.length) return reply.status(404).send({ error: 'Device not found' }) await pool.query( `UPDATE checkin_sessions SET used_at = now() WHERE device_id = $1 AND used_at IS NULL`, [deviceId] ) await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [deviceId]) pushIdle(deviceId) return { ok: true } }) // ── Staff-side admin (standard AuthGate, admin cap) ────────────────────── app.get('/api/devices', { preHandler: requireCap('admin') }, async () => { const { rows } = await pool.query('SELECT * FROM devices ORDER BY name') return rows }) app.patch('/api/devices/:id', { preHandler: requireCap('admin') }, async (req, reply) => { const { name } = req.body || {} if (!name || !String(name).trim()) return reply.status(400).send({ error: 'name is required' }) const { rows } = await pool.query( 'UPDATE devices SET name = $1 WHERE device_id = $2 RETURNING *', [String(name).trim(), req.params.id] ) if (!rows.length) return reply.status(404).send({ error: 'Device not found' }) return rows[0] }) app.delete('/api/devices/:id', { preHandler: requireCap('admin') }, async (req, reply) => { const { rowCount } = await pool.query('DELETE FROM devices WHERE device_id = $1', [req.params.id]) if (!rowCount) return reply.status(404).send({ error: 'Device not found' }) return { ok: true } }) // POST /api/devices/:id/test-push — manual "Push to Tablet" from inside the // app itself (Devices page), same underlying logic the extension's // terminal-key-authed route uses (see lib/push.js). Exists so the 'push' // capability has a real, testable surface without needing the extension // installed — e.g. to verify a tablet's SSE connection is alive. app.post('/api/devices/:id/test-push', { preHandler: requireCap('push') }, async (req, reply) => { const { booking_id } = req.body || {} if (!booking_id) return reply.status(400).send({ error: 'booking_id is required' }) const result = await pushBookingToDevice(req.params.id, booking_id) if (!result.ok) return reply.status(result.status).send({ error: result.error }) return { ok: true, delivered: result.delivered } }) }