diff --git a/src/config/database.js b/src/config/database.js index 35b6381..517920f 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -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 diff --git a/src/models/index.js b/src/models/index.js index b395e68..78a10e3 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -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); + } +}; diff --git a/src/routes/admin.js b/src/routes/admin.js index 331f5d7..8ede8a9 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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); diff --git a/src/routes/hunts.js b/src/routes/hunts.js index 62bca30..e9deecc 100644 --- a/src/routes/hunts.js +++ b/src/routes/hunts.js @@ -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); diff --git a/src/views/admin/dashboard.ejs b/src/views/admin/dashboard.ejs index 18d4407..0d597a1 100644 --- a/src/views/admin/dashboard.ejs +++ b/src/views/admin/dashboard.ejs @@ -63,6 +63,32 @@ <% } %> + <% if (typeof applications !== 'undefined' && applications && applications.length > 0) { %> +

📥 Organizer Applications <%= applications.length %>

+
+ <% applications.forEach(app => { %> +
+
+
+ <%= app.display_name %> + (<%= app.username %>) +
+
+
+
+ +
+
+ +
+
+
+

<%= app.reason %>

+
+ <% }) %> +
+ <% } %> +

Manage Roles

Grant or revoke the Organizer role. Organizers can create hunts and manage their own hunts only.

diff --git a/src/views/home.ejs b/src/views/home.ejs index 0da3d35..8375b5d 100644 --- a/src/views/home.ejs +++ b/src/views/home.ejs @@ -14,34 +14,13 @@
-
-
-
-
500
-
1st Find
-
-
-
250
-
2nd Find
-
-
-
100
-
3rd Find
-
-
-
50
-
4th+ Finds
-
-
-
-
🛡️
Scan QR Codes Safely -

Not all QR codes can be trusted. Use our built-in QR scanner to safely scan codes and warn you about invalid ones - at least as far as collecting points here is concerned. 🏆

+

Not all QR codes can be trusted. Use our built-in QR scanner (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. 🏆

diff --git a/src/views/player/profile.ejs b/src/views/player/profile.ejs index 047a43c..001603f 100644 --- a/src/views/player/profile.ejs +++ b/src/views/player/profile.ejs @@ -88,6 +88,25 @@
<% if (typeof isOwnProfile !== 'undefined' && isOwnProfile) { %> + <% if (!profile.is_organizer && !profile.is_admin) { %> +
+
🎯 Become an Organizer
+ <% if (typeof pendingApplication !== 'undefined' && pendingApplication) { %> +

Your application is pending review. Hang tight!

+

<%= pendingApplication.reason %>

+ <% } else { %> +

Organizers can create hunts, generate QR codes, and manage their own events. Tell us why you'd like to become one!

+
+
+ + +
+ +
+ <% } %> +
+ <% } %> +
✏️ Display Name

This is the name shown on leaderboards, scan history, and your profile. Your username (<%= profile.username %>) is used for login and URLs.