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;