feat: organizer application system
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 30s

This commit is contained in:
2026-03-20 21:49:00 -04:00
parent 4dd3ada4e3
commit 7069dd7145
7 changed files with 152 additions and 27 deletions

View File

@@ -206,6 +206,14 @@ const ready = new Promise(resolve => { _readyResolve = resolve; });
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS organizer_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);
// Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all

View File

@@ -159,6 +159,7 @@ const Users = {
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 organizer_applications WHERE user_id = ?').run(userId);
db.prepare("DELETE FROM sessions WHERE sess LIKE ?").run('%"userId":' + userId + '%');
},
@@ -171,6 +172,7 @@ const Users = {
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 organizer_applications 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);
},
@@ -488,4 +490,36 @@ const Scans = {
}
};
module.exports = { Users, Hunts, Packages, Scans, generateCode };
module.exports = { Users, Hunts, Packages, Scans, OrganizerApplications, generateCode };
// ─── Organizer Applications ──────────────────────────────
const OrganizerApplications = {
submit(userId, reason) {
db.prepare('INSERT INTO organizer_applications (user_id, reason) VALUES (?, ?)').run(userId, reason);
},
getPending() {
return db.prepare(`
SELECT oa.*, COALESCE(u.display_name, u.username) as display_name, u.username
FROM organizer_applications oa
JOIN users u ON oa.user_id = u.id
ORDER BY oa.created_at ASC
`).all();
},
findByUser(userId) {
return db.prepare('SELECT * FROM organizer_applications WHERE user_id = ?').get(userId);
},
findById(id) {
return db.prepare('SELECT * FROM organizer_applications WHERE id = ?').get(id);
},
delete(id) {
db.prepare('DELETE FROM organizer_applications WHERE id = ?').run(id);
},
deleteByUser(userId) {
db.prepare('DELETE FROM organizer_applications WHERE user_id = ?').run(userId);
}
};

View File

