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:
TheFlow 2025-10-24 16:56:21 +13:00
parent 70ab7c0a01
commit efab76e13c
6 changed files with 774 additions and 13 deletions

View file

@ -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": []
}

View file

@ -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">&times;</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';
}
});
}
}

View 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
View 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;

View 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;

View file

@ -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) => {