tractatus/src/models/Contact.model.js
TheFlow fe3035913e 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
2025-10-24 16:56:21 +13:00

205 lines
5.1 KiB
JavaScript

/**
* 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;