Fix NewBook write mechanism, bundle push extension, remove vestigial hotel_id

- setBookingCustomFields: use bookings_update's own custom_fields array
  (confirmed from the real legacy plugin code) instead of
  instance_custom_fields_set, which was only ever proven for a different,
  unrelated plugin
- online_checkin_complete is now a configurable per-hotel custom field
  (online_checkin_complete_custom_field_id setting), not a bogus top-level
  bookings_update field NewBook would silently ignore
- Fix field-name mismatches vs a real live NewBook booking response:
  booking_arrival/departure/length, guest address (street/city/state/
  postcode/country, not a single field), phone/mobile (in contact_details,
  not top-level), and pickPrimaryGuest now checks primary_client (was
  checking fields that don't exist in the real API)
- Bundle the standalone push extension into backend/extension-src/, add
  GET /api/extension/download (zips on request via archiver) and a
  download button on the Settings page
- Remove newbook_hotel_id — vestigial from the legacy system's multi-hotel
  routing model, never actually reached NewBook's API, this stack is
  single-hotel-per-deployment so there's nothing to route between

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-09-01 18:11:35 +00:00
parent 3b2e25df3f
commit 4d3774ef0d
25 changed files with 3557 additions and 15 deletions

View file

@ -3,6 +3,7 @@ WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
COPY extension-src ./extension-src
RUN mkdir -p /app/uploads
EXPOSE 3001
CMD ["node", "src/index.js"]

View file

@ -0,0 +1,67 @@
# Send to Check-In Tablet
Standalone Chrome extension — deliberately separate from `chrome-newbook-assistant`
(see `project_checkin` memory's "Standalone push extension" decision). Its one job:
detect a NewBook booking page and push that booking to a physical check-in tablet
running the [`tablet-checkin`](../tablet-checkin) app.
## How it works
- **Detection**: a background service worker watches for NewBook booking pages
(`bookings_view/<id>`, `bookings_edit/<id>`, `bookings_checkin/<id>` — confirmed real
URLs, 2026-09-01) and shows a badge on the toolbar icon when one's open. The
SPA-navigation handling (`webNavigation.onHistoryStateUpdated`) is lifted directly from
`chrome-newbook-assistant/background.js` (`~/laptop-archive`) — already proven against
real NewBook navigation, not guessed here.
- **Popup**: click the toolbar icon → shows the detected booking number, a device
picker (busy tablets shown disabled), and a "Send to Check-In Tablet" button.
- **Row-injected button**: on NewBook's "due for arrival" list
(`bookings_lists_checkin`), a content script adds a "Send to Tablet" button under each
row's own Check In button, with an inline device picker. Row detection and the
booking-ID extraction are built from a real sample row the user provided (2026-09-01,
see `project_checkin` memory) — deliberately doesn't rely on any `id` attribute from
NewBook's own markup, since its Check In button reuses the same `id` across every row
(`generic_button_1`), only class/attribute selectors scoped per row. A
`MutationObserver` re-scans for new rows since this page refreshes its table via AJAX
without a full reload.
- **NewBook login indicator**: the popup shows whether the browser looks logged into
NewBook (cookie-based check, also lifted from `chrome-newbook-assistant`'s
`AuthManager.checkNewBookAuth`). This is a **UX signal only** — it does not authorize
anything. The actual push call authenticates with a terminal API key (below), not the
NewBook cookie.
- **Auth**: the push call itself is authorized by a **terminal API key**, generated from
tablet-checkin's own Terminals admin page (`admin` capability) and pasted into this
extension's Settings page. This mirrors the same trust model already used for the
tablets themselves — hotel-owned hardware gets a device-level credential, not a
per-user login. See `project_checkin` memory's "Extension auth" section for the full
reasoning.
## Setup
1. Deploy `tablet-checkin` and note its base URL.
2. In tablet-checkin, go to **Terminals** (requires `admin` capability) → create a
terminal (e.g. "Reception PC 1") → copy the key shown (only shown once).
3. Load this extension unpacked: `chrome://extensions` → Developer mode → **Load
unpacked** → select this folder.
4. Click the extension icon → **Open Settings** (or right-click the icon → Options) →
enter the tablet-checkin base URL and the terminal key → Save → Test connection.
## Known TODOs before this is production-ready
- **Row-detection selectors on `bookings_lists_checkin` are built from a single sample
row** (2026-09-01) — robust in principle (class/attribute-based, not relying on
NewBook's non-unique ids), but only actually tested against one row's markup so far.
Worth confirming against a live page with multiple/varied rows (different booking
statuses, group bookings, etc.) before relying on it daily.
- `host_permissions` in `manifest.json` only covers the NewBook domains (needed for the
cookie-detection API) — tablet-checkin's own backend host isn't declared there since
its production URL isn't fixed yet. Cross-origin `fetch()` calls from the popup/options
pages generally work regardless, but add the real host once known if CORS issues show
up.
- Icons are placeholder-generated (simple crimson square + gold arrow, matching
tablet-checkin's theme color) — replace if a proper design is wanted.
- Not yet published anywhere (Chrome Web Store or otherwise) — currently load-unpacked
only, meaning it needs manually installing on every reception PC. tablet-checkin's own
Settings page has a download link (a bundled copy of this source, zipped on request) as
of 2026-09-01 — keep `backend/extension-src/` in that repo in sync with this folder,
they are not currently the same copy.

View file

@ -0,0 +1,111 @@
// Background service worker — detects when the active tab is a NewBook
// booking page and shows a badge, so staff notice the extension has
// something to offer before even opening the popup. Tab-update/activated/
// history-state listeners and the booking-ID regex are adapted directly from
// chrome-newbook-assistant/background.js (~/laptop-archive) — that pattern
// is already proven working against real NewBook navigation (including its
// SPA-style in-page routing, hence the webNavigation listener), not
// reinvented here. See project_checkin memory's "Standalone push extension".
// Confirmed 2026-09-01 by the user directly (real NewBook page URLs):
// bookings_view/<id>, bookings_edit/<id>, bookings_checkin/<id>.
const BOOKING_URL_RE = /\/bookings_(?:view|edit|checkin)\/(\d+)/i;
const NEWBOOK_HOSTS = ['appeu.newbook.cloud', 'login.newbook.cloud'];
function isNewBookUrl(url) {
try {
const u = new URL(url);
return NEWBOOK_HOSTS.some(h => u.hostname === h);
} catch {
return false;
}
}
async function handleTabUpdate(tabId, url) {
if (!url || !isNewBookUrl(url)) {
await chrome.action.setBadgeText({ tabId, text: '' });
return;
}
const match = url.match(BOOKING_URL_RE);
if (match) {
const bookingId = match[1];
await chrome.storage.session.set({ [`bookingId:${tabId}`]: bookingId });
await chrome.action.setBadgeText({ tabId, text: '→' }); // →
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#be123c' });
await chrome.action.setTitle({ tabId, title: `Send booking #${bookingId} to a check-in tablet` });
} else {
await chrome.storage.session.remove(`bookingId:${tabId}`);
await chrome.action.setBadgeText({ tabId, text: '' });
await chrome.action.setTitle({ tabId, title: 'Send to Check-In Tablet' });
}
}
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' && tab.url) handleTabUpdate(tabId, tab.url);
});
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
const tab = await chrome.tabs.get(tabId);
if (tab.url) handleTabUpdate(tabId, tab.url);
});
// NewBook's booking view is (per chrome-newbook-assistant's own findings) an
// SPA that can change URL without a full page load — this catches that case,
// same as the assistant already has to.
chrome.webNavigation.onHistoryStateUpdated.addListener(details => {
handleTabUpdate(details.tabId, details.url);
});
// ── Messages from content-script.js (bookings_lists_checkin row buttons) ───
// Content scripts route their actual network calls through here rather than
// fetching directly — keeps the baseUrl/apiKey read-and-fetch logic in one
// place instead of duplicated across popup.js and content-script.js.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.action === 'tcpListDevices') {
listDevices().then(sendResponse);
return true; // keep the message channel open for the async response
}
if (message.action === 'tcpPush') {
pushBooking(message.bookingId, message.deviceId).then(sendResponse);
return true;
}
return false;
});
async function getConfig() {
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
return { baseUrl, apiKey };
}
async function listDevices() {
const { baseUrl, apiKey } = await getConfig();
if (!baseUrl || !apiKey) return { ok: false, error: 'not configured' };
try {
const res = await fetch(`${baseUrl}/api/staff/devices`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: body.error || `HTTP ${res.status}` };
return { ok: true, devices: body };
} catch (err) {
return { ok: false, error: err.message };
}
}
async function pushBooking(bookingId, deviceId) {
const { baseUrl, apiKey } = await getConfig();
if (!baseUrl || !apiKey) return { ok: false, error: 'not configured' };
try {
const res = await fetch(`${baseUrl}/api/staff/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ booking_id: bookingId, device_id: deviceId }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: body.error || `HTTP ${res.status}` };
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
}

View file

@ -0,0 +1,161 @@
// Injects a "Send to Tablet" button into each row of NewBook's "due for
// arrival" list (bookings_lists_checkin), right under the page's own
// Check In button. Only runs on that one page (see manifest.json's
// content_scripts.matches) — the popup covers single-booking pages
// (bookings_view/edit/checkin) already.
//
// Row markup, booking-ID extraction, and the "generic_button_1"-is-not-
// actually-unique gotcha are all confirmed from a real sample row the user
// provided 2026-09-01 (see project_checkin memory) — not guessed. NewBook's
// own Check In button uses a non-unique id (`generic_button_1` repeated
// across every row), so this script never relies on ids from NewBook's own
// markup — only class/attribute selectors scoped per row, and its own
// injected elements get ids built from the booking ID
// (`tcp-btn-<bookingId>`) to stay collision-free and support idempotent
// re-injection checks.
//
// MutationObserver pattern (watching for AJAX-refreshed rows, not just
// initial page load) is the same approach chrome-newbook-assistant already
// had to use for this same NewBook UI (~/laptop-archive) — this system
// clearly refreshes table content via AJAX (see the `via_ajax` tooltip
// classes in the sample row) without a full page reload, so a one-time scan
// on document_idle alone would miss rows added/replaced afterward.
const PROCESSED_ATTR = 'data-tcp-processed';
function extractBookingId(row) {
const checkbox = row.querySelector('input.booking_id_checkbox');
if (checkbox?.value) return checkbox.value;
// Fallback: class name carries it too (`booking_37117`) — confirmed from
// the sample row, kept as a fallback in case a future NewBook markup
// change drops the checkbox but keeps the class.
const match = row.className.match(/\bbooking_(\d+)\b/);
return match ? match[1] : null;
}
function extractGuestName(row) {
// The guest/company link's own text node — tooltip content is a sibling
// element (easytooltip_content), not part of the visible text, so a plain
// textContent read on the link itself would double up with it. Grab just
// the first non-empty text child instead.
const link = row.querySelector('a.easytooltip[class*="booking_"]');
if (!link) return '';
for (const node of link.childNodes) {
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
return node.textContent.trim();
}
}
return link.textContent.trim();
}
function findCheckInCell(row) {
const btn = row.querySelector('button[aria-label="Check In"]');
return btn ? btn.closest('td') : null;
}
function makeSendButton(bookingId, guestName) {
const btn = document.createElement('button');
btn.id = `tcp-btn-${bookingId}`;
btn.type = 'button';
btn.textContent = 'Send to Tablet';
btn.style.cssText = `
display: block; margin-top: 6px; width: 100%;
padding: 4px 8px; font-size: 12px; font-weight: 600;
background: #be123c; color: #fff; border: none; border-radius: 6px;
cursor: pointer; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
`;
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
togglePanel(btn, bookingId, guestName);
});
return btn;
}
let openPanel = null;
async function togglePanel(anchorBtn, bookingId, guestName) {
if (openPanel) { openPanel.remove(); openPanel = null; }
const panel = document.createElement('div');
panel.style.cssText = `
margin-top: 6px; padding: 8px; background: #f4f5f7; border: 1px solid #e4e8ee;
border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 12px; color: #1e293b;
`;
panel.textContent = 'Loading tablets…';
anchorBtn.insertAdjacentElement('afterend', panel);
openPanel = panel;
const res = await chrome.runtime.sendMessage({ action: 'tcpListDevices' });
if (!res?.ok) {
panel.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`;
return;
}
if (!res.devices.length) {
panel.textContent = 'No tablets registered yet.';
return;
}
const select = document.createElement('select');
select.style.cssText = 'width: 100%; margin-bottom: 6px; padding: 4px;';
select.innerHTML = res.devices.map(d =>
`<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}</option>`
).join('');
const sendBtn = document.createElement('button');
sendBtn.textContent = `Send${guestName ? ` — ${guestName}` : ''}`;
sendBtn.style.cssText = `
width: 100%; padding: 4px 8px; background: #c9a84c; color: #1a1a2e;
border: none; border-radius: 6px; font-weight: 600; cursor: pointer;
`;
sendBtn.addEventListener('click', async () => {
sendBtn.disabled = true;
sendBtn.textContent = 'Sending…';
const result = await chrome.runtime.sendMessage({
action: 'tcpPush', bookingId, deviceId: select.value,
});
if (result?.ok) {
panel.textContent = 'Sent — the tablet should navigate now.';
} else {
panel.textContent = `Failed: ${result?.error || 'unknown error'}`;
}
});
panel.innerHTML = '';
panel.appendChild(select);
panel.appendChild(sendBtn);
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function processRow(row) {
if (row.hasAttribute(PROCESSED_ATTR)) return;
const bookingId = extractBookingId(row);
const cell = findCheckInCell(row);
if (!bookingId || !cell) return; // not a real booking row, or no Check In button in it
row.setAttribute(PROCESSED_ATTR, '1');
cell.appendChild(makeSendButton(bookingId, extractGuestName(row)));
}
function scanForRows(root = document) {
root.querySelectorAll('tr.checkbox_row').forEach(processRow);
}
scanForRows();
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.matches?.('tr.checkbox_row')) processRow(node);
else scanForRows(node);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

View file

@ -0,0 +1,38 @@
{
"manifest_version": 3,
"name": "Send to Check-In Tablet",
"version": "0.1.0",
"description": "Pushes the currently open NewBook booking to a check-in tablet — standalone from NewBook Assistant, see project_checkin memory.",
"permissions": ["cookies", "storage", "activeTab", "webNavigation"],
"host_permissions": [
"https://appeu.newbook.cloud/*",
"https://login.newbook.cloud/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://appeu.newbook.cloud/bookings_lists_checkin*"],
"js": ["content-script.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup/popup.html",
"default_title": "Send to Check-In Tablet"
},
"options_page": "options/options.html",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

View file

@ -0,0 +1,38 @@
// Detects whether the browser currently has a live NewBook session — used
// ONLY to decide whether to show a "you don't look logged into NewBook"
// hint in the popup. NOT used as authorization for the push call itself —
// that's the terminal API key (see options.js/popup.js). A NewBook cookie
// proves you're logged into NewBook, not that you hold this extension's own
// terminal credential; conflating the two was explicitly rejected as a
// design decision, see project_checkin memory's "Extension auth" section.
//
// Cookie-detection heuristic (domains + name match) is lifted directly from
// chrome-newbook-assistant/sidepanel/sidepanel.js's AuthManager.checkNewBookAuth
// (~/laptop-archive) — already proven against real NewBook cookies, not
// guessed here.
const NEWBOOK_COOKIE_DOMAINS = [
'https://appeu.newbook.cloud',
'https://login.newbook.cloud',
];
export async function isNewBookLoggedIn() {
try {
let allCookies = [];
for (const url of NEWBOOK_COOKIE_DOMAINS) {
const cookies = await chrome.cookies.getAll({ url });
allCookies = allCookies.concat(cookies);
}
const sessionCookie = allCookies.find(
c => c.name === 'PHPSESSID'
|| c.name.toLowerCase().includes('session')
|| c.name.toLowerCase().includes('newbook')
);
if (!sessionCookie) return false;
// A session cookie with no expirationDate is a browser-session cookie —
// still valid until the browser closes, not "expired".
if (sessionCookie.expirationDate && sessionCookie.expirationDate * 1000 < Date.now()) return false;
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,32 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 480px;
margin: 32px auto;
padding: 0 16px;
color: #1e293b;
}
h1 { font-size: 18px; color: #1a1a2e; }
label { display: block; margin-top: 16px; font-weight: 600; font-size: 13px; }
input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
margin-top: 4px;
border: 1px solid #e4e8ee;
border-radius: 8px;
font-size: 13px;
}
.hint { color: #64748b; font-size: 12px; margin: 4px 0 0; }
button {
margin-top: 20px;
margin-right: 8px;
padding: 8px 16px;
border: none;
border-radius: 8px;
background: #c9a84c;
color: #1a1a2e;
font-weight: 600;
cursor: pointer;
}
button#test { background: #f4f5f7; color: #1e293b; border: 1px solid #e4e8ee; }
#status { margin-top: 12px; font-size: 13px; }

View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Send to Check-In Tablet — Settings</title>
<link rel="stylesheet" href="options.css" />
</head>
<body>
<h1>Send to Check-In Tablet</h1>
<p class="hint">
Two things needed before this extension can push a booking to a tablet — both come from the
<strong>tablet-checkin</strong> app itself, not from here.
</p>
<label for="baseUrl">Tablet Check-In base URL</label>
<input id="baseUrl" type="text" placeholder="https://<host>/tablet-checkin — not confirmed yet, see TODO in background.js" />
<p class="hint">The app's own address once deployed. Include no trailing slash.</p>
<label for="apiKey">Terminal API key</label>
<input id="apiKey" type="password" placeholder="Paste the key shown once when you created this terminal" />
<p class="hint">
Create one from tablet-checkin's <strong>Terminals</strong> admin page (requires the <code>admin</code>
capability). The raw key is shown exactly once at creation — if you've lost it, revoke it there and
create a new one.
</p>
<button id="save">Save</button>
<button id="test">Test connection</button>
<p id="status"></p>
<script type="module" src="options.js"></script>
</body>
</html>

View file

@ -0,0 +1,48 @@
const baseUrlInput = document.getElementById('baseUrl');
const apiKeyInput = document.getElementById('apiKey');
const statusEl = document.getElementById('status');
async function load() {
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
if (baseUrl) baseUrlInput.value = baseUrl;
if (apiKey) apiKeyInput.value = apiKey; // deliberately shown back here (options page,
// not the popup) so the value can be corrected without regenerating the key.
}
async function save() {
const baseUrl = baseUrlInput.value.trim().replace(/\/$/, '');
const apiKey = apiKeyInput.value.trim();
await chrome.storage.local.set({ baseUrl, apiKey });
statusEl.textContent = 'Saved.';
statusEl.style.color = '#16a34a';
}
async function test() {
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
if (!baseUrl || !apiKey) {
statusEl.textContent = 'Set both fields and save first.';
statusEl.style.color = '#dc2626';
return;
}
statusEl.textContent = 'Testing…';
statusEl.style.color = '#64748b';
try {
const res = await fetch(`${baseUrl}/api/staff/devices`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
const devices = await res.json();
statusEl.textContent = `Connected — found ${devices.length} tablet(s).`;
statusEl.style.color = '#16a34a';
} catch (err) {
statusEl.textContent = `Failed: ${err.message}`;
statusEl.style.color = '#dc2626';
}
}
document.getElementById('save').addEventListener('click', save);
document.getElementById('test').addEventListener('click', test);
load();

View file

@ -0,0 +1,47 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
width: 300px;
margin: 0;
padding: 12px 14px;
color: #1e293b;
background: #f4f5f7;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.title { font-weight: 700; font-size: 14px; color: #1a1a2e; }
.nb-status { font-size: 11px; padding: 2px 8px; border-radius: 999px; }
.nb-status.ok { background: #dcfce7; color: #166534; }
.nb-status.no { background: #fee2e2; color: #991b1b; }
.muted { color: #64748b; font-size: 13px; }
.booking { font-size: 13px; margin-bottom: 10px; }
.booking strong { color: #1a1a2e; }
select {
width: 100%;
box-sizing: border-box;
padding: 8px;
border-radius: 8px;
border: 1px solid #e4e8ee;
margin-bottom: 10px;
font-size: 13px;
}
button.primary {
width: 100%;
padding: 10px;
border: none;
border-radius: 8px;
background: #c9a84c;
color: #1a1a2e;
font-weight: 700;
font-size: 13px;
cursor: pointer;
}
button.primary:disabled { opacity: 0.5; cursor: default; }
.msg { margin-top: 10px; font-size: 12px; }
.msg.error { color: #dc2626; }
.msg.ok { color: #16a34a; }
.msg.warn { color: #b45309; }
a.settings-link { display: block; margin-top: 12px; font-size: 12px; color: #7b4f00; }

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Send to Check-In Tablet</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div class="header">
<span class="title">Check-In Tablet</span>
<span id="nbStatus" class="nb-status"></span>
</div>
<div id="content">
<p class="muted">Loading…</p>
</div>
<script type="module" src="popup.js"></script>
</body>
</html>

View file

@ -0,0 +1,113 @@
import { isNewBookLoggedIn } from '../newbook-auth.js';
// Same regex as background.js — re-derived independently here from the live
// tab URL each time the popup opens (rather than trusting chrome.storage
// left over from the background script) so a stale badge state can never
// show a booking that isn't actually the one currently open. Confirmed
// 2026-09-01 by the user directly (real NewBook page URLs).
const BOOKING_URL_RE = /\/bookings_(?:view|edit|checkin)\/(\d+)/i;
const contentEl = document.getElementById('content');
const nbStatusEl = document.getElementById('nbStatus');
async function main() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const bookingMatch = tab?.url?.match(BOOKING_URL_RE);
const bookingId = bookingMatch ? bookingMatch[1] : null;
isNewBookLoggedIn().then(ok => {
nbStatusEl.textContent = ok ? 'NewBook: signed in' : 'NewBook: not detected';
nbStatusEl.className = `nb-status ${ok ? 'ok' : 'no'}`;
});
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
if (!baseUrl || !apiKey) {
contentEl.innerHTML = `
<p class="muted">Not set up yet — add the tablet-checkin URL and a terminal API key first.</p>
<a class="settings-link" href="#" id="openSettings">Open Settings</a>
`;
document.getElementById('openSettings').addEventListener('click', e => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
return;
}
if (!bookingId) {
contentEl.innerHTML = `<p class="muted">Open a booking in NewBook to send it to a check-in tablet.</p>`;
return;
}
contentEl.innerHTML = `
<p class="booking">Booking <strong>#${bookingId}</strong></p>
<select id="device"><option>Loading tablets…</option></select>
<button class="primary" id="send" disabled>Send to Check-In Tablet</button>
<div id="msg"></div>
`;
const deviceSelect = document.getElementById('device');
const sendBtn = document.getElementById('send');
const msgEl = document.getElementById('msg');
let devices = [];
try {
const res = await fetch(`${baseUrl}/api/staff/devices`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
devices = await res.json();
} catch (err) {
deviceSelect.innerHTML = '<option>Could not load tablets</option>';
setMsg(msgEl, `Could not reach tablet-checkin: ${err.message}`, 'error');
return;
}
if (!devices.length) {
deviceSelect.innerHTML = '<option>No tablets registered yet</option>';
return;
}
deviceSelect.innerHTML = devices.map(d =>
`<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>
${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}
</option>`
).join('');
sendBtn.disabled = false;
sendBtn.addEventListener('click', async () => {
sendBtn.disabled = true;
setMsg(msgEl, 'Sending…', 'warn');
try {
const res = await fetch(`${baseUrl}/api/staff/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ booking_id: bookingId, device_id: deviceSelect.value }),
});
const body = await res.json().catch(() => ({}));
if (res.status === 409) {
setMsg(msgEl, body.error || 'That tablet is busy with another guest right now.', 'warn');
} else if (!res.ok) {
throw new Error(body.error || `HTTP ${res.status}`);
} else {
setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok');
}
} catch (err) {
setMsg(msgEl, `Failed: ${err.message}`, 'error');
} finally {
sendBtn.disabled = false;
}
});
}
function setMsg(el, text, kind) {
el.textContent = text;
el.className = `msg ${kind}`;
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
main();

2774
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"jose": "^5.9.6",

View file

@ -120,7 +120,6 @@ export async function initDb() {
}
const SETTINGS_DEFAULTS = {
newbook_hotel_id: '',
regcard_custom_field_id: '',
online_checkin_complete_custom_field_id: '',
kiosk_password_hash: '',

View file

@ -10,6 +10,7 @@ import { checkinsRoutes } from './routes/checkins.js'
import { marketingRoutes } from './routes/marketing.js'
import { settingsRoutes } from './routes/settings.js'
import { kioskRoutes } from './routes/kiosk.js'
import { extensionRoutes } from './routes/extension.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
@ -30,6 +31,7 @@ await app.register(checkinsRoutes)
await app.register(marketingRoutes)
await app.register(settingsRoutes)
await app.register(kioskRoutes)
await app.register(extensionRoutes)
try {
await initDb()

View file

@ -1,5 +1,5 @@
import { randomBytes } from 'crypto'
import { pool, getSetting } from '../db.js'
import { pool } from '../db.js'
import { pushNavigate } from './device-stream.js'
const SESSION_TTL_MINUTES = 30
@ -24,7 +24,14 @@ export async function pushBookingToDevice(deviceId, bookingId) {
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 || ''
// Single-hotel-per-stack deployment (see project_checkin memory) — no
// per-request "which hotel" lookup needed, this whole app instance already
// is one hotel. hotel_id here is purely a local audit-row label, sourced
// from the same env var lib/newbook.js sends to the NewBook API itself
// (not a separate app-level setting — a local `newbook_hotel_id` Settings
// field existed here before 2026-09-01 but was removed as vestigial
// multi-hotel-model plumbing that no longer did anything real).
const hotelId = process.env.NEWBOOK_LOCATION_ID || ''
const token = randomBytes(24).toString('hex')
const expiresAt = new Date(Date.now() + SESSION_TTL_MINUTES * 60 * 1000)

View file

@ -0,0 +1,39 @@
import path from 'path'
import { fileURLToPath } from 'url'
import archiver from 'archiver'
import { requireAuth, requireCap } from '../auth.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// Bundled copy of tablet-checkin-push (~/dev/HNF-PROXMOX/tablet-checkin-push),
// baked into this image at build time (see Dockerfile) so the extension ships
// with the app rather than needing separate distribution. Zipped fresh on
// each request rather than a pre-built static file — small folder, cheap to
// zip, and always reflects whatever's actually deployed (no separate
// "remember to rebuild the zip" step to forget). See project_checkin memory's
// "Standalone push extension" section.
const EXTENSION_SRC = path.join(__dirname, '..', '..', 'extension-src')
export async function extensionRoutes(app) {
// Gated the same as the Settings page it's linked from — any staff who can
// see Settings can download this, nothing sensitive lives in the extension
// itself (the terminal API key is entered by whoever installs it, not
// baked in here).
app.get('/api/extension/download', { preHandler: [requireAuth, requireCap('settings')] }, async (req, reply) => {
reply.header('Content-Type', 'application/zip')
reply.header('Content-Disposition', 'attachment; filename="tablet-checkin-push.zip"')
const archive = archiver('zip', { zlib: { level: 9 } })
archive.on('error', err => {
app.log.error(`[extension] zip stream error: ${err.message}`)
reply.raw.destroy(err)
})
// Root the zip's contents at "tablet-checkin-push/" rather than dumping
// files at the archive root — so extracting it doesn't scatter files
// into whatever folder the user unzipped into.
archive.directory(EXTENSION_SRC, 'tablet-checkin-push')
reply.hijack()
archive.pipe(reply.raw)
await archive.finalize()
})
}

View file

@ -9,7 +9,7 @@ import { testConnection } from '../lib/newbook.js'
// 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',
'regcard_custom_field_id', 'online_checkin_complete_custom_field_id',
'terms_and_conditions_text',
]

View file

@ -54,6 +54,11 @@ export function deleteTerminal(id: string): Promise<{ ok: boolean }> {
export function fetchCheckins(limit = 100): Promise<Checkin[]> { return request(`/checkins?limit=${limit}`) }
export function checkinPdfUrl(id: number): string { return `${BASE}/checkins/${id}/pdf` }
// Standalone push extension — bundled into this app at build time (see
// backend/extension-src), zipped fresh on each request rather than served as
// a static file. See project_checkin memory's "Standalone push extension".
export function extensionDownloadUrl(): string { return `${BASE}/extension/download` }
// Marketing
export function fetchMarketingProviders(): Promise<MarketingProvider[]> { return request('/marketing/providers') }
export function updateMarketingProvider(provider: string, body: { active: boolean; config: Record<string, string> }): Promise<MarketingProvider> {

View file

@ -1,14 +1,14 @@
import { useEffect, useState } from 'react'
import { MonitorCog } from 'lucide-react'
import { MonitorCog, Download } from 'lucide-react'
import {
fetchSettings, updateSettings, testNewbookConnection,
fetchKioskPasswordStatus, setKioskPassword,
fetchKioskPasswordStatus, setKioskPassword, extensionDownloadUrl,
} from '../api'
import type { AppSettings } from '../types'
export default function Settings() {
const [settings, setSettings] = useState<AppSettings>({
newbook_hotel_id: '', regcard_custom_field_id: '',
regcard_custom_field_id: '',
online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '',
})
const [loading, setLoading] = useState(true)
@ -76,13 +76,6 @@ export default function Settings() {
<div className="section-title">NewBook Integration</div>
<div className="field-row">
<div className="field">
<label>NewBook Hotel / Location ID</label>
<input
type="text" value={settings.newbook_hotel_id}
onChange={e => setSettings(s => ({ ...s, newbook_hotel_id: e.target.value }))}
/>
</div>
<div className="field">
<label>Regcard PDF Link — Custom Field ID</label>
<input
@ -127,6 +120,20 @@ export default function Settings() {
</button>
</div>
<div className="section-title">Push Extension</div>
<p className="field-hint" style={{ marginBottom: 10 }}>
Standalone Chrome extension — adds a "Send to Check-In Tablet" button when a NewBook booking
is open. Not published anywhere yet, so it needs installing manually on each reception PC:
download, unzip, then <code>chrome://extensions</code> → enable Developer mode → Load
unpacked → select the unzipped folder. It'll need a Terminal API key from the Terminals page
to actually authorize pushes — create one there first.
</p>
<div style={{ marginBottom: 24 }}>
<a className="btn" href={extensionDownloadUrl()} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, textDecoration: 'none' }}>
<Download size={16} strokeWidth={1.75} /> Download Extension (.zip)
</a>
</div>
<div className="section-title">Kiosk Tablets</div>
<p className="field-hint" style={{ marginBottom: 10 }}>
A shared password every check-in tablet uses to set itself up at <code>/tablet-checkin/kiosk</code> —

View file

@ -82,7 +82,6 @@ export interface MarketingSyncRow {
}
export interface AppSettings {
newbook_hotel_id: string
regcard_custom_field_id: string
online_checkin_complete_custom_field_id: string
terms_and_conditions_text: string