From 34f18b3b50596052b68e143fae4cb8ad099efc22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Fri, 5 Dec 2025 17:15:25 +0100 Subject: [PATCH] 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 --- backend/prisma/schema.prisma | 35 ++ backend/src/routes/admin.js | 164 +++++++++ backend/src/routes/public.js | 89 +++++ frontend/src/App.jsx | 13 + frontend/src/components/layout/Navbar.jsx | 101 ++++- frontend/src/pages/ContactPage.jsx | 254 +++++++++++++ .../src/pages/admin/ContactMessagesPage.jsx | 348 ++++++++++++++++++ frontend/src/services/api.js | 9 + 8 files changed, 997 insertions(+), 16 deletions(-) create mode 100644 frontend/src/pages/ContactPage.jsx create mode 100644 frontend/src/pages/admin/ContactMessagesPage.jsx 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 + +
+ )} + )}