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

This commit is contained in:
2026-09-01 15:14:46 +10:00
parent a011587d66
commit 9da7e88eb3
11 changed files with 629 additions and 99 deletions
+95 -2
View File
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import config from './config.js';
import { decryptPin, pinLookup } from './pins.js';
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
fs.mkdirSync(config.photoDir, { recursive: true });
@@ -16,9 +17,10 @@ CREATE TABLE IF NOT EXISTS sites (
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
badge_enabled INTEGER NOT NULL DEFAULT 0,
badge_width_mm REAL NOT NULL DEFAULT 86,
badge_height_mm REAL NOT NULL DEFAULT 54,
badge_width_mm REAL NOT NULL DEFAULT 62,
badge_height_mm REAL NOT NULL DEFAULT 100,
badge_show_photo INTEGER NOT NULL DEFAULT 1,
badge_accent INTEGER NOT NULL DEFAULT 0,
badge_note TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -46,6 +48,8 @@ CREATE TABLE IF NOT EXISTS frequent_visitors (
check_expiry TEXT,
default_host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
pin_enc TEXT NOT NULL,
pin_lookup TEXT,
photo_path TEXT,
notes TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -138,8 +142,17 @@ addColumn('hosts', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE CASCADE');
addColumn('visits', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
addColumn('visits', 'site_name', 'TEXT');
addColumn('visits', 'check_expiry', 'TEXT');
addColumn('visits', 'photo_path', 'TEXT');
// NULL site_id on a recurring visitor means they are welcome at every site.
addColumn('frequent_visitors', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
// A photo kept on file, so a regular visitor is not asked to pose every visit.
addColumn('frequent_visitors', 'photo_path', 'TEXT');
// PINs are stored encrypted with a random IV, so the same PIN encrypts differently
// every time and cannot be compared. This deterministic digest makes the uniqueness
// check and the index possible.
addColumn('frequent_visitors', 'pin_lookup', 'TEXT');
// Two-colour printing, for rolls like the Brother DK-22251.
addColumn('sites', 'badge_accent', 'INTEGER NOT NULL DEFAULT 0');
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
@@ -156,6 +169,86 @@ db.prepare('UPDATE hosts SET site_id = ? WHERE site_id IS NULL').run(firstSite.i
db.prepare('UPDATE visits SET site_id = ? WHERE site_id IS NULL').run(firstSite.id);
db.prepare('UPDATE visits SET site_name = ? WHERE site_name IS NULL').run(firstSite.name);
/* -------------------------------------------------- one record per person */
/**
* Existing records predate the PIN digest, so fill it in once. Without this,
* a legacy visitor's PIN would be invisible to the uniqueness check and could be
* handed out to somebody else.
*/
const needingLookup = db
.prepare('SELECT id, pin_enc FROM frequent_visitors WHERE pin_lookup IS NULL')
.all();
if (needingLookup.length) {
const setLookup = db.prepare('UPDATE frequent_visitors SET pin_lookup = ? WHERE id = ?');
let filled = 0;
for (const row of needingLookup) {
const pin = decryptPin(row.pin_enc);
if (pin) {
setLookup.run(pinLookup(pin), row.id);
filled += 1;
}
}
console.log(`[db] indexed ${filled} existing PIN(s) for the uniqueness check`);
}
/** Names any records that already collide, so an admin knows who to fix. */
function reportDuplicates(column, label) {
const rows = db
.prepare(
`SELECT ${column} AS value, GROUP_CONCAT(first_name || ' ' || last_name, ', ') AS people
FROM frequent_visitors
WHERE ${column} IS NOT NULL AND ${column} <> ''
GROUP BY ${column} HAVING COUNT(*) > 1`
)
.all();
for (const row of rows) {
console.warn(`[db] duplicate ${label} shared by: ${row.people}`);
}
return rows.length;
}
const duplicates =
reportDuplicates('lower(email)', 'email address') + reportDuplicates('pin_lookup', 'PIN');
if (duplicates) {
console.warn(
'[db] Fix the records above in Admin -> Recurring visitors. Until then the database ' +
'cannot enforce uniqueness, though new and edited records are still checked.'
);
}
/**
* Saved people must be unique on mobile number, email address and PIN. The phone
* column has carried a UNIQUE constraint from the start; these add the other two.
* Existing data may already contain duplicates, so a failure here is reported
* rather than thrown — the application-level checks still refuse new collisions.
*/
function addUniqueIndex(name, sql, what) {
try {
db.exec(sql);
} catch (err) {
console.warn(
`[db] could not enforce unique ${what}: ${err.message}\n` +
` Existing records collide. Fix them in Admin -> Recurring visitors; ` +
`new and edited records are still checked.`
);
}
}
addUniqueIndex(
'idx_freq_email',
`CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_email
ON frequent_visitors(lower(email)) WHERE email IS NOT NULL AND email <> ''`,
'email addresses'
);
addUniqueIndex(
'idx_freq_pin',
`CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_pin
ON frequent_visitors(pin_lookup) WHERE pin_lookup IS NOT NULL`,
'PINs'
);
export function getSetting(key, fallback = null) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : fallback;
+23
View File
@@ -31,6 +31,29 @@ export function savePhoto(dataUrl) {
return path.join(folder, name);
}
/**
* Copies a recurring visitor's stored photo into a new file for one visit.
*
* A copy rather than a shared reference on purpose: the visit record is a snapshot
* of who was in the building that day, so replacing someone's profile photo later
* must not retroactively change what every past visit shows. It also keeps photo
* retention simple — purging old visits can never delete a live profile photo.
*/
export function copyStoredPhoto(relative) {
const source = photoAbsolutePath(relative);
if (!source) return null;
const now = new Date();
const folder = path.join(String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, '0'));
const dir = path.join(config.photoDir, folder);
fs.mkdirSync(dir, { recursive: true });
const ext = path.extname(source) || '.jpeg';
const name = `${now.toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(4).toString('hex')}${ext}`;
fs.copyFileSync(source, path.join(dir, name));
return path.join(folder, name);
}
export function photoAbsolutePath(relative) {
if (!relative) return null;
const resolved = path.resolve(config.photoDir, relative);
+24 -8
View File
@@ -43,12 +43,28 @@ export function verifyPin(stored, candidate) {
return crypto.timingSafeEqual(a, b);
}
export function generatePin() {
// Avoids the handful of PINs people will misread on a printed pass.
const banned = new Set(['0000', '1111', '1234', '4321', '9999']);
let pin;
do {
pin = String(crypto.randomInt(0, 10000)).padStart(4, '0');
} while (banned.has(pin));
return pin;
// PINs people will misread on a printed pass, or guess first.
const BANNED_PINS = new Set(['0000', '1111', '1234', '4321', '9999', '1122', '2580']);
/**
* A deterministic digest of a PIN, so two records can be compared without either
* being decrypted. Keyed with APP_SECRET, so the database alone does not let
* anyone build a lookup table of all 10,000 possibilities.
*/
export function pinLookup(pin) {
return crypto.createHmac('sha256', key).update(String(pin)).digest('hex');
}
/**
* A PIN nobody else holds. `isTaken` is passed in by the caller so this module
* stays free of database knowledge.
*/
export function generatePin(isTaken = () => false) {
for (let attempt = 0; attempt < 200; attempt += 1) {
const pin = String(crypto.randomInt(0, 10000)).padStart(4, '0');
if (!BANNED_PINS.has(pin) && !isTaken(pin)) return pin;
}
throw new Error(
'No unused 4 digit PIN could be found. Deactivate some old recurring visitors first.'
);
}
+83 -18
View File
@@ -4,8 +4,8 @@ import QRCode from 'qrcode';
import fs from 'node:fs';
import db from '../db.js';
import config from '../config.js';
import { decryptPin, encryptPin, generatePin } from '../pins.js';
import { photoAbsolutePath, deletePhoto, purgeOldPhotos } from '../photos.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 users from '../users.js';
@@ -378,7 +378,7 @@ router.patch('/sites/:id', (req, res) => {
const badge = req.body?.badge || {};
db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_note = ? WHERE id = ?`
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ? WHERE id = ?`
).run(
clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
@@ -387,6 +387,7 @@ router.patch('/sites/:id', (req, res) => {
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
, site.id);
@@ -549,6 +550,7 @@ function shapeFrequent(row, includePin = false) {
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,
@@ -607,7 +609,25 @@ router.get('/alerts', (req, res) => {
});
});
function validateFrequent(body, { existingPhone = null } = {}) {
/** 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);
@@ -621,9 +641,24 @@ function validateFrequent(body, { existingPhone = null } = {}) {
if (checkType !== 'NONE' && !clean(body?.checkNumber)) {
throw new Error(`A ${checkType} number is required.`);
}
if (phone !== existingPhone) {
const clash = db.prepare('SELECT id FROM frequent_visitors WHERE phone = ?').get(phone);
if (clash) throw new Error('Another recurring visitor already uses that mobile number.');
// 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,
@@ -644,17 +679,19 @@ router.post('/frequent', (req, res) => {
const v = validateFrequent(req.body);
const scope = scopedSiteId(req);
const siteId = scope || v.siteId;
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
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, phone, email, check_type, check_number, check_expiry,
default_host_id, site_id, pin_enc, notes, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId, siteId, encryptPin(pin), v.notes
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) {
@@ -666,16 +703,31 @@ 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 });
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 = ?, phone = ?, email = ?,
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
).run(
v.firstName, v.lastName, 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
@@ -686,14 +738,27 @@ router.patch('/frequent/:id', (req, res) => {
}
});
/** 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.' });
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
db.prepare("UPDATE frequent_visitors SET pin_enc = ?, updated_at = datetime('now') WHERE id = ?").run(
encryptPin(pin),
row.id
);
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 });
});
+9 -2
View File
@@ -2,7 +2,7 @@ import express from 'express';
import rateLimit from 'express-rate-limit';
import db from '../db.js';
import config from '../config.js';
import { savePhoto, photoAbsolutePath } from '../photos.js';
import { savePhoto, photoAbsolutePath, copyStoredPhoto } from '../photos.js';
import { mirror } from '../sheets.js';
import { verifyPin } from '../pins.js';
import { listSites, resolveSite, badgeHtml } from '../sites.js';
@@ -150,10 +150,15 @@ router.post('/signin', signInLimiter, (req, res) => {
});
}
// A recurring visitor with a photo on file is not asked to pose again; the
// stored photo is copied onto this visit as its own snapshot.
let photoPath = null;
if (body.photo) {
photoPath = savePhoto(body.photo);
} else if (config.requirePhoto) {
} else if (isFrequent && frequent.photo_path) {
photoPath = copyStoredPhoto(frequent.photo_path);
}
if (!photoPath && config.requirePhoto) {
return res.status(400).json({ error: 'A photo is required to sign in.' });
}
@@ -336,6 +341,8 @@ router.post('/frequent/auth', pinLimiter, (req, res) => {
checkType: person.check_type,
checkNumber: person.check_number,
defaultHostId: person.default_host_id,
// Tells the kiosk it can skip the camera step entirely.
hasPhoto: Boolean(person.photo_path),
openVisit: open || null,
});
});
+73 -34
View File
@@ -53,6 +53,7 @@ export function shapeSite(site) {
widthMm: site.badge_width_mm,
heightMm: site.badge_height_mm,
showPhoto: Boolean(site.badge_show_photo),
accent: Boolean(site.badge_accent),
note: site.badge_note,
},
};
@@ -70,14 +71,43 @@ const esc = (value) =>
* on load so a kiosk can drop it into a hidden iframe and get one badge out.
*/
export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {}) {
const width = Number(site.badge_width_mm) || 86;
const height = Number(site.badge_height_mm) || 54;
const width = Number(site.badge_width_mm) || 62;
const height = Number(site.badge_height_mm) || 100;
const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl);
// Scale the type with the smaller dimension so tiny labels stay legible.
const unit = Math.min(width, height);
const nameSize = Math.max(3.4, unit * 0.115);
const bodySize = Math.max(2.1, unit * 0.062);
// A label noticeably taller than it is wide gets a stacked layout. That is the
// normal case on a 62mm roll printer like the Brother QL-820NWB, where the roll
// fixes the width and the length runs down the badge.
const portrait = height >= width * 1.2;
// Type scales with the dimension that constrains it: the width on a portrait
// badge, the shorter side on a wide one. Keeps small stock legible.
const unit = portrait ? width : Math.min(width, height);
const pad = unit * 0.07;
const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115));
const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062));
const photoWidth = portrait ? unit * 0.52 : unit * 0.42;
// Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere
// else it prints as grey, so it is off unless the site opts in.
const accent = site.badge_accent ? '#d00019' : '#000';
const timeIn = new Date(visit.signed_in_at);
const noCheck = visit.check_type === 'NONE';
const photo = showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : '';
const details = `
<div class="rows">
<div>Visiting <b>${esc(visit.host_name)}</b></div>
<div>In at <b>${esc(
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
<div>${
noCheck
? '<span class="flag">No WWCC / VIT</span>'
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
}</div>
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
</div>`;
return `<!doctype html>
<html lang="en-AU">
@@ -91,27 +121,40 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
.badge {
width: ${width}mm;
height: ${height}mm;
padding: ${unit * 0.075}mm ${unit * 0.09}mm;
padding: ${pad}mm;
display: flex;
gap: ${unit * 0.07}mm;
align-items: stretch;
flex-direction: ${portrait ? 'column' : 'row'};
align-items: ${portrait ? 'center' : 'stretch'};
text-align: ${portrait ? 'center' : 'left'};
gap: ${unit * 0.05}mm;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
color: #000;
overflow: hidden;
}
.photo {
width: ${unit * 0.42}mm;
width: ${photoWidth}mm;
${portrait ? `height: ${photoWidth * 0.78}mm;` : ''}
flex: 0 0 auto;
object-fit: cover;
border: 0.3mm solid #000;
}
.body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.body {
flex: 1 1 auto;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
${portrait ? 'align-items: center;' : ''}
}
.site {
font-size: ${bodySize * 0.85}mm;
letter-spacing: 0.02em;
border-bottom: 0.35mm solid #000;
padding-bottom: ${unit * 0.025}mm;
margin-bottom: ${unit * 0.045}mm;
width: 100%;
font-size: ${bodySize * 0.8}mm;
letter-spacing: 0.03em;
text-transform: uppercase;
color: ${accent};
border-bottom: 0.35mm solid ${accent};
padding-bottom: ${unit * 0.02}mm;
margin-bottom: ${unit * 0.035}mm;
}
.name {
font-size: ${nameSize}mm;
@@ -120,14 +163,21 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.rows { margin-top: auto; font-size: ${bodySize}mm; line-height: 1.35; }
.rows {
margin-top: auto;
padding-top: ${unit * 0.04}mm;
font-size: ${bodySize}mm;
line-height: 1.35;
}
.rows b { font-weight: 700; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.03}mm; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; }
.flag {
display: inline-block;
padding: 0 ${unit * 0.03}mm;
border: 0.3mm solid #000;
font-size: ${bodySize * 0.85}mm;
border: 0.35mm solid ${accent};
color: ${accent};
font-weight: 700;
font-size: ${bodySize * 0.9}mm;
}
@media screen {
body { background: #e7ecf0; padding: 12mm; }
@@ -137,22 +187,11 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
</head>
<body>
<div class="badge">
${showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : ''}
${photo}
<div class="body">
<div class="site">${esc(site.name)} &middot; VISITOR</div>
<div class="site">${esc(site.name)} &middot; Visitor</div>
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
<div class="rows">
<div>Visiting <b>${esc(visit.host_name)}</b></div>
<div>In at <b>${esc(
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
<div>${
visit.check_type === 'NONE'
? '<span class="flag">No WWCC / VIT</span>'
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
}</div>
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
</div>
${details}
</div>
</div>
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}