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
This commit is contained in:
parent
49a5c07248
commit
fe3035913e
5 changed files with 762 additions and 1 deletions
|
|
@ -86,7 +86,7 @@
|
||||||
<h3 class="text-white font-semibold mb-4" data-i18n="footer.legal_heading">Legal</h3>
|
<h3 class="text-white font-semibold mb-4" data-i18n="footer.legal_heading">Legal</h3>
|
||||||
<ul class="space-y-2 text-sm">
|
<ul class="space-y-2 text-sm">
|
||||||
<li><a href="/privacy.html" class="hover:text-white transition" data-i18n="footer.legal_links.privacy">Privacy Policy</a></li>
|
<li><a href="/privacy.html" class="hover:text-white transition" data-i18n="footer.legal_links.privacy">Privacy Policy</a></li>
|
||||||
<li><a href="mailto:hello@agenticgovernance.digital" class="hover:text-white transition" data-i18n="footer.legal_links.contact">Contact Us</a></li>
|
<li><button id="open-contact-modal" class="hover:text-white transition cursor-pointer text-left" data-i18n="footer.legal_links.contact">Contact Us</button></li>
|
||||||
<li><a href="https://github.com/AgenticGovernance/tractatus-framework" class="hover:text-white transition" target="_blank" rel="noopener">GitHub</a></li>
|
<li><a href="https://github.com/AgenticGovernance/tractatus-framework" class="hover:text-white transition" target="_blank" rel="noopener">GitHub</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,6 +118,83 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!-- Contact Modal -->
|
||||||
|
<div id="contact-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900">Contact Us</h2>
|
||||||
|
<button id="close-contact-modal" class="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-600 mb-6">Have a question or want to get in touch? Fill out the form below and we'll respond within 24 hours.</p>
|
||||||
|
|
||||||
|
<form id="contact-form" class="space-y-4">
|
||||||
|
<!-- Type -->
|
||||||
|
<div>
|
||||||
|
<label for="contact-type" class="block text-sm font-medium text-gray-700 mb-1">Inquiry Type</label>
|
||||||
|
<select id="contact-type" name="type" required class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="general">General Inquiry</option>
|
||||||
|
<option value="partnership">Partnership</option>
|
||||||
|
<option value="technical">Technical Support</option>
|
||||||
|
<option value="feedback">Feedback</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<label for="contact-name" class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||||
|
<input type="text" id="contact-name" name="name" required maxlength="100" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div>
|
||||||
|
<label for="contact-email" class="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||||
|
<input type="email" id="contact-email" name="email" required maxlength="254" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Organization (optional) -->
|
||||||
|
<div>
|
||||||
|
<label for="contact-organization" class="block text-sm font-medium text-gray-700 mb-1">Organization (optional)</label>
|
||||||
|
<input type="text" id="contact-organization" name="organization" maxlength="200" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subject (optional) -->
|
||||||
|
<div>
|
||||||
|
<label for="contact-subject" class="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
|
||||||
|
<input type="text" id="contact-subject" name="subject" maxlength="200" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<div>
|
||||||
|
<label for="contact-message" class="block text-sm font-medium text-gray-700 mb-1">Message *</label>
|
||||||
|
<textarea id="contact-message" name="message" required maxlength="5000" rows="5" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Maximum 5000 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
<div id="contact-success" class="hidden bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded">
|
||||||
|
Thank you for contacting us! We'll respond within 24 hours.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
<div id="contact-error" class="hidden bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
|
||||||
|
<span id="contact-error-message"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<p class="text-sm text-gray-500">Or email us at <a href="mailto:hello@agenticgovernance.digital" class="text-blue-600 hover:underline">hello@agenticgovernance.digital</a></p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" id="cancel-contact" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">Cancel</button>
|
||||||
|
<button type="submit" id="contact-submit" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Send Message</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Insert footer at end of body
|
// Insert footer at end of body
|
||||||
|
|
@ -168,6 +245,118 @@
|
||||||
console.log('[Footer] Language changed to:', event.detail.language);
|
console.log('[Footer] Language changed to:', event.detail.language);
|
||||||
this.render();
|
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';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
270
src/controllers/contact.controller.js
Normal file
270
src/controllers/contact.controller.js
Normal file
|
|
@ -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
|
||||||
|
};
|
||||||
205
src/models/Contact.model.js
Normal file
205
src/models/Contact.model.js
Normal file
|
|
@ -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;
|
||||||
95
src/routes/contact.routes.js
Normal file
95
src/routes/contact.routes.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -27,6 +27,7 @@ const analyticsRoutes = require('./analytics.routes');
|
||||||
const publicationsRoutes = require('./publications.routes');
|
const publicationsRoutes = require('./publications.routes');
|
||||||
const submissionsRoutes = require('./submissions.routes');
|
const submissionsRoutes = require('./submissions.routes');
|
||||||
const relationshipsRoutes = require('./relationships.routes');
|
const relationshipsRoutes = require('./relationships.routes');
|
||||||
|
const contactRoutes = require('./contact.routes');
|
||||||
|
|
||||||
// Development/test routes (only in development)
|
// Development/test routes (only in development)
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
|
@ -55,6 +56,7 @@ router.use('/analytics', analyticsRoutes);
|
||||||
router.use('/publications', publicationsRoutes);
|
router.use('/publications', publicationsRoutes);
|
||||||
router.use('/submissions', submissionsRoutes);
|
router.use('/submissions', submissionsRoutes);
|
||||||
router.use('/relationships', relationshipsRoutes);
|
router.use('/relationships', relationshipsRoutes);
|
||||||
|
router.use('/contact', contactRoutes);
|
||||||
|
|
||||||
// API root endpoint - redirect browsers to documentation
|
// API root endpoint - redirect browsers to documentation
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue