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
209
backend/src/routes/checkin.js
Normal file
209
backend/src/routes/checkin.js
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue