Public Access
287 lines
8.8 KiB
JavaScript
287 lines
8.8 KiB
JavaScript
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));
|
|
}
|