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

This commit is contained in:
2026-09-04 14:55:59 +10:00
commit 3278890491
41 changed files with 8920 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
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 });
export const db = new Database(config.dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
badge_enabled INTEGER NOT NULL DEFAULT 0,
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,
printer_enabled INTEGER NOT NULL DEFAULT 0,
printer_host TEXT,
printer_port INTEGER NOT NULL DEFAULT 9100,
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
printer_rotate INTEGER NOT NULL DEFAULT 0,
banner_path TEXT,
banner_height INTEGER NOT NULL DEFAULT 64,
banner_align TEXT NOT NULL DEFAULT 'left',
colour_brand TEXT,
colour_signout TEXT,
colour_page TEXT,
colour_text TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT,
area TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS frequent_visitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
company TEXT,
phone TEXT NOT NULL UNIQUE,
email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE',
check_number TEXT,
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')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
site_name TEXT,
visitor_type TEXT NOT NULL,
frequent_visitor_id INTEGER REFERENCES frequent_visitors(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
company TEXT,
phone TEXT,
email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE',
check_number TEXT,
check_expiry TEXT,
host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
host_name TEXT NOT NULL,
visit_reason TEXT,
photo_path TEXT,
signed_in_at TEXT NOT NULL,
signed_out_at TEXT,
signed_out_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_visits_open ON visits(signed_out_at, last_name);
CREATE INDEX IF NOT EXISTS idx_visits_in ON visits(signed_in_at);
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT,
password_hash TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
recovery_codes TEXT,
role TEXT NOT NULL DEFAULT 'admin',
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY,
fails INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
CREATE TABLE IF NOT EXISTS sheet_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS pin_attempts (
phone TEXT PRIMARY KEY,
fails INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
`);
/* ---------------------------------------------------------- migrations */
function hasColumn(table, column) {
return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
}
function addColumn(table, column, definition) {
if (!hasColumn(table, column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
console.log(`[db] added ${table}.${column}`);
}
}
// Multi-site arrived after the first release, so these run once on an existing database.
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');
// Per-site branding on the kiosk.
addColumn('sites', 'banner_path', 'TEXT');
addColumn('sites', 'banner_height', 'INTEGER NOT NULL DEFAULT 64');
addColumn('sites', 'banner_align', "TEXT NOT NULL DEFAULT 'left'");
addColumn('sites', 'colour_brand', 'TEXT');
addColumn('sites', 'colour_signout', 'TEXT');
addColumn('sites', 'colour_page', 'TEXT');
addColumn('sites', 'colour_text', 'TEXT');
// Optional "who are you from", handy for contractors and visiting staff.
addColumn('visits', 'company', 'TEXT');
// Server-side printing, so a kiosk needs no printer driver of its own.
addColumn('sites', 'printer_enabled', 'INTEGER NOT NULL DEFAULT 0');
addColumn('sites', 'printer_host', 'TEXT');
addColumn('sites', 'printer_port', 'INTEGER NOT NULL DEFAULT 9100');
addColumn('sites', 'printer_model', "TEXT NOT NULL DEFAULT 'QL-820NWB'");
addColumn('sites', 'printer_rotate', 'INTEGER NOT NULL DEFAULT 0');
addColumn('frequent_visitors', 'company', 'TEXT');
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)');
/* -------------------------------------------------------- default site */
const siteCount = db.prepare('SELECT COUNT(*) AS n FROM sites').get().n;
if (siteCount === 0) {
db.prepare('INSERT INTO sites (name, slug) VALUES (?, ?)').run(config.siteName, 'main');
console.log(`[db] created the first site: ${config.siteName}`);
}
const firstSite = db.prepare('SELECT id, name FROM sites ORDER BY id LIMIT 1').get();
db.prepare('UPDATE hosts SET site_id = ? WHERE site_id IS NULL').run(firstSite.id);
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;
}
export function setSetting(key, value) {
db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, String(value));
}
export default db;