Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-09-02 16:45:48 +10:00
parent ff9f51eaf9
commit 8c97c314e0
11 changed files with 211 additions and 247 deletions
-1
View File
@@ -67,7 +67,6 @@ SHEETS_ENABLED=false
# The long id from the sheet URL: docs.google.com/spreadsheets/d/<THIS PART>/edit # The long id from the sheet URL: docs.google.com/spreadsheets/d/<THIS PART>/edit
SHEETS_SPREADSHEET_ID= SHEETS_SPREADSHEET_ID=
# Append-only history of every sign in and sign out. # Append-only history of every sign in and sign out.
SHEETS_LOG_TAB=Visitor log
# Rewritten on every change: only the people currently on site. Open this one # Rewritten on every change: only the people currently on site. Open this one
# during an evacuation. Both tabs are created automatically if missing. # during an evacuation. Both tabs are created automatically if missing.
SHEETS_ONSITE_TAB=On site now SHEETS_ONSITE_TAB=On site now
+27 -22
View File
@@ -115,19 +115,23 @@ whatever space the clock leaves over. The setting applies to the site name too,
uploaded. SVG is deliberately not accepted: uploaded. SVG is deliberately not accepted:
it can carry script, and this file is served to every kiosk. it can carry script, and this file is served to every kiosk.
**Colours.** Three are settable: **Colours.** Four are settable:
| Setting | Where it shows | | Setting | Where it shows |
|---|---| |---|---|
| Bar and buttons | The top bar, the Sign in door, primary buttons, the confirmation mark | | Bar and buttons | The top bar, the Sign in door, primary buttons, the confirmation mark |
| Sign out | The Sign out door and the signed-out confirmation | | Sign out | The Sign out door and the signed-out confirmation |
| Page background | Behind everything, with card and rule colours derived from it | | Page background | Behind everything, with card and rule colours derived from it |
| Body text | Headings, answers, and the source for the softer label colour |
Everything else is worked out from those three. In particular the **text colour on a coloured Two things are still worked out rather than set. **Text on a coloured background** is chosen by
background is chosen by contrast**, not fixed — pick a pale yellow for the bar and the text on contrast, so a pale yellow bar gets dark text instead of unreadable white. And the **muted colour**
it turns dark automatically, instead of staying white and becoming unreadable. Card, border and used for field labels and hints is your body text mixed towards the background only as far as it
body-text colours follow the page colour, so a dark background gives a usable dark theme rather can go while still clearing WCAG AA at 4.5:1 — a fixed grey looks fine on the default background
than white boxes. and vanishes on a custom one, which is the usual cause of text that blends in.
The site editor shows the contrast ratio as you type and warns below 4.5:1. Aim for 7:1 or better
on a kiosk people read standing up.
Leave a colour box empty to fall back to the default. Anything that is not a six-digit hex value Leave a colour box empty to fall back to the default. Anything that is not a six-digit hex value
is ignored rather than applied. is ignored rather than applied.
@@ -310,28 +314,29 @@ database and retried every minute, so a dropped internet connection never blocks
6. Restart, then **Admin → System → Test the sheet connection**. Both tabs and their headers 6. Restart, then **Admin → System → Test the sheet connection**. Both tabs and their headers
are created the first time. are created the first time.
### Two tabs, two jobs ### What the sheet holds
The spreadsheet gets two tabs, both created automatically: **Only the people currently on site.** One tab, rewritten in full whenever anyone signs in or
out. Nothing is appended, so there is no history to scroll past while you are standing in a car
park counting heads — the top row says `On site now — 3 people — updated 31/08/26, 14:12`, and
everything under it is someone still in the building.
**On site now** — rewritten every time anyone signs in or out, so it only ever lists the people Rewriting rather than patching is deliberate: a failed update can never leave a stale name on the
currently in the building. No filtering, no scrolling to the bottom. The top row shows a head evacuation list, because whatever is on the tab is what the database said at the time shown. If a
count and the time it was last updated, so you can tell at a glance whether it is live. This is write fails the tab is marked stale and rewritten on the next pass, once a minute. It also
the tab to bookmark on the phones that matter and to open at the assembly point. refreshes every 15 minutes on its own to keep the "on site for" column honest.
**Visitor log** — append only. Every sign in and sign out, forever, with times in and out. The **full visit history stays in the application** — searchable under **Visit log** in the admin
This is the record you go back through weeks later. console, and downloadable as CSV. It is not sent to Google, which keeps visitor contact details
and movement history off a cloud service that only exists here for the evacuation case.
Rename them with `SHEETS_LOG_TAB` and `SHEETS_ONSITE_TAB`. Names with spaces are fine. Every row carries the site name, so one spreadsheet covers every site.
The live tab is rebuilt from the database rather than edited row by row, so it is self-healing: **Bookmark the sheet on the phones that would actually be used in an evacuation, and check it
if a write fails, the next one puts everything right. It also refreshes every 15 minutes on its after setup.** A sheet nobody can find is not a safety measure.
own to keep the *On site for* column honest, and rebuilds at startup in case anyone signed out
while the container was down. **Admin → System → Rebuild the live list** forces it.
Anything you type into these tabs by hand will be overwritten. The sheet is a mirror, not the *Upgrading from an earlier version:* the old "Visitor log" tab is left alone but no longer
source of truth — nothing is ever read back from it. Every row carries the site name, so one written to. Delete it by hand when you are ready.
spreadsheet covers every site.
## Recurring visitors and PINs ## Recurring visitors and PINs
+64 -30
View File
@@ -777,8 +777,11 @@ function openSiteModal(site) {
${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')} ${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')}
${colourField('Page background', 'page', site.branding.page, '#e7ecf0')} ${colourField('Page background', 'page', site.branding.page, '#e7ecf0')}
</div> </div>
<p class="hint">Text colours are worked out from these, so a pale brand colour gets dark ${colourField('Body text', 'text', site.branding.text, site.branding.theme.ink)}
text rather than white. Clear a box to go back to the default.</p>`, <p class="hint" id="contrast-note"></p>
<p class="hint">Text on the bar and on buttons is chosen automatically for contrast, so a
pale brand colour gets dark text rather than white. Labels and hints are a softened version
of the body text, kept readable against the background. Clear a box for the default.</p>`,
async (form) => { async (form) => {
const data = Object.fromEntries(form.entries()); const data = Object.fromEntries(form.entries());
try { try {
@@ -800,6 +803,7 @@ function openSiteModal(site) {
brand: data.brand || null, brand: data.brand || null,
signout: data.signout || null, signout: data.signout || null,
page: data.page || null, page: data.page || null,
text: data.text || null,
bannerHeight: Number(data.bannerHeight) || 64, bannerHeight: Number(data.bannerHeight) || 64,
bannerAlign: data.bannerAlign, bannerAlign: data.bannerAlign,
}, },
@@ -844,6 +848,18 @@ function colourField(label, name, value, fallback) {
</label>`; </label>`;
} }
/** WCAG contrast ratio, mirroring the server so the console can warn as you type. */
function contrastRatio(a, b) {
const lum = (hex) => {
const [r, g, bl] = [1, 3, 5]
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
};
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const bannerEditor = { dataUrl: null, remove: false }; const bannerEditor = { dataUrl: null, remove: false };
function wireBannerEditor() { function wireBannerEditor() {
@@ -861,6 +877,36 @@ function wireBannerEditor() {
}); });
}); });
// Live contrast readout, because a colour that looks fine in a swatch can be
// unreadable as body text.
const pageInput = $('#modal-form [name="page"]');
const textInput = $('#modal-form [name="text"]');
const note = $('#contrast-note');
const showContrast = () => {
const page = pageInput.value.trim() || pageInput.placeholder;
const text = textInput.value.trim() || textInput.placeholder;
if (!/^#[0-9a-fA-F]{6}$/.test(page) || !/^#[0-9a-fA-F]{6}$/.test(text)) {
note.hidden = true;
return;
}
const ratio = contrastRatio(text, page);
note.hidden = false;
if (ratio >= 7) {
note.className = 'hint';
note.textContent = `Contrast ${ratio.toFixed(1)}:1 — comfortable at arm's length.`;
} else if (ratio >= 4.5) {
note.className = 'hint';
note.textContent = `Contrast ${ratio.toFixed(1)}:1 — readable, but aim for 7:1 on a kiosk people read standing up.`;
} else {
note.className = 'hint warn';
note.textContent = `Contrast only ${ratio.toFixed(1)}:1. This will be hard to read — pick a darker or lighter body text.`;
}
};
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
showContrast();
$('#banner-file').addEventListener('change', (event) => { $('#banner-file').addEventListener('change', (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) return;
@@ -1100,20 +1146,22 @@ async function loadSystem() {
<dt>On site now</dt><dd>${s.onSite}</dd> <dt>On site now</dt><dd>${s.onSite}</dd>
<dt>Google Sheet</dt><dd>${ <dt>Google Sheet</dt><dd>${
s.sheets.enabled s.sheets.enabled
? `Connected. ${s.sheets.queued} row(s) waiting to send.${s.sheets.lastError ? ` Last error: ${esc(s.sheets.lastError)}` : ''}` ? `Mirroring who is on site to the "${esc(s.sheets.tab)}" tab${
s.sheets.stale ? ' <span class="pill warn">waiting to retry</span>' : ''
}${s.sheets.lastError ? `<br><span class="pill bad">${esc(s.sheets.lastError)}</span>` : ''}`
: 'Turned off in the environment file.' : 'Turned off in the environment file.'
}</dd> }</dd>
<dt>History tab</dt><dd>${esc(s.sheets.logTab)} — last written ${stamp(s.sheets.lastOk)}</dd> ${
<dt>Live tab</dt><dd>${esc(s.sheets.onSiteTab)}${ s.sheets.enabled
s.sheets.onSiteCount === null ? 'not synced yet' : `${s.sheets.onSiteCount} on site` ? `<dt>On the sheet</dt><dd>${
}, last synced ${stamp(s.sheets.lastOnSiteSync)}${ s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site`
s.sheets.onSiteError ? ` <span class="pill bad">${esc(s.sheets.onSiteError)}</span>` : '' }, last written ${stamp(s.sheets.lastOk)}</dd>`
}</dd> : ''
}
</dl> </dl>
<div class="sys-actions"> <div class="sys-actions">
<button class="ghost" id="sheet-test">Test the sheet connection</button> <button class="ghost" id="sheet-test">Test the sheet connection</button>
<button class="ghost" id="sheet-flush">Send queued rows now</button> <button class="ghost" id="sheet-resync">Rebuild the sheet now</button>
<button class="ghost" id="sheet-resync">Rebuild the live list</button>
<button class="ghost danger" id="photo-purge">Purge photos past retention</button> <button class="ghost danger" id="photo-purge">Purge photos past retention</button>
</div> </div>
<h3 class="section-gap">Certificate</h3> <h3 class="section-gap">Certificate</h3>
@@ -1128,28 +1176,14 @@ async function loadSystem() {
toast(err.message, true); toast(err.message, true);
} }
}); });
$('#sheet-resync').addEventListener('click', async () => {
try {
const r = await api('/sheets/sync', { method: 'POST' });
toast(r.skipped ? 'Sheet mirroring is off.' : `Live tab rewritten with ${r.rows} on site.`);
loadSystem();
} catch (err) {
toast(err.message, true);
}
});
$('#sheet-flush').addEventListener('click', async () => {
try {
const r = await api('/sheets/flush', { method: 'POST' });
toast(`${r.sent} sent, ${r.remaining} still queued.`);
loadSystem();
} catch (err) {
toast(err.message, true);
}
});
$('#sheet-resync').addEventListener('click', async () => { $('#sheet-resync').addEventListener('click', async () => {
try { try {
const r = await api('/sheets/resync', { method: 'POST' }); const r = await api('/sheets/resync', { method: 'POST' });
toast(`Live list rebuilt with ${r.rows} ${r.rows === 1 ? 'person' : 'people'}.`); toast(
r.skipped
? 'Sheet mirroring is off.'
: `Sheet rewritten with ${r.rows} ${r.rows === 1 ? 'person' : 'people'} on site.`
);
loadSystem(); loadSystem();
} catch (err) { } catch (err) {
toast(err.message, true); toast(err.message, true);
+1
View File
@@ -452,6 +452,7 @@ function applyTheme(theme, banner, align = 'left') {
root.setProperty('--paper', theme.page); root.setProperty('--paper', theme.page);
root.setProperty('--card', theme.card); root.setProperty('--card', theme.card);
root.setProperty('--ink', theme.ink); root.setProperty('--ink', theme.ink);
root.setProperty('--muted', theme.muted);
root.setProperty('--rule', theme.rule); root.setProperty('--rule', theme.rule);
root.setProperty('--focus', theme.brand); root.setProperty('--focus', theme.brand);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand); document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand);
+37 -1
View File
@@ -16,6 +16,7 @@ export const DEFAULT_THEME = {
brand: '#0b4f4a', brand: '#0b4f4a',
signout: '#2c4a6b', signout: '#2c4a6b',
page: '#e7ecf0', page: '#e7ecf0',
text: '#16202b',
}; };
const BANNER_DIR = path.join(config.dataDir, 'branding'); const BANNER_DIR = path.join(config.dataDir, 'branding');
@@ -51,6 +52,36 @@ export function readableOn(hex) {
return luminance(hex) > 0.45 ? '#16202b' : '#ffffff'; return luminance(hex) > 0.45 ? '#16202b' : '#ffffff';
} }
/** WCAG contrast ratio between two colours, from 1 (identical) to 21. */
export function contrastRatio(a, b) {
const la = luminance(a);
const lb = luminance(b);
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
function mix(a, b, amount) {
const [ar, ag, ab] = toRgb(a);
const [br, bg, bb] = toRgb(b);
const channel = (x, y) => Math.round(x + (y - x) * amount);
return `#${[channel(ar, br), channel(ag, bg), channel(ab, bb)]
.map((c) => c.toString(16).padStart(2, '0'))
.join('')}`;
}
/**
* A softer version of the body text for labels and hints. It is mixed towards the
* background only as far as it can go while still clearing WCAG AA at 4.5:1 —
* a fixed grey looks fine on the default background and disappears on a custom one.
*/
export function mutedFor(text, page) {
for (const amount of [0.45, 0.38, 0.3, 0.22, 0.14]) {
const candidate = mix(text, page, amount);
if (contrastRatio(candidate, page) >= 4.5) return candidate;
}
return text;
}
/** Shifts a colour towards black (negative) or white (positive). */ /** Shifts a colour towards black (negative) or white (positive). */
export function shade(hex, amount) { export function shade(hex, amount) {
const channels = toRgb(hex).map((channel) => { const channels = toRgb(hex).map((channel) => {
@@ -66,6 +97,8 @@ export function themeFor(site) {
const brand = normaliseColour(site?.colour_brand, DEFAULT_THEME.brand); const brand = normaliseColour(site?.colour_brand, DEFAULT_THEME.brand);
const signout = normaliseColour(site?.colour_signout, DEFAULT_THEME.signout); const signout = normaliseColour(site?.colour_signout, DEFAULT_THEME.signout);
const page = normaliseColour(site?.colour_page, DEFAULT_THEME.page); const page = normaliseColour(site?.colour_page, DEFAULT_THEME.page);
// Body text: whatever was chosen, or readable-by-default against the page.
const ink = normaliseColour(site?.colour_text, readableOn(page) === '#ffffff' ? '#f2f5f7' : '#16202b');
return { return {
brand, brand,
brandDark: shade(brand, -0.25), brandDark: shade(brand, -0.25),
@@ -76,8 +109,11 @@ export function themeFor(site) {
page, page,
// A card needs to lift off the page whether the page is light or dark. // A card needs to lift off the page whether the page is light or dark.
card: luminance(page) > 0.5 ? '#ffffff' : shade(page, 0.12), card: luminance(page) > 0.5 ? '#ffffff' : shade(page, 0.12),
ink: readableOn(page) === '#ffffff' ? '#f2f5f7' : '#16202b', ink,
muted: mutedFor(ink, page),
rule: luminance(page) > 0.5 ? shade(page, -0.12) : shade(page, 0.2), rule: luminance(page) > 0.5 ? shade(page, -0.12) : shade(page, 0.2),
// Surfaced so the admin console can warn about an unreadable combination.
textContrast: Number(contrastRatio(ink, page).toFixed(2)),
}; };
} }
+2
View File
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS sites (
colour_brand TEXT, colour_brand TEXT,
colour_signout TEXT, colour_signout TEXT,
colour_page TEXT, colour_page TEXT,
colour_text TEXT,
active INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -166,6 +167,7 @@ addColumn('sites', 'banner_align', "TEXT NOT NULL DEFAULT 'left'");
addColumn('sites', 'colour_brand', 'TEXT'); addColumn('sites', 'colour_brand', 'TEXT');
addColumn('sites', 'colour_signout', 'TEXT'); addColumn('sites', 'colour_signout', 'TEXT');
addColumn('sites', 'colour_page', 'TEXT'); addColumn('sites', 'colour_page', 'TEXT');
addColumn('sites', 'colour_text', 'TEXT');
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)'); db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)'); db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
+6 -14
View File
@@ -388,8 +388,8 @@ router.patch('/sites/:id', (req, res) => {
db.prepare( db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?, `UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?, badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
colour_brand = ?, colour_signout = ?, colour_page = ?, banner_height = ?, colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
banner_align = ? WHERE id = ?` banner_height = ?, banner_align = ? WHERE id = ?`
).run( ).run(
clean(req.body?.name ?? site.name, 100) || site.name, clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug, req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
@@ -409,6 +409,7 @@ router.patch('/sites/:id', (req, res) => {
? normaliseColour(branding.signout, null) ? normaliseColour(branding.signout, null)
: site.colour_signout, : site.colour_signout,
branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page, branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page,
branding.text !== undefined ? normaliseColour(branding.text, null) : site.colour_text,
branding.bannerHeight !== undefined branding.bannerHeight !== undefined
? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64)) ? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64))
: site.banner_height, : site.banner_height,
@@ -953,7 +954,7 @@ router.post('/visits/:id/signout', (req, res) => {
'admin', 'admin',
visit.id visit.id
); );
sheets.mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT'); sheets.mirror();
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -1100,11 +1101,10 @@ router.get('/status', (req, res) => {
: db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n, : db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n,
sheets: { sheets: {
enabled: sheets.isEnabled(), enabled: sheets.isEnabled(),
queued: sheets.queueDepth(),
lastOk: sheets.status.lastOk, lastOk: sheets.status.lastOk,
lastError: sheets.status.lastError, lastError: sheets.status.lastError,
logTab: config.sheets.logTab, tab: sheets.tabName(),
onSiteTab: config.sheets.onSiteTab, stale: sheets.isStale(),
lastOnSiteSync: sheets.status.lastOnSiteSync, lastOnSiteSync: sheets.status.lastOnSiteSync,
onSiteCount: sheets.status.onSiteCount, onSiteCount: sheets.status.onSiteCount,
onSiteError: sheets.status.onSiteError, onSiteError: sheets.status.onSiteError,
@@ -1129,14 +1129,6 @@ router.post('/sheets/test', async (req, res) => {
} }
}); });
router.post('/sheets/flush', async (req, res) => {
try {
res.json(await sheets.flushQueue());
} catch (err) {
res.status(400).json({ error: err.message });
}
});
/* ---------------------------------------------------------------- tls */ /* ---------------------------------------------------------------- tls */
router.get('/tls', (req, res) => { router.get('/tls', (req, res) => {
+2 -2
View File
@@ -208,7 +208,7 @@ router.post('/signin', signInLimiter, (req, res) => {
); );
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(info.lastInsertRowid); const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(info.lastInsertRowid);
mirror(visit, 'SIGN IN'); mirror();
delete req.session.frequentVisitorId; delete req.session.frequentVisitorId;
// Lets this kiosk session fetch the badge for the visit it just created. // Lets this kiosk session fetch the badge for the visit it just created.
@@ -300,7 +300,7 @@ router.post('/signout', signInLimiter, (req, res) => {
'visitor', 'visitor',
visit.id visit.id
); );
mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT'); mirror();
res.json({ ok: true, firstName: visit.first_name, signedOutAt }); res.json({ ok: true, firstName: visit.first_name, signedOutAt });
}); });
+1 -1
View File
@@ -102,7 +102,7 @@ if (config.autoSignOutTime) {
'auto', 'auto',
visit.id visit.id
); );
sheets.mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT (AUTO)'); sheets.mirror();
} }
if (open.length) console.log(`[auto] signed out ${open.length} visitor(s) still on site`); if (open.length) console.log(`[auto] signed out ${open.length} visitor(s) still on site`);
}, 60000).unref(); }, 60000).unref();
+70 -176
View File
@@ -5,33 +5,17 @@ import db from './db.js';
import { localStamp } from './util.js'; import { localStamp } from './util.js';
/** /**
* Two tabs, doing different jobs. * The spreadsheet is an evacuation list and nothing else.
* *
* "On site now" — rewritten whenever someone signs in or out. Only the people * One tab, rewritten in full whenever anyone signs in or out, holding only the
* currently in the building. This is the evacuation list. * people currently in the building. It is never appended to, so there is no
* "Visitor log" — appended to, never rewritten. Every sign in and sign out event, * history to scroll past while standing in a car park counting heads.
* kept for the record. *
* The full visit history stays in the application's own database, where it is
* searchable in the admin console and exportable as CSV.
*/ */
const LOG_HEADER = [ const HEADER = [
'Timestamp',
'Site',
'Action',
'Visitor type',
'First name',
'Last name',
'Phone',
'Email',
'Check type',
'Check number',
'Visiting',
'Signed in',
'Signed out',
'Photo on file',
'Visit ID',
];
const ONSITE_HEADER = [
'Site', 'Site',
'First name', 'First name',
'Last name', 'Last name',
@@ -44,17 +28,17 @@ const ONSITE_HEADER = [
'Visit ID', 'Visit ID',
]; ];
const MAX_ROWS = 1000;
let client = null; let client = null;
let tabsPromise = null; let tabPromise = null;
let onSiteDirty = false; let dirty = false;
let syncing = false; let syncing = false;
export const status = { export const status = {
lastOk: null, lastOk: null,
lastError: null, lastError: null,
lastOnSiteSync: null,
onSiteCount: null, onSiteCount: null,
onSiteError: null,
}; };
/* ----------------------------------------------------------- connection */ /* ----------------------------------------------------------- connection */
@@ -86,93 +70,35 @@ export function isEnabled() {
} }
/** /**
* Creates either tab if it is missing, and writes the header row once. * Creates the tab if it is missing. Memoised as a promise rather than a boolean:
* Memoised as a promise, not a boolean: a sign in kicks off the log append and the * two syncs starting at once would otherwise both decide it was missing.
* on-site rewrite at the same moment, and two concurrent checks would each decide
* the tabs were missing and try to create them twice.
*/ */
function ensureTabs(sheets, { force = false } = {}) { function ensureTab(sheets, { force = false } = {}) {
if (force) tabsPromise = null; if (force) tabPromise = null;
if (!tabsPromise) { if (!tabPromise) {
tabsPromise = doEnsureTabs(sheets).catch((err) => { tabPromise = doEnsureTab(sheets).catch((err) => {
tabsPromise = null; // let the next attempt retry rather than caching the failure tabPromise = null;
throw err; throw err;
}); });
} }
return tabsPromise; return tabPromise;
} }
async function doEnsureTabs(sheets) { async function doEnsureTab(sheets) {
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId }); const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
const existing = meta.data.sheets.map((s) => s.properties.title); const titles = meta.data.sheets.map((s) => s.properties.title);
const wanted = [config.sheets.logTab, config.sheets.onSiteTab]; if (!titles.includes(config.sheets.onSiteTab)) {
const missing = wanted.filter((t) => !existing.includes(t));
if (missing.length) {
await sheets.spreadsheets.batchUpdate({ await sheets.spreadsheets.batchUpdate({
spreadsheetId: config.sheets.spreadsheetId, spreadsheetId: config.sheets.spreadsheetId,
requestBody: { requestBody: {
requests: missing.map((title) => ({ addSheet: { properties: { title } } })), requests: [{ addSheet: { properties: { title: config.sheets.onSiteTab } } }],
}, },
}); });
console.log(`[sheets] created tab(s): ${missing.join(', ')}`); console.log(`[sheets] created tab "${config.sheets.onSiteTab}"`);
}
// Header on the log tab only. The on-site tab gets its header on every rewrite.
const range = `${config.sheets.logTab}!A1:O1`;
const head = await sheets.spreadsheets.values.get({
spreadsheetId: config.sheets.spreadsheetId,
range,
});
if (!head.data.values?.length) {
await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId,
range,
valueInputOption: 'RAW',
requestBody: { values: [LOG_HEADER] },
});
} }
} }
/* -------------------------------------------------------------- the log */ /* --------------------------------------------------------- who is here */
export function rowForVisit(visit, action) {
return [
localStamp(new Date().toISOString()),
visit.site_name || '',
action,
visit.visitor_type === 'frequent' ? 'Recurring' : 'Guest',
visit.first_name,
visit.last_name,
visit.phone || '',
visit.email || '',
visit.check_type === 'NONE' ? 'None' : visit.check_type,
visit.check_number || '',
visit.host_name,
localStamp(visit.signed_in_at),
visit.signed_out_at ? localStamp(visit.signed_out_at) : '',
visit.photo_path ? 'Yes' : 'No',
String(visit.id),
];
}
async function appendLog(row) {
const sheets = getClient();
await ensureTabs(sheets);
await sheets.spreadsheets.values.append({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.logTab}!A:O`,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: { values: [row] },
});
}
function enqueue(row) {
db.prepare('INSERT INTO sheet_queue (payload) VALUES (?)').run(JSON.stringify(row));
}
/* --------------------------------------------------------- who's on site */
function humanDuration(fromIso) { function humanDuration(fromIso) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(fromIso).getTime()) / 60000)); const minutes = Math.max(0, Math.round((Date.now() - new Date(fromIso).getTime()) / 60000));
@@ -185,6 +111,7 @@ function onSiteRows() {
return db return db
.prepare('SELECT * FROM visits WHERE signed_out_at IS NULL ORDER BY site_name, signed_in_at') .prepare('SELECT * FROM visits WHERE signed_out_at IS NULL ORDER BY site_name, signed_in_at')
.all() .all()
.slice(0, MAX_ROWS)
.map((v) => [ .map((v) => [
v.site_name || '', v.site_name || '',
v.first_name, v.first_name,
@@ -200,113 +127,74 @@ function onSiteRows() {
} }
/** /**
* Replaces the whole on-site tab with the current state. Rewriting rather than * Replaces the whole tab with the current state. Rewriting rather than patching
* patching means a missed update never leaves a stale name on the evacuation list. * means a missed update can never leave a stale name on the evacuation list:
* whatever is on the tab is what the database says right now.
*/ */
export async function syncOnSite() { export async function syncOnSite() {
if (!isEnabled()) return { skipped: true }; if (!isEnabled()) return { skipped: true };
if (syncing) return { skipped: true }; if (syncing) {
dirty = true;
return { skipped: true };
}
syncing = true; syncing = true;
try { try {
const sheets = getClient(); const sheets = getClient();
await ensureTabs(sheets); await ensureTab(sheets);
const rows = onSiteRows(); const rows = onSiteRows();
const banner = `On site now — ${rows.length} ${rows.length === 1 ? 'person' : 'people'} — updated ${localStamp(new Date().toISOString())}`; const banner = `On site now — ${rows.length} ${rows.length === 1 ? 'person' : 'people'} — updated ${localStamp(new Date().toISOString())}`;
await sheets.spreadsheets.values.clear({ await sheets.spreadsheets.values.clear({
spreadsheetId: config.sheets.spreadsheetId, spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.onSiteTab}!A1:J1000`, range: `${config.sheets.onSiteTab}!A1:J${MAX_ROWS + 10}`,
}); });
await sheets.spreadsheets.values.update({ await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId, spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.onSiteTab}!A1`, range: `${config.sheets.onSiteTab}!A1`,
valueInputOption: 'RAW', valueInputOption: 'RAW',
requestBody: { values: [[banner], ONSITE_HEADER, ...rows] }, requestBody: { values: [[banner], HEADER, ...rows] },
}); });
onSiteDirty = false; dirty = false;
status.lastOnSiteSync = new Date().toISOString(); status.lastOk = new Date().toISOString();
status.onSiteCount = rows.length;
status.lastOk = status.lastOnSiteSync;
status.lastError = null; status.lastError = null;
status.onSiteError = null; status.onSiteCount = rows.length;
return { rows: rows.length }; return { rows: rows.length };
} catch (err) { } catch (err) {
onSiteDirty = true; dirty = true;
status.lastError = err.message; status.lastError = err.message;
status.onSiteError = err.message;
throw err; throw err;
} finally { } finally {
syncing = false; syncing = false;
} }
} }
/* -------------------------------------------------------------- mirroring */ /**
* Called after every sign in and sign out. Fire and forget: a Sheets outage must
/** Fire and forget: never let a Sheets outage block someone at the front desk. */ * never hold up someone standing at the front desk. A failure leaves the tab
export function mirror(visit, action) { * marked stale and the worker retries.
*/
export function mirror() {
if (!isEnabled()) return; if (!isEnabled()) return;
dirty = true;
appendLog(rowForVisit(visit, action)) syncOnSite().catch((err) => console.error('[sheets] sync failed, will retry:', err.message));
.then(() => {
status.lastOk = new Date().toISOString();
status.lastError = null;
})
.catch((err) => {
status.lastError = err.message;
console.error('[sheets] log append failed, queued for retry:', err.message);
enqueue(rowForVisit(visit, action));
});
onSiteDirty = true;
syncOnSite().catch((err) => console.error('[sheets] on-site sync failed:', err.message));
}
export async function flushQueue() {
if (!isEnabled()) return { sent: 0, remaining: 0 };
const rows = db.prepare('SELECT * FROM sheet_queue ORDER BY id LIMIT 50').all();
let sent = 0;
for (const item of rows) {
try {
await appendLog(JSON.parse(item.payload));
db.prepare('DELETE FROM sheet_queue WHERE id = ?').run(item.id);
sent += 1;
status.lastOk = new Date().toISOString();
status.lastError = null;
} catch (err) {
db.prepare('UPDATE sheet_queue SET attempts = attempts + 1, last_error = ? WHERE id = ?').run(
err.message,
item.id
);
status.lastError = err.message;
break; // Sheets is still unhappy; try again on the next tick.
}
}
const remaining = db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n;
return { sent, remaining };
} }
export async function testConnection() { export async function testConnection() {
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.'); if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
const sheets = getClient(); const sheets = getClient();
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId }); const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
await ensureTabs(sheets, { force: true }); await ensureTab(sheets, { force: true });
await syncOnSite(); await syncOnSite();
status.lastOk = new Date().toISOString(); return { title: meta.data.properties.title, tab: config.sheets.onSiteTab };
status.lastError = null;
return {
title: meta.data.properties.title,
logTab: config.sheets.logTab,
onSiteTab: config.sheets.onSiteTab,
};
} }
export function queueDepth() { export function tabName() {
return db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n; return config.sheets.onSiteTab;
} }
export function tabNames() { export function isStale() {
return { log: config.sheets.logTab, onSite: config.sheets.onSiteTab }; return dirty;
} }
export function startWorker() { export function startWorker() {
@@ -315,25 +203,31 @@ export function startWorker() {
return; return;
} }
console.log( console.log(
`[sheets] mirroring to ${config.sheets.spreadsheetId} ` + `[sheets] mirroring who is on site to ${config.sheets.spreadsheetId} ("${config.sheets.onSiteTab}")`
`(log: "${config.sheets.logTab}", live: "${config.sheets.onSiteTab}")`
); );
// Rows left over from the older append-only log are no longer sent anywhere.
const stale = db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n;
if (stale) {
db.prepare('DELETE FROM sheet_queue').run();
console.log(
`[sheets] discarded ${stale} queued history row(s): the sheet now holds only who is on site. ` +
'The full history is still in the visit log.'
);
}
// Retry anything that failed, and keep the "on site for" column honest.
setInterval(() => { setInterval(() => {
flushQueue().catch((err) => console.error('[sheets] flush error:', err.message)); if (dirty) {
if (onSiteDirty) { syncOnSite().catch((err) => console.error('[sheets] retry failed:', err.message));
syncOnSite().catch((err) => console.error('[sheets] on-site retry failed:', err.message));
} }
}, config.sheets.retryIntervalMs).unref(); }, config.sheets.retryIntervalMs).unref();
// Refresh the "on site for" column so the live tab does not go stale while
// someone sits in a meeting all afternoon.
setInterval(() => { setInterval(() => {
syncOnSite().catch(() => { syncOnSite().catch(() => {
/* the retry above will pick it up */ /* the retry above will pick it up */
}); });
}, 15 * 60 * 1000).unref(); }, 15 * 60 * 1000).unref();
// Bring the live tab in line with the database at boot. syncOnSite().catch((err) => console.error('[sheets] initial sync failed:', err.message));
syncOnSite().catch((err) => console.error('[sheets] initial on-site sync failed:', err.message));
} }
+1
View File
@@ -56,6 +56,7 @@ export function shapeSite(site) {
brand: site.colour_brand, brand: site.colour_brand,
signout: site.colour_signout, signout: site.colour_signout,
page: site.colour_page, page: site.colour_page,
text: site.colour_text,
theme: themeFor(site), theme: themeFor(site),
}, },
badge: { badge: {