All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
439 lines
16 KiB
JavaScript
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) > 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, 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 };
|