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:
parent
3b2e25df3f
commit
4d3774ef0d
25 changed files with 3557 additions and 15 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
67
backend/extension-src/README.md
Normal file
67
backend/extension-src/README.md
Normal 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.
|
||||
111
backend/extension-src/background.js
Normal file
111
backend/extension-src/background.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
161
backend/extension-src/content-script.js
Normal file
161
backend/extension-src/content-script.js
Normal 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 });
|
||||
BIN
backend/extension-src/icons/icon128.png
Normal file
BIN
backend/extension-src/icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 B |
BIN
backend/extension-src/icons/icon16.png
Normal file
BIN
backend/extension-src/icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 B |
BIN
backend/extension-src/icons/icon48.png
Normal file
BIN
backend/extension-src/icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 305 B |
38
backend/extension-src/manifest.json
Normal file
38
backend/extension-src/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
backend/extension-src/newbook-auth.js
Normal file
38
backend/extension-src/newbook-auth.js
Normal 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;
|
||||
}
|
||||
}
|
||||
32
backend/extension-src/options/options.css
Normal file
32
backend/extension-src/options/options.css
Normal 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; }
|
||||
33
backend/extension-src/options/options.html
Normal file
33
backend/extension-src/options/options.html
Normal 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>
|
||||
48
backend/extension-src/options/options.js
Normal file
48
backend/extension-src/options/options.js
Normal 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();
|
||||
47
backend/extension-src/popup/popup.css
Normal file
47
backend/extension-src/popup/popup.css
Normal 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; }
|
||||
20
backend/extension-src/popup/popup.html
Normal file
20
backend/extension-src/popup/popup.html
Normal 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>
|
||||
113
backend/extension-src/popup/popup.js
Normal file
113
backend/extension-src/popup/popup.js
Normal 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
2774
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
39
backend/src/routes/extension.js
Normal file
39
backend/src/routes/extension.js
Normal 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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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',
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue