feat: organizer application system
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 30s
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 30s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -63,6 +63,32 @@
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (typeof applications !== 'undefined' && applications && applications.length > 0) { %>
|
||||
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">📥 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>
|
||||
|
||||
@@ -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;">🛡️</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. 🏆</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. 🏆</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">🎯 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">✏️ 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>
|
||||
|
||||
Reference in New Issue
Block a user