feat: add complaint reporting and management system
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m11s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m11s
This commit is contained in:
+22
-1
@@ -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 */ }
|
||||
|
||||
+59
-1
@@ -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 };
|
||||
|
||||
+32
-3
@@ -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);
|
||||
|
||||
+55
-1
@@ -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);
|
||||
|
||||
@@ -89,6 +89,35 @@
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (typeof complaints !== 'undefined' && complaints && complaints.length > 0) { %>
|
||||
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">🚩 Open Complaints <span class="badge" style="font-size: 0.75rem; vertical-align: middle;"><%= complaints.length %></span></h2>
|
||||
<div class="card">
|
||||
<% complaints.forEach(c => { %>
|
||||
<div style="padding: 1rem 0; border-bottom: 1px solid var(--border-color);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<div style="flex: 1; min-width: 220px;">
|
||||
<div style="font-weight: 700;">
|
||||
<a href="/admin/hunts/<%= c.hunt_id %>" style="color: var(--primary);"><%= c.hunt_name %></a>
|
||||
· Package #<%= c.card_number %>
|
||||
</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted);">
|
||||
<% if (c.reported_by_name) { %>
|
||||
by <a href="/player/<%= c.reported_by_username %>"><%= c.reported_by_name %></a>
|
||||
<% } else if (c.reporter_name) { %>
|
||||
by <%= c.reporter_name %>
|
||||
<% } else { %>
|
||||
by anonymous reporter
|
||||
<% } %>
|
||||
· <time datetime="<%= c.created_at %>"><%= new Date(c.created_at).toLocaleString() %></time>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/hunts/<%= c.hunt_id %>" class="btn btn-sm btn-outline">Review in Hunt</a>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -97,6 +97,43 @@
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (typeof complaints !== 'undefined' && complaints && complaints.length > 0) { %>
|
||||
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">🚩 Open Complaints <span class="badge" style="font-size: 0.75rem; vertical-align: middle;"><%= complaints.length %></span></h2>
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<% complaints.forEach(c => { %>
|
||||
<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.75rem;">
|
||||
<div style="flex: 1; min-width: 240px;">
|
||||
<div style="font-weight: 700;">Package #<%= c.card_number %></div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted);">
|
||||
<% if (c.reported_by_name) { %>
|
||||
Reporter: <a href="/player/<%= c.reported_by_username %>"><%= c.reported_by_name %></a>
|
||||
<% } else if (c.reporter_name) { %>
|
||||
Reporter: <%= c.reporter_name %>
|
||||
<% } else { %>
|
||||
Reporter: anonymous
|
||||
<% } %>
|
||||
<% if (c.reporter_contact) { %> · Contact: <%= c.reporter_contact %><% } %>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 0.2rem;"><time datetime="<%= c.created_at %>"><%= new Date(c.created_at).toLocaleString() %></time></div>
|
||||
<p style="margin: 0.6rem 0 0; font-size: 0.95rem; background: var(--body-bg); border-radius: 6px; padding: 0.75rem;"><%= c.message %></p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<form method="POST" action="/admin/hunts/<%= hunt.id %>/complaints/<%= c.id %>/resolve" style="margin:0; display:flex; gap:0.4rem; align-items:center;">
|
||||
<input type="text" name="note" class="form-control" style="max-width: 220px;" placeholder="Optional resolution note">
|
||||
<button type="submit" class="btn btn-sm btn-success">Resolve</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/hunts/<%= hunt.id %>/complaints/<%= c.id %>/dismiss" style="margin:0; display:flex; gap:0.4rem; align-items:center;">
|
||||
<input type="text" name="note" class="form-control" style="max-width: 220px;" placeholder="Optional dismissal note">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Dismiss this complaint?')">Dismiss</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">All Packages</h2>
|
||||
|
||||
<div class="table-wrapper">
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
<div style="text-align: center; margin-top: 1rem;">
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">All Packages</a>
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>/report" class="btn btn-outline">Report an Issue</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container" style="max-width: 780px;">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>" style="color: var(--muted); text-decoration: none;">← Back to Package #<%= pkg.card_number %></a>
|
||||
</div>
|
||||
|
||||
<h1 style="margin-bottom: 0.4rem;">Report an Issue</h1>
|
||||
<p style="color: var(--muted); margin-top: 0;">Hunt: <strong><%= pkg.hunt_name %></strong> · Package #<strong><%= pkg.card_number %></strong></p>
|
||||
|
||||
<div class="card" style="margin-top: 1rem;">
|
||||
<div class="card-header">Sustainability & Respect for Shared Spaces</div>
|
||||
<p>Loot Hunt is meant to be fun without leaving a mess behind. We aim for zero litter, minimal waste, and respectful placement of materials.</p>
|
||||
<p style="margin-bottom: 0;">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 1rem;">
|
||||
<p style="margin-top: 0;">Formal complaints are reviewed by the hunt organizer/admin and taken seriously. If valid, they should relocate or remove the package.</p>
|
||||
<form method="POST" action="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>/report">
|
||||
<div class="form-group">
|
||||
<label for="message">What happened?</label>
|
||||
<textarea id="message" name="message" class="form-control" rows="5" minlength="10" maxlength="2000" required placeholder="Example: Card was stapled in a place where posting is not allowed, or it became litter after weather damage."></textarea>
|
||||
<div class="form-hint">Please include enough detail for the organizer to verify and fix the issue.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="reporter_name">Your Name (optional)</label>
|
||||
<input id="reporter_name" type="text" name="reporter_name" class="form-control" maxlength="80" value="<%= namePrefill || '' %>" placeholder="Optional">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="reporter_contact">Contact Info (optional)</label>
|
||||
<input id="reporter_contact" type="text" name="reporter_contact" class="form-control" maxlength="120" value="<%= contactPrefill || '' %>" placeholder="Email or other contact">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<button type="submit" class="btn btn-danger">Submit Complaint</button>
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>" class="btn btn-outline">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
Reference in New Issue
Block a user