Files
loot-hunt/src/models/index.js
Mike Johnston 34b3a4cbd0
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 31s
allow for a starting date and hidden hunt if it hasnt started
2026-03-19 14:39:06 -04:00

439 lines
16 KiB
JavaScript

const db = require('../config/database');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
// ─── Helpers ──────────────────────────────────────────────
function generateCode(length = 5) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no I/O/0/1 to avoid confusion
let code = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
}
function getPointsForScanNumber(scanNumber) {
if (scanNumber === 1) return 500;
if (scanNumber === 2) return 250;
if (scanNumber === 3) return 100;
return 50;
}
// ─── Users ────────────────────────────────────────────────
const Users = {
create(username, password) {
const hash = bcrypt.hashSync(password, 12);
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
const result = stmt.run(username, hash);
return result.lastInsertRowid;
},
findByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
},
findById(id) {
return db.prepare('SELECT id, username, is_admin, is_organizer, created_at FROM users WHERE id = ?').get(id);
},
verifyPassword(user, password) {
return bcrypt.compareSync(password, user.password_hash);
},
makeAdmin(userId) {
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId);
},
makeOrganizer(userId) {
db.prepare('UPDATE users SET is_organizer = 1 WHERE id = ?').run(userId);
},
removeOrganizer(userId) {
db.prepare('UPDATE users SET is_organizer = 0 WHERE id = ?').run(userId);
},
setPassword(userId, newPassword) {
const hash = bcrypt.hashSync(newPassword, 12);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, userId);
},
createPasswordResetToken(userId) {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24 hours
// Invalidate any existing tokens for this user
db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?').run(userId);
db.prepare('INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)').run(userId, token, expiresAt);
return token;
},
findByResetToken(token) {
return db.prepare(`
SELECT prt.*, u.username FROM password_reset_tokens prt
JOIN users u ON prt.user_id = u.id
WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now')
`).get(token);
},
consumeResetToken(token) {
db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE token = ?').run(token);
},
getAllUsers() {
return db.prepare('SELECT id, username, is_admin, is_organizer, created_at FROM users ORDER BY username ASC').all();
},
getTotalPoints(userId) {
const row = db.prepare('SELECT COALESCE(SUM(points_awarded), 0) as total FROM scans WHERE user_id = ?').get(userId);
return row.total;
},
getProfile(userId) {
const user = this.findById(userId);
if (!user) return null;
const totalPoints = this.getTotalPoints(userId);
const scanCount = db.prepare('SELECT COUNT(*) as count FROM scans WHERE user_id = ? AND points_awarded > 0').get(userId).count;
return { ...user, totalPoints, scanCount };
},
getRecentScans(userId, limit = 20) {
return db.prepare(`
SELECT s.points_awarded, s.scanned_at,
p.card_number, p.unique_code,
h.name as hunt_name, h.short_name as hunt_short_name, h.package_count
FROM scans s
JOIN packages p ON s.package_id = p.id
JOIN hunts h ON p.hunt_id = h.id
WHERE s.user_id = ?
ORDER BY s.scanned_at DESC
LIMIT ?
`).all(userId, limit);
},
getHuntBreakdown(userId) {
return db.prepare(`
SELECT h.name as hunt_name, h.short_name as hunt_short_name,
COUNT(s.id) as scans, SUM(s.points_awarded) as points
FROM scans s
JOIN packages p ON s.package_id = p.id
JOIN hunts h ON p.hunt_id = h.id
WHERE s.user_id = ? AND s.points_awarded > 0
GROUP BY h.id
ORDER BY points DESC
`).all(userId);
},
getRank(userId) {
const rows = db.prepare(`
SELECT user_id, SUM(points_awarded) as total
FROM scans WHERE points_awarded > 0
GROUP BY user_id
ORDER BY total DESC
`).all();
const idx = rows.findIndex(r => r.user_id === userId);
return idx >= 0 ? idx + 1 : null;
},
getTotalPlayerCount() {
return db.prepare('SELECT COUNT(DISTINCT user_id) as count FROM scans WHERE points_awarded > 0').get().count;
}
};
// ─── Hunts ────────────────────────────────────────────────
const Hunts = {
create(name, shortName, description, packageCount, expiryDate, createdBy, startDate, hiddenUntilStart) {
const stmt = db.prepare(
'INSERT INTO hunts (name, short_name, description, package_count, expiry_date, created_by, start_date, hidden_until_start) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(name, shortName.toUpperCase(), description, packageCount, expiryDate || null, createdBy, startDate || null, hiddenUntilStart ? 1 : 0);
const huntId = result.lastInsertRowid;
// Generate packages
const insertPkg = db.prepare('INSERT INTO packages (hunt_id, card_number, unique_code) VALUES (?, ?, ?)');
const usedCodes = new Set();
const insertAll = db.transaction(() => {
for (let i = 1; i <= packageCount; i++) {
let code;
do {
code = generateCode(5);
} while (usedCodes.has(code));
usedCodes.add(code);
insertPkg.run(huntId, i, code);
}
});
insertAll();
return huntId;
},
findById(id) {
return db.prepare('SELECT * FROM hunts WHERE id = ?').get(id);
},
findByShortName(shortName) {
return db.prepare('SELECT * FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
},
getAll() {
return db.prepare('SELECT h.*, u.username as creator_name FROM hunts h JOIN users u ON h.created_by = u.id ORDER BY h.created_at DESC').all();
},
getByCreator(userId) {
return db.prepare('SELECT * FROM hunts WHERE created_by = ? ORDER BY created_at DESC').all(userId);
},
isExpired(hunt) {
if (!hunt.expiry_date) return false;
return new Date(hunt.expiry_date) < new Date();
},
getLeaderboard(huntId, limit = null, offset = 0) {
let sql = `
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
FROM scans s
JOIN users u ON s.user_id = u.id
JOIN packages p ON s.package_id = p.id
WHERE p.hunt_id = ? AND s.points_awarded > 0
GROUP BY u.id
ORDER BY total_points DESC`;
if (limit) {
sql += ` LIMIT ${parseInt(limit)} OFFSET ${parseInt(offset)}`;
}
return db.prepare(sql).all(huntId);
},
getLeaderboardCount(huntId) {
return db.prepare(`
SELECT COUNT(DISTINCT s.user_id) as count
FROM scans s
JOIN packages p ON s.package_id = p.id
WHERE p.hunt_id = ? AND s.points_awarded > 0
`).get(huntId).count;
},
shortNameExists(shortName) {
const row = db.prepare('SELECT id FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
return !!row;
},
update(id, name, description, expiryDate, startDate, hiddenUntilStart) {
db.prepare('UPDATE hunts SET name = ?, description = ?, expiry_date = ?, start_date = ?, hidden_until_start = ? WHERE id = ?')
.run(name, description, expiryDate || null, startDate || null, hiddenUntilStart ? 1 : 0, id);
},
isHidden(hunt) {
if (!hunt.hidden_until_start || !hunt.start_date) return false;
return new Date(hunt.start_date + 'Z') > new Date();
},
hasStarted(hunt) {
if (!hunt.start_date) return true;
return new Date(hunt.start_date + 'Z') <= new Date();
},
resetScans(id) {
const doReset = db.transaction(() => {
// Delete all scans for packages in this hunt
db.prepare('DELETE FROM scans WHERE package_id IN (SELECT id FROM packages WHERE hunt_id = ?)').run(id);
// Reset package scan counters and scanner references
db.prepare('UPDATE packages SET scan_count = 0, first_scanned_by = NULL, first_scan_image = NULL, last_scanned_by = NULL, last_scan_hint = NULL WHERE hunt_id = ?').run(id);
});
doReset();
},
delete(id) {
const doDelete = db.transaction(() => {
// Delete scans for all packages in this hunt
db.prepare('DELETE FROM scans WHERE package_id IN (SELECT id FROM packages WHERE hunt_id = ?)').run(id);
// Delete packages
db.prepare('DELETE FROM packages WHERE hunt_id = ?').run(id);
// Delete hunt
db.prepare('DELETE FROM hunts WHERE id = ?').run(id);
});
doDelete();
},
getStats(huntId) {
const totalScans = db.prepare('SELECT COUNT(*) as count FROM scans s JOIN packages p ON s.package_id = p.id WHERE p.hunt_id = ? AND s.points_awarded > 0').get(huntId).count;
const uniquePlayers = db.prepare('SELECT COUNT(DISTINCT s.user_id) as count FROM scans s JOIN packages p ON s.package_id = p.id WHERE p.hunt_id = ? AND s.points_awarded > 0').get(huntId).count;
const discoveredCount = db.prepare('SELECT COUNT(*) as count FROM packages WHERE hunt_id = ? AND scan_count > 0').get(huntId).count;
const totalPackages = db.prepare('SELECT package_count FROM hunts WHERE id = ?').get(huntId).package_count;
const totalPoints = db.prepare('SELECT COALESCE(SUM(s.points_awarded), 0) as total FROM scans s JOIN packages p ON s.package_id = p.id WHERE p.hunt_id = ?').get(huntId).total;
const topFinders = db.prepare(`
SELECT u.username, SUM(s.points_awarded) as points, COUNT(s.id) as finds
FROM scans s
JOIN users u ON s.user_id = u.id
JOIN packages p ON s.package_id = p.id
WHERE p.hunt_id = ? AND s.points_awarded > 0
GROUP BY u.id ORDER BY points DESC LIMIT 5
`).all(huntId);
const recentScans = db.prepare(`
SELECT s.scanned_at, s.points_awarded, u.username, p.card_number
FROM scans s
JOIN users u ON s.user_id = u.id
JOIN packages p ON s.package_id = p.id
WHERE p.hunt_id = ? AND s.points_awarded > 0
ORDER BY s.scanned_at DESC LIMIT 10
`).all(huntId);
return { totalScans, uniquePlayers, discoveredCount, totalPackages, totalPoints, topFinders, recentScans, discoveryRate: totalPackages > 0 ? Math.round((discoveredCount / totalPackages) * 100) : 0 };
}
};
// ─── Packages ─────────────────────────────────────────────
const Packages = {
findById(id) {
return db.prepare('SELECT * FROM packages WHERE id = ?').get(id);
},
findByHuntAndCode(shortName, uniqueCode) {
return db.prepare(`
SELECT p.*, h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id, h.expiry_date
FROM packages p
JOIN hunts h ON p.hunt_id = h.id
WHERE h.short_name = ? COLLATE NOCASE AND p.unique_code = ? COLLATE NOCASE
`).get(shortName, uniqueCode);
},
findByHuntAndCardNumber(shortName, cardNumber) {
return db.prepare(`
SELECT p.*, h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id, h.expiry_date, h.package_count
FROM packages p
JOIN hunts h ON p.hunt_id = h.id
WHERE h.short_name = ? COLLATE NOCASE AND p.card_number = ?
`).get(shortName, parseInt(cardNumber, 10));
},
getByHunt(huntId) {
return db.prepare(`
SELECT p.*,
u1.username as first_scanner_name,
u2.username as last_scanner_name
FROM packages p
LEFT JOIN users u1 ON p.first_scanned_by = u1.id
LEFT JOIN users u2 ON p.last_scanned_by = u2.id
WHERE p.hunt_id = ?
ORDER BY p.card_number ASC
`).all(huntId);
},
getProfile(packageId) {
return db.prepare(`
SELECT p.*,
h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id,
u1.username as first_scanner_name,
u2.username as last_scanner_name
FROM packages p
JOIN hunts h ON p.hunt_id = h.id
LEFT JOIN users u1 ON p.first_scanned_by = u1.id
LEFT JOIN users u2 ON p.last_scanned_by = u2.id
WHERE p.id = ?
`).get(packageId);
},
getScanHistory(packageId) {
return db.prepare(`
SELECT s.*, u.username
FROM scans s
JOIN users u ON s.user_id = u.id
WHERE s.package_id = ?
ORDER BY s.scanned_at ASC
`).all(packageId);
},
updateFirstScanImage(packageId, imagePath) {
db.prepare('UPDATE packages SET first_scan_image = ? WHERE id = ?').run(imagePath, packageId);
},
removeFirstScanImage(packageId) {
db.prepare('UPDATE packages SET first_scan_image = NULL WHERE id = ?').run(packageId);
},
clearHint(packageId) {
db.prepare('UPDATE packages SET last_scan_hint = NULL WHERE id = ?').run(packageId);
},
updateLastScanHint(packageId, userId, hint) {
db.prepare('UPDATE packages SET last_scanned_by = ?, last_scan_hint = ? WHERE id = ?').run(userId, hint, packageId);
}
};
// ─── Scans ────────────────────────────────────────────────
const Scans = {
hasUserScanned(packageId, userId) {
const row = db.prepare('SELECT id FROM scans WHERE package_id = ? AND user_id = ? AND points_awarded > 0').get(packageId, userId);
return !!row;
},
recordScan(packageId, userId) {
const pkg = Packages.findById(packageId);
if (!pkg) return { error: 'Package not found' };
const alreadyScanned = this.hasUserScanned(packageId, userId);
if (alreadyScanned) {
// No points — don't store 0-point scans, just update last_scanned_by
db.prepare('UPDATE packages SET last_scanned_by = ? WHERE id = ?').run(userId, packageId);
return { points: 0, alreadyScanned: true, isFirst: false };
}
const scanNumber = pkg.scan_count + 1;
const points = getPointsForScanNumber(scanNumber);
const doScan = db.transaction(() => {
// Record the scan
db.prepare('INSERT INTO scans (package_id, user_id, points_awarded) VALUES (?, ?, ?)').run(packageId, userId, points);
// Update package
const isFirst = scanNumber === 1;
if (isFirst) {
db.prepare('UPDATE packages SET first_scanned_by = ?, last_scanned_by = ?, scan_count = ? WHERE id = ?')
.run(userId, userId, scanNumber, packageId);
} else {
db.prepare('UPDATE packages SET last_scanned_by = ?, scan_count = ? WHERE id = ?')
.run(userId, scanNumber, packageId);
}
return { points, alreadyScanned: false, isFirst, scanNumber };
});
return doScan();
},
getGlobalLeaderboard(limit = null, offset = 0) {
let sql = `
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
FROM scans s
JOIN users u ON s.user_id = u.id
WHERE s.points_awarded > 0
GROUP BY u.id
ORDER BY total_points DESC`;
if (limit) {
sql += ` LIMIT ${parseInt(limit)} OFFSET ${parseInt(offset)}`;
}
return db.prepare(sql).all();
},
getGlobalLeaderboardCount() {
return db.prepare('SELECT COUNT(DISTINCT user_id) as count FROM scans WHERE points_awarded > 0').get().count;
},
getRecentActivity(limit = 5) {
return db.prepare(`
SELECT s.points_awarded, s.scanned_at,
u.username,
p.card_number,
h.name as hunt_name, h.short_name as hunt_short_name, h.package_count
FROM scans s
JOIN users u ON s.user_id = u.id
JOIN packages p ON s.package_id = p.id
JOIN hunts h ON p.hunt_id = h.id
WHERE s.points_awarded > 0
ORDER BY s.scanned_at DESC
LIMIT ?
`).all(limit);
}
};
module.exports = { Users, Hunts, Packages, Scans, generateCode };