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>
232 lines
6.1 KiB
JavaScript
232 lines
6.1 KiB
JavaScript
/**
|
|
* Organization Model
|
|
* Shared across all projects - tracks companies, institutions, media outlets
|
|
*/
|
|
|
|
const { ObjectId } = require('mongodb');
|
|
const { getCollection } = require('../utils/db.util');
|
|
|
|
class Organization {
|
|
/**
|
|
* Create a new organization
|
|
*/
|
|
static async create(data) {
|
|
const collection = await getCollection('organizations');
|
|
|
|
const organization = {
|
|
name: data.name,
|
|
domain: data.domain || null, // Primary domain (e.g., "economist.com")
|
|
type: data.type || 'other', // media, academic, corporate, nonprofit, government, other
|
|
country: data.country || null,
|
|
|
|
// Contact information
|
|
contact_info: {
|
|
website: data.contact_info?.website || null,
|
|
email: data.contact_info?.email || null,
|
|
phone: data.contact_info?.phone || null,
|
|
address: data.contact_info?.address || null
|
|
},
|
|
|
|
// Metadata
|
|
metadata: {
|
|
industry: data.metadata?.industry || null,
|
|
size: data.metadata?.size || null, // small, medium, large
|
|
description: data.metadata?.description || null,
|
|
notes: data.metadata?.notes || null
|
|
},
|
|
|
|
// Projects this organization has interacted with
|
|
projects: data.projects || [], // ['tractatus', 'family-history', 'sydigital']
|
|
|
|
// Relationship status
|
|
relationship: {
|
|
status: data.relationship?.status || 'prospect', // prospect, active, partner, archived
|
|
since: data.relationship?.since || new Date(),
|
|
tier: data.relationship?.tier || 'standard' // standard, priority, strategic
|
|
},
|
|
|
|
// Social/web presence
|
|
social: {
|
|
linkedin: data.social?.linkedin || null,
|
|
twitter: data.social?.twitter || null,
|
|
other: data.social?.other || {}
|
|
},
|
|
|
|
created_at: new Date(),
|
|
updated_at: new Date()
|
|
};
|
|
|
|
const result = await collection.insertOne(organization);
|
|
return { ...organization, _id: result.insertedId };
|
|
}
|
|
|
|
/**
|
|
* Find organization by ID
|
|
*/
|
|
static async findById(id) {
|
|
const collection = await getCollection('organizations');
|
|
return await collection.findOne({ _id: new ObjectId(id) });
|
|
}
|
|
|
|
/**
|
|
* Find organization by name (case-insensitive)
|
|
*/
|
|
static async findByName(name) {
|
|
const collection = await getCollection('organizations');
|
|
return await collection.findOne({
|
|
name: { $regex: new RegExp(`^${name}$`, 'i') }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find organization by domain
|
|
*/
|
|
static async findByDomain(domain) {
|
|
const collection = await getCollection('organizations');
|
|
return await collection.findOne({ domain });
|
|
}
|
|
|
|
/**
|
|
* Find or create organization
|
|
*/
|
|
static async findOrCreate(data) {
|
|
// Try to find by domain first (most reliable)
|
|
if (data.domain) {
|
|
const existing = await this.findByDomain(data.domain);
|
|
if (existing) return existing;
|
|
}
|
|
|
|
// Try to find by name
|
|
if (data.name) {
|
|
const existing = await this.findByName(data.name);
|
|
if (existing) return existing;
|
|
}
|
|
|
|
// Create new organization
|
|
return await this.create(data);
|
|
}
|
|
|
|
/**
|
|
* List organizations with filtering
|
|
*/
|
|
static async list(filters = {}, options = {}) {
|
|
const collection = await getCollection('organizations');
|
|
const { limit = 50, skip = 0 } = options;
|
|
|
|
const query = {};
|
|
if (filters.type) query.type = filters.type;
|
|
if (filters.country) query.country = filters.country;
|
|
if (filters.project) query.projects = filters.project;
|
|
if (filters.status) query['relationship.status'] = filters.status;
|
|
if (filters.tier) query['relationship.tier'] = filters.tier;
|
|
|
|
return await collection
|
|
.find(query)
|
|
.sort({ name: 1 })
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Search organizations by name or domain
|
|
*/
|
|
static async search(searchTerm, options = {}) {
|
|
const collection = await getCollection('organizations');
|
|
const { limit = 20 } = options;
|
|
|
|
return await collection
|
|
.find({
|
|
$or: [
|
|
{ name: { $regex: searchTerm, $options: 'i' } },
|
|
{ domain: { $regex: searchTerm, $options: 'i' } },
|
|
{ 'contact_info.website': { $regex: searchTerm, $options: 'i' } }
|
|
]
|
|
})
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Update organization
|
|
*/
|
|
static async update(id, data) {
|
|
const collection = await getCollection('organizations');
|
|
|
|
const updateData = {
|
|
...data,
|
|
updated_at: new Date()
|
|
};
|
|
|
|
await collection.updateOne(
|
|
{ _id: new ObjectId(id) },
|
|
{ $set: updateData }
|
|
);
|
|
|
|
return await this.findById(id);
|
|
}
|
|
|
|
/**
|
|
* Add project to organization
|
|
*/
|
|
static async addProject(id, projectName) {
|
|
const collection = await getCollection('organizations');
|
|
|
|
await collection.updateOne(
|
|
{ _id: new ObjectId(id) },
|
|
{
|
|
$addToSet: { projects: projectName },
|
|
$set: { updated_at: new Date() }
|
|
}
|
|
);
|
|
|
|
return await this.findById(id);
|
|
}
|
|
|
|
/**
|
|
* Delete organization
|
|
*/
|
|
static async delete(id) {
|
|
const collection = await getCollection('organizations');
|
|
return await collection.deleteOne({ _id: new ObjectId(id) });
|
|
}
|
|
|
|
/**
|
|
* Get statistics
|
|
*/
|
|
static async getStats() {
|
|
const collection = await getCollection('organizations');
|
|
|
|
const [total, byType, byStatus, byProject] = await Promise.all([
|
|
collection.countDocuments(),
|
|
collection.aggregate([
|
|
{ $group: { _id: '$type', count: { $sum: 1 } } }
|
|
]).toArray(),
|
|
collection.aggregate([
|
|
{ $group: { _id: '$relationship.status', count: { $sum: 1 } } }
|
|
]).toArray(),
|
|
collection.aggregate([
|
|
{ $unwind: '$projects' },
|
|
{ $group: { _id: '$projects', count: { $sum: 1 } } }
|
|
]).toArray()
|
|
]);
|
|
|
|
return {
|
|
total,
|
|
by_type: byType.reduce((acc, item) => {
|
|
acc[item._id] = item.count;
|
|
return acc;
|
|
}, {}),
|
|
by_status: byStatus.reduce((acc, item) => {
|
|
acc[item._id] = item.count;
|
|
return acc;
|
|
}, {}),
|
|
by_project: byProject.reduce((acc, item) => {
|
|
acc[item._id] = item.count;
|
|
return acc;
|
|
}, {})
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = Organization;
|