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)); }