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
70ab7c0a01
commit
efab76e13c
6 changed files with 774 additions and 13 deletions
|
|
@ -43,13 +43,13 @@
|
|||
"last_deliberation": null
|
||||
},
|
||||
"FileEditHook": {
|
||||
"timestamp": "2025-10-24T03:42:14.478Z",
|
||||
"file": "/home/theflow/projects/tractatus/src/middleware/csrf-protection.middleware.js",
|
||||
"timestamp": "2025-10-24T03:55:50.791Z",
|
||||
"file": "/home/theflow/projects/tractatus/public/js/components/footer.js",
|
||||
"result": "passed"
|
||||
},
|
||||
"FileWriteHook": {
|
||||
"timestamp": "2025-10-24T00:04:27.173Z",
|
||||
"file": "/home/theflow/projects/tractatus/public/js/admin/editorial-guidelines.js",
|
||||
"timestamp": "2025-10-24T03:53:41.535Z",
|
||||
"file": "/home/theflow/projects/tractatus/src/routes/contact.routes.js",
|
||||
"result": "passed"
|
||||
}
|
||||
},
|
||||
|
|
@ -58,25 +58,25 @@
|
|||
"tokens": 30000
|
||||
},
|
||||
"alerts": [],
|
||||
"last_updated": "2025-10-24T03:42:14.478Z",
|
||||
"last_updated": "2025-10-24T03:55:50.791Z",
|
||||
"initialized": true,
|
||||
"framework_components": {
|
||||
"CrossReferenceValidator": {
|
||||
"message": 0,
|
||||
"tokens": 0,
|
||||
"timestamp": "2025-10-24T03:44:35.044Z",
|
||||
"last_validation": "2025-10-24T03:44:35.043Z",
|
||||
"validations_performed": 961
|
||||
"timestamp": "2025-10-24T03:56:06.108Z",
|
||||
"last_validation": "2025-10-24T03:56:06.108Z",
|
||||
"validations_performed": 977
|
||||
},
|
||||
"BashCommandValidator": {
|
||||
"message": 0,
|
||||
"tokens": 0,
|
||||
"timestamp": null,
|
||||
"last_validation": "2025-10-24T03:44:35.045Z",
|
||||
"validations_performed": 593,
|
||||
"blocks_issued": 68
|
||||
"last_validation": "2025-10-24T03:56:06.109Z",
|
||||
"validations_performed": 599,
|
||||
"blocks_issued": 69
|
||||
}
|
||||
},
|
||||
"action_count": 593,
|
||||
"action_count": 599,
|
||||
"auto_compact_events": []
|
||||
}
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
<h3 class="text-white font-semibold mb-4" data-i18n="footer.legal_heading">Legal</h3>
|
||||
<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="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>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -118,6 +118,83 @@
|
|||
|
||||
</div>
|
||||
</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
|
||||
|
|
@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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 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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue