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

99
backend/src/auth.js Normal file
View file

@ -0,0 +1,99 @@
import { jwtVerify } from 'jose'
import bcrypt from 'bcryptjs'
import { isOnsite } from './ip-check.js'
import { pool } from './db.js'
const APP_SLUG = process.env.APP_SLUG || 'tablet-checkin'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
// Deliberately its own cookie name — NOT 'hnf_session' (central auth's real
// staff-session cookie). Sharing that name caused a real collision bug in
// hvac's kiosk (see project_hvac_kiosk memory, hit 2026-08-26): a stale
// same-named cookie from testing the kiosk login could shadow a subsequent
// real staff login on the same origin. Mirroring that fix here rather than
// rediscovering it — this app's own requireAuth checks hnf_session first,
// falls back to this one, and the two are fully independent.
const KIOSK_COOKIE = 'tablet_checkin_kiosk_session'
// ── Tier 1: staff JWT (central auth) + tablet kiosk session ────────────────
// Gates the human-facing staff routes: admin/audit (checkins.js), device
// registry management, terminal key management, marketing config, settings.
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session || request.cookies?.[KIOSK_COOKIE]
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
caps = [] // legacy token — nothing granted until re-login
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}
export { KIOSK_COOKIE }
// ── Tier 2: terminal API key ────────────────────────────────────────────────
// Authorizes the standalone Chrome extension's push call (routes/staff.js)
// only — never used for anything human-facing. Distinct from both the staff
// JWT and the tablet's own kiosk session: hotel-owned physical/desktop
// hardware gets a device-level bearer credential instead of a per-user login.
// Header: `Authorization: Bearer <raw key>`. The raw key is shown once at
// creation (routes/terminals.js) and only its bcrypt hash is ever stored, so
// verifying means iterating the small set of non-revoked terminals and
// comparing — fine at this scale (a handful of reception PCs, not users).
export async function requireTerminalAuth(request, reply) {
const header = request.headers['authorization'] || ''
const key = header.startsWith('Bearer ') ? header.slice(7).trim() : null
if (!key) return reply.status(401).send({ error: 'Terminal API key required' })
const { rows } = await pool.query(
`SELECT terminal_id, api_key_hash FROM terminals WHERE revoked_at IS NULL`
)
for (const row of rows) {
if (await bcrypt.compare(key, row.api_key_hash)) {
await pool.query(`UPDATE terminals SET last_used_at = now() WHERE terminal_id = $1`, [row.terminal_id])
request.terminal = { terminal_id: row.terminal_id }
return
}
}
return reply.status(401).send({ error: 'Invalid or revoked terminal key' })
}

159
backend/src/db.js Normal file
View file

@ -0,0 +1,159 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
-- Multi-tablet device registry. Each physical tablet self-registers on first
-- load with a persisted device_id (generated client-side, stored in
-- localStorage) and gets a staff-given name via the Devices admin page.
-- status flips idle<->busy across a push session; last_seen is a heartbeat
-- from the tablet's own idle-page SSE connection, used to spot a tablet
-- that's gone offline (browser closed, tablet powered off) without a clean
-- disconnect. See [[project_checkin]] "Multi-tablet device registry".
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'idle', -- idle | busy
last_seen TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Per-terminal API keys — the extension's ("Send to Check-in Tablet") auth
-- mechanism, distinct from both staff JWT and the tablet's own kiosk login.
-- Raw key is shown once at creation and never stored — only its hash.
CREATE TABLE IF NOT EXISTS terminals (
terminal_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
api_key_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
-- Short-lived staff-push / matcher-minted session tokens — ties one device to
-- one booking for one check-in. TTL-cleaned; a small table (rather than
-- in-memory) so a tablet dropping mid-session is crash-safe, matching the
-- rest of the stack's conventions.
CREATE TABLE IF NOT EXISTS checkin_sessions (
token TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices(device_id) ON DELETE CASCADE,
booking_id TEXT NOT NULL,
hotel_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
-- Check-in audit/index — one row per completed (or partially-completed, see
-- 'pending' status) check-in. Lets staff browse past check-ins without
-- hitting the NewBook API per lookup, and is the PII-holding anchor row
-- behind the token-gated PDF retrieval route.
CREATE TABLE IF NOT EXISTS checkins (
id SERIAL PRIMARY KEY,
booking_id TEXT NOT NULL,
guest_id TEXT, -- nullable: a 'failed' row inserted before the booking/guest could be
-- re-fetched from NewBook (see routes/checkin.js's failCheckin()) has none yet
hotel_id TEXT NOT NULL,
device_id TEXT REFERENCES devices(device_id) ON DELETE SET NULL,
guest_name TEXT,
guest_email TEXT, -- kept locally (in addition to NewBook) so a failed
-- marketing_sync row can be retried without a NewBook re-fetch
pdf_path TEXT,
opted_in BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'pending', -- pending | complete | failed
error TEXT,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- cctv_snapshot_path added later once the deferred CCTV feature is picked up
);
CREATE INDEX IF NOT EXISTS checkins_submitted_idx ON checkins (submitted_at DESC);
-- Per-provider marketing push sync state. Not used to discover who needs
-- syncing (driven by checkins.opted_in at submission time) — purely tracks
-- whether the real-time push at submission succeeded, so a failed call can
-- be retried. See [[project_checkin]] "Marketing opt-in" section.
CREATE TABLE IF NOT EXISTS marketing_sync (
id SERIAL PRIMARY KEY,
checkin_id INTEGER NOT NULL REFERENCES checkins(id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- 'sendy' | 'mailchimp'
status TEXT NOT NULL DEFAULT 'pending', -- pending | success | failed
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS marketing_sync_checkin_idx ON marketing_sync (checkin_id);
-- Which marketing platforms are active + their API credentials. Config JSONB
-- shape is provider-specific (see lib/marketing/*.js) — e.g. sendy needs
-- { api_key, install_url, list_id }, mailchimp needs { api_key, server_prefix,
-- audience_id }.
CREATE TABLE IF NOT EXISTS marketing_providers (
provider TEXT PRIMARY KEY, -- 'sendy' | 'mailchimp'
active BOOLEAN NOT NULL DEFAULT false,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Cross-app-integration-credentials pattern (see feedback_app_conventions) —
-- generic key/value settings editable from the Settings page, DB value wins
-- over any .env fallback. Holds the NewBook hotel/location id and the
-- regcard-link custom field id (varies per NewBook install, see
-- [[project_checkin_legacy_plugin]]) rather than anything marketing-specific,
-- which has its own dedicated marketing_providers table above.
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Bcrypt hash of the tablet kiosk's own persistent login password — set via
-- the tablet's own setup screen (routes/devices.js POST /api/kiosk/login),
-- never the plaintext. Kept in app_settings alongside the other small
-- key/value config, same pattern as hvac's kiosk_password_hash in its
-- 'config' table.
`)
await seedSettings()
await seedMarketingProviders()
}
const SETTINGS_DEFAULTS = {
newbook_hotel_id: '',
regcard_custom_field_id: '',
online_checkin_complete_custom_field_id: '',
kiosk_password_hash: '',
}
async function seedSettings() {
for (const [key, value] of Object.entries(SETTINGS_DEFAULTS)) {
await pool.query(
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[key, value]
)
}
}
async function seedMarketingProviders() {
for (const provider of ['sendy', 'mailchimp']) {
await pool.query(
`INSERT INTO marketing_providers (provider, active, config) VALUES ($1, false, '{}'::jsonb)
ON CONFLICT (provider) DO NOTHING`,
[provider]
)
}
}
export async function getSetting(key, fallback = '') {
const { rows } = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key])
return rows.length && rows[0].value != null ? rows[0].value : fallback
}
export async function setSetting(key, value) {
await pool.query(
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[key, value]
)
}

40
backend/src/index.js Normal file
View file

@ -0,0 +1,40 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import { initDb } from './db.js'
import { deviceRoutes } from './routes/devices.js'
import { terminalRoutes } from './routes/terminals.js'
import { staffRoutes } from './routes/staff.js'
import { checkinRoutes } from './routes/checkin.js'
import { checkinsRoutes } from './routes/checkins.js'
import { marketingRoutes } from './routes/marketing.js'
import { settingsRoutes } from './routes/settings.js'
import { kioskRoutes } from './routes/kiosk.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
await app.register(deviceRoutes)
await app.register(terminalRoutes)
await app.register(staffRoutes)
await app.register(checkinRoutes)
await app.register(checkinsRoutes)
await app.register(marketingRoutes)
await app.register(settingsRoutes)
await app.register(kioskRoutes)
try {
await initDb()
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

View file

@ -0,0 +1,40 @@
// In-memory SSE registry — one process, no clustering, so a plain Map of
// device_id -> open response streams is sufficient (same "no new dependency"
// approach as the rest of this stack; plain `reply.raw` streaming rather than
// pulling in fastify-sse-v2 for what's a handful of small, well-understood
// event types). A tablet's idle page holds one of these open; staff.js's push
// route writes a 'navigate' event down it, the tablet's own JS redirects
// itself — see project_checkin's "Submission flow sequencing".
const streams = new Map() // device_id -> Set<FastifyReply.raw>
export function addStream(deviceId, res) {
if (!streams.has(deviceId)) streams.set(deviceId, new Set())
streams.get(deviceId).add(res)
}
export function removeStream(deviceId, res) {
streams.get(deviceId)?.delete(res)
if (streams.get(deviceId)?.size === 0) streams.delete(deviceId)
}
function send(deviceId, event, data) {
const set = streams.get(deviceId)
if (!set) return false
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
for (const res of set) {
try { res.write(payload) } catch { /* dead connection, will be cleaned up on close */ }
}
return true
}
// Returns true if the device had a live connection to push to. staff.js still
// succeeds the push either way (the tablet will pick up the session on next
// load if it reconnects), but this is useful for surfacing a "tablet may be
// offline" warning to the caller.
export function pushNavigate(deviceId, token) {
return send(deviceId, 'navigate', { token })
}
export function pushIdle(deviceId) {
return send(deviceId, 'idle', {})
}

View file

@ -0,0 +1,74 @@
// Pluggable marketing-platform push, common interface + retry-friendly
// sync-state bookkeeping. Every adapter exports a single
// `push({ email, name }, config)` that resolves on success and throws on
// failure — this module never talks to a provider directly, only via that
// interface, so adding a new provider is one new file + one ADAPTERS entry.
import { pool } from '../../db.js'
import * as sendy from './sendy.js'
import * as mailchimp from './mailchimp.js'
export const ADAPTERS = { sendy, mailchimp }
// Called at check-in submission time when opted_in = true. Deliberately
// fire-and-forget from the caller's perspective (see routes/checkin.js) —
// same lesson as hvac's TRV control routes blocking on a retry pipeline and
// causing a real gateway timeout (fixed 2026-08-26). The pending row is
// inserted synchronously (so it shows up immediately in the audit view even
// before the push resolves); the actual network call happens in the
// background.
export async function queueMarketingSync(checkinId, guest) {
const { rows: providers } = await pool.query(
`SELECT provider, config FROM marketing_providers WHERE active = true`
)
for (const p of providers) {
const { rows } = await pool.query(
`INSERT INTO marketing_sync (checkin_id, provider, status) VALUES ($1, $2, 'pending') RETURNING id`,
[checkinId, p.provider]
)
const syncId = rows[0].id
// Not awaited — see header comment.
attemptPush(syncId, p.provider, p.config, guest).catch(err => {
console.error(`[marketing] unexpected error pushing sync ${syncId} (${p.provider}):`, err.message)
})
}
}
async function attemptPush(syncId, provider, config, guest) {
const adapter = ADAPTERS[provider]
if (!adapter) {
await pool.query(
`UPDATE marketing_sync SET status = 'failed', error = $2, attempts = attempts + 1, last_attempt_at = now() WHERE id = $1`,
[syncId, `Unknown provider: ${provider}`]
)
return
}
try {
await adapter.push(guest, config)
await pool.query(
`UPDATE marketing_sync SET status = 'success', attempts = attempts + 1, last_attempt_at = now() WHERE id = $1`,
[syncId]
)
} catch (err) {
await pool.query(
`UPDATE marketing_sync SET status = 'failed', error = $2, attempts = attempts + 1, last_attempt_at = now() WHERE id = $1`,
[syncId, err.message]
)
}
}
// Manual retry of one failed sync row (routes/marketing.js) — re-reads the
// checkin's locally-stored guest_name/guest_email rather than re-fetching
// NewBook, since that's all any adapter needs.
export async function retrySync(syncId) {
const { rows } = await pool.query(
`SELECT ms.id, ms.provider, mp.config, c.guest_name, c.guest_email
FROM marketing_sync ms
JOIN marketing_providers mp ON mp.provider = ms.provider
JOIN checkins c ON c.id = ms.checkin_id
WHERE ms.id = $1`,
[syncId]
)
if (!rows.length) throw new Error('Sync row not found')
const row = rows[0]
await attemptPush(syncId, row.provider, row.config, { email: row.guest_email, name: row.guest_name })
}

View file

@ -0,0 +1,33 @@
// Mailchimp adapter — thin stub, same status as sendy.js (see that file's
// header comment). Mailchimp's real subscribe call is a PUT to
// `https://{server_prefix}.api.mailchimp.com/3.0/lists/{audience_id}/members/{md5(email)}`
// with Basic auth (`anystring:{api_key}`) and status `subscribed`/`pending`
// depending on double opt-in requirements — documented here so the TODO is a
// one-call fetch once config.api_key/server_prefix/audience_id exist.
export async function push({ email, name }, config) {
if (!email) throw new Error('Mailchimp push requires an email address')
const { api_key, server_prefix, audience_id } = config || {}
if (!api_key || !server_prefix || !audience_id) {
throw new Error('Mailchimp is active but not fully configured (api_key/server_prefix/audience_id)')
}
// TODO: real call once credentials exist, e.g.:
// const hash = createHash('md5').update(email.toLowerCase()).digest('hex')
// const res = await fetch(
// `https://${server_prefix}.api.mailchimp.com/3.0/lists/${audience_id}/members/${hash}`,
// {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// Authorization: `Basic ${Buffer.from(`anystring:${api_key}`).toString('base64')}`,
// },
// body: JSON.stringify({
// email_address: email,
// status_if_new: 'subscribed',
// merge_fields: name ? { FNAME: name.split(' ')[0] || '', LNAME: name.split(' ').slice(1).join(' ') } : {},
// }),
// }
// )
// if (!res.ok) throw new Error(`Mailchimp ${res.status}: ${await res.text()}`)
throw new Error('Mailchimp adapter not yet implemented — no live credentials configured')
}

View file

@ -0,0 +1,26 @@
// Sendy adapter — thin stub. Real credentials aren't available yet (see
// project_checkin's marketing opt-in section); the interface + sync-state
// bookkeeping around this (see lib/marketing/index.js) is real, this file
// just needs its TODO filled in with the actual API call once a Sendy
// install URL / API key / list_id are configured under Marketing settings.
//
// Sendy's real subscribe endpoint is a simple form-POST to
// `${install_url}/subscribe` with api_key/list/email/name — documented here
// so the TODO is a one-line fetch once config.install_url is populated.
export async function push({ email, name }, config) {
if (!email) throw new Error('Sendy push requires an email address')
const { api_key, install_url, list_id } = config || {}
if (!api_key || !install_url || !list_id) {
throw new Error('Sendy is active but not fully configured (api_key/install_url/list_id)')
}
// TODO: real call once credentials exist, e.g.:
// const res = await fetch(`${install_url}/subscribe`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
// body: new URLSearchParams({ api_key, list: list_id, email, name: name || '', boolean: 'true' }),
// })
// const text = await res.text()
// if (text.trim() !== '1') throw new Error(`Sendy rejected subscribe: ${text}`)
throw new Error('Sendy adapter not yet implemented — no live credentials configured')
}

194
backend/src/lib/newbook.js Normal file
View file

@ -0,0 +1,194 @@
// Adapted from hvac/backend/src/lib/newbook.js (closest architectural match —
// see project_checkin memory's scaffold checklist). Credentials come from the
// shared settings service, never a docker-compose env var.
const API_BASE = 'https://api.newbook.cloud/rest/'
async function getCredentials() {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching NewBook credentials`)
const s = await res.json()
return {
username: s.username || '',
password: s.password || '',
apiKey: s.api_key || '',
region: s.region || 'eu',
}
}
async function callApi(endpoint, data = {}) {
const creds = await getCredentials()
if (!creds.username || !creds.password || !creds.apiKey) {
throw new Error('NewBook API credentials not configured')
}
const locationId = process.env.NEWBOOK_LOCATION_ID
const body = { ...data, region: creds.region, api_key: creds.apiKey }
if (locationId) body.location_id = locationId
const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 30000)
try {
const res = await fetch(API_BASE + endpoint, {
method: 'POST',
signal: ctrl.signal,
headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}` },
body: JSON.stringify(body),
})
clearTimeout(timer)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`NewBook API ${res.status}: ${text.slice(0, 200)}`)
}
return await res.json()
} catch (err) {
clearTimeout(timer)
throw err
}
}
// Fetch a single booking by internal booking_id, with its guest list and
// contact details expanded — this is the staff-push path's whole identity
// check (see project_checkin: "staff selecting the booking in NewBook IS the
// identity verification"), so no separate matcher call is needed here.
export async function fetchBooking(bookingId) {
const res = await callApi('bookings_get', { booking_id: bookingId })
return res?.data ?? null
}
// Update a guest's contact_details array (name/address/phone/email/car_rego/
// allow_marketing all live in this one container in NewBook — see
// project_checkin's data-model decision). `contactDetails` is passed through
// as NewBook's own shape; callers build it (see routes/checkin.js).
export async function updateGuestContactDetails(guestId, contactDetails) {
return callApi('guests_update', { guest_id: guestId, contact_details: contactDetails })
}
// Sets one or more top-level (non-custom) fields on a booking. Kept generic
// for any future genuinely-native field — NOT for online_checkin_complete or
// regcard_link, which are per-hotel custom fields (see below), never native
// NewBook fields, despite an earlier draft of this code treating
// online_checkin_complete as if it were one.
export async function updateBooking(bookingId, fields) {
return callApi('bookings_update', { booking_id: bookingId, ...fields })
}
// Sets one or more custom fields on a booking in a single request. This is
// the mechanism the actual legacy contactlesscheckin plugin used — bundled
// directly into bookings_update's own `custom_fields` array (confirmed from
// form_submitted.php / save_pdf_newbook_link.php, see
// project_checkin_legacy_plugin) — NOT `instance_custom_fields_set`, which
// was only ever confirmed working for a different, unrelated plugin
// elsewhere in the archive and isn't established as valid for this account.
// `fields` is an array of { field_id, field_value }; entries with no
// field_id (not yet configured under Settings) are silently dropped so a
// caller can pass a mix of configured/unconfigured fields without checking
// each one first. Returns null (no API call made) if nothing is configured.
export async function setBookingCustomFields(bookingId, fields) {
const custom_fields = fields
.filter(f => f.field_id)
.map(f => ({ field_id: f.field_id, field_value: f.field_value }))
if (!custom_fields.length) return null
return callApi('bookings_update', { booking_id: bookingId, custom_fields })
}
// Known OTA relay-email domains — a guest's real inbox is never actually
// behind one of these, so it's flagged for the shared gap-fill step to prompt
// a correction rather than silently accepting it as "the guest's email".
// Mirrors the legacy plugin's filtering (see project_checkin_legacy_plugin) —
// deliberately a shared helper (not duplicated per-route) so both the tablet
// and any future online-checkin path inherit new domains added here for free
// (see project_checkin's "Flow-parity risk" note).
export const OTA_RELAY_EMAIL_DOMAINS = ['guest.booking.com', 'm.expediapartnercentral.com']
export function isRelayEmail(email) {
const domain = String(email || '').split('@')[1]?.toLowerCase()
return !!domain && OTA_RELAY_EMAIL_DOMAINS.some(d => domain === d || domain.endsWith(`.${d}`))
}
// Picks the guest whose contact details this session edits. Tablet check-in
// is a single-form flow triggered by staff selecting one booking (no guest
// disambiguation step — see project_checkin's "no matcher at all" decision),
// so this always resolves to the booking's primary guest. Real NewBook
// bookings_get responses mark this with `primary_client: "1"` (confirmed from
// a live sample, 2026-09-01) — matters on multi-guest bookings, where falling
// back to array order instead could write contact details onto the wrong
// guest. Falls back to guests[0] only if no guest is explicitly marked
// primary (defensive — not yet observed on a real response).
export function pickPrimaryGuest(booking) {
if (!booking) return null
if (Array.isArray(booking.guests) && booking.guests.length) {
return booking.guests.find(g => g.primary_client === '1' || g.primary_client === 1) || booking.guests[0]
}
return booking.guest || null
}
// NewBook doesn't expose a single `address` field on a guest — street/city/
// state/postcode/country are all separate top-level fields (confirmed from a
// live sample). Formats them into one display string for the review screen
// and PDF.
export function formatGuestAddress(guest) {
if (!guest) return ''
return [guest.street, guest.city, guest.state_name || guest.state, guest.postcode, guest.country_name || guest.country]
.filter(Boolean)
.join(', ')
}
// Phone/mobile also aren't top-level guest fields — like email and car_rego,
// they live inside contact_details (confirmed from a live sample, which had
// a `type: 'mobile'` entry; the legacy plugin's guest_phones.php also checked
// for a distinct 'phone' type, so both are handled here). Returns the first
// match across the given types, in order.
export function getContactByType(contactDetails, types) {
const list = Array.isArray(contactDetails) ? contactDetails : []
for (const type of types) {
const found = list.find(d => d.type === type)
if (found) return found.content
}
return ''
}
// Builds an updated contact_details array for guests_update — additive only
// (existing entries are never removed/replaced, matching the legacy system's
// explicit "can't remove/replace, only add" convention). Updates
// allow_marketing on a matching existing email write-only (see
// project_checkin's marketing opt-in decision: never read back, just kept
// accurate going forward).
export function buildContactDetails(existing, { email, carRego, optedIn }) {
const details = Array.isArray(existing) ? [...existing] : []
if (email) {
const idx = details.findIndex(d => d.type === 'email' && String(d.content).toLowerCase() === email.toLowerCase())
if (idx >= 0) {
details[idx] = { ...details[idx], allow_marketing: optedIn ? 1 : 0 }
} else {
details.push({ type: 'email', content: email, allow_transactional: 1, allow_marketing: optedIn ? 1 : 0 })
}
}
if (carRego) {
const idx = details.findIndex(d => d.type === 'car_rego')
if (idx >= 0) {
details[idx] = { ...details[idx], content: carRego }
} else {
details.push({ type: 'car_rego', content: carRego, allow_transactional: 1, allow_marketing: 0 })
}
}
return details
}
export async function testConnection() {
try {
const res = await callApi('sites_list', {})
const sites = res?.data ?? []
return { ok: true, message: `Connected. Found ${sites.length} site(s).` }
} catch (err) {
return { ok: false, error: err.message }
}
}

95
backend/src/lib/pdf.js Normal file
View file

@ -0,0 +1,95 @@
// Pure-JS PDF generation (pdfkit) — deliberately NOT Puppeteer/Playwright.
// See project_checkin memory: headless-Chromium PDF rendering wedged the
// Rate Monitor app's 2GB LXC before; pdfkit draws text/shapes directly with
// no browser dependency at all.
import PDFDocument from 'pdfkit'
// Builds the signed regcard PDF for one check-in and returns it as a Buffer.
// The signature is drawn in here and then discarded by the caller — no
// separate raw-signature asset is ever persisted (see project_checkin's data
// model decision).
export function generateRegcardPdf({
hotelName,
booking,
guest,
contactEmail,
carRego,
optedIn,
termsText,
signaturePngBase64,
}) {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50 })
const chunks = []
doc.on('data', c => chunks.push(c))
doc.on('end', () => resolve(Buffer.concat(chunks)))
doc.on('error', reject)
// ── Header ────────────────────────────────────────────────────────────
doc.fontSize(18).fillColor('#1a1a2e').text(hotelName || 'Guest Registration', { align: 'left' })
doc.fontSize(10).fillColor('#64748b').text('Guest Registration Card', { align: 'left' })
doc.moveDown(1)
doc.strokeColor('#e4e8ee').moveTo(50, doc.y).lineTo(545, doc.y).stroke()
doc.moveDown(1)
// ── Booking summary ──────────────────────────────────────────────────
doc.fontSize(12).fillColor('#1a1a2e').text('Booking Summary', { underline: false })
doc.fontSize(10).fillColor('#1e293b')
doc.moveDown(0.3)
row(doc, 'Booking #', String(booking.booking_id ?? ''))
row(doc, 'Arrive', formatDate(booking.booking_arrival))
row(doc, 'Depart', formatDate(booking.booking_departure))
row(doc, 'Room / Site', String(booking.site_name ?? ''))
doc.moveDown(1)
// ── Guest details ────────────────────────────────────────────────────
doc.fontSize(12).fillColor('#1a1a2e').text('Guest Details')
doc.fontSize(10).fillColor('#1e293b')
doc.moveDown(0.3)
row(doc, 'Name', String(guest?.name ?? ''))
row(doc, 'Address', String(guest?.address ?? ''))
row(doc, 'Phone', String(guest?.phone ?? ''))
row(doc, 'Email', String(contactEmail ?? ''))
row(doc, 'Car Registration', String(carRego ?? '—'))
row(doc, 'Marketing opt-in', optedIn ? 'Yes' : 'No')
doc.moveDown(1)
// ── T&Cs ──────────────────────────────────────────────────────────────
doc.fontSize(12).fillColor('#1a1a2e').text('Terms & Conditions')
doc.moveDown(0.3)
doc.fontSize(8.5).fillColor('#475569').text(termsText || 'No terms text configured.', {
align: 'justify',
})
doc.moveDown(1.5)
// ── Signature ─────────────────────────────────────────────────────────
doc.fontSize(12).fillColor('#1a1a2e').text('Signature')
doc.moveDown(0.3)
if (signaturePngBase64) {
try {
const b64 = signaturePngBase64.replace(/^data:image\/png;base64,/, '')
const buf = Buffer.from(b64, 'base64')
doc.image(buf, { fit: [220, 90] })
} catch {
doc.fontSize(9).fillColor('#dc2626').text('(signature image could not be rendered)')
}
} else {
doc.fontSize(9).fillColor('#dc2626').text('(no signature captured)')
}
doc.moveDown(1)
doc.fontSize(8).fillColor('#94a3b8').text(`Generated ${new Date().toISOString()}`)
doc.end()
})
}
function row(doc, label, value) {
doc.font('Helvetica-Bold').text(`${label}: `, { continued: true }).font('Helvetica').text(value || '—')
}
function formatDate(v) {
if (!v) return '—'
const d = new Date(v)
return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10)
}

