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 excludedDomains = await getExcludedDomains() // Matches the legacy system's confirmed behaviour (2026-09-01, see // project_checkin_legacy_plugin): excluded-domain emails aren't shown as // "on file" at all, not just flagged — hadExcludedEmail tells the // frontend whether to explain *why* nothing (or fewer than expected) // showed up, without exposing the actual excluded address. const allEmails = contactDetails.filter(d => d.type === 'email') const existingEmails = allEmails .filter(d => !isRelayEmail(d.content, excludedDomains)) .map(d => ({ content: d.content })) const hadExcludedEmail = allEmails.length > existingEmails.length const existingCarRegos = contactDetails.filter(d => d.type === 'car_rego').map(d => d.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, tariff_names: extractTariffNames(booking), 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, had_excluded_email: hadExcludedEmail, car_regos: existingCarRegos, }, terms_text: await getSetting('terms_and_conditions_text', DEFAULT_TERMS), } }) app.post('/api/checkin/:token/submit', async (req, reply) => { const { email, car_regos, 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, carRegos: Array.isArray(car_regos) ? car_regos : [], 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, carRegos: Array.isArray(car_regos) ? car_regos : [], 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 getExcludedDomains() { const raw = await getSetting('excluded_email_domains', '') return raw.split(',').map(d => d.trim().toLowerCase()).filter(Boolean) } // "Rate type name(s)" for the booking-confirm step — a stay can run across // more than one tariff if the rate changed mid-stay, so this surfaces every // distinct label actually charged rather than just the single top-level // tariff_name (which reflects whichever tariff applies to the first night). // Falls back to that single field if tariffs_quoted isn't present at all. function extractTariffNames(booking) { const quoted = Array.isArray(booking?.tariffs_quoted) ? booking.tariffs_quoted : [] const labels = [...new Set(quoted.map(t => t.label).filter(Boolean))] if (labels.length) return labels return booking?.tariff_name ? [booking.tariff_name] : [] } 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 }