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;