38
backend/src/lib/push.js Normal file
View file

@ -0,0 +1,38 @@
import { randomBytes } from 'crypto'
import { pool, getSetting } from '../db.js'
import { pushNavigate } from './device-stream.js'
const SESSION_TTL_MINUTES = 30
// Shared by routes/staff.js (terminal-key auth, the extension's real call)
// and routes/devices.js's admin test-push (staff JWT auth) — both just need
// "mint a session for this booking on this device", the auth tier differs,
// the logic doesn't. See project_checkin's "Submission flow sequencing".
export async function pushBookingToDevice(deviceId, bookingId) {
const { rows: existing } = await pool.query('SELECT * FROM devices WHERE device_id = $1', [deviceId])
if (!existing.length) return { ok: false, status: 404, error: 'Device not found' }
// Atomic claim — the UPDATE's own WHERE status='idle' is the source of
// 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(
`UPDATE devices SET status = 'busy' WHERE device_id = $1 AND status = 'idle' RETURNING *`,
[deviceId]
)
if (!claimed.length) {
return { ok: false, status: 409, error: `${existing[0].name} is busy with another check-in` }
}
const hotelId = (await getSetting('newbook_hotel_id', '')) || process.env.NEWBOOK_LOCATION_ID || ''
const token = randomBytes(24).toString('hex')
const expiresAt = new Date(Date.now() + SESSION_TTL_MINUTES * 60 * 1000)
await pool.query(
`INSERT INTO checkin_sessions (token, device_id, booking_id, hotel_id, expires_at) VALUES ($1,$2,$3,$4,$5)`,
[token, deviceId, String(bookingId), hotelId, expiresAt]
)
const delivered = pushNavigate(deviceId, token)
return { ok: true, token, delivered }
}

