feat: add display name functionality for users and update related views
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s

This commit is contained in:
2026-03-20 12:58:30 -04:00
parent 051e35c581
commit ea537ff293
14 changed files with 81 additions and 39 deletions

View File

@@ -226,7 +226,8 @@ const ready = new Promise(resolve => { _readyResolve = resolve; });
const migrations = [
'ALTER TABLE users ADD COLUMN is_organizer INTEGER DEFAULT 0',
'ALTER TABLE hunts ADD COLUMN start_date DATETIME',
'ALTER TABLE hunts ADD COLUMN hidden_until_start INTEGER DEFAULT 0'
'ALTER TABLE hunts ADD COLUMN hidden_until_start INTEGER DEFAULT 0',
'ALTER TABLE users ADD COLUMN display_name TEXT'
];
for (const m of migrations) {
try { _db.run(m); } catch (e) { /* column already exists */ }

View File

@@ -36,6 +36,7 @@ function loadUser(req, res, next) {
res.locals.currentUser = {
id: req.session.userId,
username: req.session.username,
displayName: req.session.displayName || req.session.username,
isAdmin: req.session.isAdmin,
isOrganizer: req.session.isOrganizer
};

View File

@@ -24,8 +24,8 @@ function getPointsForScanNumber(scanNumber) {
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);
const stmt = db.prepare('INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)');
const result = stmt.run(username, hash, username);
return result.lastInsertRowid;
},
@@ -34,7 +34,7 @@ const Users = {
},
findById(id) {
return db.prepare('SELECT id, username, is_admin, is_organizer, created_at FROM users WHERE id = ?').get(id);
return db.prepare('SELECT id, username, display_name, is_admin, is_organizer, created_at FROM users WHERE id = ?').get(id);
},
verifyPassword(user, password) {
@@ -58,6 +58,10 @@ const Users = {
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
@@ -80,7 +84,7 @@ const Users = {
},
getAllUsers() {
return db.prepare("SELECT id, username, is_admin, is_organizer, created_at FROM users WHERE username NOT LIKE '[deleted_%]' ORDER BY username ASC").all();
return db.prepare("SELECT id, username, COALESCE(display_name, username) as display_name, is_admin, is_organizer, created_at FROM users WHERE password_hash != '' ORDER BY COALESCE(display_name, username) ASC").all();
},
getTotalPoints(userId) {
@@ -139,9 +143,8 @@ const Users = {
},
deleteUser(userId) {
const placeholder = '[deleted_' + userId + ']';
db.prepare('UPDATE users SET username = ?, password_hash = ?, is_admin = 0, is_organizer = 0 WHERE id = ?')
.run(placeholder, '', userId);
db.prepare('UPDATE users SET display_name = ?, password_hash = ?, is_admin = 0, is_organizer = 0 WHERE id = ?')
.run('[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 + '%');
}
@@ -184,7 +187,7 @@ const Hunts = {
},
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();
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) {
@@ -198,7 +201,7 @@ const Hunts = {
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
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
@@ -269,7 +272,7 @@ const Hunts = {
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
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
@@ -277,7 +280,7 @@ const Hunts = {
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
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
@@ -315,8 +318,10 @@ const Packages = {
getByHunt(huntId) {
return db.prepare(`
SELECT p.*,
u1.username as first_scanner_name,
u2.username as last_scanner_name
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
@@ -329,8 +334,10 @@ const Packages = {
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
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
@@ -341,7 +348,7 @@ const Packages = {
getScanHistory(packageId) {
return db.prepare(`
SELECT s.*, u.username
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 = ?
@@ -410,7 +417,7 @@ const Scans = {
getGlobalLeaderboard(limit = null, offset = 0) {
let sql = `
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
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
@@ -429,7 +436,7 @@ const Scans = {
getRecentActivity(limit = 5) {
return db.prepare(`
SELECT s.points_awarded, s.scanned_at,
u.username,
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

View File

@@ -20,6 +20,7 @@ router.post('/login', (req, res) => {
req.session.userId = user.id;
req.session.username = user.username;
req.session.displayName = user.display_name || user.username;
req.session.isAdmin = !!user.is_admin;
req.session.isOrganizer = !!user.is_organizer;
@@ -63,6 +64,7 @@ router.post('/register', (req, res) => {
const userId = Users.create(username, password);
req.session.userId = userId;
req.session.username = username;
req.session.displayName = username;
req.session.isAdmin = false;
req.session.isOrganizer = false;
req.session.flash = { type: 'success', message: 'Account created! Welcome to Loot Hunt.' };
@@ -113,6 +115,7 @@ router.post('/reset/:token', (req, res) => {
// Log the user in
req.session.userId = tokenRecord.user_id;
req.session.username = tokenRecord.username;
req.session.displayName = tokenRecord.username;
req.session.isAdmin = false; // they can re-check on next load
res.redirect('/');
});

View File

@@ -136,6 +136,25 @@ router.post('/player/:username/password', requireAuth, (req, res) => {
res.redirect(`/player/${user.username}`);
});
// ─── Update display name ──────────────────────────────────
router.post('/player/:username/display-name', requireAuth, (req, res) => {
const user = Users.findByUsername(req.params.username);
if (!user || user.id !== req.session.userId) {
return res.status(403).render('error', { title: 'Forbidden', message: 'You can only change your own display name.' });
}
const displayName = (req.body.display_name || '').trim();
if (displayName.length < 2 || displayName.length > 32) {
req.session.flash = { type: 'danger', message: 'Display name must be 2-32 characters.' };
return res.redirect(`/player/${user.username}`);
}
Users.setDisplayName(user.id, displayName);
req.session.displayName = displayName;
req.session.flash = { type: 'success', message: 'Display name updated.' };
res.redirect(`/player/${user.username}`);
});
// ─── Delete own account ───────────────────────────────────
router.post('/player/:username/delete', requireAuth, (req, res) => {
const user = Users.findByUsername(req.params.username);

View File

@@ -44,7 +44,7 @@
<select name="username" class="form-control" required>
<option value="">Select user...</option>
<% if (typeof users !== 'undefined' && users) { users.forEach(u => { %>
<option value="<%= u.username %>"><%= u.username %><%= u.is_admin ? ' (admin)' : u.is_organizer ? ' (organizer)' : '' %></option>
<option value=\"<%= u.username %>\"><%= u.display_name %><%= u.is_admin ? ' (admin)' : u.is_organizer ? ' (organizer)' : '' %></option>
<% }); } %>
</select>
</div>
@@ -74,7 +74,7 @@
<tbody>
<% if (typeof users !== 'undefined' && users) { users.filter(u => !u.is_admin).forEach(u => { %>
<tr>
<td><a href="/player/<%= u.username %>"><%= u.username %></a></td>
<td><a href="/player/<%= u.username %>"><%= u.display_name %></a></td>
<td><%= u.is_organizer ? 'Organizer' : 'Player' %></td>
<td>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">

View File

@@ -65,7 +65,7 @@
<tbody>
<% stats.topFinders.forEach(f => { %>
<tr>
<td><a href="/player/<%= f.username %>"><%= f.username %></a></td>
<td><a href="/player/<%= f.username %>"><%= f.display_name %></a></td>
<td><%= f.finds %></td>
<td><span class="points-badge">+<%= f.points %></span></td>
</tr>
@@ -84,7 +84,7 @@
<% stats.recentScans.forEach(s => { %>
<tr>
<td><%= s.card_number %></td>
<td><a href="/player/<%= s.username %>"><%= s.username %></a></td>
<td><a href="/player/<%= s.username %>"><%= s.display_name %></a></td>
<td><span class="points-badge">+<%= s.points_awarded %></span></td>
<td style="font-size: 0.85rem; color: var(--muted);"><time datetime="<%= s.scanned_at %>"><%= new Date(s.scanned_at).toLocaleString() %></time></td>
</tr>
@@ -117,8 +117,8 @@
<td class="hide-mobile"><strong><%= pkg.card_number %></strong></td>
<td style="font-family: monospace;"><%= pkg.unique_code %></td>
<td class="hide-mobile"><%= pkg.scan_count %></td>
<td class="hide-mobile"><% if (pkg.first_scanner_name) { %><a href="/player/<%= pkg.first_scanner_name %>"><%= pkg.first_scanner_name %></a><% } else { %>---<% } %></td>
<td><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></td>
<td class="hide-mobile"><% if (pkg.first_scanner_name) { %><a href="/player/<%= pkg.first_scanner_username %>"><%= pkg.first_scanner_name %></a><% } else { %>---<% } %></td>
<td><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_username %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></td>
<td>
<div style="display: flex; gap: 0.4rem; align-items: stretch;">
<a href="/hunt/<%= hunt.short_name %>/<%= pkg.card_number %>" class="btn btn-sm btn-outline">View</a>

View File

@@ -74,7 +74,7 @@
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; border-bottom: 1px solid #eee;">
<span class="points-badge" style="font-size: 0.85rem; padding: 0.25rem 0.6rem;">+<%= a.points_awarded %></span>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600;"><a href="/player/<%= a.username %>" style="text-decoration: none; color: inherit;"><%= a.username %></a> found <a href="/hunt/<%= a.hunt_short_name %>/<%= a.card_number %>" style="color: var(--primary);">#<%= a.card_number %></a> in <a href="/hunt/<%= a.hunt_short_name %>" style="color: var(--primary);"><%= a.hunt_name %></a></div>
<div style="font-weight: 600;"><a href="/player/<%= a.username %>" style="text-decoration: none; color: inherit;"><%= a.display_name %></a> found <a href="/hunt/<%= a.hunt_short_name %>/<%= a.card_number %>" style="color: var(--primary);">#<%= a.card_number %></a> in <a href="/hunt/<%= a.hunt_short_name %>" style="color: var(--primary);"><%= a.hunt_name %></a></div>
<div style="font-size: 0.8rem; color: var(--muted);"><time datetime="<%= a.scanned_at %>"><%= new Date(a.scanned_at).toLocaleString() %></time></div>
</div>
</div>

View File

@@ -28,7 +28,7 @@
<% const rank = (typeof offset !== 'undefined' ? offset : 0) + i + 1; %>
<tr>
<td class="rank-cell rank-<%= rank %>"><%= rank %></td>
<td><strong><a href="/player/<%= entry.username %>"><%= entry.username %></a></strong></td>
<td><strong><a href="/player/<%= entry.username %>"><%= entry.display_name %></a></strong></td>
<td><span class="points-badge"><%= entry.total_points %></span></td>
<td><%= entry.scans %></td>
</tr>

View File

@@ -26,7 +26,7 @@
<td class="rank-cell rank-<%= rank %>">
<% if (rank === 1) { %>&#x1F947;<% } else if (rank === 2) { %>&#x1F948;<% } else if (rank === 3) { %>&#x1F949;<% } else { %><%= rank %><% } %>
</td>
<td><strong><a href="/player/<%= entry.username %>"><%= entry.username %></a></strong></td>
<td><strong><a href="/player/<%= entry.username %>"><%= entry.display_name %></a></strong></td>
<td><span class="points-badge"><%= entry.total_points %></span></td>
<td><%= entry.scans %></td>
</tr>

View File

@@ -17,11 +17,11 @@
<div class="label">Total Scans</div>
</div>
<div class="stat-box">
<div class="value"><% if (pkg.first_scanner_name) { %><a href="/player/<%= pkg.first_scanner_name %>"><%= pkg.first_scanner_name %></a><% } else { %>---<% } %></div>
<div class="value"><% if (pkg.first_scanner_name) { %><a href="/player/<%= pkg.first_scanner_username %>"><%= pkg.first_scanner_name %></a><% } else { %>---<% } %></div>
<div class="label">First Finder</div>
</div>
<div class="stat-box">
<div class="value"><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></div>
<div class="value"><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_username %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></div>
<div class="label">Most Recent</div>
</div>
</div>
@@ -67,7 +67,7 @@
<div class="card-header">&#x1F4AC; Package Hint</div>
<% if (pkg.last_scan_hint) { %>
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
<p style="font-size: 0.8rem; color: var(--muted);">Left by <a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a></p>
<p style="font-size: 0.8rem; color: var(--muted);">Left by <a href="/player/<%= pkg.last_scanner_username %>"><%= pkg.last_scanner_name %></a></p>
<% } else { %>
<p style="color: var(--muted);">No hint has been left yet.</p>
<% } %>
@@ -105,7 +105,7 @@
<% scanHistory.forEach((scan, i) => { %>
<tr>
<td><%= i + 1 %></td>
<td><a href="/player/<%= scan.username %>"><%= scan.username %></a></td>
<td><a href="/player/<%= scan.username %>"><%= scan.display_name %></a></td>
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
<td style="font-size: 0.85rem; color: var(--muted);"><time datetime="<%= scan.scanned_at %>"><%= new Date(scan.scanned_at).toLocaleString() %></time></td>
</tr>

View File

@@ -31,11 +31,11 @@
<div class="label">Total Scans</div>
</div>
<div class="stat-box">
<div class="value"><% if (pkg.first_scanner_name) { %><a href="/player/<%= pkg.first_scanner_name %>"><%= pkg.first_scanner_name %></a><% } else { %>---<% } %></div>
<div class="value"><% if (pkg.first_scanner_name) { %><a href="/player/<%= pkg.first_scanner_username %>"><%= pkg.first_scanner_name %></a><% } else { %>---<% } %></div>
<div class="label">First Finder</div>
</div>
<div class="stat-box">
<div class="value"><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></div>
<div class="value"><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_username %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></div>
<div class="label">Most Recent</div>
</div>
</div>
@@ -82,7 +82,7 @@
<div class="card-header">&#x1F4AC; Package Hint</div>
<% if (pkg.last_scan_hint) { %>
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
<p style="font-size: 0.8rem; color: var(--muted);">Left by <a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a></p>
<p style="font-size: 0.8rem; color: var(--muted);">Left by <a href="/player/<%= pkg.last_scanner_username %>"><%= pkg.last_scanner_name %></a></p>
<% } else { %>
<p style="color: var(--muted);">No hint has been left yet.</p>
<% } %>
@@ -121,7 +121,7 @@
<% scanHistory.forEach((scan, i) => { %>
<tr>
<td><%= i + 1 %></td>
<td><a href="/player/<%= scan.username %>"><%= scan.username %></a></td>
<td><a href="/player/<%= scan.username %>"><%= scan.display_name %></a></td>
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
<td style="font-size: 0.85rem; color: var(--muted);"><time datetime="<%= scan.scanned_at %>"><%= new Date(scan.scanned_at).toLocaleString() %></time></td>
</tr>

View File

@@ -35,7 +35,7 @@
<% } else if (currentUser.isOrganizer) { %>
<li><a href="/admin">Organizer</a></li>
<% } %>
<li><a href="/auth/logout">Logout (<%= currentUser.username %>)</a></li>
<li><a href="/auth/logout">Logout (<%= currentUser.displayName %>)</a></li>
<% } else { %>
<li><a href="/auth/login">Login</a></li>
<li><a href="/auth/register">Register</a></li>

View File

@@ -2,8 +2,8 @@
<div class="container">
<div style="text-align: center; margin-bottom: 1.5rem;">
<h1 style="margin-bottom: 0.25rem;">&#x1F464; <%= profile.username %></h1>
<p style="color: var(--muted); margin: 0;">Joined <%= new Date(profile.created_at).toLocaleDateString() %></p>
<h1 style="margin-bottom: 0.25rem;">&#x1F464; <%= profile.display_name || profile.username %></h1>
<p style="color: var(--muted); margin: 0;"><%= profile.username %> &middot; Joined <%= new Date(profile.created_at).toLocaleDateString() %></p>
</div>
<div class="stats-row">
@@ -88,6 +88,17 @@
</div>
<% if (typeof isOwnProfile !== 'undefined' && isOwnProfile) { %>
<div class="card" style="margin-top: 1.5rem;">
<div class="card-header">&#x270F;&#xFE0F; Display Name</div>
<p style="color: var(--muted); font-size: 0.9rem;">This is the name shown on leaderboards, scan history, and your profile. Your username (<strong><%= profile.username %></strong>) is used for login and URLs.</p>
<form method="POST" action="/player/<%= profile.username %>/display-name">
<div class="form-group">
<input type="text" name="display_name" class="form-control" value="<%= profile.display_name || profile.username %>" required maxlength="32" minlength="2" placeholder="Display Name">
</div>
<button type="submit" class="btn btn-primary btn-sm">Update Display Name</button>
</form>
</div>
<div class="card" style="margin-top: 1.5rem;">
<div class="card-header">&#x1F512; Change Password</div>
<form method="POST" action="/player/<%= profile.username %>/password">