const db = require('../config/database'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const uploadsDir = process.env.UPLOADS_DIR || './data/uploads'; // ─── 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, display_name) VALUES (?, ?, ?)'); const result = stmt.run(username, hash, username); return result.lastInsertRowid; }, findByUsername(username) { return db.prepare('SELECT * FROM users WHERE username = ?').get(username); }, findById(id) { return db.prepare('SELECT id, username, display_name, 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); }, setDisplayName(userId, displayName) { db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(displayName, 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, COALESCE(display_name, username) as display_name, password_hash, is_admin, is_organizer, created_at FROM users ORDER BY password_hash = '' ASC, COALESCE(display_name, username) ASC").all(); }, isDeleted(user) { return user.password_hash === ''; }, 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; }, deleteUser(userId) { // get username from userId before scrambling const user = this.findById(userId); const scrambled = `_deleted_${user.username}_${Date.now()}`; // Delete uploaded images from disk and clear from DB this._scrubUserContent(userId); db.prepare('UPDATE users SET username = ?, display_name = ?, password_hash = ?, is_admin = 0, is_organizer = 0 WHERE id = ?') .run(scrambled, '[deleted]', '', userId); db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?').run(userId); db.prepare("DELETE FROM sessions WHERE sess LIKE ?").run('%"userId":' + userId + '%'); }, hardDeleteUser(userId, reassignTo) { this._scrubUserContent(userId); if (reassignTo) { db.prepare('UPDATE hunts SET created_by = ? WHERE created_by = ?').run(reassignTo, userId); } db.prepare('DELETE FROM scans WHERE user_id = ?').run(userId); db.prepare('UPDATE packages SET first_scanned_by = NULL WHERE first_scanned_by = ?').run(userId); db.prepare('UPDATE packages SET last_scanned_by = NULL WHERE last_scanned_by = ?').run(userId); db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId); db.prepare("DELETE FROM sessions WHERE sess LIKE ?").run('%"userId":' + userId + '%'); db.prepare('DELETE FROM users WHERE id = ?').run(userId); }, _scrubUserContent(userId) { // Delete images uploaded by this user const images = db.prepare('SELECT first_scan_image FROM packages WHERE first_scanned_by = ? AND first_scan_image IS NOT NULL').all(userId); for (const row of images) { const filePath = path.resolve(uploadsDir, path.basename(row.first_scan_image)); try { fs.unlinkSync(filePath); } catch (_) { /* file may already be gone */ } } db.prepare('UPDATE packages SET first_scan_image = NULL WHERE first_scanned_by = ?').run(userId); // Clear hints left by this user db.prepare('UPDATE packages SET last_scan_hint = NULL WHERE last_scanned_by = ?').run(userId); } }; // ─── 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.*, COALESCE(u.display_name, 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, COALESCE(u.display_name, u.username) as display_name, 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) > new Date(); }, hasStarted(hunt) { if (!hunt.start_date) return true; return new Date(hunt.start_date) <= 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, COALESCE(u.display_name, u.username) as display_name, 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, COALESCE(u.display_name, u.username) as display_name, 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.*, COALESCE(u1.display_name, u1.username) as first_scanner_name, u1.username as first_scanner_username, COALESCE(u2.display_name, u2.username) as last_scanner_name, u2.username as last_scanner_username 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, COALESCE(u1.display_name, u1.username) as first_scanner_name, u1.username as first_scanner_username, COALESCE(u2.display_name, u2.username) as last_scanner_name, u2.username as last_scanner_username 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, COALESCE(u.display_name, u.username) as display_name 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, COALESCE(u.display_name, u.username) as display_name, 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, COALESCE(u.display_name, u.username) as display_name, 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 };