View file

@ -0,0 +1,26 @@
// Regcard PDF storage — standard uploads_data:/app/uploads volume pattern
// (matches cashup/calendar/hvac/plant). Files are never served as a bare
// public URL: retrieval goes through an admin-capped, token-checked route
// (routes/checkins.js GET /api/checkins/:id/pdf) since the PDF holds guest
// PII + a signature image.
import { mkdir, writeFile } from 'fs/promises'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
export const UPLOADS_DIR = join(__dirname, '..', '..', 'uploads')
// Saves a regcard PDF for one check-in and returns the path stored in
// checkins.pdf_path (relative to UPLOADS_DIR, so the DB never hardcodes an
// absolute host path).
export async function saveRegcardPdf(checkinId, buffer) {
const dir = join(UPLOADS_DIR, 'regcards')
await mkdir(dir, { recursive: true })
const relPath = `/regcards/${checkinId}.pdf`
await writeFile(join(UPLOADS_DIR, relPath), buffer)
return relPath
}
export function absolutePath(relPath) {
return join(UPLOADS_DIR, relPath)
}

View file

@ -0,0 +1,209 @@
import { randomUUID } from 'crypto'
import { pool, getSetting } from '../db.js'
import {
fetchBooking, updateGuestContactDetails, setBookingCustomFields,
buildContactDetails, pickPrimaryGuest, isRelayEmail,
formatGuestAddress, getContactByType,
} from '../lib/newbook.js'
import { generateRegcardPdf } from '../lib/pdf.js'
import { saveRegcardPdf } from '../lib/uploads.js'
import { queueMarketingSync } from '../lib/marketing/index.js'
import { pushIdle } from '../lib/device-stream.js'
const DEFAULT_TERMS = 'No terms & conditions text has been configured yet — set one under Settings.'
// Public/token-gated — deliberately NOT behind requireAuth. The
// checkin_sessions.token itself (short-lived, single-use, minted only by an
// already-authenticated staff push or, later, the online matcher) is the
// authorization here, same as every other app's write routes are gated by a
// capability — this route group is gated by "possession of a live token"
// instead. See project_checkin's "Submission flow sequencing + schema".
export async function checkinRoutes(app) {
app.get('/api/checkin/:token', async (req, reply) => {
const session = await loadSession(req.params.token)
if (!session.ok) return reply.status(session.status).send({ error: session.error })
let booking
try {
booking = await fetchBooking(session.row.booking_id)
} catch (err) {
return reply.status(502).send({ error: `Could not reach NewBook: ${err.message}` })
}
if (!booking) return reply.status(404).send({ error: 'Booking not found' })
const guest = pickPrimaryGuest(booking)
const contactDetails = Array.isArray(guest?.contact_details) ? guest.contact_details : []
const existingEmails = contactDetails
.filter(d => d.type === 'email')
.map(d => ({ content: d.content, is_relay: isRelayEmail(d.content) }))
const existingCarRego = contactDetails.find(d => d.type === 'car_rego')?.content || ''
return {
booking: {
booking_id: booking.booking_id ?? session.row.booking_id,
arrival: booking.booking_arrival ?? null,
departure: booking.booking_departure ?? null,
site_name: booking.site_name ?? null,
tariff_total: booking.tariff_total ?? null,
nights: booking.booking_length ?? null,
},
guest: {
guest_id: guest?.guest_id ?? null,
name: [guest?.firstname, guest?.lastname].filter(Boolean).join(' ') || '',
address: formatGuestAddress(guest),
phone: getContactByType(contactDetails, ['mobile', 'phone']),
existing_emails: existingEmails,
car_rego: existingCarRego,
},
terms_text: await getSetting('terms_and_conditions_text', DEFAULT_TERMS),
}
})
app.post('/api/checkin/:token/submit', async (req, reply) => {
const { email, car_rego, opted_in, signature_png_base64, accepted_terms } = req.body || {}
if (!signature_png_base64 || !accepted_terms) {
return reply.status(400).send({ error: 'Signature and accepted terms are required' })
}
// Step 1 — atomic claim. Both double-submit AND a token that expired
// between page-load and submit are rejected here outright.
const { rows: claimedRows } = await pool.query(
`UPDATE checkin_sessions SET used_at = now()
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
RETURNING *`,
[req.params.token]
)
if (!claimedRows.length) {
return reply.status(409).send({ error: 'This check-in link has already been used or has expired.' })
}
const session = claimedRows[0]
let booking, guest
try {
booking = await fetchBooking(session.booking_id)
guest = pickPrimaryGuest(booking)
if (!guest) throw new Error('Could not resolve a guest for this booking')
} catch (err) {
await failCheckin(session, `Could not re-fetch booking from NewBook: ${err.message}`)
return reply.status(502).send({ error: 'Could not load your booking — please ask a member of staff for help.' })
}
const contactDetails = buildContactDetails(guest.contact_details, {
email: email?.trim() || null,
carRego: car_rego?.trim() || null,
optedIn: !!opted_in,
})
// Step 2 — NewBook write-back first. Only the guest's own contact details
// are treated as business-critical (must succeed or the check-in fails
// outright) — that's real guest data with no fallback. online_checkin_complete
// is a per-hotel custom field, same shape of problem as regcard_link below
// (its numeric field_id varies per hotel and may not be configured yet) —
// non-fatal for the same reason: a missing admin setting shouldn't block a
// guest from actually checking in.
try {
await retry(() => updateGuestContactDetails(guest.guest_id, contactDetails), 2)
} catch (err) {
await failCheckin(session, `NewBook update failed: ${err.message}`)
return reply.status(502).send({ error: 'Could not save your details — please ask a member of staff for help.' })
}
const checkinCompleteFieldId = await getSetting('online_checkin_complete_custom_field_id', '')
if (checkinCompleteFieldId) {
await retry(() => setBookingCustomFields(session.booking_id, [
{ field_id: checkinCompleteFieldId, field_value: 1 },
]), 2).catch(err => {
app.log.warn(`[checkin] online_checkin_complete write-back failed: ${err.message}`)
})
}
// Local audit row, inserted now that the business-critical write has
// landed — status starts 'pending' and only flips to 'complete' once the
// PDF is generated below, so a PDF failure never loses the record.
const guestName = [guest.firstname, guest.lastname].filter(Boolean).join(' ') || guest.name || ''
const { rows: checkinRows } = await pool.query(
`INSERT INTO checkins (booking_id, guest_id, hotel_id, device_id, guest_name, guest_email, opted_in, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,'pending') RETURNING *`,
[session.booking_id, guest.guest_id, session.hotel_id, session.device_id, guestName, email || null, !!opted_in]
)
const checkin = checkinRows[0]
// Step 3 — PDF generation (synchronous, pure-JS — no headless browser).
try {
const buffer = await generateRegcardPdf({
hotelName: process.env.HOTEL_NAME || 'Hotel',
booking,
guest: { name: guestName, address: formatGuestAddress(guest), phone: getContactByType(guest.contact_details, ['mobile', 'phone']) },
contactEmail: email,
carRego: car_rego,
optedIn: !!opted_in,
termsText: await getSetting('terms_and_conditions_text', DEFAULT_TERMS),
signaturePngBase64: signature_png_base64,
})
const pdfPath = await saveRegcardPdf(randomUUID(), buffer)
await pool.query(`UPDATE checkins SET pdf_path = $1, status = 'complete' WHERE id = $2`, [pdfPath, checkin.id])
checkin.pdf_path = pdfPath
checkin.status = 'complete'
// Step 4 — write the PDF link back to NewBook, non-fatal if it fails
// (the guest is still checked in either way — reception can find the
// PDF from this app's own Checkins page regardless).
const fieldId = await getSetting('regcard_custom_field_id', '')
if (fieldId) {
const publicUrl = `${(process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '')}/tablet-checkin/api/checkins/${checkin.id}/pdf`
await setBookingCustomFields(session.booking_id, [
{ field_id: fieldId, field_value: publicUrl },
]).catch(err => {
app.log.warn(`[checkin] regcard_link custom field write-back failed: ${err.message}`)
})
}
} catch (err) {
await pool.query(`UPDATE checkins SET status = 'pending', error = $1 WHERE id = $2`, [err.message, checkin.id])
app.log.error(`[checkin] PDF generation failed for checkin ${checkin.id}: ${err.message}`)
}
// Step 6 — marketing push, fire-and-forget, never blocks the response.
if (opted_in) {
queueMarketingSync(checkin.id, { email, name: guestName }).catch(err => {
app.log.error(`[checkin] queueMarketingSync failed for checkin ${checkin.id}: ${err.message}`)
})
}
// Step 7 — flip the device back to idle so the next guest can be pushed.
await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [session.device_id])
pushIdle(session.device_id)
return { ok: true, checkin_id: checkin.id, pdf_generated: checkin.status === 'complete' }
})
}
async function loadSession(token) {
const { rows } = await pool.query('SELECT * FROM checkin_sessions WHERE token = $1', [token])
if (!rows.length) return { ok: false, status: 404, error: 'Check-in link not found' }
const row = rows[0]
if (row.used_at) return { ok: false, status: 410, error: 'This check-in link has already been used' }
if (new Date(row.expires_at) < new Date()) return { ok: false, status: 410, error: 'This check-in link has expired' }
return { ok: true, row }
}
async function failCheckin(session, errorMessage) {
await pool.query(
`INSERT INTO checkins (booking_id, guest_id, hotel_id, device_id, status, error)
VALUES ($1, NULL, $2, $3, 'failed', $4)`,
[session.booking_id, session.hotel_id, session.device_id, errorMessage]
)
await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [session.device_id])
pushIdle(session.device_id)
}
async function retry(fn, times) {
let lastErr
for (let i = 0; i <= times; i++) {
try {
return await fn()
} catch (err) {
lastErr = err
}
}
throw lastErr
}

View file

@ -0,0 +1,47 @@
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { absolutePath } from '../lib/uploads.js'
// Staff-facing audit browser (Dashboard page) — separate from the public
// token-gated flow in routes/checkin.js. Everything here needs the 'admin'
// capability: this data is guest PII, not general 'view'-level information.
export async function checkinsRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('admin'))
app.get('/api/checkins', async (req) => {
const limit = Math.min(parseInt(req.query?.limit) || 100, 500)
const { rows } = await pool.query(
`SELECT c.*, d.name AS device_name,
(SELECT json_agg(json_build_object('provider', ms.provider, 'status', ms.status, 'error', ms.error))
FROM marketing_sync ms WHERE ms.checkin_id = c.id) AS marketing_sync
FROM checkins c
LEFT JOIN devices d ON d.device_id = c.device_id
ORDER BY c.submitted_at DESC
LIMIT $1`,
[limit]
)
return rows
})
// Streams the PDF rather than serving it from a bare static path — this
// holds a signature image + guest PII, so retrieval must go through the
// same admin capability check as the rest of this file, not nginx statics.
app.get('/api/checkins/:id/pdf', async (req, reply) => {
const { rows } = await pool.query('SELECT pdf_path, booking_id FROM checkins WHERE id = $1', [req.params.id])
if (!rows.length || !rows[0].pdf_path) return reply.status(404).send({ error: 'No PDF available for this check-in' })
const filePath = absolutePath(rows[0].pdf_path)
try {
await stat(filePath)
} catch {
return reply.status(404).send({ error: 'PDF file is missing on disk' })
}
reply.header('Content-Type', 'application/pdf')
reply.header('Content-Disposition', `inline; filename="regcard-${rows[0].booking_id}.pdf"`)
return reply.send(createReadStream(filePath))
})
}

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 }
})
}

