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
205 lines
5.1 KiB
JavaScript
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;
|