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

This commit is contained in:
2026-09-03 14:43:03 +10:00
parent 8c97c314e0
commit e6731dbdfa
11 changed files with 169 additions and 31 deletions
+19 -10
View File
@@ -606,6 +606,7 @@ function shapeFrequent(row, includePin = false) {
id: row.id,
firstName: row.first_name,
lastName: row.last_name,
company: row.company,
phone: row.phone,
email: row.email,
checkType: row.check_type,
@@ -737,6 +738,7 @@ function validateFrequent(body, { existingPhone = null, existingId = null } = {}
return {
firstName,
lastName,
company: clean(body?.company, 80) || null,
phone,
email: email || null,
checkType,
@@ -759,13 +761,13 @@ router.post('/frequent', (req, res) => {
const info = db
.prepare(
`INSERT INTO frequent_visitors
(first_name, last_name, phone, email, check_type, check_number, check_expiry,
(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)`
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes
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) {
@@ -794,11 +796,11 @@ router.patch('/frequent/:id', (req, res) => {
}
db.prepare(
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, phone = ?, email = ?,
`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.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
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,
@@ -892,6 +894,7 @@ function shapeVisit(v) {
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,
@@ -932,8 +935,10 @@ router.get('/visits', (req, res) => {
params.push(`${to}T23:59:59.999Z`);
}
if (q) {
where.push('(last_name LIKE ? OR first_name LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)');
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${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));
@@ -969,9 +974,9 @@ router.get('/visits.csv', (req, res) => {
.all(...params);
const csv = toCsv([
['Visit ID', 'Site', 'Type', 'First name', 'Last name', 'Phone', 'Email', 'Check type', 'Check number', 'Visiting', 'Reason', 'Signed in', 'Signed out', 'Closed by', 'Photo'],
['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.phone, v.email,
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',
@@ -1041,6 +1046,7 @@ router.get('/pass/:id', (req, res) => {
border:1px solid var(--rule); }
h1 { margin:0; font-size: 21px; letter-spacing:-0.01em; }
.site { margin:0 0 14px; font-size:12px; color:var(--muted); }
.org { margin: 3px 0 0; font-size: 13px; color: var(--muted); }
.pin { margin: 14px 0 4px; font-size: 46px; font-weight: 700; letter-spacing: 0.22em;
font-variant-numeric: tabular-nums; color: var(--deep); }
.pin-label { margin:0 0 16px; font-size:12px; color:var(--muted); }
@@ -1064,6 +1070,7 @@ router.get('/pass/:id', (req, res) => {
<div class="card">
<p class="site">${esc(site ? site.name : config.siteName)}</p>
<h1>${esc(row.first_name)} ${esc(row.last_name)}</h1>
${row.company ? `<p class="org">${esc(row.company)}</p>` : ''}
<p class="pin">${esc(pin)}</p>
<p class="pin-label">Your PIN. Keep this card, it is not sent to you again.</p>
<dl>
@@ -1105,6 +1112,8 @@ router.get('/status', (req, res) => {
lastError: sheets.status.lastError,
tab: sheets.tabName(),
stale: sheets.isStale(),
serviceAccount: sheets.serviceAccountEmail(),
spreadsheetId: config.sheets.spreadsheetId || null,
lastOnSiteSync: sheets.status.lastOnSiteSync,
onSiteCount: sheets.status.onSiteCount,
onSiteError: sheets.status.onSiteError,