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:
commit
3b2e25df3f
51 changed files with 10296 additions and 0 deletions
144
backend/src/routes/devices.js
Normal file
144
backend/src/routes/devices.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue