Files
spotlightcam/backend/src/routes/admin.js
Radosław Gierwiało 34f18b3b50 feat(contact): add contact form with admin panel and navbar dropdown
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
2025-12-05 17:15:25 +01:00

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;