Phase 3 Multi-Project CRM Implementation:
- Add UnifiedContact model for cross-project contact linking
- Add Organization model with domain-based auto-detection
- Add ActivityTimeline model for comprehensive interaction tracking
- Add SLATracking model for 24-hour response commitment
- Add ResponseTemplate model with variable substitution
- Add CRM controller with 8 API endpoints
- Add Inbox controller for unified communications
- Add CRM dashboard frontend with tabs (Contacts, Orgs, SLA, Templates)
- Add Contact Management interface (Phase 1)
- Add Unified Inbox interface (Phase 2)
- Integrate CRM routes into main API
Critical Bug Fixes:
- Fix newsletter DELETE button (event handler context issue)
- Fix case submission invisible button (invalid CSS class)
- Fix Chart.js CSP violation (add cdn.jsdelivr.net to policy)
- Fix Chart.js SRI integrity hash mismatch
Technical Details:
- Email-based contact deduplication across projects
- Automatic organization linking via email domain
- Cross-project activity timeline aggregation
- SLA breach detection and alerting system
- Template rendering with {placeholder} substitution
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
354 lines
10 KiB
JavaScript
354 lines
10 KiB
JavaScript
/**
|
|
* UnifiedContact Model
|
|
* Cross-project contact management - links same person across projects
|
|
*/
|
|
|
|
const { ObjectId } = require('mongodb');
|
|
const { getCollection } = require('../utils/db.util');
|
|
|
|
class UnifiedContact {
|
|
/**
|
|
* Create a new unified contact
|
|
*/
|
|
static async create(data) {
|
|
const collection = await getCollection('unified_contacts');
|
|
|
|
const contact = {
|
|
// Primary identity (canonical)
|
|
name: data.name,
|
|
email: data.email, // Primary email (unique index)
|
|
|
|
// Additional emails (aliases)
|
|
additional_emails: data.additional_emails || [],
|
|
|
|
// Organization link
|
|
organization_id: data.organization_id ? new ObjectId(data.organization_id) : null,
|
|
job_title: data.job_title || null,
|
|
|
|
// Contact methods
|
|
contact_methods: {
|
|
phone: data.contact_methods?.phone || null,
|
|
mobile: data.contact_methods?.mobile || null,
|
|
preferred: data.contact_methods?.preferred || 'email' // email, phone, mobile
|
|
},
|
|
|
|
// Social profiles
|
|
social: {
|
|
linkedin: data.social?.linkedin || null,
|
|
twitter: data.social?.twitter || null,
|
|
other: data.social?.other || {}
|
|
},
|
|
|
|
// Projects this contact has interacted with
|
|
projects: data.projects || [], // ['tractatus', 'family-history', 'sydigital']
|
|
|
|
// Related records across projects
|
|
related_records: data.related_records || {
|
|
// tractatus: [{ type: 'contact', id: ObjectId }],
|
|
// family-history: [{ type: 'inquiry', id: ObjectId }]
|
|
},
|
|
|
|
// Relationship metadata
|
|
relationship: {
|
|
status: data.relationship?.status || 'active', // active, inactive, blocked
|
|
first_contact: data.relationship?.first_contact || new Date(),
|
|
last_contact: data.relationship?.last_contact || new Date(),
|
|
total_interactions: data.relationship?.total_interactions || 0,
|
|
tags: data.relationship?.tags || [] // journalist, researcher, partner, etc
|
|
},
|
|
|
|
// Preferences
|
|
preferences: {
|
|
language: data.preferences?.language || 'en',
|
|
timezone: data.preferences?.timezone || null,
|
|
communication_frequency: data.preferences?.communication_frequency || 'normal', // low, normal, high
|
|
opt_out: data.preferences?.opt_out || false
|
|
},
|
|
|
|
// Notes and metadata
|
|
notes: data.notes || null,
|
|
custom_fields: data.custom_fields || {},
|
|
|
|
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('unified_contacts');
|
|
return await collection.findOne({ _id: new ObjectId(id) });
|
|
}
|
|
|
|
/**
|
|
* Find contact by email (primary or additional)
|
|
*/
|
|
static async findByEmail(email) {
|
|
const collection = await getCollection('unified_contacts');
|
|
return await collection.findOne({
|
|
$or: [
|
|
{ email: email.toLowerCase() },
|
|
{ additional_emails: email.toLowerCase() }
|
|
]
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find or create contact by email
|
|
*/
|
|
static async findOrCreate(data) {
|
|
const existing = await this.findByEmail(data.email);
|
|
if (existing) {
|
|
// Update last_contact and increment interactions
|
|
await this.recordInteraction(existing._id, data.project || 'tractatus');
|
|
return existing;
|
|
}
|
|
|
|
return await this.create(data);
|
|
}
|
|
|
|
/**
|
|
* Link existing project-specific contact to unified contact
|
|
*/
|
|
static async linkProjectContact(unifiedContactId, projectName, contactType, contactId) {
|
|
const collection = await getCollection('unified_contacts');
|
|
|
|
await collection.updateOne(
|
|
{ _id: new ObjectId(unifiedContactId) },
|
|
{
|
|
$addToSet: { projects: projectName },
|
|
$push: {
|
|
[`related_records.${projectName}`]: {
|
|
type: contactType,
|
|
id: new ObjectId(contactId),
|
|
linked_at: new Date()
|
|
}
|
|
},
|
|
$set: { updated_at: new Date() }
|
|
}
|
|
);
|
|
|
|
return await this.findById(unifiedContactId);
|
|
}
|
|
|
|
/**
|
|
* Record an interaction with this contact
|
|
*/
|
|
static async recordInteraction(id, projectName, interactionType = 'general') {
|
|
const collection = await getCollection('unified_contacts');
|
|
|
|
await collection.updateOne(
|
|
{ _id: new ObjectId(id) },
|
|
{
|
|
$set: {
|
|
'relationship.last_contact': new Date(),
|
|
updated_at: new Date()
|
|
},
|
|
$inc: { 'relationship.total_interactions': 1 },
|
|
$addToSet: { projects: projectName }
|
|
}
|
|
);
|
|
|
|
return await this.findById(id);
|
|
}
|
|
|
|
/**
|
|
* Add tag to contact
|
|
*/
|
|
static async addTag(id, tag) {
|
|
const collection = await getCollection('unified_contacts');
|
|
|
|
await collection.updateOne(
|
|
{ _id: new ObjectId(id) },
|
|
{
|
|
$addToSet: { 'relationship.tags': tag.toLowerCase() },
|
|
$set: { updated_at: new Date() }
|
|
}
|
|
);
|
|
|
|
return await this.findById(id);
|
|
}
|
|
|
|
/**
|
|
* Find contacts by organization
|
|
*/
|
|
static async findByOrganization(organizationId, options = {}) {
|
|
const collection = await getCollection('unified_contacts');
|
|
const { limit = 50, skip = 0 } = options;
|
|
|
|
return await collection
|
|
.find({ organization_id: new ObjectId(organizationId) })
|
|
.sort({ name: 1 })
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* List contacts with filtering
|
|
*/
|
|
static async list(filters = {}, options = {}) {
|
|
const collection = await getCollection('unified_contacts');
|
|
const { limit = 50, skip = 0 } = options;
|
|
|
|
const query = {};
|
|
if (filters.project) query.projects = filters.project;
|
|
if (filters.status) query['relationship.status'] = filters.status;
|
|
if (filters.tag) query['relationship.tags'] = filters.tag;
|
|
if (filters.organization_id) query.organization_id = new ObjectId(filters.organization_id);
|
|
|
|
return await collection
|
|
.find(query)
|
|
.sort({ 'relationship.last_contact': -1 })
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Search contacts by name or email
|
|
*/
|
|
static async search(searchTerm, options = {}) {
|
|
const collection = await getCollection('unified_contacts');
|
|
const { limit = 20 } = options;
|
|
|
|
return await collection
|
|
.find({
|
|
$or: [
|
|
{ name: { $regex: searchTerm, $options: 'i' } },
|
|
{ email: { $regex: searchTerm, $options: 'i' } },
|
|
{ additional_emails: { $regex: searchTerm, $options: 'i' } }
|
|
]
|
|
})
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Update contact
|
|
*/
|
|
static async update(id, data) {
|
|
const collection = await getCollection('unified_contacts');
|
|
|
|
const updateData = {
|
|
...data,
|
|
updated_at: new Date()
|
|
};
|
|
|
|
await collection.updateOne(
|
|
{ _id: new ObjectId(id) },
|
|
{ $set: updateData }
|
|
);
|
|
|
|
return await this.findById(id);
|
|
}
|
|
|
|
/**
|
|
* Merge two contacts (combine duplicate records)
|
|
*/
|
|
static async merge(primaryId, secondaryId) {
|
|
const collection = await getCollection('unified_contacts');
|
|
|
|
const [primary, secondary] = await Promise.all([
|
|
this.findById(primaryId),
|
|
this.findById(secondaryId)
|
|
]);
|
|
|
|
if (!primary || !secondary) {
|
|
throw new Error('Contact not found');
|
|
}
|
|
|
|
// Merge data
|
|
const mergedData = {
|
|
additional_emails: [...new Set([
|
|
...primary.additional_emails,
|
|
...secondary.additional_emails,
|
|
secondary.email // Add secondary's primary email as additional
|
|
])],
|
|
projects: [...new Set([...primary.projects, ...secondary.projects])],
|
|
'relationship.tags': [...new Set([
|
|
...primary.relationship.tags,
|
|
...secondary.relationship.tags
|
|
])],
|
|
'relationship.total_interactions':
|
|
primary.relationship.total_interactions + secondary.relationship.total_interactions,
|
|
'relationship.first_contact':
|
|
primary.relationship.first_contact < secondary.relationship.first_contact
|
|
? primary.relationship.first_contact
|
|
: secondary.relationship.first_contact,
|
|
updated_at: new Date()
|
|
};
|
|
|
|
// Merge related_records
|
|
Object.keys(secondary.related_records || {}).forEach(project => {
|
|
if (!mergedData.related_records) mergedData.related_records = {};
|
|
if (!mergedData.related_records[project]) {
|
|
mergedData.related_records[project] = [];
|
|
}
|
|
mergedData.related_records[project].push(...secondary.related_records[project]);
|
|
});
|
|
|
|
// Update primary contact
|
|
await collection.updateOne(
|
|
{ _id: new ObjectId(primaryId) },
|
|
{ $set: mergedData }
|
|
);
|
|
|
|
// Delete secondary contact
|
|
await collection.deleteOne({ _id: new ObjectId(secondaryId) });
|
|
|
|
return await this.findById(primaryId);
|
|
}
|
|
|
|
/**
|
|
* Delete contact
|
|
*/
|
|
static async delete(id) {
|
|
const collection = await getCollection('unified_contacts');
|
|
return await collection.deleteOne({ _id: new ObjectId(id) });
|
|
}
|
|
|
|
/**
|
|
* Get statistics
|
|
*/
|
|
static async getStats() {
|
|
const collection = await getCollection('unified_contacts');
|
|
|
|
const [total, byProject, byStatus, byTag] = await Promise.all([
|
|
collection.countDocuments(),
|
|
collection.aggregate([
|
|
{ $unwind: '$projects' },
|
|
{ $group: { _id: '$projects', count: { $sum: 1 } } }
|
|
]).toArray(),
|
|
collection.aggregate([
|
|
{ $group: { _id: '$relationship.status', count: { $sum: 1 } } }
|
|
]).toArray(),
|
|
collection.aggregate([
|
|
{ $unwind: '$relationship.tags' },
|
|
{ $group: { _id: '$relationship.tags', count: { $sum: 1 } } },
|
|
{ $sort: { count: -1 } },
|
|
{ $limit: 10 }
|
|
]).toArray()
|
|
]);
|
|
|
|
return {
|
|
total,
|
|
by_project: byProject.reduce((acc, item) => {
|
|
acc[item._id] = item.count;
|
|
return acc;
|
|
}, {}),
|
|
by_status: byStatus.reduce((acc, item) => {
|
|
acc[item._id] = item.count;
|
|
return acc;
|
|
}, {}),
|
|
top_tags: byTag.map(item => ({ tag: item._id, count: item.count }))
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = UnifiedContact;
|