@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { requireAdmin, requireOrganizerOrAdmin } = require('../middleware/auth');
const { Hunts, Packages, Users } = require('../models');
const { Hunts, Packages, Users, OrganizerApplications } = require('../models');
const { generateHuntPDF } = require('../utils/pdf');
// Helper: check if user owns this hunt (or is admin)
@@ -26,7 +26,8 @@ router.get('/', (req, res) => {
// Only admins see the full user list and password reset
const users = isAdmin ? Users.getAllUsers() : [];
res.render('admin/dashboard', { title: isAdmin ? 'Admin Dashboard' : 'Organizer Dashboard', hunts, users, resetUrl: null, resetUsername: null, isAdmin });
const applications = isAdmin ? OrganizerApplications.getPending() : [];
res.render('admin/dashboard', { title: isAdmin ? 'Admin Dashboard' : 'Organizer Dashboard', hunts, users, applications, resetUrl: null, resetUsername: null, isAdmin });
});
// Create hunt form
@@ -172,6 +173,32 @@ router.post('/users/:id/role', requireAdmin, (req, res) => {
res.redirect('/admin');
});
// ─── Approve organizer application ────────────────────────
router.post('/applications/:id/approve', requireAdmin, (req, res) => {
const app = OrganizerApplications.findById(parseInt(req.params.id, 10));
if (!app) {
req.session.flash = { type: 'danger', message: 'Application not found.' };
return res.redirect('/admin');
}
Users.makeOrganizer(app.user_id);
OrganizerApplications.delete(app.id);
const user = Users.findById(app.user_id);
req.session.flash = { type: 'success', message: `${user ? user.display_name || user.username : 'User'} is now an Organizer!` };
res.redirect('/admin');
});
// ─── Deny organizer application ──────────────────────────
router.post('/applications/:id/deny', requireAdmin, (req, res) => {
const app = OrganizerApplications.findById(parseInt(req.params.id, 10));
if (!app) {
req.session.flash = { type: 'danger', message: 'Application not found.' };
return res.redirect('/admin');
}
OrganizerApplications.delete(app.id);
req.session.flash = { type: 'success', message: 'Application denied.' };
res.redirect('/admin');
});
// ─── Delete user account (admin only) ─────────────────────
router.post('/users/:id/delete', requireAdmin, (req, res) => {
const userId = parseInt(req.params.id, 10);

View File

@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { requireAuth } = require('../middleware/auth');
const { Hunts, Packages, Scans, Users } = require('../models');
const { Hunts, Packages, Scans, Users, OrganizerApplications } = require('../models');
// ─── Hunt profile ─────────────────────────────────────────
router.get('/hunt/:shortName', (req, res) => {
@@ -94,6 +94,7 @@ router.get('/player/:username', (req, res) => {
const totalPlayers = Users.getTotalPlayerCount();
const isOwnProfile = req.session && req.session.userId === user.id;
const pendingApplication = isOwnProfile ? OrganizerApplications.findByUser(user.id) : null;
res.render('player/profile', {
title: `${user.username}'s Profile`,
@@ -102,7 +103,8 @@ router.get('/player/:username', (req, res) => {
huntBreakdown,
rank,
totalPlayers,
isOwnProfile
isOwnProfile,
pendingApplication
});
});
@@ -155,6 +157,36 @@ router.post('/player/:username/display-name', requireAuth, (req, res) => {
res.redirect(`/player/${user.username}`);
});
// ─── Apply to become organizer ────────────────────────────
router.post('/player/:username/apply-organizer', 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 submit your own application.' });
}
if (user.is_organizer || user.is_admin) {
req.session.flash = { type: 'info', message: 'You already have organizer access.' };
return res.redirect(`/player/${user.username}`);
}
if (OrganizerApplications.findByUser(user.id)) {
req.session.flash = { type: 'info', message: 'You already have a pending application.' };
return res.redirect(`/player/${user.username}`);
}
const reason = (req.body.reason || '').trim();
if (!reason || reason.length < 10) {
req.session.flash = { type: 'danger', message: 'Please provide a reason (at least 10 characters).' };
return res.redirect(`/player/${user.username}`);
}
if (reason.length > 1000) {
req.session.flash = { type: 'danger', message: 'Reason is too long (max 1000 characters).' };
return res.redirect(`/player/${user.username}`);
}
OrganizerApplications.submit(user.id, reason);
req.session.flash = { type: 'success', message: 'Your organizer application has been submitted!' };
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

@@ -63,6 +63,32 @@
<% } %>
</div>
<% if (typeof applications !== 'undefined' && applications && applications.length > 0) { %>
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">&#x1F4E5; Organizer Applications <span class="badge" style="font-size: 0.75rem; vertical-align: middle;"><%= applications.length %></span></h2>
<div class="card">
<% applications.forEach(app => { %>
<div style="padding: 1rem 0; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 0.5rem;">
<div style="flex: 1; min-width: 200px;">
<strong><a href="/player/<%= app.username %>" style="color: var(--primary);"><%= app.display_name %></a></strong>
<span style="color: var(--muted); font-size: 0.8rem;">(<%= app.username %>)</span>
<div style="font-size: 0.8rem; color: var(--muted);"><time datetime="<%= app.created_at %>"><%= new Date(app.created_at).toLocaleString() %></time></div>
</div>
<div style="display: flex; gap: 0.5rem;">
<form method="POST" action="/admin/applications/<%= app.id %>/approve" style="margin:0;">
<button type="submit" class="btn btn-sm btn-success">Approve</button>
</form>
<form method="POST" action="/admin/applications/<%= app.id %>/deny" style="margin:0;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Deny this application?')">Deny</button>
</form>
</div>
</div>
<p style="margin: 0.5rem 0 0; font-size: 0.9rem; padding: 0.75rem; background: var(--body-bg); border-radius: 6px;"><%= app.reason %></p>
</div>
<% }) %>
</div>
<% } %>
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Manage Roles</h2>
<div class="card">
<p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 1rem;">Grant or revoke the <strong>Organizer</strong> role. Organizers can create hunts and manage their own hunts only.</p>

View File

@@ -14,34 +14,13 @@
</div>
</div>
<div style="margin-top: 1rem;">
<div class="stats-row">
<div class="stat-box">
<div class="value">500</div>
<div class="label">1st Find</div>
</div>
<div class="stat-box">
<div class="value">250</div>
<div class="label">2nd Find</div>
</div>
<div class="stat-box">
<div class="value">100</div>
<div class="label">3rd Find</div>
</div>
<div class="stat-box">
<div class="value">50</div>
<div class="label">4th+ Finds</div>
</div>
</div>
</div>
<div style="margin-top: 3rem;">
<div class="scanner-disclaimer">
<div style="display: flex; align-items: flex-start; gap: 0.75rem;">
<span style="font-size: 1.5rem; flex-shrink: 0;">&#x1F6E1;&#xFE0F;</span>
<div>
<strong>Scan QR Codes Safely</strong>
<p style="margin: 0.25rem 0 0.5rem; color: var(--muted); font-size: 0.9rem;">Not all QR codes can be trusted. Use our <a href="/scanner" style="color: var(--primary); font-weight: 600;">built-in QR scanner</a> to safely scan codes and warn you about invalid ones - at least as far as collecting points here is concerned. &#x1F3C6;</p>
<p style="margin: 0.25rem 0 0.5rem; color: var(--muted); font-size: 0.9rem;">Not all QR codes can be trusted. Use our <a href="/scanner" style="color: var(--primary); font-weight: 600;">built-in QR scanner</a> (quick scan button located in the lower right of the screen) to safely scan codes and warn you about invalid ones - at least as far as collecting points here is concerned. &#x1F3C6;</p>
</div>
</div>
</div>

View File

@@ -88,6 +88,25 @@
</div>
<% if (typeof isOwnProfile !== 'undefined' && isOwnProfile) { %>
<% if (!profile.is_organizer && !profile.is_admin) { %>
<div class="card" style="margin-top: 1.5rem;">
<div class="card-header">&#x1F3AF; Become an Organizer</div>
<% if (typeof pendingApplication !== 'undefined' && pendingApplication) { %>
<p style="color: var(--muted); font-size: 0.9rem;">Your application is pending review. Hang tight!</p>
<p style="font-size: 0.85rem; padding: 0.75rem; background: var(--body-bg); border-radius: 6px; color: var(--muted);"><%= pendingApplication.reason %></p>
<% } else { %>
<p style="color: var(--muted); font-size: 0.9rem;">Organizers can create hunts, generate QR codes, and manage their own events. Tell us why you'd like to become one!</p>
<form method="POST" action="/player/<%= profile.username %>/apply-organizer">
<div class="form-group">
<label for="reason">Why do you want to become an organizer?</label>
<textarea id="reason" name="reason" class="form-control" rows="4" required minlength="10" maxlength="1000" placeholder="I'd like to organize a hunt because..."></textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm">Submit Application</button>
</form>
<% } %>
</div>
<% } %>
<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>