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>
181 lines
5.3 KiB
JavaScript
181 lines
5.3 KiB
JavaScript
/**
|
|
* ActivityTimeline Model
|
|
* Tracks all interactions with contacts and organizations across projects
|
|
*/
|
|
|
|
const { ObjectId } = require('mongodb');
|
|
const { getCollection } = require('../utils/db.util');
|
|
|
|
class ActivityTimeline {
|
|
/**
|
|
* Create a new activity entry
|
|
*/
|
|
static async create(data) {
|
|
const collection = await getCollection('activity_timeline');
|
|
|
|
const activity = {
|
|
// Who/what this activity is related to
|
|
contact_id: data.contact_id ? new ObjectId(data.contact_id) : null,
|
|
organization_id: data.organization_id ? new ObjectId(data.organization_id) : null,
|
|
|
|
// Activity details
|
|
type: data.type, // contact_form, media_inquiry, case_submission, email_sent, call, meeting, note
|
|
project: data.project || 'tractatus', // Which project this activity belongs to
|
|
|
|
// Activity content
|
|
title: data.title,
|
|
description: data.description || null,
|
|
|
|
// Related record (original submission/inquiry)
|
|
related_record: data.related_record ? {
|
|
type: data.related_record.type, // contact, media, case, email, etc
|
|
id: new ObjectId(data.related_record.id)
|
|
} : null,
|
|
|
|
// Activity metadata
|
|
metadata: {
|
|
direction: data.metadata?.direction || 'inbound', // inbound, outbound
|
|
channel: data.metadata?.channel || 'web', // web, email, phone, meeting
|
|
status: data.metadata?.status || 'completed', // completed, pending, failed
|
|
outcome: data.metadata?.outcome || null, // responded, no_response, follow_up_needed
|
|
duration: data.metadata?.duration || null, // for calls/meetings (minutes)
|
|
attachments: data.metadata?.attachments || []
|
|
},
|
|
|
|
// Who performed this activity (user)
|
|
performed_by: data.performed_by ? new ObjectId(data.performed_by) : null,
|
|
performed_by_name: data.performed_by_name || 'System',
|
|
|
|
// Timestamps
|
|
occurred_at: data.occurred_at || new Date(),
|
|
created_at: new Date()
|
|
};
|
|
|
|
const result = await collection.insertOne(activity);
|
|
return { ...activity, _id: result.insertedId };
|
|
}
|
|
|
|
/**
|
|
* Get timeline for a contact
|
|
*/
|
|
static async getByContact(contactId, options = {}) {
|
|
const collection = await getCollection('activity_timeline');
|
|
const { limit = 50, skip = 0 } = options;
|
|
|
|
return await collection
|
|
.find({ contact_id: new ObjectId(contactId) })
|
|
.sort({ occurred_at: -1 })
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Get timeline for an organization
|
|
*/
|
|
static async getByOrganization(organizationId, options = {}) {
|
|
const collection = await getCollection('activity_timeline');
|
|
const { limit = 50, skip = 0 } = options;
|
|
|
|
return await collection
|
|
.find({ organization_id: new ObjectId(organizationId) })
|
|
.sort({ occurred_at: -1 })
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Get combined timeline (contact + organization)
|
|
*/
|
|
static async getCombinedTimeline(contactId, organizationId, options = {}) {
|
|
const collection = await getCollection('activity_timeline');
|
|
const { limit = 100, skip = 0 } = options;
|
|
|
|
const query = {
|
|
$or: [
|
|
{ contact_id: new ObjectId(contactId) },
|
|
{ organization_id: new ObjectId(organizationId) }
|
|
]
|
|
};
|
|
|
|
return await collection
|
|
.find(query)
|
|
.sort({ occurred_at: -1 })
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Get recent activity across all projects
|
|
*/
|
|
static async getRecent(options = {}) {
|
|
const collection = await getCollection('activity_timeline');
|
|
const { limit = 50, project = null, type = null } = options;
|
|
|
|
const query = {};
|
|
if (project) query.project = project;
|
|
if (type) query.type = type;
|
|
|
|
return await collection
|
|
.find(query)
|
|
.sort({ occurred_at: -1 })
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Delete activity
|
|
*/
|
|
static async delete(id) {
|
|
const collection = await getCollection('activity_timeline');
|
|
return await collection.deleteOne({ _id: new ObjectId(id) });
|
|
}
|
|
|
|
/**
|
|
* Get statistics
|
|
*/
|
|
static async getStats(filters = {}) {
|
|
const collection = await getCollection('activity_timeline');
|
|
|
|
const query = {};
|
|
if (filters.project) query.project = filters.project;
|
|
if (filters.contact_id) query.contact_id = new ObjectId(filters.contact_id);
|
|
if (filters.organization_id) query.organization_id = new ObjectId(filters.organization_id);
|
|
|
|
const [total, byType, byProject, byChannel] = await Promise.all([
|
|
collection.countDocuments(query),
|
|
collection.aggregate([
|
|
{ $match: query },
|
|
{ $group: { _id: '$type', count: { $sum: 1 } } }
|
|
]).toArray(),
|
|
collection.aggregate([
|
|
{ $match: query },
|
|
{ $group: { _id: '$project', count: { $sum: 1 } } }
|
|
]).toArray(),
|
|
collection.aggregate([
|
|
{ $match: query },
|
|
{ $group: { _id: '$metadata.channel', count: { $sum: 1 } } }
|
|
]).toArray()
|
|
]);
|
|
|
|
return {
|
|
total,
|
|
by_type: byType.reduce((acc, item) => {
|
|
acc[item._id] = item.count;
|
|
return acc;
|
|
}, {}),
|
|
by_project: byProject.reduce((acc, item) => {
|
|
acc[item._id] = item.count;
|
|
return acc;
|
|
}, {}),
|
|
by_channel: byChannel.reduce((acc, item) => {
|
|
acc[item._id] = item.count;
|
|
return acc;
|
|
}, {})
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = ActivityTimeline;
|