From fe3035913e02e9cc2bff65d7ddb4b35049837158 Mon Sep 17 00:00:00 2001 From: TheFlow Date: Fri, 24 Oct 2025 16:56:21 +1300 Subject: [PATCH] feat(crm): implement unified contact form system Complete CRM foundation with contact modal in footer Backend: - Contact.model.js: Full CRUD model with statistics tracking - contact.controller.js: Submit, list, assign, respond, update, delete - contact.routes.js: Public submission + admin management endpoints - routes/index.js: Mount contact routes at /api/contact Frontend: - footer.js: Replace mailto link with Contact Us modal button - Contact modal: Form with type, name, email, org, subject, message - CSRF protection: Extracts token from cookie (like newsletter) - Rate limiting: formRateLimiter (5/min) - Validation: Input sanitization + required fields - UX: Success/error messages, auto-close on success Admin UI: - navbar-admin.js: New 'CRM & Communications' section - Links: Contact Management, Case Submissions, Media Inquiries Foundation for multi-project CRM across tractatus, family-history, sydigital Next: Build /admin/contact-management.html page --- public/js/components/footer.js | 191 +++++++++++++++++- src/controllers/contact.controller.js | 270 ++++++++++++++++++++++++++ src/models/Contact.model.js | 205 +++++++++++++++++++ src/routes/contact.routes.js | 95 +++++++++ src/routes/index.js | 2 + 5 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 src/controllers/contact.controller.js create mode 100644 src/models/Contact.model.js create mode 100644 src/routes/contact.routes.js diff --git a/public/js/components/footer.js b/public/js/components/footer.js index adb05c02..a41de994 100644 --- a/public/js/components/footer.js +++ b/public/js/components/footer.js @@ -86,7 +86,7 @@

Legal

