first commit
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s

This commit is contained in:
2026-02-28 00:01:41 -05:00
commit 4255d95c68
36 changed files with 4665 additions and 0 deletions

219
src/config/database.js Normal file
View File

@@ -0,0 +1,219 @@
const initSqlJs = require('sql.js');
const path = require('path');
const fs = require('fs');
const dbPath = process.env.DB_PATH || './data/loot-hunt.db';
const dbDir = path.dirname(dbPath);
// Ensure data directory exists
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// ─── Compatibility wrapper ────────────────────────────────
// Wraps sql.js to provide a better-sqlite3compatible API so models
// can call db.prepare(sql).get(...), .all(...), .run(...) seamlessly.
let _db = null; // raw sql.js Database instance
let _saveTimer = null; // debounced persistence timer
function save() {
if (!_db) return;
const data = _db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
}
function scheduleSave() {
if (_saveTimer) return; // already scheduled
_saveTimer = setTimeout(() => {
_saveTimer = null;
save();
}, 250);
}
/** Convert sql.js result set to array of plain objects */
function rowsToObjects(stmt) {
const cols = stmt.getColumnNames();
const rows = [];
while (stmt.step()) {
const values = stmt.get();
const obj = {};
for (let i = 0; i < cols.length; i++) {
obj[cols[i]] = values[i];
}
rows.push(obj);
}
stmt.free();
return rows;
}
/** The public API that models import */
const db = {
/** Execute raw SQL (DDL, multi-statement) */
exec(sql) {
_db.run(sql);
scheduleSave();
},
/** Set a pragma */
pragma(str) {
_db.run(`PRAGMA ${str}`);
},
/** Prepare a statement — returns object with get/all/run */
prepare(sql) {
return {
/** Return first matching row as object, or undefined */
get(...params) {
const stmt = _db.prepare(sql);
if (params.length) stmt.bind(params);
const rows = rowsToObjects(stmt);
return rows[0] || undefined;
},
/** Return all matching rows as array of objects */
all(...params) {
const stmt = _db.prepare(sql);
if (params.length) stmt.bind(params);
return rowsToObjects(stmt);
},
/** Execute a write statement; returns { lastInsertRowid, changes } */
run(...params) {
const stmt = _db.prepare(sql);
if (params.length) stmt.bind(params);
stmt.step();
stmt.free();
const changes = _db.getRowsModified();
// sql.js uses last_insert_rowid() for the last rowid
const idResult = _db.exec('SELECT last_insert_rowid() as id');
const lastInsertRowid = idResult.length > 0 ? idResult[0].values[0][0] : 0;
scheduleSave();
return { lastInsertRowid, changes };
}
};
},
/** Wrap a function in a transaction */
transaction(fn) {
return (...args) => {
_db.run('BEGIN TRANSACTION');
try {
const result = fn(...args);
_db.run('COMMIT');
scheduleSave();
return result;
} catch (err) {
_db.run('ROLLBACK');
throw err;
}
};
},
/** Force an immediate save to disk */
forceSave() {
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
save();
}
};
// ─── Initialisation (synchronous-style via top-level await alternative) ───
// sql.js init is async, so we expose a ready promise that app.js awaits.
let _readyResolve;
const ready = new Promise(resolve => { _readyResolve = resolve; });
(async () => {
const SQL = await initSqlJs();
// Load existing DB from disk or create new
if (fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
_db = new SQL.Database(fileBuffer);
} else {
_db = new SQL.Database();
}
// Enable foreign keys
_db.run('PRAGMA foreign_keys = ON');
// Initialize schema
_db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS hunts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
short_name TEXT UNIQUE NOT NULL,
description TEXT,
package_count INTEGER NOT NULL,
expiry_date DATETIME,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hunt_id INTEGER NOT NULL,
card_number INTEGER NOT NULL,
unique_code TEXT NOT NULL,
first_scanned_by INTEGER,
first_scan_image TEXT,
last_scanned_by INTEGER,
last_scan_hint TEXT,
scan_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (hunt_id) REFERENCES hunts(id) ON DELETE CASCADE,
FOREIGN KEY (first_scanned_by) REFERENCES users(id),
FOREIGN KEY (last_scanned_by) REFERENCES users(id),
UNIQUE(hunt_id, card_number),
UNIQUE(hunt_id, unique_code)
);
CREATE TABLE IF NOT EXISTS packages_idx1 (id INTEGER);
DROP TABLE IF EXISTS packages_idx1;
CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
points_awarded INTEGER NOT NULL DEFAULT 0,
scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess TEXT NOT NULL,
expired DATETIME NOT NULL
);
`);
// Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all
// versions, so wrap in try/catch)
const indexes = [
'CREATE INDEX IF NOT EXISTS idx_packages_hunt ON packages(hunt_id)',
'CREATE INDEX IF NOT EXISTS idx_packages_code ON packages(unique_code)',
'CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_id)',
'CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id)',
'CREATE INDEX IF NOT EXISTS idx_hunts_short_name ON hunts(short_name)',
'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)'
];
for (const idx of indexes) {
try { _db.run(idx); } catch (e) { /* index may already exist */ }
}
save(); // persist initial schema
_readyResolve();
})();
db.ready = ready;
module.exports = db;