Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
+286
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user