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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user