@@ -118,6 +118,83 @@ + + + `; // Insert footer at end of body @@ -168,6 +245,118 @@ console.log('[Footer] Language changed to:', event.detail.language); this.render(); }); + + // Contact modal functionality + this.setupContactModal(); + } + + setupContactModal() { + const modal = document.getElementById('contact-modal'); + const openBtn = document.getElementById('open-contact-modal'); + const closeBtn = document.getElementById('close-contact-modal'); + const cancelBtn = document.getElementById('cancel-contact'); + const form = document.getElementById('contact-form'); + const successMsg = document.getElementById('contact-success'); + const errorMsg = document.getElementById('contact-error'); + const errorText = document.getElementById('contact-error-message'); + const submitBtn = document.getElementById('contact-submit'); + + if (!modal || !openBtn || !form) { + console.warn('[Footer] Contact modal elements not found'); + return; + } + + const openModal = () => { + modal.classList.remove('hidden'); + document.getElementById('contact-name')?.focus(); + }; + + const closeModal = () => { + modal.classList.add('hidden'); + form.reset(); + successMsg.classList.add('hidden'); + errorMsg.classList.add('hidden'); + }; + + // Event listeners + openBtn.addEventListener('click', (e) => { + e.preventDefault(); + openModal(); + }); + + closeBtn?.addEventListener('click', closeModal); + cancelBtn?.addEventListener('click', closeModal); + + // Close on backdrop click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + closeModal(); + } + }); + + // Form submission + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + // Reset messages + successMsg.classList.add('hidden'); + errorMsg.classList.add('hidden'); + + const formData = { + type: document.getElementById('contact-type').value, + name: document.getElementById('contact-name').value, + email: document.getElementById('contact-email').value, + organization: document.getElementById('contact-organization').value || null, + subject: document.getElementById('contact-subject').value || null, + message: document.getElementById('contact-message').value + }; + + // Disable submit button + submitBtn.disabled = true; + submitBtn.textContent = 'Sending...'; + + try { + // Get CSRF token from cookie + const csrfToken = document.cookie + .split('; ') + .find(row => row.startsWith('csrf-token=')) + ?.split('=')[1]; + + const response = await fetch('/api/contact/submit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken || '' + }, + body: JSON.stringify(formData) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + // Show success message + successMsg.classList.remove('hidden'); + form.reset(); + + // Close modal after 2 seconds + setTimeout(() => { + closeModal(); + }, 2000); + } else { + // Show error message + errorText.textContent = data.message || data.error || 'Failed to send message. Please try again.'; + errorMsg.classList.remove('hidden'); + } + } catch (error) { + console.error('Contact form error:', error); + errorText.textContent = 'Network error. Please check your connection and try again.'; + errorMsg.classList.remove('hidden'); + } finally { + // Re-enable submit button + submitBtn.disabled = false; + submitBtn.textContent = 'Send Message'; + } + }); } } diff --git a/src/controllers/contact.controller.js b/src/controllers/contact.controller.js new file mode 100644 index 00000000..d00b7897 --- /dev/null +++ b/src/controllers/contact.controller.js @@ -0,0 +1,270 @@ +/** + * Contact Controller + * Handle general contact form submissions + */ + +const Contact = require('../models/Contact.model'); +const logger = require('../utils/logger.util'); +const { getClientIp } = require('../utils/security-logger'); + +/** + * POST /api/contact/submit + * Submit a contact form + */ +async function submit(req, res) { + try { + const { type, name, email, organization, phone, subject, message } = req.body; + + // Create contact record + const contact = await Contact.create({ + type: type || 'general', + contact: { + name, + email, + organization, + phone + }, + inquiry: { + subject, + message + }, + source: 'footer_contact', + metadata: { + user_agent: req.get('user-agent'), + ip: getClientIp(req), + source_page: req.get('referer'), + referrer: req.get('referer') + } + }); + + logger.info(`[Contact] New submission: ${contact._id} from ${email}`); + + res.status(201).json({ + success: true, + message: 'Thank you for contacting us. We will respond within 24 hours.', + contact_id: contact._id + }); + + } catch (error) { + logger.error('[Contact] Submit error:', error); + res.status(500).json({ + success: false, + error: 'Failed to submit contact form', + message: 'An error occurred while submitting your message. Please try again.' + }); + } +} + +/** + * GET /api/contact/admin/list + * List contact submissions (admin) + */ +async function list(req, res) { + try { + const { status, type, priority, limit = 20, skip = 0 } = req.query; + + const filters = {}; + if (status) filters.status = status; + if (type) filters.type = type; + if (priority) filters.priority = priority; + + const contacts = await Contact.list(filters, { + limit: parseInt(limit), + skip: parseInt(skip) + }); + + const total = await Contact.countByStatus(status || undefined); + + res.json({ + success: true, + contacts, + pagination: { + total, + limit: parseInt(limit), + skip: parseInt(skip), + hasMore: parseInt(skip) + contacts.length < total + } + }); + + } catch (error) { + logger.error('[Contact] List error:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch contacts' + }); + } +} + +/** + * GET /api/contact/admin/stats + * Get contact statistics (admin) + */ +async function getStats(req, res) { + try { + const stats = await Contact.getStats(); + + res.json({ + success: true, + stats + }); + + } catch (error) { + logger.error('[Contact] Stats error:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch statistics' + }); + } +} + +/** + * GET /api/contact/admin/:id + * Get single contact by ID (admin) + */ +async function getById(req, res) { + try { + const { id } = req.params; + + const contact = await Contact.findById(id); + + if (!contact) { + return res.status(404).json({ + success: false, + error: 'Contact not found' + }); + } + + res.json({ + success: true, + contact + }); + + } catch (error) { + logger.error('[Contact] Get by ID error:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch contact' + }); + } +} + +/** + * POST /api/contact/admin/:id/assign + * Assign contact to admin user + */ +async function assign(req, res) { + try { + const { id } = req.params; + const { user_id } = req.body; + + const contact = await Contact.assign(id, user_id); + + res.json({ + success: true, + message: 'Contact assigned successfully', + contact + }); + + } catch (error) { + logger.error('[Contact] Assign error:', error); + res.status(500).json({ + success: false, + error: 'Failed to assign contact' + }); + } +} + +/** + * POST /api/contact/admin/:id/respond + * Mark contact as responded + */ +async function respond(req, res) { + try { + const { id } = req.params; + const { content } = req.body; + + const contact = await Contact.markResponded(id, { + content, + responder: req.user.id + }); + + res.json({ + success: true, + message: 'Response recorded successfully', + contact + }); + + } catch (error) { + logger.error('[Contact] Respond error:', error); + res.status(500).json({ + success: false, + error: 'Failed to record response' + }); + } +} + +/** + * PUT /api/contact/admin/:id + * Update contact + */ +async function update(req, res) { + try { + const { id } = req.params; + const { status, priority, assigned_to } = req.body; + + const updateData = {}; + if (status) updateData.status = status; + if (priority) updateData.priority = priority; + if (assigned_to !== undefined) updateData.assigned_to = assigned_to; + + const contact = await Contact.update(id, updateData); + + res.json({ + success: true, + message: 'Contact updated successfully', + contact + }); + + } catch (error) { + logger.error('[Contact] Update error:', error); + res.status(500).json({ + success: false, + error: 'Failed to update contact' + }); + } +} + +/** + * DELETE /api/contact/admin/:id + * Delete contact + */ +async function deleteContact(req, res) { + try { + const { id } = req.params; + + await Contact.delete(id); + + res.json({ + success: true, + message: 'Contact deleted successfully' + }); + + } catch (error) { + logger.error('[Contact] Delete error:', error); + res.status(500).json({ + success: false, + error: 'Failed to delete contact' + }); + } +} + +module.exports = { + submit, + list, + getStats, + getById, + assign, + respond, + update, + deleteContact +}; diff --git a/src/models/Contact.model.js b/src/models/Contact.model.js new file mode 100644 index 00000000..9d00d7b0 --- /dev/null +++ b/src/models/Contact.model.js @@ -0,0 +1,205 @@ +/** + * Contact Model + * General contact form submissions (non-media, non-case) + * Foundation for unified CRM system + */ + +const { ObjectId } = require('mongodb'); +const { getCollection } = require('../utils/db.util'); + +class Contact { + /** + * Create a new contact submission + */ + static async create(data) { + const collection = await getCollection('contacts'); + + const contact = { + type: data.type || 'general', // general, partnership, technical, feedback + contact: { + name: data.contact.name, + email: data.contact.email, + organization: data.contact.organization || null, + phone: data.contact.phone || null + }, + inquiry: { + subject: data.inquiry.subject || null, + message: data.inquiry.message + }, + source: data.source || 'footer_contact', // Track where submission came from + status: data.status || 'new', // new, assigned, responded, closed + priority: data.priority || 'normal', // low, normal, high + assigned_to: data.assigned_to || null, + metadata: { + user_agent: data.metadata?.user_agent, + ip: data.metadata?.ip, + source_page: data.metadata?.source_page, + referrer: data.metadata?.referrer + }, + response: { + sent_at: data.response?.sent_at || null, + content: data.response?.content || null, + responder: data.response?.responder || null + }, + created_at: new Date(), + updated_at: new Date() + }; + + const result = await collection.insertOne(contact); + return { ...contact, _id: result.insertedId }; + } + + /** + * Find contact by ID + */ + static async findById(id) { + const collection = await getCollection('contacts'); + return await collection.findOne({ _id: new ObjectId(id) }); + } + + /** + * Find contacts by status + */ + static async findByStatus(status, options = {}) { + const collection = await getCollection('contacts'); + const { limit = 20, skip = 0 } = options; + + return await collection + .find({ status }) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Find contacts by type + */ + static async findByType(type, options = {}) { + const collection = await getCollection('contacts'); + const { limit = 20, skip = 0 } = options; + + return await collection + .find({ type }) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * List all contacts with filtering + */ + static async list(filters = {}, options = {}) { + const collection = await getCollection('contacts'); + const { limit = 20, skip = 0 } = options; + + const query = {}; + if (filters.status) query.status = filters.status; + if (filters.type) query.type = filters.type; + if (filters.priority) query.priority = filters.priority; + if (filters.assigned_to) query.assigned_to = new ObjectId(filters.assigned_to); + + return await collection + .find(query) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Count contacts by status + */ + static async countByStatus(status) { + const collection = await getCollection('contacts'); + return await collection.countDocuments({ status }); + } + + /** + * Update contact + */ + static async update(id, data) { + const collection = await getCollection('contacts'); + + const updateData = { + ...data, + updated_at: new Date() + }; + + await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: updateData } + ); + + return await this.findById(id); + } + + /** + * Assign contact to user + */ + static async assign(id, userId) { + return await this.update(id, { + assigned_to: userId ? new ObjectId(userId) : null, + status: 'assigned' + }); + } + + /** + * Mark contact as responded + */ + static async markResponded(id, responseData) { + return await this.update(id, { + status: 'responded', + response: { + sent_at: new Date(), + content: responseData.content, + responder: responseData.responder + } + }); + } + + /** + * Delete contact + */ + static async delete(id) { + const collection = await getCollection('contacts'); + return await collection.deleteOne({ _id: new ObjectId(id) }); + } + + /** + * Get statistics + */ + static async getStats() { + const collection = await getCollection('contacts'); + + const [total, newCount, assignedCount, respondedCount, closedCount] = await Promise.all([ + collection.countDocuments(), + collection.countDocuments({ status: 'new' }), + collection.countDocuments({ status: 'assigned' }), + collection.countDocuments({ status: 'responded' }), + collection.countDocuments({ status: 'closed' }) + ]); + + // Type breakdown + const typeBreakdown = await collection.aggregate([ + { $group: { _id: '$type', count: { $sum: 1 } } } + ]).toArray(); + + return { + total, + by_status: { + new: newCount, + assigned: assignedCount, + responded: respondedCount, + closed: closedCount + }, + by_type: typeBreakdown.reduce((acc, item) => { + acc[item._id] = item.count; + return acc; + }, {}) + }; + } +} + +module.exports = Contact; diff --git a/src/routes/contact.routes.js b/src/routes/contact.routes.js new file mode 100644 index 00000000..7462a727 --- /dev/null +++ b/src/routes/contact.routes.js @@ -0,0 +1,95 @@ +/** + * Contact Routes + * Public contact form and admin management + */ + +const express = require('express'); +const router = express.Router(); + +const contactController = require('../controllers/contact.controller'); +const { authenticateToken, requireRole } = require('../middleware/auth.middleware'); +const { validateRequired } = require('../middleware/validation.middleware'); +const { asyncHandler } = require('../middleware/error.middleware'); +const { createInputValidationMiddleware } = require('../middleware/input-validation.middleware'); +const { formRateLimiter } = require('../middleware/rate-limit.middleware'); +const { csrfProtection } = require('../middleware/csrf-protection.middleware'); + +/** + * Public Routes + */ + +// Validation schema for contact submission +const contactSubmitSchema = { + 'type': { required: false, type: 'string', maxLength: 50 }, + 'name': { required: true, type: 'name', maxLength: 100 }, + 'email': { required: true, type: 'email', maxLength: 254 }, + 'organization': { required: false, type: 'string', maxLength: 200 }, + 'phone': { required: false, type: 'phone', maxLength: 50 }, + 'subject': { required: false, type: 'string', maxLength: 200 }, + 'message': { required: true, type: 'string', maxLength: 5000 } +}; + +// POST /api/contact/submit - Submit contact form +router.post('/submit', + formRateLimiter, // 5 requests per minute + csrfProtection, // CSRF validation + createInputValidationMiddleware(contactSubmitSchema), + validateRequired(['name', 'email', 'message']), + asyncHandler(contactController.submit) +); + +/** + * Admin Routes (require authentication) + */ + +// GET /api/contact/admin/stats - Get contact statistics +router.get('/admin/stats', + authenticateToken, + requireRole('admin', 'moderator'), + asyncHandler(contactController.getStats) +); + +// GET /api/contact/admin/list - List contacts with filtering +router.get('/admin/list', + authenticateToken, + requireRole('admin', 'moderator'), + asyncHandler(contactController.list) +); + +// GET /api/contact/admin/:id - Get single contact +router.get('/admin/:id', + authenticateToken, + requireRole('admin', 'moderator'), + asyncHandler(contactController.getById) +); + +// POST /api/contact/admin/:id/assign - Assign contact to user +router.post('/admin/:id/assign', + authenticateToken, + requireRole('admin', 'moderator'), + asyncHandler(contactController.assign) +); + +// POST /api/contact/admin/:id/respond - Mark as responded +router.post('/admin/:id/respond', + authenticateToken, + requireRole('admin', 'moderator'), + validateRequired(['content']), + asyncHandler(contactController.respond) +); + +// PUT /api/contact/admin/:id - Update contact +router.put('/admin/:id', + authenticateToken, + requireRole('admin', 'moderator'), + asyncHandler(contactController.update) +); + +// DELETE /api/contact/admin/:id - Delete contact +router.delete('/admin/:id', + authenticateToken, + requireRole('admin'), + asyncHandler(contactController.deleteContact) +); + +module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js index bf1caf2d..b64fa463 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -27,6 +27,7 @@ const analyticsRoutes = require('./analytics.routes'); const publicationsRoutes = require('./publications.routes'); const submissionsRoutes = require('./submissions.routes'); const relationshipsRoutes = require('./relationships.routes'); +const contactRoutes = require('./contact.routes'); // Development/test routes (only in development) if (process.env.NODE_ENV !== 'production') { @@ -55,6 +56,7 @@ router.use('/analytics', analyticsRoutes); router.use('/publications', publicationsRoutes); router.use('/submissions', submissionsRoutes); router.use('/relationships', relationshipsRoutes); +router.use('/contact', contactRoutes); // API root endpoint - redirect browsers to documentation router.get('/', (req, res) => {