diff --git a/src/config/database.js b/src/config/database.js index 03a5384..5668f26 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -214,6 +214,25 @@ 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 complaint_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hunt_id INTEGER NOT NULL, + package_id INTEGER NOT NULL, + reported_by_user_id INTEGER, + reporter_name TEXT, + reporter_contact TEXT, + message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + resolution_note TEXT, + reviewed_by INTEGER, + reviewed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (hunt_id) REFERENCES hunts(id) ON DELETE CASCADE, + FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE, + FOREIGN KEY (reported_by_user_id) REFERENCES users(id), + FOREIGN KEY (reviewed_by) REFERENCES users(id) + ); `); // Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all @@ -227,7 +246,9 @@ const ready = new Promise(resolve => { _readyResolve = resolve; }); 'CREATE INDEX IF NOT EXISTS idx_hunts_short_name ON hunts(short_name)', 'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)', 'CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)', - 'CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens(expires_at)' + 'CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens(expires_at)', + 'CREATE INDEX IF NOT EXISTS idx_complaints_hunt_status ON complaint_reports(hunt_id, status)', + 'CREATE INDEX IF NOT EXISTS idx_complaints_created ON complaint_reports(created_at)' ]; for (const idx of indexes) { try { _db.run(idx); } catch (e) { /* index may already exist */ } diff --git a/src/models/index.js b/src/models/index.js index 8acdee7..f728117 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -534,4 +534,62 @@ const OrganizerApplications = { } }; -module.exports = { Users, Hunts, Packages, Scans, OrganizerApplications, generateCode }; +// ─── Complaint Reports ─────────────────────────────────── +const ComplaintReports = { + submit({ huntId, packageId, reportedByUserId, reporterName, reporterContact, message }) { + return db.prepare(` + INSERT INTO complaint_reports + (hunt_id, package_id, reported_by_user_id, reporter_name, reporter_contact, message) + VALUES (?, ?, ?, ?, ?, ?) + `).run(huntId, packageId, reportedByUserId || null, reporterName || null, reporterContact || null, message).lastInsertRowid; + }, + + getOpenByHunt(huntId) { + return db.prepare(` + SELECT c.*, p.card_number, + COALESCE(u.display_name, u.username) as reported_by_name, + u.username as reported_by_username + FROM complaint_reports c + JOIN packages p ON c.package_id = p.id + LEFT JOIN users u ON c.reported_by_user_id = u.id + WHERE c.hunt_id = ? AND c.status = 'open' + ORDER BY c.created_at ASC + `).all(huntId); + }, + + getOpenForAdmin() { + return db.prepare(` + SELECT c.*, p.card_number, h.name as hunt_name, h.short_name as hunt_short_name, + COALESCE(u.display_name, u.username) as reported_by_name, + u.username as reported_by_username + FROM complaint_reports c + JOIN packages p ON c.package_id = p.id + JOIN hunts h ON c.hunt_id = h.id + LEFT JOIN users u ON c.reported_by_user_id = u.id + WHERE c.status = 'open' + ORDER BY c.created_at ASC + `).all(); + }, + + findById(id) { + return db.prepare('SELECT * FROM complaint_reports WHERE id = ?').get(id); + }, + + resolve(id, reviewedBy, resolutionNote) { + db.prepare(` + UPDATE complaint_reports + SET status = 'resolved', reviewed_by = ?, reviewed_at = datetime('now'), resolution_note = ? + WHERE id = ? + `).run(reviewedBy, resolutionNote || null, id); + }, + + dismiss(id, reviewedBy, resolutionNote) { + db.prepare(` + UPDATE complaint_reports + SET status = 'dismissed', reviewed_by = ?, reviewed_at = datetime('now'), resolution_note = ? + WHERE id = ? + `).run(reviewedBy, resolutionNote || null, id); + } +}; + +module.exports = { Users, Hunts, Packages, Scans, OrganizerApplications, ComplaintReports, generateCode }; diff --git a/src/routes/admin.js b/src/routes/admin.js index 66615b6..52bb145 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, OrganizerApplications } = require('../models'); +const { Hunts, Packages, Users, OrganizerApplications, ComplaintReports } = require('../models'); const { generateHuntPDF } = require('../utils/pdf'); // Helper: check if user owns this hunt (or is admin) @@ -27,7 +27,8 @@ router.get('/', (req, res) => { // Only admins see the full user list and password reset const users = isAdmin ? Users.getAllUsers() : []; const applications = isAdmin ? OrganizerApplications.getPending() : []; - res.render('admin/dashboard', { title: isAdmin ? 'Admin Dashboard' : 'Organizer Dashboard', hunts, users, applications, resetUrl: null, resetUsername: null, isAdmin }); + const complaints = isAdmin ? ComplaintReports.getOpenForAdmin() : []; + res.render('admin/dashboard', { title: isAdmin ? 'Admin Dashboard' : 'Organizer Dashboard', hunts, users, applications, complaints, resetUrl: null, resetUsername: null, isAdmin }); }); // Create hunt form @@ -90,7 +91,8 @@ router.get('/hunts/:id', requireHuntAccess, (req, res) => { const packages = Packages.getByHunt(hunt.id); const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`; const stats = Hunts.getStats(hunt.id); - res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages, baseUrl, stats }); + const complaints = ComplaintReports.getOpenByHunt(hunt.id); + res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages, baseUrl, stats, complaints }); }); // Edit hunt form @@ -163,6 +165,33 @@ router.post('/hunts/:id/packages/:pkgId/reroll', requireHuntAccess, (req, res) = res.redirect(`/admin/hunts/${hunt.id}`); }); +// ─── Complaint moderation ───────────────────────────────── +router.post('/hunts/:id/complaints/:complaintId/resolve', requireHuntAccess, (req, res) => { + const hunt = req.hunt; + const complaint = ComplaintReports.findById(parseInt(req.params.complaintId, 10)); + if (!complaint || complaint.hunt_id !== hunt.id || complaint.status !== 'open') { + req.session.flash = { type: 'danger', message: 'Complaint not found.' }; + return res.redirect(`/admin/hunts/${hunt.id}`); + } + const note = (req.body.note || '').trim(); + ComplaintReports.resolve(complaint.id, req.session.userId, note || 'Organizer confirmed and handled.'); + req.session.flash = { type: 'success', message: 'Complaint marked as resolved.' }; + res.redirect(`/admin/hunts/${hunt.id}`); +}); + +router.post('/hunts/:id/complaints/:complaintId/dismiss', requireHuntAccess, (req, res) => { + const hunt = req.hunt; + const complaint = ComplaintReports.findById(parseInt(req.params.complaintId, 10)); + if (!complaint || complaint.hunt_id !== hunt.id || complaint.status !== 'open') { + req.session.flash = { type: 'danger', message: 'Complaint not found.' }; + return res.redirect(`/admin/hunts/${hunt.id}`); + } + const note = (req.body.note || '').trim(); + ComplaintReports.dismiss(complaint.id, req.session.userId, note || 'Dismissed after review.'); + req.session.flash = { type: 'success', message: 'Complaint dismissed.' }; + res.redirect(`/admin/hunts/${hunt.id}`); +}); + // ─── Manage user roles (admin only) ─────────────────────── router.post('/users/:id/role', requireAdmin, (req, res) => { const userId = parseInt(req.params.id, 10); diff --git a/src/routes/hunts.js b/src/routes/hunts.js index 1480fd0..7ad8e11 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, OrganizerApplications } = require('../models'); +const { Hunts, Packages, Scans, Users, OrganizerApplications, ComplaintReports } = require('../models'); // ─── Hunt profile ───────────────────────────────────────── router.get('/hunt/:shortName', (req, res) => { @@ -80,6 +80,60 @@ router.get('/hunt/:shortName/:cardNumber', (req, res) => { }); }); +// ─── Report package complaint ───────────────────────────── +router.get('/hunt/:shortName/:cardNumber/report', (req, res) => { + const { shortName, cardNumber } = req.params; + if (!/^\d+$/.test(cardNumber)) { + return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' }); + } + const pkg = Packages.findByHuntAndCardNumber(shortName, cardNumber); + if (!pkg) { + return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' }); + } + res.render('loot/report-complaint', { + title: `Report Issue - Package ${pkg.card_number}`, + pkg, + namePrefill: req.session && req.session.displayName ? req.session.displayName : '', + contactPrefill: '' + }); +}); + +router.post('/hunt/:shortName/:cardNumber/report', (req, res) => { + const { shortName, cardNumber } = req.params; + if (!/^\d+$/.test(cardNumber)) { + return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' }); + } + const pkg = Packages.findByHuntAndCardNumber(shortName, cardNumber); + if (!pkg) { + return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' }); + } + + const reporterName = (req.body.reporter_name || '').trim(); + const reporterContact = (req.body.reporter_contact || '').trim(); + const message = (req.body.message || '').trim(); + + if (!message || message.length < 10) { + req.session.flash = { type: 'danger', message: 'Please include a short description (at least 10 characters).' }; + return res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}/report`); + } + if (message.length > 2000) { + req.session.flash = { type: 'danger', message: 'Complaint is too long (max 2000 characters).' }; + return res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}/report`); + } + + ComplaintReports.submit({ + huntId: pkg.hunt_id, + packageId: pkg.id, + reportedByUserId: req.session && req.session.userId ? req.session.userId : null, + reporterName: reporterName || null, + reporterContact: reporterContact || null, + message + }); + + req.session.flash = { type: 'success', message: 'Thanks for reporting this. The organizer will review it and take action if needed.' }; + res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}`); +}); + // ─── User profile ───────────────────────────────────────── router.get('/player/:username', (req, res) => { const user = Users.findByUsername(req.params.username); diff --git a/src/views/admin/dashboard.ejs b/src/views/admin/dashboard.ejs index 0d597a1..b360362 100644 --- a/src/views/admin/dashboard.ejs +++ b/src/views/admin/dashboard.ejs @@ -89,6 +89,35 @@ <% } %> + <% if (typeof complaints !== 'undefined' && complaints && complaints.length > 0) { %> +