View file

@ -0,0 +1,60 @@
import { SignJWT } from 'jose'
import bcrypt from 'bcryptjs'
import { getSetting } from '../db.js'
import { KIOSK_COOKIE } from '../auth.js'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
const APP_SLUG = process.env.APP_SLUG || 'tablet-checkin'
// ~10 years — a wall-mounted/counter tablet's whole point is entering this
// once and never again. Mirrors hvac's kiosk session exactly, including the
// distinct-cookie-name fix (see auth.js's KIOSK_COOKIE comment) — that bug is
// specifically what this file is built to avoid repeating from scratch.
const KIOSK_SESSION_SECONDS = 60 * 60 * 24 * 3650
function cookieOpts(request) {
const proto = request.headers['x-forwarded-proto'] || request.protocol
return {
httpOnly: true,
secure: proto === 'https',
sameSite: 'lax',
path: '/',
maxAge: KIOSK_SESSION_SECONDS,
}
}
export async function kioskRoutes(app) {
// Deliberately outside requireAuth — this IS the login route. Narrow caps
// only: a tablet can register/heartbeat itself and run the guest check-in
// flow, never reach Devices/Terminals/Marketing/Settings admin pages.
app.post('/api/kiosk/login', async (req, reply) => {
const { password } = req.body || {}
if (!password || typeof password !== 'string') {
return reply.status(400).send({ error: 'Password required' })
}
const hash = await getSetting('kiosk_password_hash', '')
if (!hash) {
return reply.status(503).send({ error: 'Kiosk login has not been set up yet — set a Kiosk Password under Settings first.' })
}
const ok = await bcrypt.compare(password, hash)
if (!ok) return reply.status(401).send({ error: 'Incorrect password' })
const token = await new SignJWT({
apps: [APP_SLUG],
caps: [`${APP_SLUG}:checkin`], // narrower than any staff cap set — see routes/checkin.js
is_admin: false,
offsite_allowed: true, // physical on-site tablet — no IP-based onsite check needed
name: 'Kiosk',
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject('tablet-checkin-kiosk')
.setIssuedAt()
.setExpirationTime(`${KIOSK_SESSION_SECONDS}s`)
.sign(secret)
reply.setCookie(KIOSK_COOKIE, token, cookieOpts(req))
return { ok: true }
})
}

View file

@ -0,0 +1,48 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { retrySync, ADAPTERS } from '../lib/marketing/index.js'
export async function marketingRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('admin'))
app.get('/api/marketing/providers', async () => {
const { rows } = await pool.query('SELECT provider, active, config, updated_at FROM marketing_providers ORDER BY provider')
return rows.map(r => ({ ...r, implemented: !!ADAPTERS[r.provider] }))
})
app.put('/api/marketing/providers/:provider', async (req, reply) => {
const { provider } = req.params
if (!ADAPTERS[provider]) return reply.status(400).send({ error: `Unknown provider: ${provider}` })
const { active, config } = req.body || {}
const { rows } = await pool.query(
`UPDATE marketing_providers SET active = $1, config = $2, updated_at = now() WHERE provider = $3 RETURNING *`,
[!!active, JSON.stringify(config || {}), provider]
)
if (!rows.length) return reply.status(404).send({ error: 'Provider not found' })
return rows[0]
})
// Failed-sync list for the Marketing admin page's retry UI.
app.get('/api/marketing/sync', async (req) => {
const status = req.query?.status || 'failed'
const { rows } = await pool.query(
`SELECT ms.*, c.booking_id, c.guest_name, c.guest_email
FROM marketing_sync ms JOIN checkins c ON c.id = ms.checkin_id
WHERE ms.status = $1
ORDER BY ms.created_at DESC
LIMIT 200`,
[status]
)
return rows
})
app.post('/api/marketing/sync/:id/retry', async (req, reply) => {
try {
await retrySync(parseInt(req.params.id))
return { ok: true }
} catch (err) {
return reply.status(400).send({ error: err.message })
}
})
}

