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
This commit is contained in:
Radosław Gierwiało
2025-12-05 17:15:25 +01:00
parent f90945aa47
commit 34f18b3b50
8 changed files with 997 additions and 16 deletions

View File

@@ -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;

View File

@@ -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;