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