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

@ -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 };
}
}