import express from 'express'; import rateLimit from 'express-rate-limit'; import QRCode from 'qrcode'; import fs from 'node:fs'; import db from '../db.js'; import config from '../config.js'; import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js'; import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js'; import * as sheets from '../sheets.js'; import * as tls from '../tls.js'; import * as printer from '../printer.js'; import * as users from '../users.js'; import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js'; import { DEFAULT_THEME, bannerAbsolutePath, deleteBanner, normaliseAlign, normaliseColour, saveBanner, } from '../branding.js'; import { consumeRecoveryCode, generateRecoveryCodes, generateTotpSecret, hashPassword, hashRecoveryCodes, otpauthUrl, passwordProblem, verifyPassword, verifyTotp, } from '../auth.js'; import { clean, isEmail, isPhone, localStamp, normaliseEmail, normalisePhone, nowIso, parseCsv, titleCase, toCsv, } from '../util.js'; const router = express.Router(); const CHECK_TYPES = new Set(['WWCC', 'VIT', 'NONE']); const loginLimiter = rateLimit({ windowMs: 15 * 60000, max: 20, standardHeaders: true }); /* ------------------------------------------------------------ sessions */ function currentUser(req) { if (!req.session?.adminUserId) return null; const user = users.findById(req.session.adminUserId); return user && user.active ? user : null; } function requireAdmin(req, res, next) { const user = currentUser(req); if (!user) return res.status(401).json({ error: 'Sign in to the admin console first.' }); req.user = user; // Someone on a temporary password can only change it or sign out. if (user.must_change_password && !req.path.startsWith('/account/password') && req.path !== '/logout') { return res.status(403).json({ error: 'Set a new password before continuing.', mustChangePassword: true }); } next(); } function requireOwner(req, res, next) { if (req.user.role !== 'owner') { return res.status(403).json({ error: 'Only an owner account can do that.' }); } next(); } /** null means "every site". Otherwise the single site this admin is limited to. */ function scopedSiteId(req) { return req.user.site_id || null; } function assertSiteAllowed(req, siteId) { const scope = scopedSiteId(req); if (scope && Number(siteId) !== scope) { const error = new Error('That site is outside your access.'); error.status = 403; throw error; } } /** Adds a site filter to a WHERE clause built from `where`/`params`. */ function applySiteFilter(req, where, params) { const scope = scopedSiteId(req); const requested = req.query.siteId && req.query.siteId !== 'all' ? Number(req.query.siteId) : null; const siteId = scope || requested; if (siteId) { where.push('site_id = ?'); params.push(siteId); } } /* ---------------------------------------------------------------- login */ router.post('/login', loginLimiter, (req, res) => { const email = normaliseEmail(req.body?.email); const password = String(req.body?.password || ''); const generic = { error: 'That email address and password do not match.' }; if (!email || !password) return res.status(400).json({ error: 'Enter your email and password.' }); const locked = users.lockState(email); if (locked) { return res.status(429).json({ error: 'Too many attempts. Try again in 15 minutes.' }); } if (!users.domainAllowed(email)) { return res.status(403).json({ error: `Sign in with an ${users.domainRuleText()} address.` }); } const user = users.findByEmail(email); if (!user || !user.active || !verifyPassword(password, user.password_hash)) { users.noteFailure(email); return res.status(401).json(generic); } users.clearFailures(email); if (user.totp_enabled) { req.session.pendingUserId = user.id; // Told up front so the sign in pages can show an honest "step 2 of 3". return res.json({ status: 'twoFactorRequired', passwordChangeToFollow: Boolean(user.must_change_password), }); } if (config.admin.require2fa) { req.session.pendingUserId = user.id; return startTwoFactorSetup(req, res, user); } return completeLogin(req, res, user); }); function completeLogin(req, res, user) { delete req.session.pendingUserId; delete req.session.pendingTotpSecret; req.session.adminUserId = user.id; db.prepare('UPDATE admin_users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id); res.json({ status: user.must_change_password ? 'passwordChangeRequired' : 'ok', user: users.shape(user), }); } async function startTwoFactorSetup(req, res, user) { const secret = generateTotpSecret(); req.session.pendingTotpSecret = secret; const url = otpauthUrl({ secret, email: user.email, issuer: config.siteName }); const qr = await QRCode.toDataURL(url, { margin: 1, width: 240 }); res.json({ status: 'twoFactorSetup', secret, qr, passwordChangeToFollow: Boolean(user.must_change_password), }); } router.post('/login/2fa', loginLimiter, (req, res) => { const user = req.session.pendingUserId ? users.findById(req.session.pendingUserId) : null; if (!user) return res.status(401).json({ error: 'Start again from the sign in screen.' }); const code = clean(req.body?.code, 20); // Enrolling: the secret is only saved once a real code from the app proves it works. if (req.session.pendingTotpSecret) { if (!verifyTotp(req.session.pendingTotpSecret, code)) { return res.status(401).json({ error: 'That code did not match. Try the next one.' }); } const recovery = generateRecoveryCodes(); db.prepare( 'UPDATE admin_users SET totp_secret = ?, totp_enabled = 1, recovery_codes = ? WHERE id = ?' ).run(req.session.pendingTotpSecret, hashRecoveryCodes(recovery), user.id); delete req.session.pendingTotpSecret; req.session.adminUserId = user.id; delete req.session.pendingUserId; db.prepare('UPDATE admin_users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id); return res.json({ status: users.findById(user.id).must_change_password ? 'passwordChangeRequired' : 'ok', recoveryCodes: recovery, user: users.shape(users.findById(user.id)), }); } if (verifyTotp(user.totp_secret, code)) { users.clearFailures(user.email); return completeLogin(req, res, user); } // Recovery codes are one shot each. const remaining = consumeRecoveryCode(user.recovery_codes, code); if (remaining !== null) { db.prepare('UPDATE admin_users SET recovery_codes = ? WHERE id = ?').run(remaining, user.id); const left = JSON.parse(remaining).length; req.session.adminUserId = user.id; delete req.session.pendingUserId; return res.json({ status: 'ok', usedRecoveryCode: true, recoveryCodesLeft: left, user: users.shape(user), }); } users.noteFailure(user.email); res.status(401).json({ error: 'That code is not right.' }); }); router.post('/logout', (req, res) => { req.session.destroy(() => res.json({ ok: true })); }); router.get('/session', (req, res) => { const user = currentUser(req); const anyUsers = users.countActive() > 0; res.json({ admin: Boolean(user), setupNeeded: !anyUsers, user: user ? users.shape(user) : null, siteName: config.siteName, domainRule: users.domainRuleText(), require2fa: config.admin.require2fa, mustChangePassword: Boolean(user?.must_change_password), }); }); router.use(requireAdmin); /* -------------------------------------------------------------- account */ router.post('/account/password', (req, res) => { const current = String(req.body?.currentPassword || ''); const next = String(req.body?.newPassword || ''); if (!verifyPassword(current, req.user.password_hash)) { return res.status(401).json({ error: 'Your current password is not right.' }); } const problem = passwordProblem(next); if (problem) return res.status(400).json({ error: problem }); db.prepare('UPDATE admin_users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run( hashPassword(next), req.user.id ); res.json({ ok: true }); }); router.post('/account/2fa/start', async (req, res) => { const secret = generateTotpSecret(); req.session.selfTotpSecret = secret; const url = otpauthUrl({ secret, email: req.user.email, issuer: config.siteName }); res.json({ secret, qr: await QRCode.toDataURL(url, { margin: 1, width: 240 }) }); }); router.post('/account/2fa/enable', (req, res) => { const secret = req.session.selfTotpSecret; if (!secret) return res.status(400).json({ error: 'Start the setup again.' }); if (!verifyTotp(secret, clean(req.body?.code, 20))) { return res.status(401).json({ error: 'That code did not match. Try the next one.' }); } const recovery = generateRecoveryCodes(); db.prepare( 'UPDATE admin_users SET totp_secret = ?, totp_enabled = 1, recovery_codes = ? WHERE id = ?' ).run(secret, hashRecoveryCodes(recovery), req.user.id); delete req.session.selfTotpSecret; res.json({ ok: true, recoveryCodes: recovery }); }); router.post('/account/2fa/disable', (req, res) => { if (config.admin.require2fa) { return res.status(403).json({ error: 'Two factor is required for every admin on this server.' }); } if (!verifyPassword(String(req.body?.password || ''), req.user.password_hash)) { return res.status(401).json({ error: 'Your password is not right.' }); } db.prepare( 'UPDATE admin_users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?' ).run(req.user.id); res.json({ ok: true }); }); /* ---------------------------------------------------------------- users */ router.get('/users', requireOwner, (req, res) => { const rows = db.prepare('SELECT * FROM admin_users ORDER BY email').all(); res.json(rows.map(users.shape)); }); router.post('/users', requireOwner, (req, res) => { try { const siteId = req.body?.siteId ? Number(req.body.siteId) : null; if (siteId && !db.prepare('SELECT id FROM sites WHERE id = ?').get(siteId)) { return res.status(400).json({ error: 'That site does not exist.' }); } const { user, temporaryPassword } = users.createUser({ email: req.body?.email, name: req.body?.name, role: req.body?.role === 'owner' ? 'owner' : 'admin', siteId, }); res.json({ ...users.shape(user), temporaryPassword }); } catch (err) { res.status(400).json({ error: err.message }); } }); router.patch('/users/:id', requireOwner, (req, res) => { const target = users.findById(req.params.id); if (!target) return res.status(404).json({ error: 'Not found.' }); const makingInactive = req.body?.active === false; const demoting = req.body?.role && req.body.role !== 'owner' && target.role === 'owner'; if ((makingInactive || demoting) && target.id === req.user.id) { return res.status(400).json({ error: 'You cannot lock yourself out of your own account.' }); } const owners = db .prepare("SELECT COUNT(*) AS n FROM admin_users WHERE role = 'owner' AND active = 1").get().n; if ((makingInactive || demoting) && target.role === 'owner' && owners <= 1) { return res.status(400).json({ error: 'Keep at least one active owner account.' }); } db.prepare('UPDATE admin_users SET name = ?, role = ?, site_id = ?, active = ? WHERE id = ?').run( req.body?.name !== undefined ? clean(req.body.name, 80) || null : target.name, req.body?.role === 'owner' ? 'owner' : req.body?.role === 'admin' ? 'admin' : target.role, req.body?.siteId !== undefined ? (req.body.siteId ? Number(req.body.siteId) : null) : target.site_id, req.body?.active !== undefined ? (req.body.active ? 1 : 0) : target.active, target.id ); res.json(users.shape(users.findById(target.id))); }); router.post('/users/:id/reset-password', requireOwner, (req, res) => { const target = users.findById(req.params.id); if (!target) return res.status(404).json({ error: 'Not found.' }); const temporary = req.body?.password || undefined; const problem = temporary ? passwordProblem(temporary) : null; if (problem) return res.status(400).json({ error: problem }); const password = temporary || `Vs${Math.random().toString(36).slice(2, 10)}9A`; db.prepare('UPDATE admin_users SET password_hash = ?, must_change_password = 1 WHERE id = ?').run( hashPassword(password), target.id ); users.clearFailures(target.email); res.json({ ok: true, temporaryPassword: password }); }); router.post('/users/:id/reset-2fa', requireOwner, (req, res) => { const target = users.findById(req.params.id); if (!target) return res.status(404).json({ error: 'Not found.' }); db.prepare( 'UPDATE admin_users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?' ).run(target.id); res.json({ ok: true }); }); /* ---------------------------------------------------------------- sites */ router.get('/sites', (req, res) => { const scope = scopedSiteId(req); const rows = listSites().filter((s) => !scope || s.id === scope); res.json( rows.map((row) => ({ ...shapeSite(row), printerStatus: printer.printerStatus(row.id), })) ); }); router.post('/sites', requireOwner, (req, res) => { const name = clean(req.body?.name, 100); if (!name) return res.status(400).json({ error: 'Give the site a name.' }); const slug = uniqueSlug(req.body?.slug || name); const info = db.prepare('INSERT INTO sites (name, slug) VALUES (?, ?)').run(name, slug); res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(info.lastInsertRowid))); }); router.patch('/sites/:id', (req, res) => { try { assertSiteAllowed(req, req.params.id); } catch (err) { return res.status(err.status || 403).json({ error: err.message }); } const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).json({ error: 'Not found.' }); const badge = req.body?.badge || {}; const branding = req.body?.branding || {}; const printerCfg = req.body?.printer || {}; db.prepare( `UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?, badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?, colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?, banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?, printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? WHERE id = ?` ).run( clean(req.body?.name ?? site.name, 100) || site.name, req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug, req.body?.active !== undefined ? (req.body.active ? 1 : 0) : site.active, badge.enabled !== undefined ? (badge.enabled ? 1 : 0) : site.badge_enabled, Math.min(200, Math.max(20, Number(badge.widthMm ?? site.badge_width_mm) || 86)), Math.min(200, Math.max(15, Number(badge.heightMm ?? site.badge_height_mm) || 54)), badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo, badge.accent !== undefined ? (badge.accent ? 1 : 0) : site.badge_accent, badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note, // A blank colour means "use the default", so it is stored as NULL rather than // being quietly frozen at whatever the default happens to be today. branding.brand !== undefined ? normaliseColour(branding.brand, null) : site.colour_brand, branding.signout !== undefined ? normaliseColour(branding.signout, null) : site.colour_signout, branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page, branding.text !== undefined ? normaliseColour(branding.text, null) : site.colour_text, branding.bannerHeight !== undefined ? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64)) : site.banner_height, branding.bannerAlign !== undefined ? normaliseAlign(branding.bannerAlign, site.banner_align) : site.banner_align, printerCfg.enabled !== undefined ? (printerCfg.enabled ? 1 : 0) : site.printer_enabled, printerCfg.host !== undefined ? clean(printerCfg.host, 120) || null : site.printer_host, printerCfg.port !== undefined ? Math.min(65535, Math.max(1, Number(printerCfg.port) || 9100)) : site.printer_port, printerCfg.model !== undefined ? clean(printerCfg.model, 40) || 'QL-820NWB' : site.printer_model, printerCfg.rotate !== undefined ? ([0, 90, 180, 270].includes(Number(printerCfg.rotate)) ? Number(printerCfg.rotate) : 0) : site.printer_rotate, printerCfg.label !== undefined ? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62') : site.printer_label , site.id); res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id))); }); /* ------------------------------------------------------------- branding */ router.post('/sites/:id/banner', (req, res) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).json({ error: 'Not found.' }); try { assertSiteAllowed(req, site.id); const name = saveBanner(site.id, req.body?.image); if (site.banner_path) deleteBanner(site.banner_path); db.prepare('UPDATE sites SET banner_path = ? WHERE id = ?').run(name, site.id); res.json({ ok: true, url: `/api/branding/${site.id}/banner?t=${Date.now()}` }); } catch (err) { res.status(err.status || 400).json({ error: err.message }); } }); router.delete('/sites/:id/banner', (req, res) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).json({ error: 'Not found.' }); try { assertSiteAllowed(req, site.id); } catch (err) { return res.status(403).json({ error: err.message }); } if (site.banner_path) deleteBanner(site.banner_path); db.prepare('UPDATE sites SET banner_path = NULL WHERE id = ?').run(site.id); res.json({ ok: true }); }); router.get('/sites/:id/banner', (req, res) => { const site = db.prepare('SELECT banner_path FROM sites WHERE id = ?').get(req.params.id); const abs = site && bannerAbsolutePath(site.banner_path); if (!abs) return res.status(404).send('No banner set.'); res.sendFile(abs); }); /** * The exact bitmap that would be sent to the printer, so the layout and the * rotation can be checked without using a label. */ router.get('/sites/:id/badge-bitmap', async (req, res) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).send('Not found.'); try { const visit = req.query.visitId ? db.prepare('SELECT * FROM visits WHERE id = ?').get(req.query.visitId) : printer.sampleVisit(site); if (!visit) return res.status(404).send('No such visit.'); const png = await printer.renderBadgePng(visit, site); res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', 'no-store'); res.send(png); } catch (err) { res.status(500).send(`Could not render the badge: ${err.message}`); } }); router.post('/sites/:id/test-print', async (req, res) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).json({ error: 'Not found.' }); try { assertSiteAllowed(req, site.id); } catch (err) { return res.status(403).json({ error: err.message }); } try { const result = await printer.printBadge(printer.sampleVisit(site), site); res.json({ ok: true, ...result }); } catch (err) { // The raw output and the exact command matter when the friendly message is // not enough — a printer refusing a job says why in its own words. res.status(400).json({ error: err.message, raw: err.raw || null, command: err.command || null }); } }); /** Everything about how this site would print, for working out why it will not. */ router.get('/sites/:id/printer-diagnostics', (req, res) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).json({ error: 'Not found.' }); res.json({ site: site.name, printing: { enabled: Boolean(site.printer_enabled), host: site.printer_host, port: site.printer_port, model: site.printer_model, rotate: site.printer_rotate, }, rollSetting: site.printer_label, labelSentToPrinter: printer.labelFor(site), redRequested: Boolean(site.badge_accent), redWillPrint: printer.accentWillPrintRed(site), badgeMm: { width: site.badge_width_mm, height: site.badge_height_mm }, lastResult: printer.printerStatus(site.id), command: [ 'brother_ql --backend network', `--model ${site.printer_model || 'QL-820NWB'}`, `--printer tcp://${site.printer_host}:${site.printer_port || 9100}`, `print --label ${printer.labelFor(site)} badge.png`, ].join(' '), }); }); /** Reprints a real visitor's badge on the server's printer. */ router.post('/visits/:id/print', async (req, res) => { const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id); if (!visit) return res.status(404).json({ error: 'Not found.' }); const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id); if (!site) return res.status(404).json({ error: 'That site no longer exists.' }); if (!printer.isConfigured(site)) { return res.status(400).json({ error: 'Server printing is not turned on for this site.' }); } try { await printer.printBadge(visit, site); res.json({ ok: true }); } catch (err) { res.status(400).json({ error: err.message }); } }); router.get('/sites/:id/badge-preview', (req, res) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).send('Not found.'); const sample = { first_name: 'Sample', last_name: 'Visitor', host_name: 'Jess Rogerson', check_type: 'WWCC', check_number: 'WWC1234567E', signed_in_at: nowIso(), }; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(badgeHtml(sample, site, { autoPrint: false })); }); /* ---------------------------------------------------------------- hosts */ function hostSiteId(req) { const scope = scopedSiteId(req); const asked = req.body?.siteId ?? req.query.siteId; const siteId = scope || (asked && asked !== 'all' ? Number(asked) : null); return siteId; } router.get('/hosts', (req, res) => { const siteId = hostSiteId(req); const sql = siteId ? 'SELECT * FROM hosts WHERE site_id = ? ORDER BY name COLLATE NOCASE' : 'SELECT * FROM hosts ORDER BY name COLLATE NOCASE'; const rows = siteId ? db.prepare(sql).all(siteId) : db.prepare(sql).all(); res.json(rows); }); router.post('/hosts', (req, res) => { const name = titleCase(req.body?.name, 120); const siteId = hostSiteId(req); if (!name) return res.status(400).json({ error: 'Name is required.' }); if (!siteId) return res.status(400).json({ error: 'Choose which site this person belongs to.' }); try { assertSiteAllowed(req, siteId); } catch (err) { return res.status(403).json({ error: err.message }); } const info = db .prepare('INSERT INTO hosts (name, email, area, site_id, active) VALUES (?, ?, ?, ?, 1)') .run(name, normaliseEmail(req.body?.email) || null, clean(req.body?.area, 80) || null, siteId); res.json(db.prepare('SELECT * FROM hosts WHERE id = ?').get(info.lastInsertRowid)); }); router.patch('/hosts/:id', (req, res) => { const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(req.params.id); if (!host) return res.status(404).json({ error: 'Not found.' }); try { assertSiteAllowed(req, host.site_id); } catch (err) { return res.status(403).json({ error: err.message }); } db.prepare('UPDATE hosts SET name = ?, email = ?, area = ?, active = ? WHERE id = ?').run( titleCase(req.body?.name ?? host.name, 120), req.body?.email !== undefined ? normaliseEmail(req.body.email) || null : host.email, req.body?.area !== undefined ? clean(req.body.area, 80) || null : host.area, req.body?.active !== undefined ? (req.body.active ? 1 : 0) : host.active, host.id ); res.json(db.prepare('SELECT * FROM hosts WHERE id = ?').get(host.id)); }); router.delete('/hosts/:id', (req, res) => { const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(req.params.id); if (!host) return res.status(404).json({ error: 'Not found.' }); try { assertSiteAllowed(req, host.site_id); } catch (err) { return res.status(403).json({ error: err.message }); } db.prepare('UPDATE hosts SET active = 0 WHERE id = ?').run(host.id); res.json({ ok: true }); }); /** * CSV import, scoped to one site. Headings understood: name, email, area * (or department / team / role). A single unnamed column is treated as the name. */ router.post('/hosts/import', (req, res) => { const siteId = hostSiteId(req); if (!siteId) return res.status(400).json({ error: 'Choose which site this list belongs to.' }); try { assertSiteAllowed(req, siteId); } catch (err) { return res.status(403).json({ error: err.message }); } const rows = parseCsv(req.body?.csv || ''); if (!rows.length) return res.status(400).json({ error: 'That CSV had no rows in it.' }); const header = rows[0].map((h) => h.trim().toLowerCase()); const looksLikeHeader = header.some((h) => ['name', 'full name', 'staff', 'email', 'area', 'department', 'team'].includes(h) ); const body = looksLikeHeader ? rows.slice(1) : rows; const idx = { name: looksLikeHeader ? header.findIndex((h) => ['name', 'full name', 'staff'].includes(h)) : 0, email: looksLikeHeader ? header.findIndex((h) => h === 'email') : -1, area: looksLikeHeader ? header.findIndex((h) => ['area', 'department', 'team', 'role'].includes(h)) : -1, }; if (idx.name < 0) idx.name = 0; const replace = Boolean(req.body?.replace); const insert = db.prepare( 'INSERT INTO hosts (name, email, area, site_id, active) VALUES (?, ?, ?, ?, 1)' ); const existing = db.prepare('SELECT id FROM hosts WHERE lower(name) = lower(?) AND site_id = ?'); const reactivate = db.prepare('UPDATE hosts SET active = 1, email = ?, area = ? WHERE id = ?'); let added = 0; let updated = 0; db.transaction(() => { if (replace) db.prepare('UPDATE hosts SET active = 0 WHERE site_id = ?').run(siteId); for (const row of body) { const name = titleCase(row[idx.name], 120); if (!name) continue; const email = idx.email >= 0 ? normaliseEmail(row[idx.email]) || null : null; const area = idx.area >= 0 ? clean(row[idx.area], 80) || null : null; const found = existing.get(name, siteId); if (found) { reactivate.run(email, area, found.id); updated += 1; } else { insert.run(name, email, area, siteId); added += 1; } } })(); const total = db .prepare('SELECT COUNT(*) AS n FROM hosts WHERE active = 1 AND site_id = ?') .get(siteId).n; res.json({ added, updated, total }); }); /* ------------------------------------------------- recurring visitors */ function shapeFrequent(row, includePin = false) { return { id: row.id, firstName: row.first_name, lastName: row.last_name, company: row.company, phone: row.phone, email: row.email, checkType: row.check_type, checkNumber: row.check_number, checkExpiry: row.check_expiry, defaultHostId: row.default_host_id, siteId: row.site_id, hasPhoto: Boolean(row.photo_path), notes: row.notes, active: Boolean(row.active), createdAt: row.created_at, expiry: expiryState(row.check_type, row.check_expiry), ...(includePin ? { pin: decryptPin(row.pin_enc) } : {}), }; } /** Days until a WWCC or VIT lapses, plus a plain status an admin can act on. */ export function expiryState(checkType, checkExpiry) { if (checkType === 'NONE' || !checkExpiry) return { status: 'none', daysLeft: null }; const due = new Date(`${checkExpiry}T23:59:59`); if (Number.isNaN(due.getTime())) return { status: 'none', daysLeft: null }; const daysLeft = Math.ceil((due.getTime() - Date.now()) / 86400000); if (daysLeft < 0) return { status: 'expired', daysLeft }; if (daysLeft <= config.expiryWarningDays) return { status: 'expiring', daysLeft }; return { status: 'ok', daysLeft }; } router.get('/frequent', (req, res) => { const scope = scopedSiteId(req); const rows = db .prepare( `SELECT * FROM frequent_visitors ${scope ? 'WHERE site_id IS NULL OR site_id = ?' : ''} ORDER BY last_name COLLATE NOCASE, first_name COLLATE NOCASE` ) .all(...(scope ? [scope] : [])); res.json(rows.map((r) => shapeFrequent(r))); }); router.get('/frequent/:id', (req, res) => { const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'Not found.' }); res.json({ ...shapeFrequent(row, true), // Context for the removal confirmation. visitCount: db .prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?') .get(row.id).n, onSite: Boolean( db .prepare('SELECT 1 FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL') .get(row.id) ), }); }); /** Everyone whose check lapses inside the warning window, or already has. */ router.get('/alerts', (req, res) => { const scope = scopedSiteId(req); const rows = db .prepare( `SELECT * FROM frequent_visitors WHERE active = 1 AND check_type <> 'NONE' AND check_expiry IS NOT NULL ${scope ? 'AND (site_id IS NULL OR site_id = ?)' : ''}` ) .all(...(scope ? [scope] : [])) .map((r) => shapeFrequent(r)) .filter((r) => r.expiry.status === 'expiring' || r.expiry.status === 'expired') .sort((a, b) => a.expiry.daysLeft - b.expiry.daysLeft); res.json({ warningDays: config.expiryWarningDays, expired: rows.filter((r) => r.expiry.status === 'expired'), expiring: rows.filter((r) => r.expiry.status === 'expiring'), }); }); /** True when another recurring visitor already holds this PIN. */ function pinTaken(pin, excludeId = null) { const row = db .prepare('SELECT id FROM frequent_visitors WHERE pin_lookup = ?') .get(pinLookup(pin)); return Boolean(row) && row.id !== excludeId; } function allocatePin(requested, excludeId = null) { if (requested && /^\d{4}$/.test(String(requested))) { if (pinTaken(String(requested), excludeId)) { throw new Error('Another recurring visitor already uses that PIN. Choose a different one.'); } return String(requested); } return generatePin((candidate) => pinTaken(candidate, excludeId)); } function validateFrequent(body, { existingPhone = null, existingId = null } = {}) { const firstName = titleCase(body?.firstName, 60); const lastName = titleCase(body?.lastName, 60); const phone = normalisePhone(body?.phone); const email = normaliseEmail(body?.email); const checkType = clean(body?.checkType, 10).toUpperCase() || 'NONE'; if (!firstName || !lastName) throw new Error('First and last name are required.'); if (!isPhone(phone)) throw new Error('A valid mobile number is required — it is their username.'); if (email && !isEmail(email)) throw new Error('That email address is not valid.'); if (!CHECK_TYPES.has(checkType)) throw new Error('Check type must be WWCC, VIT or NONE.'); if (checkType !== 'NONE' && !clean(body?.checkNumber)) { throw new Error(`A ${checkType} number is required.`); } // One record per person: mobile number, email and PIN must each be unique. const phoneClash = db .prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE phone = ?') .get(phone); if (phoneClash && phoneClash.id !== existingId) { throw new Error( `${phoneClash.first_name} ${phoneClash.last_name} already uses that mobile number.` ); } if (email) { const emailClash = db .prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE lower(email) = ?') .get(email); if (emailClash && emailClash.id !== existingId) { throw new Error( `${emailClash.first_name} ${emailClash.last_name} already uses that email address.` ); } } return { firstName, lastName, company: clean(body?.company, 80) || null, phone, email: email || null, checkType, checkNumber: clean(body?.checkNumber, 40) || null, checkExpiry: clean(body?.checkExpiry, 20) || null, defaultHostId: body?.defaultHostId ? Number(body.defaultHostId) : null, siteId: body?.siteId ? Number(body.siteId) : null, notes: clean(body?.notes, 300) || null, }; } router.post('/frequent', (req, res) => { try { const v = validateFrequent(req.body); const scope = scopedSiteId(req); const siteId = scope || v.siteId; const pin = allocatePin(req.body?.pin); const photoPath = req.body?.photo ? savePhoto(req.body.photo) : null; const info = db .prepare( `INSERT INTO frequent_visitors (first_name, last_name, company, phone, email, check_type, check_number, check_expiry, default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` ) .run( v.firstName, v.lastName, v.company, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry, v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes ); res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true)); } catch (err) { res.status(400).json({ error: err.message }); } }); router.patch('/frequent/:id', (req, res) => { const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'Not found.' }); try { const v = validateFrequent( { ...shapeFrequent(row), ...req.body }, { existingPhone: row.phone, existingId: row.id } ); const scope = scopedSiteId(req); // A new photo replaces the old file; removePhoto clears it entirely. let photoPath = row.photo_path; if (req.body?.photo) { photoPath = savePhoto(req.body.photo); if (row.photo_path) deletePhoto(row.photo_path); } else if (req.body?.removePhoto) { if (row.photo_path) deletePhoto(row.photo_path); photoPath = null; } db.prepare( `UPDATE frequent_visitors SET first_name = ?, last_name = ?, company = ?, phone = ?, email = ?, check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?, photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?` ).run( v.firstName, v.lastName, v.company, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry, v.defaultHostId, scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id), photoPath, v.notes, req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active, row.id ); res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(row.id), true)); } catch (err) { res.status(400).json({ error: err.message }); } }); /** The photo kept on file for a recurring visitor. */ router.get('/frequent/:id/photo', (req, res) => { const row = db.prepare('SELECT photo_path FROM frequent_visitors WHERE id = ?').get(req.params.id); const abs = row && photoAbsolutePath(row.photo_path); if (!abs) return res.status(404).send('No photo on file.'); res.setHeader('Cache-Control', 'private, max-age=60'); res.sendFile(abs); }); router.post('/frequent/:id/pin', (req, res) => { const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'Not found.' }); let pin; try { pin = allocatePin(req.body?.pin, row.id); } catch (err) { return res.status(400).json({ error: err.message }); } db.prepare( "UPDATE frequent_visitors SET pin_enc = ?, pin_lookup = ?, updated_at = datetime('now') WHERE id = ?" ).run(encryptPin(pin), pinLookup(pin), row.id); db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone); res.json({ ok: true, pin }); }); /** * Removes a recurring visitor for good. * * Their visit history is deliberately kept: visits store the name, contact details * and host as their own columns, so the log stays a complete record of who was in * the building even after the person's saved record is gone. Deleting the record * frees their mobile number, email and PIN for someone else. * * To keep someone on file but stop them signing in, untick Active instead. */ router.delete('/frequent/:id', (req, res) => { const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'Not found.' }); const scope = scopedSiteId(req); if (scope && row.site_id && row.site_id !== scope) { return res.status(403).json({ error: 'That visitor belongs to another site.' }); } const openVisit = db .prepare('SELECT id FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL') .get(row.id); if (openVisit && !req.query.force) { return res.status(409).json({ error: `${row.first_name} is signed in right now. Sign them out first, or confirm to remove anyway.`, onSite: true, }); } const visitCount = db .prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?') .get(row.id).n; if (row.photo_path) deletePhoto(row.photo_path); db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone); // visits.frequent_visitor_id is ON DELETE SET NULL, so the history survives. db.prepare('DELETE FROM frequent_visitors WHERE id = ?').run(row.id); res.json({ ok: true, name: `${row.first_name} ${row.last_name}`, visitsKept: visitCount, }); }); /* ------------------------------------------------------------- visits */ function shapeVisit(v) { return { id: v.id, siteId: v.site_id, siteName: v.site_name, visitorType: v.visitor_type, firstName: v.first_name, lastName: v.last_name, company: v.company, phone: v.phone, email: v.email, checkType: v.check_type, checkNumber: v.check_number, hostName: v.host_name, visitReason: v.visit_reason, hasPhoto: Boolean(v.photo_path), signedInAt: v.signed_in_at, signedOutAt: v.signed_out_at, signedOutBy: v.signed_out_by, }; } router.get('/onsite', (req, res) => { const where = ['signed_out_at IS NULL']; const params = []; applySiteFilter(req, where, params); const rows = db .prepare(`SELECT * FROM visits WHERE ${where.join(' AND ')} ORDER BY signed_in_at`) .all(...params); res.json(rows.map(shapeVisit)); }); router.get('/visits', (req, res) => { const where = []; const params = []; applySiteFilter(req, where, params); const from = clean(req.query.from, 10); const to = clean(req.query.to, 10); const q = clean(req.query.q, 60); if (from) { where.push('signed_in_at >= ?'); params.push(`${from}T00:00:00.000Z`); } if (to) { where.push('signed_in_at <= ?'); params.push(`${to}T23:59:59.999Z`); } if (q) { where.push( '(last_name LIKE ? OR first_name LIKE ? OR company LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)' ); params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`); } const sql = `SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC LIMIT 500`; res.json(db.prepare(sql).all(...params).map(shapeVisit)); }); router.post('/visits/:id/signout', (req, res) => { const visit = db .prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL') .get(req.params.id); if (!visit) return res.status(404).json({ error: 'That visit is already closed.' }); try { if (scopedSiteId(req)) assertSiteAllowed(req, visit.site_id); } catch (err) { return res.status(403).json({ error: err.message }); } db.prepare('UPDATE visits SET signed_out_at = ?, signed_out_by = ? WHERE id = ?').run( nowIso(), 'admin', visit.id ); sheets.mirror(); res.json({ ok: true }); }); router.get('/visits.csv', (req, res) => { const where = []; const params = []; applySiteFilter(req, where, params); const rows = db .prepare( `SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC` ) .all(...params); const csv = toCsv([ ['Visit ID', 'Site', 'Type', 'First name', 'Last name', 'Company', 'Phone', 'Email', 'Check type', 'Check number', 'Visiting', 'Reason', 'Signed in', 'Signed out', 'Closed by', 'Photo'], ...rows.map((v) => [ v.id, v.site_name, v.visitor_type, v.first_name, v.last_name, v.company, v.phone, v.email, v.check_type, v.check_number, v.host_name, v.visit_reason, localStamp(v.signed_in_at), localStamp(v.signed_out_at), v.signed_out_by, v.photo_path ? 'yes' : 'no', ]), ]); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="visits-${new Date().toISOString().slice(0, 10)}.csv"`); res.send(csv); }); router.get('/photo/:id', (req, res) => { const visit = db.prepare('SELECT photo_path FROM visits WHERE id = ?').get(req.params.id); const abs = visit && photoAbsolutePath(visit.photo_path); if (!abs) return res.status(404).send('No photo on file.'); res.setHeader('Cache-Control', 'private, max-age=300'); res.sendFile(abs); }); router.delete('/photo/:id', (req, res) => { const visit = db.prepare('SELECT photo_path FROM visits WHERE id = ?').get(req.params.id); if (visit?.photo_path) { deletePhoto(visit.photo_path); db.prepare('UPDATE visits SET photo_path = NULL WHERE id = ?').run(req.params.id); } res.json({ ok: true }); }); /** Reprint a badge for someone already on site. */ router.get('/badge/:id', (req, res) => { const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id); if (!visit) return res.status(404).send('Not found.'); const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id); if (!site) return res.status(404).send('That site no longer exists.'); let photoUrl = null; const abs = photoAbsolutePath(visit.photo_path); if (abs && site.badge_show_photo) { photoUrl = `data:image/jpeg;base64,${fs.readFileSync(abs).toString('base64')}`; } res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(badgeHtml(visit, site, { autoPrint: true, photoUrl })); }); /* --------------------------------------------------- printable PIN card */ router.get('/pass/:id', (req, res) => { const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id); if (!row) return res.status(404).send('Not found.'); const pin = decryptPin(row.pin_enc) || '????'; const host = row.default_host_id ? db.prepare('SELECT name FROM hosts WHERE id = ?').get(row.default_host_id) : null; const site = row.site_id ? db.prepare('SELECT name FROM sites WHERE id = ?').get(row.site_id) : null; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(`
${esc(site ? site.name : config.siteName)}
${esc(row.company)}
` : ''}${esc(pin)}
Your PIN. Keep this card, it is not sent to you again.
At the kiosk, tap I have a PIN, enter your mobile number and this PIN, pick who you are visiting, and take a photo. Sign out with your last name and mobile number on the way out.