Initial scaffold: tablet-checkin app

Staff-push guest check-in tablet. Fastify backend + React/Vite frontend,
three-tier auth (staff JWT, terminal API key, kiosk cookie), NewBook
integration via bookings_get/guests_update/bookings_update, pdfkit-based
regcard generation, marketing opt-in with Sendy/Mailchimp adapters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-09-01 17:35:39 +00:00
commit 3b2e25df3f
51 changed files with 10296 additions and 0 deletions

View file

@ -0,0 +1,144 @@
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 }
})
}