🚩 Open Complaints <%= complaints.length %>

+
+ <% complaints.forEach(c => { %> +
+
+
+
+ <%= c.hunt_name %> + · Package #<%= c.card_number %> +
+
+ <% if (c.reported_by_name) { %> + by <%= c.reported_by_name %> + <% } else if (c.reporter_name) { %> + by <%= c.reporter_name %> + <% } else { %> + by anonymous reporter + <% } %> + · +
+
+ Review in Hunt +
+
+ <% }) %> +
+ <% } %> +

Manage Roles

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

diff --git a/src/views/admin/manage-hunt.ejs b/src/views/admin/manage-hunt.ejs index d5c45cf..a4946ac 100644 --- a/src/views/admin/manage-hunt.ejs +++ b/src/views/admin/manage-hunt.ejs @@ -97,6 +97,43 @@
<% } %> + <% if (typeof complaints !== 'undefined' && complaints && complaints.length > 0) { %> +

🚩 Open Complaints <%= complaints.length %>

+
+ <% complaints.forEach(c => { %> +
+
+
+
Package #<%= c.card_number %>
+
+ <% if (c.reported_by_name) { %> + Reporter: <%= c.reported_by_name %> + <% } else if (c.reporter_name) { %> + Reporter: <%= c.reporter_name %> + <% } else { %> + Reporter: anonymous + <% } %> + <% if (c.reporter_contact) { %> · Contact: <%= c.reporter_contact %><% } %> +
+
+

<%= c.message %>

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

All Packages

diff --git a/src/views/loot/profile.ejs b/src/views/loot/profile.ejs index 5f3c276..b3a92cb 100644 --- a/src/views/loot/profile.ejs +++ b/src/views/loot/profile.ejs @@ -119,6 +119,7 @@
All Packages Leaderboard + Report an Issue
diff --git a/src/views/loot/report-complaint.ejs b/src/views/loot/report-complaint.ejs new file mode 100644 index 0000000..1439952 --- /dev/null +++ b/src/views/loot/report-complaint.ejs @@ -0,0 +1,44 @@ +<%- include('../partials/header') %> + +
+
+ ← Back to Package #<%= pkg.card_number %> +
+ +

Report an Issue

+

Hunt: <%= pkg.hunt_name %> · Package #<%= pkg.card_number %>

+ +
+
Sustainability & Respect for Shared Spaces
+

Loot Hunt is meant to be fun without leaving a mess behind. We aim for zero litter, minimal waste, and respectful placement of materials.

+

If a card is in a bad spot, damaged, creating litter, or causing concern, you are welcome to remove it and recycle it. Thank you for helping keep the game clean and considerate.

+
+ +
+

Formal complaints are reviewed by the hunt organizer/admin and taken seriously. If valid, they should relocate or remove the package.

+
+
+ + +
Please include enough detail for the organizer to verify and fix the issue.
+
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+
+ +<%- include('../partials/footer') %>