Database changes: - Added ContactMessage model to Prisma schema - Fields: userId, username, firstName, lastName, email, subject, message, status, ipAddress - Status enum: new, read, resolved - Relation to User model Backend changes: - Added POST /api/public/contact endpoint for form submissions - Works for both authenticated and non-authenticated users - Validation for email, subject (3-255 chars), message (10-5000 chars) - Activity logging for submissions - Added admin endpoints: - GET /api/admin/contact-messages - list with filtering by status - GET /api/admin/contact-messages/:id - view single message (auto-marks as read) - PATCH /api/admin/contact-messages/:id/status - update status - DELETE /api/admin/contact-messages/:id - delete message Frontend changes: - Created ContactPage at /contact route - For non-logged-in users: firstName, lastName, email, subject, message fields - For logged-in users: auto-fills username, shows only email, subject, message - Character counter for message (max 5000) - Success screen with auto-redirect to homepage - Created ContactMessagesPage at /admin/contact-messages - Two-column layout: message list + detail view - Filter by status (all, new, read, resolved) - View message details with sender info and IP address - Update status and delete messages - Added admin dropdown menu to Navbar - Desktop: dropdown with Activity Logs and Contact Messages - Mobile: expandable submenu - Click outside to close on desktop - ChevronDown icon rotates when open Note: CAPTCHA integration planned for future enhancement
499 lines
14 KiB
JavaScript
499 lines
14 KiB
JavaScript
const express = require('express');
|
|
const { prisma } = require('../utils/db');
|
|
const { authenticate } = require('../middleware/auth');
|
|
const { requireAdmin } = require('../middleware/admin');
|
|
const matchingService = require('../services/matching');
|
|
const { SUGGESTION_STATUS } = require('../constants');
|
|
const { ACTIONS, log: activityLog, queryLogs, getActionTypes, getStats } = require('../services/activityLog');
|
|
const { getClientIP } = require('../utils/request');
|
|
|
|
const router = express.Router();
|
|
|
|
// POST /api/admin/events/:slug/run-now - Trigger matching immediately for an event
|
|
router.post('/events/:slug/run-now', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
|
|
const event = await prisma.event.findUnique({
|
|
where: { slug },
|
|
select: { id: true, slug: true },
|
|
});
|
|
|
|
if (!event) {
|
|
return res.status(404).json({ success: false, error: 'Event not found' });
|
|
}
|
|
|
|
const startedAt = new Date();
|
|
const runRow = await prisma.matchingRun.create({
|
|
data: {
|
|
eventId: event.id,
|
|
trigger: 'manual',
|
|
status: 'running',
|
|
startedAt,
|
|
},
|
|
});
|
|
|
|
try {
|
|
const suggestions = await matchingService.runMatching(event.id);
|
|
await matchingService.saveMatchingResults(event.id, suggestions, runRow.id);
|
|
|
|
const notFoundCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.NOT_FOUND).length;
|
|
const matchedCount = suggestions.filter(s => s.status === SUGGESTION_STATUS.PENDING).length;
|
|
|
|
await prisma.matchingRun.update({
|
|
where: { id: runRow.id },
|
|
data: {
|
|
status: 'success',
|
|
endedAt: new Date(),
|
|
matchedCount,
|
|
notFoundCount,
|
|
},
|
|
});
|
|
|
|
// Log admin matching run activity
|
|
activityLog({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
ipAddress: getClientIP(req),
|
|
action: ACTIONS.ADMIN_MATCHING_RUN,
|
|
resource: `event:${event.id}`,
|
|
method: req.method,
|
|
path: req.path,
|
|
metadata: {
|
|
eventSlug: event.slug,
|
|
runId: runRow.id,
|
|
matchedCount,
|
|
notFoundCount,
|
|
},
|
|
});
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
eventSlug: event.slug,
|
|
startedAt,
|
|
endedAt: new Date(),
|
|
matched: matchedCount,
|
|
notFound: notFoundCount,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
await prisma.matchingRun.update({
|
|
where: { id: runRow.id },
|
|
data: {
|
|
status: 'error',
|
|
endedAt: new Date(),
|
|
error: String(err?.message || err),
|
|
},
|
|
});
|
|
return res.status(500).json({ success: false, error: 'Matching failed', details: String(err?.message || err) });
|
|
}
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// GET /api/admin/events/:slug/matching-runs?limit=20 - List recent runs
|
|
router.get('/events/:slug/matching-runs', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
|
|
|
|
const event = await prisma.event.findUnique({
|
|
where: { slug },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!event) {
|
|
return res.status(404).json({ success: false, error: 'Event not found' });
|
|
}
|
|
|
|
const runs = await prisma.matchingRun.findMany({
|
|
where: { eventId: event.id },
|
|
orderBy: { startedAt: 'desc' },
|
|
take: limit,
|
|
select: {
|
|
id: true,
|
|
trigger: true,
|
|
status: true,
|
|
startedAt: true,
|
|
endedAt: true,
|
|
matchedCount: true,
|
|
notFoundCount: true,
|
|
error: true,
|
|
},
|
|
});
|
|
|
|
// Aggregate fresh stats per run from recording_suggestions (origin_run_id)
|
|
// Cheap and valuable: shows actual created pairs in this run.
|
|
if (runs.length > 0) {
|
|
const runIds = runs.map(r => r.id);
|
|
// Single SQL query for all listed runs (using parameterized query to prevent SQL injection)
|
|
const aggRows = await prisma.$queryRaw`
|
|
SELECT origin_run_id AS "originRunId",
|
|
COUNT(*)::int AS "totalSuggestions",
|
|
COUNT(*) FILTER (WHERE recorder_id IS NOT NULL)::int AS "assignedCount",
|
|
COUNT(*) FILTER (WHERE status = 'not_found')::int AS "notFoundCount"
|
|
FROM recording_suggestions
|
|
WHERE event_id = ${event.id} AND origin_run_id = ANY(${runIds})
|
|
GROUP BY origin_run_id
|
|
`;
|
|
const aggByRun = new Map(aggRows.map(r => [r.originRunId, r]));
|
|
for (const r of runs) {
|
|
const agg = aggByRun.get(r.id) || { totalSuggestions: 0, assignedCount: 0, notFoundCount: 0 };
|
|
r.totalSuggestions = agg.totalSuggestions;
|
|
r.assignedCount = agg.assignedCount;
|
|
r.aggregatedNotFoundCount = agg.notFoundCount; // keep original notFoundCount for backward compat
|
|
}
|
|
}
|
|
|
|
res.json({ success: true, count: runs.length, data: runs });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
|
|
// GET /api/admin/events/:slug/matching-runs/:runId/suggestions - List suggestions created in this run
|
|
router.get('/events/:slug/matching-runs/:runId/suggestions', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const { slug, runId } = req.params;
|
|
const onlyAssigned = String(req.query.onlyAssigned || 'true') === 'true';
|
|
const includeNotFound = String(req.query.includeNotFound || 'false') === 'true';
|
|
const limit = Math.min(parseInt(req.query.limit || '100', 10), 200);
|
|
|
|
const event = await prisma.event.findUnique({ where: { slug }, select: { id: true } });
|
|
if (!event) {
|
|
return res.status(404).json({ success: false, error: 'Event not found' });
|
|
}
|
|
|
|
const run = await prisma.matchingRun.findFirst({
|
|
where: { id: Number(runId), eventId: event.id },
|
|
select: {
|
|
id: true,
|
|
trigger: true,
|
|
status: true,
|
|
startedAt: true,
|
|
endedAt: true,
|
|
matchedCount: true,
|
|
notFoundCount: true,
|
|
},
|
|
});
|
|
if (!run) {
|
|
return res.status(404).json({ success: false, error: 'Run not found for event' });
|
|
}
|
|
|
|
// Build where for suggestions in this run
|
|
const where = {
|
|
eventId: event.id,
|
|
originRunId: Number(runId),
|
|
};
|
|
|
|
if (onlyAssigned && !includeNotFound) {
|
|
// Only pairs with assigned recorder
|
|
where.recorderId = { not: null };
|
|
}
|
|
|
|
const suggestions = await prisma.recordingSuggestion.findMany({
|
|
where,
|
|
take: limit,
|
|
orderBy: { id: 'asc' },
|
|
include: {
|
|
heat: {
|
|
include: {
|
|
division: { select: { id: true, name: true, abbreviation: true } },
|
|
competitionType: { select: { id: true, name: true, abbreviation: true } },
|
|
user: { select: { id: true, username: true, avatar: true, city: true, country: true } },
|
|
},
|
|
},
|
|
recorder: { select: { id: true, username: true, avatar: true, city: true, country: true } },
|
|
},
|
|
});
|
|
|
|
const formatted = suggestions
|
|
.filter((s) => includeNotFound || s.status !== 'not_found')
|
|
.map((s) => {
|
|
const div = s.heat.division;
|
|
const ct = s.heat.competitionType;
|
|
const label = `${div?.name || ''} ${ct?.abbreviation || ct?.name || ''} #${s.heat.heatNumber}`.trim();
|
|
return {
|
|
id: s.id,
|
|
status: s.status,
|
|
heat: {
|
|
id: s.heat.id,
|
|
heatNumber: s.heat.heatNumber,
|
|
label,
|
|
division: div,
|
|
competitionType: ct,
|
|
},
|
|
dancer: s.heat.user,
|
|
recorder: s.recorder || null,
|
|
};
|
|
});
|
|
|
|
return res.json({ success: true, run, suggestions: formatted });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ==================================================================
|
|
// ACTIVITY LOGS ENDPOINTS
|
|
// ==================================================================
|
|
|
|
// GET /api/admin/activity-logs - Query activity logs with filters
|
|
router.get('/activity-logs', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const {
|
|
startDate,
|
|
endDate,
|
|
action,
|
|
category,
|
|
username,
|
|
userId,
|
|
success,
|
|
limit = 100,
|
|
offset = 0,
|
|
} = req.query;
|
|
|
|
// Parse query parameters
|
|
const filters = {
|
|
startDate: startDate ? new Date(startDate) : null,
|
|
endDate: endDate ? new Date(endDate) : null,
|
|
action: action || null,
|
|
category: category || null,
|
|
username: username || null,
|
|
userId: userId ? parseInt(userId) : null,
|
|
success: success !== undefined ? success === 'true' : null,
|
|
limit: parseInt(limit),
|
|
offset: parseInt(offset),
|
|
};
|
|
|
|
// Query logs
|
|
const result = await queryLogs(filters);
|
|
|
|
// Log admin viewing activity logs
|
|
activityLog({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
ipAddress: getClientIP(req),
|
|
action: ACTIONS.ADMIN_VIEW_LOGS,
|
|
method: req.method,
|
|
path: req.path,
|
|
metadata: {
|
|
filters: {
|
|
startDate,
|
|
endDate,
|
|
action,
|
|
category,
|
|
username,
|
|
userId,
|
|
success,
|
|
},
|
|
resultCount: result.logs.length,
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// GET /api/admin/activity-logs/actions - Get unique action types
|
|
router.get('/activity-logs/actions', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const actions = await getActionTypes();
|
|
|
|
res.json({
|
|
success: true,
|
|
count: actions.length,
|
|
data: actions,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// GET /api/admin/activity-logs/stats - Get activity log statistics
|
|
router.get('/activity-logs/stats', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const stats = await getStats();
|
|
|
|
res.json({
|
|
success: true,
|
|
data: stats,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// GET /api/admin/contact-messages - Get all contact messages with filtering
|
|
router.get('/contact-messages', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const { status, limit = 50, offset = 0 } = req.query;
|
|
|
|
const where = {};
|
|
if (status && status !== 'all') {
|
|
where.status = status;
|
|
}
|
|
|
|
const [messages, total] = await Promise.all([
|
|
prisma.contactMessage.findMany({
|
|
where,
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: parseInt(limit),
|
|
skip: parseInt(offset),
|
|
}),
|
|
prisma.contactMessage.count({ where }),
|
|
]);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
messages,
|
|
total,
|
|
limit: parseInt(limit),
|
|
offset: parseInt(offset),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// GET /api/admin/contact-messages/:id - Get single contact message
|
|
router.get('/contact-messages/:id', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const message = await prisma.contactMessage.findUnique({
|
|
where: { id: parseInt(id) },
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!message) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Contact message not found',
|
|
});
|
|
}
|
|
|
|
// Mark as read if it's new
|
|
if (message.status === 'new') {
|
|
await prisma.contactMessage.update({
|
|
where: { id: parseInt(id) },
|
|
data: { status: 'read' },
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: message,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// PATCH /api/admin/contact-messages/:id/status - Update contact message status
|
|
router.patch('/contact-messages/:id/status', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { status } = req.body;
|
|
|
|
if (!['new', 'read', 'resolved'].includes(status)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Invalid status. Must be one of: new, read, resolved',
|
|
});
|
|
}
|
|
|
|
const message = await prisma.contactMessage.update({
|
|
where: { id: parseInt(id) },
|
|
data: { status },
|
|
});
|
|
|
|
// Log the action
|
|
activityLog({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
ipAddress: getClientIP(req),
|
|
action: 'contact.update_status',
|
|
category: 'admin',
|
|
resource: `contact:${id}`,
|
|
method: 'PATCH',
|
|
path: `/api/admin/contact-messages/${id}/status`,
|
|
metadata: { status },
|
|
success: true,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: message,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// DELETE /api/admin/contact-messages/:id - Delete contact message
|
|
router.delete('/contact-messages/:id', authenticate, requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
await prisma.contactMessage.delete({
|
|
where: { id: parseInt(id) },
|
|
});
|
|
|
|
// Log the action
|
|
activityLog({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
ipAddress: getClientIP(req),
|
|
action: 'contact.delete',
|
|
category: 'admin',
|
|
resource: `contact:${id}`,
|
|
method: 'DELETE',
|
|
path: `/api/admin/contact-messages/${id}`,
|
|
metadata: {},
|
|
success: true,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Contact message deleted successfully',
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|