Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user