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

This commit is contained in:
2026-09-04 14:55:59 +10:00
commit 3278890491
41 changed files with 8920 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
import fs from 'node:fs';
import { google } from 'googleapis';
import config from './config.js';
import db from './db.js';
import { localStamp } from './util.js';
/**
* The spreadsheet is an evacuation list and nothing else.
*
* One tab, rewritten in full whenever anyone signs in or out, holding only the
* people currently in the building. It is never appended to, so there is no
* history to scroll past while standing in a car park counting heads.
*
* The full visit history stays in the application's own database, where it is
* searchable in the admin console and exportable as CSV.
*/
const HEADER = [
'Site',
'First name',
'Last name',
'Company',
'Visiting',
'Phone',
'Email',
'Check',
'Signed in',
'On site for',
'Visit ID',
];
const MAX_ROWS = 1000;
let client = null;
let tabPromise = null;
let dirty = false;
let syncing = false;
export const status = {
lastOk: null,
lastError: null,
onSiteCount: null,
};
/* ----------------------------------------------------------- connection */
function loadCredentials() {
if (config.sheets.credentialsB64) {
return JSON.parse(Buffer.from(config.sheets.credentialsB64, 'base64').toString('utf8'));
}
if (config.sheets.credentialsPath && fs.existsSync(config.sheets.credentialsPath)) {
return JSON.parse(fs.readFileSync(config.sheets.credentialsPath, 'utf8'));
}
throw new Error('No Google service account credentials found.');
}
function getClient() {
if (client) return client;
const creds = loadCredentials();
const auth = new google.auth.JWT({
email: creds.client_email,
key: creds.private_key,
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
client = google.sheets({ version: 'v4', auth });
return client;
}
/**
* The service account's own address. Nothing works until the spreadsheet is
* shared with it, and it is buried in a JSON key file nobody wants to open on a
* server, so the admin console shows it.
*/
export function serviceAccountEmail() {
try {
return loadCredentials().client_email || null;
} catch {
return null;
}
}
/**
* Google's own wording for these failures says what went wrong but never what to
* do about it, so the common ones are rewritten with the actual next step.
*/
function explain(err) {
const code = err?.code || err?.response?.status;
const raw = String(err?.message || '');
const email = serviceAccountEmail();
if (code === 403 && /caller does not have permission|permission/i.test(raw)) {
return (
`The service account cannot open this spreadsheet. Share the sheet with ` +
`${email || 'the service account address'} and give it Editor access.`
);
}
if (code === 403 && /has not been used|accessNotConfigured|disabled/i.test(raw)) {
return 'The Google Sheets API is not enabled on that Google Cloud project. Enable it, then wait a minute and retry.';
}
if (code === 404) {
return 'No spreadsheet was found with that ID. Check SHEETS_SPREADSHEET_ID against the sheet URL.';
}
if (code === 400 && /Unable to parse range/i.test(raw)) {
return `The tab "${config.sheets.onSiteTab}" could not be addressed. Check SHEETS_ONSITE_TAB matches the tab name exactly.`;
}
if (/invalid_grant|Invalid JWT|clock/i.test(raw)) {
return "Google rejected the credentials. Check the server's clock is correct and the service account key has not been deleted.";
}
return raw || 'Unknown error talking to Google Sheets.';
}
export function isEnabled() {
return Boolean(config.sheets.enabled && config.sheets.spreadsheetId);
}
/**
* Creates the tab if it is missing. Memoised as a promise rather than a boolean:
* two syncs starting at once would otherwise both decide it was missing.
*/
function ensureTab(sheets, { force = false } = {}) {
if (force) tabPromise = null;
if (!tabPromise) {
tabPromise = doEnsureTab(sheets).catch((err) => {
tabPromise = null;
throw err;
});
}
return tabPromise;
}
async function doEnsureTab(sheets) {
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
const titles = meta.data.sheets.map((s) => s.properties.title);
if (!titles.includes(config.sheets.onSiteTab)) {
await sheets.spreadsheets.batchUpdate({
spreadsheetId: config.sheets.spreadsheetId,
requestBody: {
requests: [{ addSheet: { properties: { title: config.sheets.onSiteTab } } }],
},
});
console.log(`[sheets] created tab "${config.sheets.onSiteTab}"`);
}
}
/* --------------------------------------------------------- who is here */
function humanDuration(fromIso) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(fromIso).getTime()) / 60000));
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`;
}
function onSiteRows() {
return db
.prepare('SELECT * FROM visits WHERE signed_out_at IS NULL ORDER BY site_name, signed_in_at')
.all()
.slice(0, MAX_ROWS)
.map((v) => [
v.site_name || '',
v.first_name,
v.last_name,
v.company || '',
v.host_name,
v.phone || '',
v.email || '',
v.check_type === 'NONE' ? 'None' : `${v.check_type} ${v.check_number || ''}`.trim(),
localStamp(v.signed_in_at),
humanDuration(v.signed_in_at),
String(v.id),
]);
}
/**
* Replaces the whole tab with the current state. Rewriting rather than patching
* 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() {
if (!isEnabled()) return { skipped: true };
if (syncing) {
dirty = true;
return { skipped: true };
}
syncing = true;
try {
const sheets = getClient();
await ensureTab(sheets);
const rows = onSiteRows();
const banner = `On site now — ${rows.length} ${rows.length === 1 ? 'person' : 'people'} — updated ${localStamp(new Date().toISOString())}`;
await sheets.spreadsheets.values.clear({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.onSiteTab}!A1:K${MAX_ROWS + 10}`,
});
await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.onSiteTab}!A1`,
valueInputOption: 'RAW',
requestBody: { values: [[banner], HEADER, ...rows] },
});
dirty = false;
status.lastOk = new Date().toISOString();
status.lastError = null;
status.onSiteCount = rows.length;
return { rows: rows.length };
} catch (err) {
dirty = true;
status.lastError = explain(err);
const wrapped = new Error(status.lastError);
wrapped.cause = err;
throw wrapped;
} finally {
syncing = false;
}
}
/**
* Called after every sign in and sign out. Fire and forget: a Sheets outage must
* never hold up someone standing at the front desk. A failure leaves the tab
* marked stale and the worker retries.
*/
export function mirror() {
if (!isEnabled()) return;
dirty = true;
syncOnSite().catch((err) => console.error('[sheets] sync failed, will retry:', err.message));
}
export async function testConnection() {
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
try {
const sheets = getClient();
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
await ensureTab(sheets, { force: true });
await syncOnSite();
status.lastError = null;
return { title: meta.data.properties.title, tab: config.sheets.onSiteTab };
} catch (err) {
status.lastError = explain(err);
throw new Error(status.lastError);
}
}
export function tabName() {
return config.sheets.onSiteTab;
}
export function isStale() {
return dirty;
}
export function startWorker() {
if (!isEnabled()) {
console.log('[sheets] mirroring disabled');
return;
}
console.log(
`[sheets] mirroring who is on site to ${config.sheets.spreadsheetId} ("${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(() => {
if (dirty) {
syncOnSite().catch((err) => console.error('[sheets] retry failed:', err.message));
}
}, config.sheets.retryIntervalMs).unref();
setInterval(() => {
syncOnSite().catch(() => {
/* the retry above will pick it up */
});
}, 15 * 60 * 1000).unref();
syncOnSite().catch((err) => console.error('[sheets] initial sync failed:', err.message));
}