Public Access
300 lines
11 KiB
JavaScript
300 lines
11 KiB
JavaScript
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,
|
|
printer_label TEXT NOT NULL DEFAULT '62',
|
|
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');
|
|
// Which roll is physically loaded. Kept separate from the red styling option:
|
|
// the printer refuses a two-colour job on a plain roll, so guessing the media
|
|
// from a design setting means a wrong-roll error nobody can explain.
|
|
addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'");
|
|
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;
|