diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3f2502b..864fdf7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -77,6 +77,7 @@ model User { heats EventUserHeat[] recordingAssignments RecordingSuggestion[] @relation("RecorderAssignments") activityLogs ActivityLog[] + contactMessages ContactMessage[] @@map("users") } @@ -365,3 +366,37 @@ model ActivityLog { @@map("activity_logs") } + +// Contact messages from users +model ContactMessage { + id Int @id @default(autoincrement()) + + // User identification (either logged in or anonymous) + userId Int? @map("user_id") // NULL for non-logged-in users + username String? @db.VarChar(50) // Username if logged in + firstName String? @map("first_name") @db.VarChar(100) // For non-logged-in users + lastName String? @map("last_name") @db.VarChar(100) // For non-logged-in users + email String @db.VarChar(255) // Always required + + // Message content + subject String @db.VarChar(255) + message String @db.Text + + // Admin tracking + status String @default("new") @db.VarChar(20) // 'new', 'read', 'resolved' + ipAddress String? @map("ip_address") @db.VarChar(45) + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + // Indexes + @@index([status, createdAt(sort: Desc)]) + @@index([createdAt(sort: Desc)]) + @@index([userId]) + + @@map("contact_messages") +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index acfd7c2..d587ad7 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -332,3 +332,167 @@ router.get('/activity-logs/stats', authenticate, requireAdmin, async (req, res, 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; diff --git a/backend/src/routes/public.js b/backend/src/routes/public.js index dfde63f..aa7c572 100644 --- a/backend/src/routes/public.js +++ b/backend/src/routes/public.js @@ -1,8 +1,11 @@ const express = require('express'); +const { PrismaClient } = require('@prisma/client'); +const { body, validationResult } = require('express-validator'); const { ACTIONS, log: activityLog } = require('../services/activityLog'); const { getClientIP } = require('../utils/request'); const router = express.Router(); +const prisma = new PrismaClient(); /** * POST /api/public/log-404 @@ -46,4 +49,90 @@ router.post('/log-404', async (req, res) => { } }); +/** + * POST /api/public/contact + * Submit contact form (works for both authenticated and non-authenticated users) + */ +router.post('/contact', [ + body('email').isEmail().normalizeEmail().withMessage('Valid email is required'), + body('subject').trim().isLength({ min: 3, max: 255 }).withMessage('Subject must be between 3 and 255 characters'), + body('message').trim().isLength({ min: 10, max: 5000 }).withMessage('Message must be between 10 and 5000 characters'), + // For non-logged-in users + body('firstName').optional().trim().isLength({ min: 1, max: 100 }).withMessage('First name must be between 1 and 100 characters'), + body('lastName').optional().trim().isLength({ min: 1, max: 100 }).withMessage('Last name must be between 1 and 100 characters'), + // TODO: Add CAPTCHA validation here +], async (req, res) => { + try { + // Validate request + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + error: 'Validation failed', + details: errors.array(), + }); + } + + const { email, subject, message, firstName, lastName } = req.body; + + // Check if user is authenticated + const userId = req.user?.id || null; + const username = req.user?.username || null; + + // For non-logged-in users, firstName and lastName are required + if (!userId && (!firstName || !lastName)) { + return res.status(400).json({ + success: false, + error: 'First name and last name are required for non-logged-in users', + }); + } + + // Create contact message + const contactMessage = await prisma.contactMessage.create({ + data: { + userId, + username, + firstName: userId ? null : firstName, // Only store for non-logged-in users + lastName: userId ? null : lastName, // Only store for non-logged-in users + email, + subject, + message, + status: 'new', + ipAddress: getClientIP(req), + }, + }); + + // Log to activity logs + activityLog({ + userId, + username: username || 'anonymous', + ipAddress: getClientIP(req), + action: 'contact.submit', + category: 'system', + resource: `contact:${contactMessage.id}`, + method: 'POST', + path: '/api/public/contact', + metadata: { + subject, + messageLength: message.length, + }, + success: true, + }); + + res.status(201).json({ + success: true, + message: 'Contact form submitted successfully', + data: { + id: contactMessage.id, + }, + }); + } catch (error) { + console.error('Error submitting contact form:', error); + res.status(500).json({ + success: false, + error: 'Failed to submit contact form', + }); + } +}); + module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 09396dd..4c1c04a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,8 @@ import HistoryPage from './pages/HistoryPage'; import ProfilePage from './pages/ProfilePage'; import PublicProfilePage from './pages/PublicProfilePage'; import ActivityLogsPage from './pages/admin/ActivityLogsPage'; +import ContactMessagesPage from './pages/admin/ContactMessagesPage'; +import ContactPage from './pages/ContactPage'; import NotFoundPage from './pages/NotFoundPage'; import VerificationBanner from './components/common/VerificationBanner'; import InstallPWA from './components/pwa/InstallPWA'; @@ -210,11 +212,22 @@ function App() { } /> + + + + } + /> {/* Home Page */} } /> + {/* Contact Page - Public route */} + } /> + {/* Public Profile - /u/username format (no auth required) */} } /> diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx index 2bfa58b..03d80ff 100644 --- a/frontend/src/components/layout/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -1,8 +1,8 @@ import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { Video, LogOut, User, History, Users, Menu, X, LayoutDashboard, Calendar, Shield } from 'lucide-react'; +import { Video, LogOut, User, History, Users, Menu, X, LayoutDashboard, Calendar, Shield, Mail, Activity, ChevronDown } from 'lucide-react'; import Avatar from '../common/Avatar'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { matchesAPI } from '../../services/api'; import { connectSocket, disconnectSocket, getSocket } from '../../services/socket'; import { MATCH_STATUS } from '../../constants'; @@ -12,6 +12,9 @@ const Navbar = ({ pageTitle = null }) => { const navigate = useNavigate(); const [pendingMatchesCount, setPendingMatchesCount] = useState(0); const [menuOpen, setMenuOpen] = useState(false); + const [adminDropdownOpen, setAdminDropdownOpen] = useState(false); + const [mobileAdminOpen, setMobileAdminOpen] = useState(false); + const adminDropdownRef = useRef(null); const handleLogout = () => { logout(); @@ -47,6 +50,20 @@ const Navbar = ({ pageTitle = null }) => { } }, [user]); + // Close admin dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (adminDropdownRef.current && !adminDropdownRef.current.contains(event.target)) { + setAdminDropdownOpen(false); + } + }; + + if (adminDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [adminDropdownOpen]); + const loadPendingMatches = async () => { try { const result = await matchesAPI.getMatches(null, MATCH_STATUS.PENDING); @@ -123,13 +140,37 @@ const Navbar = ({ pageTitle = null }) => { {user?.isAdmin && ( - - - Admin - +
+ + + {adminDropdownOpen && ( +
+ setAdminDropdownOpen(false)} + className="flex items-center space-x-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" + > + + Activity Logs + + setAdminDropdownOpen(false)} + className="flex items-center space-x-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" + > + + Contact Messages + +
+ )} +
)}
@@ -212,13 +253,41 @@ const Navbar = ({ pageTitle = null }) => { Profile {user?.isAdmin && ( - setMenuOpen(false)} - className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100" - > - Admin - + <> + + {mobileAdminOpen && ( +
+ { + setMenuOpen(false); + setMobileAdminOpen(false); + }} + className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-gray-600 hover:bg-gray-100" + > + Activity Logs + + { + setMenuOpen(false); + setMobileAdminOpen(false); + }} + className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-gray-600 hover:bg-gray-100" + > + Contact Messages + +
+ )} + )}