View file

@ -0,0 +1,55 @@
import bcrypt from 'bcryptjs'
import { requireAuth, requireCap } from '../auth.js'
import { getSetting, setSetting } from '../db.js'
import { testConnection } from '../lib/newbook.js'
// Generic key/value settings — cross-app-integration-credentials pattern (see
// feedback_app_conventions). kiosk_password_hash deliberately excluded from
// both GET and the ALLOWED_KEYS allowlist: it's a password hash, never
// readable back to the browser, only settable via its own dedicated route
// below (mirrors hvac's Settings -> Kiosk pattern).
const ALLOWED_KEYS = [
'newbook_hotel_id', 'regcard_custom_field_id', 'online_checkin_complete_custom_field_id',
'terms_and_conditions_text',
]
export async function settingsRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('settings'))
app.get('/api/settings', async () => {
const out = {}
for (const key of ALLOWED_KEYS) out[key] = await getSetting(key, '')
return out
})
app.put('/api/settings', async (req, reply) => {
const body = req.body || {}
for (const key of Object.keys(body)) {
if (!ALLOWED_KEYS.includes(key)) return reply.status(400).send({ error: `Unknown or protected setting: ${key}` })
}
for (const [key, value] of Object.entries(body)) {
await setSetting(key, String(value ?? ''))
}
return { ok: true }
})
app.post('/api/settings/newbook-test', async () => {
return testConnection()
})
app.get('/api/settings/kiosk-password-status', async () => {
const hash = await getSetting('kiosk_password_hash', '')
return { configured: !!hash }
})
app.post('/api/settings/kiosk-password', async (req, reply) => {
const { password } = req.body || {}
if (!password || typeof password !== 'string' || password.length < 6) {
return reply.status(400).send({ error: 'Password must be at least 6 characters' })
}
const hash = await bcrypt.hash(password, 12)
await setSetting('kiosk_password_hash', hash)
return { ok: true }
})
}

View file

@ -0,0 +1,29 @@
import { requireTerminalAuth } from '../auth.js'
import { pool } from '../db.js'
import { pushBookingToDevice } from '../lib/push.js'
// The standalone Chrome extension's calls — terminal-key authenticated (NOT
// a staff JWT/cookie), see auth.js's requireTerminalAuth and project_checkin's
// "Extension auth" decision.
export async function staffRoutes(app) {
// GET /api/staff/devices — device picker source for the extension's popup.
// Deliberately its own minimal read (id/name/status only, no last_seen/
// created_at) rather than reusing the admin GET /api/devices response
// shape — the extension has no staff login, only a terminal key, so it
// gets the narrowest view that's actually useful for picking a target.
app.get('/api/staff/devices', { preHandler: requireTerminalAuth }, async () => {
const { rows } = await pool.query('SELECT device_id, name, status FROM devices ORDER BY name')
return rows
})
app.post('/api/staff/push', { preHandler: requireTerminalAuth }, async (req, reply) => {
const { booking_id, device_id } = req.body || {}
if (!booking_id || !device_id) {
return reply.status(400).send({ error: 'booking_id and device_id are required' })
}
const result = await pushBookingToDevice(device_id, booking_id)
if (!result.ok) return reply.status(result.status).send({ error: result.error })
return { ok: true, delivered: result.delivered }
})
}

View file

@ -0,0 +1,54 @@
import { randomUUID, randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
// Admin-capped CRUD for the standalone Chrome extension's per-terminal API
// keys (see auth.js's requireTerminalAuth, consumed by routes/staff.js). The
// raw key is returned exactly once, at creation — only its bcrypt hash is
// ever stored, so a lost key means "revoke and issue a new one", not
// "look it up again".
export async function terminalRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('admin'))
app.get('/api/terminals', async () => {
const { rows } = await pool.query(
`SELECT terminal_id, name, created_at, last_used_at, revoked_at FROM terminals ORDER BY created_at DESC`
)
return rows
})
app.post('/api/terminals', async (req, reply) => {
const { name } = req.body || {}
if (!name || !String(name).trim()) return reply.status(400).send({ error: 'name is required' })
const rawKey = randomBytes(32).toString('hex')
const hash = await bcrypt.hash(rawKey, 12)
const terminalId = randomUUID()
await pool.query(
`INSERT INTO terminals (terminal_id, name, api_key_hash) VALUES ($1, $2, $3)`,
[terminalId, String(name).trim(), hash]
)
// The only time the raw key is ever returned — the extension's settings
// popup must capture it now (chrome.storage.local) or a new key needs issuing.
return { terminal_id: terminalId, name: String(name).trim(), api_key: rawKey }
})
app.post('/api/terminals/:id/revoke', async (req, reply) => {
const { rows } = await pool.query(
`UPDATE terminals SET revoked_at = now() WHERE terminal_id = $1 AND revoked_at IS NULL RETURNING terminal_id`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Terminal not found or already revoked' })
return { ok: true }
})
app.delete('/api/terminals/:id', async (req, reply) => {
const { rowCount } = await pool.query('DELETE FROM terminals WHERE terminal_id = $1', [req.params.id])
if (!rowCount) return reply.status(404).send({ error: 'Terminal not found' })
return { ok: true }
})
}