feat(crm): complete Phase 3 multi-project CRM + critical bug fixes

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>
This commit is contained in:
TheFlow 2025-10-24 18:10:14 +13:00
parent efab76e13c
commit 0b853c537d
21 changed files with 3267 additions and 6 deletions

View file

@ -1 +0,0 @@
,theflow,the-flow,24.10.2025 08:46,file:///home/theflow/.config/libreoffice/4;

View file

@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Management | Tractatus Admin</title>
<link rel="stylesheet" href="/css/tailwind.css?v=0.1.0.1729786000000">
<link rel="stylesheet" href="/css/tractatus-theme.min.css?v=0.1.0.1729786000000">
<script defer src="/js/admin/auth-check.js?v=0.1.0.1729786000000"></script>
</head>
<body class="bg-gray-50">
<div id="admin-navbar" data-page-title="Contact Management" data-page-icon="default"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Contact Management</h1>
<p class="mt-2 text-gray-600">Manage contact form submissions and track response times</p>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">Total Contacts</div>
<div class="mt-2 text-3xl font-semibold text-gray-900" id="stat-total">-</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">New</div>
<div class="mt-2 text-3xl font-semibold text-orange-600" id="stat-new">-</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">Assigned</div>
<div class="mt-2 text-3xl font-semibold text-blue-600" id="stat-assigned">-</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">Responded</div>
<div class="mt-2 text-3xl font-semibold text-green-600" id="stat-responded">-</div>
</div>
</div>
<!-- Filters -->
<div class="bg-white rounded-lg shadow p-6 mb-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<select id="filter-status" class="w-full border border-gray-300 rounded px-3 py-2">
<option value="">All Statuses</option>
<option value="new" selected>New</option>
<option value="assigned">Assigned</option>
<option value="responded">Responded</option>
<option value="closed">Closed</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Type</label>
<select id="filter-type" class="w-full border border-gray-300 rounded px-3 py-2">
<option value="">All Types</option>
<option value="general">General Inquiry</option>
<option value="partnership">Partnership</option>
<option value="technical">Technical</option>
<option value="feedback">Feedback</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority</label>
<select id="filter-priority" class="w-full border border-gray-300 rounded px-3 py-2">
<option value="">All Priorities</option>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
</select>
</div>
<div class="flex items-end">
<button id="refresh-btn" class="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Refresh
</button>
</div>
</div>
</div>
<!-- Contacts List -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Contact Submissions</h2>
</div>
<div id="contacts-container" class="divide-y divide-gray-200">
<!-- Will be populated by JS -->
</div>
</div>
</div>
<!-- Contact Detail Modal -->
<div id="contact-detail-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-900">Contact Details</h2>
<button id="close-detail-modal" class="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
</div>
<div id="contact-detail-content">
<!-- Will be populated dynamically -->
</div>
<div class="flex items-center justify-end gap-3 mt-6 pt-6 border-t">
<button id="close-detail-btn" class="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50">Close</button>
</div>
</div>
</div>
</div>
<script src="/js/components/navbar-admin.js?v=0.1.0.1729786000000"></script>
<script src="/js/admin/contact-management.js?v=0.1.0.1729786000000"></script>
</body>
</html>

View file

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CRM Dashboard | Tractatus Admin</title>
<link rel="stylesheet" href="/css/tailwind.css?v=0.1.0.1729786000000">
<link rel="stylesheet" href="/css/tractatus-theme.min.css?v=0.1.0.1729786000000">
<script defer src="/js/admin/auth-check.js?v=0.1.0.1729786000000"></script>
</head>
<body class="bg-gray-50">
<div id="admin-navbar" data-page-title="CRM Dashboard" data-page-icon="default"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Multi-Project CRM</h1>
<p class="mt-2 text-gray-600">Unified relationship management across all projects</p>
</div>
<!-- Quick Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">Total Contacts</div>
<div class="mt-2 text-3xl font-semibold text-gray-900" id="stat-contacts">-</div>
<div class="mt-1 text-xs text-gray-500" id="stat-contacts-projects">Across all projects</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">Organizations</div>
<div class="mt-2 text-3xl font-semibold text-blue-600" id="stat-organizations">-</div>
<div class="mt-1 text-xs text-gray-500" id="stat-organizations-types">Multiple types</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">SLA Compliance</div>
<div class="mt-2 text-3xl font-semibold text-green-600" id="stat-sla-compliance">-</div>
<div class="mt-1 text-xs text-gray-500">Response rate</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">SLA Breaches</div>
<div class="mt-2 text-3xl font-semibold text-red-600" id="stat-sla-breached">-</div>
<div class="mt-1 text-xs text-gray-500" id="stat-sla-pending">Pending responses</div>
</div>
</div>
<!-- SLA Alert Dashboard -->
<div class="bg-white rounded-lg shadow mb-8" id="sla-alerts-section">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">🚨 SLA Alerts</h2>
</div>
<div id="sla-alerts-container" class="p-6">
<!-- Will be populated by JS -->
</div>
</div>
<!-- Main Content Tabs -->
<div class="bg-white rounded-lg shadow">
<!-- Tab Navigation -->
<div class="border-b border-gray-200">
<nav class="flex space-x-8 px-6" aria-label="Tabs">
<button class="crm-tab active py-4 px-1 border-b-2 font-medium text-sm" data-tab="contacts">
Contacts
</button>
<button class="crm-tab py-4 px-1 border-b-2 font-medium text-sm" data-tab="organizations">
Organizations
</button>
<button class="crm-tab py-4 px-1 border-b-2 font-medium text-sm" data-tab="sla">
SLA Tracking
</button>
<button class="crm-tab py-4 px-1 border-b-2 font-medium text-sm" data-tab="templates">
Response Templates
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="p-6">
<!-- Contacts Tab -->
<div id="tab-contacts" class="crm-tab-content">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Unified Contacts</h3>
<div class="flex gap-2">
<select id="filter-contact-project" class="border border-gray-300 rounded px-3 py-2 text-sm">
<option value="">All Projects</option>
<option value="tractatus">Tractatus</option>
<option value="family-history">Family History</option>
<option value="sydigital">SYDigital</option>
</select>
<button id="refresh-contacts" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
Refresh
</button>
</div>
</div>
<div id="contacts-list" class="space-y-2">
<!-- Will be populated by JS -->
</div>
</div>
<!-- Organizations Tab -->
<div id="tab-organizations" class="crm-tab-content hidden">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Organizations</h3>
<button id="refresh-organizations" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
Refresh
</button>
</div>
<div id="organizations-list" class="space-y-2">
<!-- Will be populated by JS -->
</div>
</div>
<!-- SLA Tracking Tab -->
<div id="tab-sla" class="crm-tab-content hidden">
<h3 class="text-lg font-semibold mb-4">SLA Performance</h3>
<div id="sla-details">
<!-- Will be populated by JS -->
</div>
</div>
<!-- Templates Tab -->
<div id="tab-templates" class="crm-tab-content hidden">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Response Templates</h3>
<select id="filter-template-category" class="border border-gray-300 rounded px-3 py-2 text-sm">
<option value="">All Categories</option>
<option value="general">General</option>
<option value="media">Media</option>
<option value="technical">Technical</option>
<option value="partnership">Partnership</option>
</select>
</div>
<div id="templates-list" class="space-y-2">
<!-- Will be populated by JS -->
</div>
</div>
</div>
</div>
</div>
<script src="/js/components/navbar-admin.js?v=0.1.0.1729786000000"></script>
<script src="/js/admin/crm-dashboard.js?v=0.1.0.1729786000000"></script>
</body>
</html>

View file

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unified Inbox | Tractatus Admin</title>
<link rel="stylesheet" href="/css/tailwind.css?v=0.1.0.1729786000000">
<link rel="stylesheet" href="/css/tractatus-theme.min.css?v=0.1.0.1729786000000">
<script defer src="/js/admin/auth-check.js?v=0.1.0.1729786000000"></script>
</head>
<body class="bg-gray-50">
<div id="admin-navbar" data-page-title="Unified Inbox" data-page-icon="default"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Unified Inbox</h1>
<p class="mt-2 text-gray-600">All communications in one place: contacts, media inquiries, and case submissions</p>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">Total Items</div>
<div class="mt-2 text-3xl font-semibold text-gray-900" id="stat-total">-</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">New / Pending</div>
<div class="mt-2 text-3xl font-semibold text-orange-600" id="stat-new">-</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">Assigned / Triaged</div>
<div class="mt-2 text-3xl font-semibold text-blue-600" id="stat-assigned">-</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm font-medium text-gray-500">Responded</div>
<div class="mt-2 text-3xl font-semibold text-green-600" id="stat-responded">-</div>
</div>
</div>
<!-- Filters -->
<div class="bg-white rounded-lg shadow p-6 mb-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Type</label>
<select id="filter-type" class="w-full border border-gray-300 rounded px-3 py-2">
<option value="all" selected>All Types</option>
<option value="contact">Contacts</option>
<option value="media">Media Inquiries</option>
<option value="case">Case Submissions</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<select id="filter-status" class="w-full border border-gray-300 rounded px-3 py-2">
<option value="">All Statuses</option>
<option value="new" selected>New / Pending</option>
<option value="assigned">Assigned / Triaged</option>
<option value="responded">Responded</option>
<option value="closed">Closed</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority</label>
<select id="filter-priority" class="w-full border border-gray-300 rounded px-3 py-2">
<option value="">All Priorities</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
</div>
<div class="flex items-end">
<button id="refresh-btn" class="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Refresh
</button>
</div>
</div>
</div>
<!-- Inbox List -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Inbox Items</h2>
</div>
<div id="inbox-container" class="divide-y divide-gray-200">
<!-- Will be populated by JS -->
</div>
</div>
</div>
<!-- Item Detail Modal -->
<div id="item-detail-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-900">Item Details</h2>
<button id="close-detail-modal" class="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
</div>
<div id="item-detail-content">
<!-- Will be populated dynamically -->
</div>
<div class="flex items-center justify-end gap-3 mt-6 pt-6 border-t">
<button id="close-detail-btn" class="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50">Close</button>
</div>
</div>
</div>
</div>
<script src="/js/components/navbar-admin.js?v=0.1.0.1729786000000"></script>
<script src="/js/admin/unified-inbox.js?v=0.1.0.1729786000000"></script>
</body>
</html>

View file

@ -194,7 +194,7 @@
<!-- Submit Button -->
<div class="mt-8">
<button type="submit" id="submit-button" class="w-full text-white px-6 py-3 rounded-lg font-semibold transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5" class="bg-gradient-cyan-blue">
<button type="submit" id="submit-button" class="w-full bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold transition-all duration-200 hover:bg-blue-700 hover:shadow-lg hover:-translate-y-0.5">
Submit Case Study
</button>
<p class="form-help mt-3 text-center">

View file

@ -0,0 +1,447 @@
/**
* Contact Management Admin Page
* View and manage contact form submissions
*/
let allContacts = [];
let currentContact = null;
// Initialize page
document.addEventListener('DOMContentLoaded', async () => {
await loadStats();
await loadContacts();
setupEventListeners();
});
/**
* Load statistics
*/
async function loadStats() {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch('/api/contact/admin/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to load stats');
const data = await response.json();
const stats = data.stats;
document.getElementById('stat-total').textContent = stats.total;
document.getElementById('stat-new').textContent = stats.by_status.new;
document.getElementById('stat-assigned').textContent = stats.by_status.assigned;
document.getElementById('stat-responded').textContent = stats.by_status.responded;
} catch (error) {
console.error('Error loading stats:', error);
}
}
/**
* Load contacts
*/
async function loadContacts() {
try {
const token = localStorage.getItem('admin_token');
const status = document.getElementById('filter-status').value;
const type = document.getElementById('filter-type').value;
const priority = document.getElementById('filter-priority').value;
let url = '/api/contact/admin/list?limit=50';
if (status) url += `&status=${status}`;
if (type) url += `&type=${type}`;
if (priority) url += `&priority=${priority}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to load contacts');
const data = await response.json();
allContacts = data.contacts;
renderContacts();
} catch (error) {
console.error('Error loading contacts:', error);
showError('Failed to load contacts');
}
}
/**
* Render contacts list
*/
function renderContacts() {
const container = document.getElementById('contacts-container');
if (allContacts.length === 0) {
container.innerHTML = \`
<div class="px-6 py-12 text-center text-gray-500">
<p>No contacts found matching the selected filters.</p>
</div>
\`;
return;
}
container.innerHTML = allContacts.map(contact => \`
<div class="px-6 py-4 hover:bg-gray-50 cursor-pointer" data-contact-id="\${contact._id}">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-gray-900">\${escapeHtml(contact.contact.name)}</h3>
\${getTypeBadge(contact.type)}
\${getPriorityBadge(contact.priority)}
\${getStatusBadge(contact.status)}
</div>
<p class="text-sm text-gray-600 mb-2">
<strong>Email:</strong> \${escapeHtml(contact.contact.email)}
\${contact.contact.organization ? \` | <strong>Org:</strong> \${escapeHtml(contact.contact.organization)}\` : ''}
</p>
\${contact.inquiry.subject ? \`<p class="text-sm text-gray-700 font-medium mb-1">\${escapeHtml(contact.inquiry.subject)}</p>\` : ''}
<p class="text-sm text-gray-600 line-clamp-2">\${escapeHtml(contact.inquiry.message)}</p>
</div>
<div class="text-right text-sm text-gray-500 ml-4">
<div>\${formatDate(contact.created_at)}</div>
\${contact.response.sent_at ? \`<div class="text-green-600 text-xs mt-1">Responded \${formatDate(contact.response.sent_at)}</div>\` : ''}
</div>
</div>
</div>
\`).join('');
// Add click listeners
container.querySelectorAll('[data-contact-id]').forEach(el => {
el.addEventListener('click', () => {
const contactId = el.getAttribute('data-contact-id');
showContactDetail(contactId);
});
});
}
// ... rest of file continues in next command
/**
* Show contact detail modal
*/
async function showContactDetail(contactId) {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(\`/api/contact/admin/\${contactId}\`, {
headers: {
'Authorization': \`Bearer \${token}\`
}
});
if (!response.ok) throw new Error('Failed to load contact');
const data = await response.json();
currentContact = data.contact;
renderContactDetail(currentContact);
document.getElementById('contact-detail-modal').classList.remove('hidden');
} catch (error) {
console.error('Error loading contact detail:', error);
alert('Failed to load contact details');
}
}
/**
* Render contact detail
*/
function renderContactDetail(contact) {
const content = document.getElementById('contact-detail-content');
content.innerHTML = \`
<div class="space-y-6">
<!-- Contact Info -->
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Contact Information</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Name:</span>
<div class="font-medium">\${escapeHtml(contact.contact.name)}</div>
</div>
<div>
<span class="text-gray-500">Email:</span>
<div class="font-medium"><a href="mailto:\${escapeHtml(contact.contact.email)}" class="text-blue-600 hover:underline">\${escapeHtml(contact.contact.email)}</a></div>
</div>
\${contact.contact.organization ? \`
<div>
<span class="text-gray-500">Organization:</span>
<div class="font-medium">\${escapeHtml(contact.contact.organization)}</div>
</div>
\` : ''}
\${contact.contact.phone ? \`
<div>
<span class="text-gray-500">Phone:</span>
<div class="font-medium">\${escapeHtml(contact.contact.phone)}</div>
</div>
\` : ''}
</div>
</div>
<!-- Inquiry Details -->
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Inquiry</h3>
<div class="space-y-3">
<div class="flex gap-2">
\${getTypeBadge(contact.type)}
\${getPriorityBadge(contact.priority)}
\${getStatusBadge(contact.status)}
</div>
\${contact.inquiry.subject ? \`
<div>
<span class="text-sm text-gray-500">Subject:</span>
<div class="font-medium">\${escapeHtml(contact.inquiry.subject)}</div>
</div>
\` : ''}
<div>
<span class="text-sm text-gray-500">Message:</span>
<div class="mt-1 p-4 bg-gray-50 rounded border border-gray-200 whitespace-pre-wrap">\${escapeHtml(contact.inquiry.message)}</div>
</div>
</div>
</div>
<!-- Metadata -->
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Metadata</h3>
<div class="text-sm space-y-2">
<div><span class="text-gray-500">Submitted:</span> \${formatDate(contact.created_at)}</div>
<div><span class="text-gray-500">Source:</span> \${contact.source}</div>
\${contact.metadata.source_page ? \`<div><span class="text-gray-500">Source Page:</span> \${contact.metadata.source_page}</div>\` : ''}
\${contact.metadata.ip ? \`<div><span class="text-gray-500">IP:</span> \${contact.metadata.ip}</div>\` : ''}
</div>
</div>
<!-- Response Info -->
\${contact.response.sent_at ? \`
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Response</h3>
<div class="text-sm space-y-2">
<div><span class="text-gray-500">Responded:</span> \${formatDate(contact.response.sent_at)}</div>
\${contact.response.content ? \`<div class="mt-2 p-4 bg-green-50 rounded border border-green-200 whitespace-pre-wrap">\${escapeHtml(contact.response.content)}</div>\` : ''}
</div>
</div>
\` : ''}
<!-- Actions -->
<div class="flex gap-3">
\${contact.status === 'new' ? \`
<button onclick="updateContactStatus('\${contact._id}', 'assigned')" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Mark as Assigned
</button>
\` : ''}
\${contact.status === 'assigned' ? \`
<button onclick="markAsResponded('\${contact._id}')" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Mark as Responded
</button>
\` : ''}
\${contact.status !== 'closed' ? \`
<button onclick="updateContactStatus('\${contact._id}', 'closed')" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">
Close
</button>
\` : ''}
<button onclick="deleteContact('\${contact._id}')" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
Delete
</button>
</div>
</div>
\`;
}
/**
* Update contact status
*/
async function updateContactStatus(contactId, newStatus) {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(\`/api/contact/admin/\${contactId}\`, {
method: 'PUT',
headers: {
'Authorization': \`Bearer \${token}\`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: newStatus })
});
if (!response.ok) throw new Error('Failed to update status');
await loadStats();
await loadContacts();
closeDetailModal();
} catch (error) {
console.error('Error updating status:', error);
alert('Failed to update status');
}
}
/**
* Mark as responded
*/
async function markAsResponded(contactId) {
const responseContent = prompt('Enter response summary (optional):');
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(\`/api/contact/admin/\${contactId}/respond\`, {
method: 'POST',
headers: {
'Authorization': \`Bearer \${token}\`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: responseContent || 'Responded via email' })
});
if (!response.ok) throw new Error('Failed to mark as responded');
await loadStats();
await loadContacts();
closeDetailModal();
} catch (error) {
console.error('Error marking as responded:', error);
alert('Failed to mark as responded');
}
}
/**
* Delete contact
*/
async function deleteContact(contactId) {
if (!confirm('Are you sure you want to delete this contact? This cannot be undone.')) {
return;
}
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(\`/api/contact/admin/\${contactId}\`, {
method: 'DELETE',
headers: {
'Authorization': \`Bearer \${token}\`
}
});
if (!response.ok) throw new Error('Failed to delete contact');
await loadStats();
await loadContacts();
closeDetailModal();
} catch (error) {
console.error('Error deleting contact:', error);
alert('Failed to delete contact');
}
}
/**
* Setup event listeners
*/
function setupEventListeners() {
document.getElementById('filter-status').addEventListener('change', loadContacts);
document.getElementById('filter-type').addEventListener('change', loadContacts);
document.getElementById('filter-priority').addEventListener('change', loadContacts);
document.getElementById('refresh-btn').addEventListener('click', () => {
loadStats();
loadContacts();
});
document.getElementById('close-detail-modal').addEventListener('click', closeDetailModal);
document.getElementById('close-detail-btn').addEventListener('click', closeDetailModal);
document.getElementById('contact-detail-modal').addEventListener('click', (e) => {
if (e.target.id === 'contact-detail-modal') {
closeDetailModal();
}
});
}
/**
* Close detail modal
*/
function closeDetailModal() {
document.getElementById('contact-detail-modal').classList.add('hidden');
currentContact = null;
}
/**
* Utility: Get type badge
*/
function getTypeBadge(type) {
const colors = {
general: 'bg-gray-100 text-gray-800',
partnership: 'bg-purple-100 text-purple-800',
technical: 'bg-blue-100 text-blue-800',
feedback: 'bg-green-100 text-green-800'
};
return \`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium \${colors[type] || colors.general}">\${type}</span>\`;
}
/**
* Utility: Get priority badge
*/
function getPriorityBadge(priority) {
const colors = {
low: 'bg-gray-100 text-gray-600',
normal: 'bg-blue-100 text-blue-800',
high: 'bg-red-100 text-red-800'
};
return \`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium \${colors[priority] || colors.normal}">\${priority}</span>\`;
}
/**
* Utility: Get status badge
*/
function getStatusBadge(status) {
const colors = {
new: 'bg-orange-100 text-orange-800',
assigned: 'bg-blue-100 text-blue-800',
responded: 'bg-green-100 text-green-800',
closed: 'bg-gray-100 text-gray-600'
};
return \`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium \${colors[status] || colors.new}">\${status}</span>\`;
}
/**
* Utility: Format date
*/
function formatDate(dateString) {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleString('en-NZ', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Utility: Escape HTML
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Utility: Show error
*/
function showError(message) {
const container = document.getElementById('contacts-container');
container.innerHTML = \`
<div class="px-6 py-12 text-center">
<p class="text-red-600">\${escapeHtml(message)}</p>
</div>
\`;
}

View file

@ -155,11 +155,18 @@ function renderSubscribers(subscriptions) {
// Add event listeners to buttons
tbody.querySelectorAll('.view-details-btn').forEach(btn => {
btn.addEventListener('click', () => viewDetails(btn.dataset.id));
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
viewDetails(id);
});
});
tbody.querySelectorAll('.delete-subscriber-btn').forEach(btn => {
btn.addEventListener('click', () => deleteSubscriber(btn.dataset.id, btn.dataset.email));
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
const email = this.getAttribute('data-email');
deleteSubscriber(id, email);
});
});
}

View file

@ -0,0 +1,521 @@
/**
* Unified Inbox Admin Page
* View and manage all communications: contacts, media inquiries, case submissions
*/
let allItems = [];
let currentItem = null;
// Initialize page
document.addEventListener('DOMContentLoaded', async () => {
await loadStats();
await loadInbox();
setupEventListeners();
});
/**
* Load statistics
*/
async function loadStats() {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch('/api/inbox/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to load stats');
const data = await response.json();
const stats = data.stats;
document.getElementById('stat-total').textContent = stats.total.all;
document.getElementById('stat-new').textContent = stats.total.new;
document.getElementById('stat-assigned').textContent = stats.total.assigned;
document.getElementById('stat-responded').textContent = stats.total.responded;
} catch (error) {
console.error('Error loading stats:', error);
}
}
/**
* Load inbox items
*/
async function loadInbox() {
try {
const token = localStorage.getItem('admin_token');
const type = document.getElementById('filter-type').value;
const status = document.getElementById('filter-status').value;
const priority = document.getElementById('filter-priority').value;
let url = '/api/inbox?limit=50';
if (type && type !== 'all') url += `&type=${type}`;
if (status) url += `&status=${status}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to load inbox');
const data = await response.json();
allItems = data.items;
// Apply client-side priority filter if needed
if (priority) {
allItems = allItems.filter(item => item._priority === priority);
}
renderInbox();
} catch (error) {
console.error('Error loading inbox:', error);
showError('Failed to load inbox');
}
}
/**
* Render inbox items list
*/
function renderInbox() {
const container = document.getElementById('inbox-container');
if (allItems.length === 0) {
container.innerHTML = `
<div class="px-6 py-12 text-center text-gray-500">
<p>No items found matching the selected filters.</p>
</div>
`;
return;
}
container.innerHTML = allItems.map(item => renderInboxItem(item)).join('');
// Add click listeners
container.querySelectorAll('[data-item-id]').forEach(el => {
el.addEventListener('click', () => {
const itemId = el.getAttribute('data-item-id');
const itemType = el.getAttribute('data-item-type');
showItemDetail(itemId, itemType);
});
});
}
/**
* Render single inbox item
*/
function renderInboxItem(item) {
const typeBadge = getTypeBadge(item._type);
const statusBadge = getStatusBadge(item._displayStatus);
const priorityBadge = getPriorityBadge(item._priority);
// Extract display data based on type
let title, subtitle, preview;
if (item._type === 'contact') {
title = item.contact.name;
subtitle = item.contact.email;
preview = item.inquiry.subject || item.inquiry.message;
} else if (item._type === 'media') {
title = item.contact.name;
subtitle = `${item.contact.outlet}${item.contact.email}`;
preview = item.inquiry.subject;
} else if (item._type === 'case') {
title = item.submitter.name;
subtitle = item.submitter.email;
preview = item.case_study.title;
}
return `
<div class="px-6 py-4 hover:bg-gray-50 cursor-pointer" data-item-id="${item._id}" data-item-type="${item._type}">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-gray-900">${escapeHtml(title)}</h3>
${typeBadge}
${priorityBadge}
${statusBadge}
</div>
<p class="text-sm text-gray-600 mb-2">${escapeHtml(subtitle)}</p>
<p class="text-sm text-gray-700 font-medium line-clamp-2">${escapeHtml(preview)}</p>
</div>
<div class="text-right text-sm text-gray-500 ml-4">
<div>${formatDate(item._created)}</div>
</div>
</div>
</div>
`;
}
/**
* Show item detail modal
*/
async function showItemDetail(itemId, itemType) {
try {
const token = localStorage.getItem('admin_token');
let endpoint;
if (itemType === 'contact') {
endpoint = `/api/contact/admin/${itemId}`;
} else if (itemType === 'media') {
endpoint = `/api/media/inquiries/${itemId}`;
} else if (itemType === 'case') {
endpoint = `/api/cases/submissions/${itemId}`;
}
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to load item');
const data = await response.json();
currentItem = { ...data.contact || data.inquiry || data.submission, _type: itemType };
renderItemDetail(currentItem);
document.getElementById('item-detail-modal').classList.remove('hidden');
} catch (error) {
console.error('Error loading item detail:', error);
alert('Failed to load item details');
}
}
/**
* Render item detail based on type
*/
function renderItemDetail(item) {
const content = document.getElementById('item-detail-content');
if (item._type === 'contact') {
content.innerHTML = renderContactDetail(item);
} else if (item._type === 'media') {
content.innerHTML = renderMediaDetail(item);
} else if (item._type === 'case') {
content.innerHTML = renderCaseDetail(item);
}
}
/**
* Render contact detail
*/
function renderContactDetail(contact) {
return `
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Contact Information</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Name:</span>
<div class="font-medium">${escapeHtml(contact.contact.name)}</div>
</div>
<div>
<span class="text-gray-500">Email:</span>
<div class="font-medium"><a href="mailto:${escapeHtml(contact.contact.email)}" class="text-blue-600 hover:underline">${escapeHtml(contact.contact.email)}</a></div>
</div>
${contact.contact.organization ? `
<div>
<span class="text-gray-500">Organization:</span>
<div class="font-medium">${escapeHtml(contact.contact.organization)}</div>
</div>
` : ''}
</div>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Inquiry</h3>
<div class="space-y-3">
${contact.inquiry.subject ? `
<div>
<span class="text-sm text-gray-500">Subject:</span>
<div class="font-medium">${escapeHtml(contact.inquiry.subject)}</div>
</div>
` : ''}
<div>
<span class="text-sm text-gray-500">Message:</span>
<div class="mt-1 p-4 bg-gray-50 rounded border border-gray-200 whitespace-pre-wrap">${escapeHtml(contact.inquiry.message)}</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-4 border-t">
<a href="/admin/contact-management.html" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Manage in Contact Manager
</a>
</div>
</div>
`;
}
/**
* Render media inquiry detail
*/
function renderMediaDetail(media) {
return `
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Media Contact</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Name:</span>
<div class="font-medium">${escapeHtml(media.contact.name)}</div>
</div>
<div>
<span class="text-gray-500">Email:</span>
<div class="font-medium"><a href="mailto:${escapeHtml(media.contact.email)}" class="text-blue-600 hover:underline">${escapeHtml(media.contact.email)}</a></div>
</div>
<div>
<span class="text-gray-500">Outlet:</span>
<div class="font-medium">${escapeHtml(media.contact.outlet)}</div>
</div>
${media.contact.phone ? `
<div>
<span class="text-gray-500">Phone:</span>
<div class="font-medium">${escapeHtml(media.contact.phone)}</div>
</div>
` : ''}
</div>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Inquiry</h3>
<div class="space-y-3">
<div>
<span class="text-sm text-gray-500">Subject:</span>
<div class="font-medium">${escapeHtml(media.inquiry.subject)}</div>
</div>
<div>
<span class="text-sm text-gray-500">Message:</span>
<div class="mt-1 p-4 bg-gray-50 rounded border border-gray-200 whitespace-pre-wrap">${escapeHtml(media.inquiry.message)}</div>
</div>
${media.inquiry.deadline ? `
<div>
<span class="text-sm text-gray-500">Deadline:</span>
<div class="font-medium text-red-600">${formatDate(media.inquiry.deadline)}</div>
</div>
` : ''}
</div>
</div>
${media.ai_triage ? `
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">AI Triage</h3>
<div class="space-y-2 text-sm">
<div><span class="text-gray-500">Urgency:</span> ${getUrgencyBadge(media.ai_triage.urgency)}</div>
${media.ai_triage.claude_summary ? `
<div>
<span class="text-gray-500">Summary:</span>
<div class="mt-1 p-3 bg-blue-50 rounded border border-blue-200 text-sm">${escapeHtml(media.ai_triage.claude_summary)}</div>
</div>
` : ''}
</div>
</div>
` : ''}
<div class="flex gap-3 pt-4 border-t">
<a href="/admin/media-triage.html" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Manage in Media Triage
</a>
</div>
</div>
`;
}
/**
* Render case submission detail
*/
function renderCaseDetail(caseItem) {
return `
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Submitter</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Name:</span>
<div class="font-medium">${escapeHtml(caseItem.submitter.name)}</div>
</div>
<div>
<span class="text-gray-500">Email:</span>
<div class="font-medium"><a href="mailto:${escapeHtml(caseItem.submitter.email)}" class="text-blue-600 hover:underline">${escapeHtml(caseItem.submitter.email)}</a></div>
</div>
${caseItem.submitter.organization ? `
<div>
<span class="text-gray-500">Organization:</span>
<div class="font-medium">${escapeHtml(caseItem.submitter.organization)}</div>
</div>
` : ''}
</div>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Case Study</h3>
<div class="space-y-3">
<div>
<span class="text-sm text-gray-500">Title:</span>
<div class="font-medium">${escapeHtml(caseItem.case_study.title)}</div>
</div>
<div>
<span class="text-sm text-gray-500">Description:</span>
<div class="mt-1 p-4 bg-gray-50 rounded border border-gray-200 whitespace-pre-wrap">${escapeHtml(caseItem.case_study.description)}</div>
</div>
<div>
<span class="text-sm text-gray-500">Failure Mode:</span>
<div class="font-medium">${escapeHtml(caseItem.case_study.failure_mode)}</div>
</div>
</div>
</div>
${caseItem.ai_review && caseItem.ai_review.relevance_score ? `
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">AI Review</h3>
<div class="space-y-2 text-sm">
<div><span class="text-gray-500">Relevance Score:</span> ${Math.round(caseItem.ai_review.relevance_score * 100)}%</div>
${caseItem.ai_review.recommended_category ? `
<div><span class="text-gray-500">Recommended Category:</span> ${escapeHtml(caseItem.ai_review.recommended_category)}</div>
` : ''}
</div>
</div>
` : ''}
<div class="flex gap-3 pt-4 border-t">
<a href="/admin/case-moderation.html" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Manage in Case Moderation
</a>
</div>
</div>
`;
}
/**
* Setup event listeners
*/
function setupEventListeners() {
document.getElementById('filter-type').addEventListener('change', loadInbox);
document.getElementById('filter-status').addEventListener('change', loadInbox);
document.getElementById('filter-priority').addEventListener('change', loadInbox);
document.getElementById('refresh-btn').addEventListener('click', () => {
loadStats();
loadInbox();
});
document.getElementById('close-detail-modal').addEventListener('click', closeDetailModal);
document.getElementById('close-detail-btn').addEventListener('click', closeDetailModal);
document.getElementById('item-detail-modal').addEventListener('click', (e) => {
if (e.target.id === 'item-detail-modal') {
closeDetailModal();
}
});
}
/**
* Close detail modal
*/
function closeDetailModal() {
document.getElementById('item-detail-modal').classList.add('hidden');
currentItem = null;
}
/**
* Utility: Get type badge
*/
function getTypeBadge(type) {
const colors = {
contact: 'bg-blue-100 text-blue-800',
media: 'bg-purple-100 text-purple-800',
case: 'bg-green-100 text-green-800'
};
const labels = {
contact: '📬 Contact',
media: '📰 Media',
case: '📋 Case'
};
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type] || colors.contact}">${labels[type] || type}</span>`;
}
/**
* Utility: Get priority badge
*/
function getPriorityBadge(priority) {
const colors = {
low: 'bg-gray-100 text-gray-600',
normal: 'bg-blue-100 text-blue-800',
medium: 'bg-blue-100 text-blue-800',
high: 'bg-red-100 text-red-800'
};
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[priority] || colors.normal}">${priority}</span>`;
}
/**
* Utility: Get status badge
*/
function getStatusBadge(status) {
const colors = {
new: 'bg-orange-100 text-orange-800',
pending: 'bg-orange-100 text-orange-800',
assigned: 'bg-blue-100 text-blue-800',
triaged: 'bg-blue-100 text-blue-800',
responded: 'bg-green-100 text-green-800',
closed: 'bg-gray-100 text-gray-600',
approved: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
needs_info: 'bg-yellow-100 text-yellow-800'
};
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.new}">${status}</span>`;
}
/**
* Utility: Get urgency badge
*/
function getUrgencyBadge(urgency) {
const colors = {
low: 'bg-gray-100 text-gray-800',
medium: 'bg-blue-100 text-blue-800',
high: 'bg-red-100 text-red-800'
};
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[urgency] || colors.medium}">${urgency}</span>`;
}
/**
* Utility: Format date
*/
function formatDate(dateString) {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleString('en-NZ', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Utility: Escape HTML
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Utility: Show error
*/
function showError(message) {
const container = document.getElementById('inbox-container');
container.innerHTML = `
<div class="px-6 py-12 text-center">
<p class="text-red-600">${escapeHtml(message)}</p>
</div>
`;
}

View file

@ -125,6 +125,12 @@
<!-- CRM & Communications -->
<div class="pb-3 mb-3 border-b border-gray-200">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 px-3">CRM & Communications</p>
<a href="/admin/crm-dashboard.html" class="block px-3 py-2.5 text-gray-700 hover:bg-blue-50 hover:text-blue-700 rounded-lg transition">
<span class="text-sm">🎯 CRM Dashboard</span>
</a>
<a href="/admin/unified-inbox.html" class="block px-3 py-2.5 text-gray-700 hover:bg-blue-50 hover:text-blue-700 rounded-lg transition">
<span class="text-sm">📥 Unified Inbox</span>
</a>
<a href="/admin/contact-management.html" class="block px-3 py-2.5 text-gray-700 hover:bg-blue-50 hover:text-blue-700 rounded-lg transition">
<span class="text-sm">📬 Contact Management</span>
</a>

View file

@ -8,7 +8,7 @@
<link rel="stylesheet" href="/css/tailwind.css?v=1761163813">
<link rel="stylesheet" href="/css/tractatus-theme.min.css?v=1761163813">
<!-- Chart.js for visual analytics -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js?v=1761163813" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" integrity="sha256-DiMmxoaAcr7BWSdgxnKQQ8ruopYKK0bO5qIZKqxqv/A=" crossorigin="anonymous"></script>
<style>
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: 0; z-index: 100; background: white; padding: 1rem; }

View file

@ -0,0 +1,287 @@
/**
* CRM Controller
* Multi-project CRM system for contacts, organizations, activities, and SLAs
*/
const UnifiedContact = require('../models/UnifiedContact.model');
const Organization = require('../models/Organization.model');
const ActivityTimeline = require('../models/ActivityTimeline.model');
const ResponseTemplate = require('../models/ResponseTemplate.model');
const SLATracking = require('../models/SLATracking.model');
const Contact = require('../models/Contact.model');
const MediaInquiry = require('../models/MediaInquiry.model');
const CaseSubmission = require('../models/CaseSubmission.model');
/**
* Get CRM dashboard statistics
*/
async function getDashboardStats(req, res) {
try {
const [contactStats, orgStats, slaStats, templateStats] = await Promise.all([
UnifiedContact.getStats(),
Organization.getStats(),
SLATracking.getStats({ project: 'tractatus' }),
ResponseTemplate.getStats()
]);
res.json({
success: true,
stats: {
contacts: contactStats,
organizations: orgStats,
sla: slaStats,
templates: templateStats
}
});
} catch (error) {
console.error('CRM dashboard stats error:', error);
res.status(500).json({
success: false,
error: 'Failed to load dashboard statistics'
});
}
}
/**
* List unified contacts
*/
async function listContacts(req, res) {
try {
const { project, status, tag, organization_id, limit = 50, skip = 0 } = req.query;
const filters = {};
if (project) filters.project = project;
if (status) filters.status = status;
if (tag) filters.tag = tag;
if (organization_id) filters.organization_id = organization_id;
const contacts = await UnifiedContact.list(filters, {
limit: parseInt(limit),
skip: parseInt(skip)
});
res.json({
success: true,
total: contacts.length,
contacts
});
} catch (error) {
console.error('List contacts error:', error);
res.status(500).json({
success: false,
error: 'Failed to list contacts'
});
}
}
/**
* Get single contact with full details
*/
async function getContact(req, res) {
try {
const { id } = req.params;
const contact = await UnifiedContact.findById(id);
if (!contact) {
return res.status(404).json({
success: false,
error: 'Contact not found'
});
}
// Get organization if linked
let organization = null;
if (contact.organization_id) {
organization = await Organization.findById(contact.organization_id);
}
// Get activity timeline
const activities = await ActivityTimeline.getByContact(id, { limit: 50 });
res.json({
success: true,
contact,
organization,
activities
});
} catch (error) {
console.error('Get contact error:', error);
res.status(500).json({
success: false,
error: 'Failed to load contact'
});
}
}
/**
* List organizations
*/
async function listOrganizations(req, res) {
try {
const { type, country, project, status, tier, limit = 50, skip = 0 } = req.query;
const filters = {};
if (type) filters.type = type;
if (country) filters.country = country;
if (project) filters.project = project;
if (status) filters.status = status;
if (tier) filters.tier = tier;
const organizations = await Organization.list(filters, {
limit: parseInt(limit),
skip: parseInt(skip)
});
res.json({
success: true,
total: organizations.length,
organizations
});
} catch (error) {
console.error('List organizations error:', error);
res.status(500).json({
success: false,
error: 'Failed to list organizations'
});
}
}
/**
* Get single organization with contacts and activity
*/
async function getOrganization(req, res) {
try {
const { id } = req.params;
const organization = await Organization.findById(id);
if (!organization) {
return res.status(404).json({
success: false,
error: 'Organization not found'
});
}
// Get contacts at this organization
const contacts = await UnifiedContact.findByOrganization(id, { limit: 100 });
// Get activity timeline
const activities = await ActivityTimeline.getByOrganization(id, { limit: 50 });
res.json({
success: true,
organization,
contacts,
activities
});
} catch (error) {
console.error('Get organization error:', error);
res.status(500).json({
success: false,
error: 'Failed to load organization'
});
}
}
/**
* Get SLA dashboard
*/
async function getSLADashboard(req, res) {
try {
const { project = 'tractatus' } = req.query;
const [stats, pending, approaching, breached] = await Promise.all([
SLATracking.getStats({ project }),
SLATracking.getPending({ project, limit: 20 }),
SLATracking.getApproachingBreach({ project }),
SLATracking.getBreached({ project, limit: 20 })
]);
res.json({
success: true,
stats,
pending,
approaching_breach: approaching,
breached
});
} catch (error) {
console.error('SLA dashboard error:', error);
res.status(500).json({
success: false,
error: 'Failed to load SLA dashboard'
});
}
}
/**
* List response templates
*/
async function listTemplates(req, res) {
try {
const { category, language, project, tag, limit = 50, skip = 0 } = req.query;
const filters = {};
if (category) filters.category = category;
if (language) filters.language = language;
if (project) filters.project = project;
if (tag) filters.tag = tag;
const templates = await ResponseTemplate.list(filters, {
limit: parseInt(limit),
skip: parseInt(skip)
});
res.json({
success: true,
total: templates.length,
templates
});
} catch (error) {
console.error('List templates error:', error);
res.status(500).json({
success: false,
error: 'Failed to list templates'
});
}
}
/**
* Render template with variables
*/
async function renderTemplate(req, res) {
try {
const { id } = req.params;
const { variables } = req.body;
const rendered = await ResponseTemplate.render(id, variables || {});
res.json({
success: true,
rendered
});
} catch (error) {
console.error('Render template error:', error);
res.status(500).json({
success: false,
error: 'Failed to render template'
});
}
}
module.exports = {
getDashboardStats,
listContacts,
getContact,
listOrganizations,
getOrganization,
getSLADashboard,
listTemplates,
renderTemplate
};

View file

@ -0,0 +1,189 @@
/**
* Unified Inbox Controller
* Combines contact, media, and case submissions into single view
*/
const Contact = require('../models/Contact.model');
const MediaInquiry = require('../models/MediaInquiry.model');
const CaseSubmission = require('../models/CaseSubmission.model');
/**
* Get unified inbox with all communication types
*/
async function getInbox(req, res) {
try {
const {
type, // filter: contact, media, case, or all
status, // filter: new, pending, assigned, etc
limit = 20,
skip = 0
} = req.query;
const options = {
limit: parseInt(limit),
skip: parseInt(skip)
};
let items = [];
// Fetch contacts
if (!type || type === 'all' || type === 'contact') {
const contacts = status
? await Contact.findByStatus(status, options)
: await Contact.list({}, options);
items.push(...contacts.map(c => ({
...c,
_type: 'contact',
_displayStatus: c.status,
_priority: c.priority || 'normal',
_created: c.created_at
})));
}
// Fetch media inquiries
if (!type || type === 'all' || type === 'media') {
const media = status
? await MediaInquiry.findByStatus(status, options)
: await MediaInquiry.findByStatus('new', options);
items.push(...media.map(m => ({
...m,
_type: 'media',
_displayStatus: m.status,
_priority: m.ai_triage?.urgency || 'medium',
_created: m.created_at
})));
}
// Fetch case submissions
if (!type || type === 'all' || type === 'case') {
const cases = status
? await CaseSubmission.findByStatus(status, options)
: await CaseSubmission.findByStatus('pending', options);
items.push(...cases.map(c => ({
...c,
_type: 'case',
_displayStatus: c.moderation?.status || 'pending',
_priority: c.ai_review?.relevance_score >= 0.7 ? 'high' : 'normal',
_created: c.submitted_at
})));
}
// Sort by created date (most recent first)
items.sort((a, b) => new Date(b._created) - new Date(a._created));
// Apply limit to combined results
const paginatedItems = items.slice(0, options.limit);
res.json({
success: true,
total: items.length,
items: paginatedItems,
hasMore: items.length > options.limit
});
} catch (error) {
console.error('Unified inbox error:', error);
res.status(500).json({
success: false,
error: 'Failed to load inbox'
});
}
}
/**
* Get unified inbox statistics
*/
async function getStats(req, res) {
try {
// Get statistics from all three sources
const [contactStats, mediaStats, caseStats] = await Promise.all([
Contact.getStats(),
getMediaStats(),
getCaseStats()
]);
res.json({
success: true,
stats: {
contacts: contactStats,
media: mediaStats,
cases: caseStats,
total: {
new: (contactStats.by_status.new || 0) +
(mediaStats.by_status.new || 0) +
(caseStats.by_status.pending || 0),
assigned: (contactStats.by_status.assigned || 0) +
(mediaStats.by_status.triaged || 0),
responded: (contactStats.by_status.responded || 0) +
(mediaStats.by_status.responded || 0),
all: contactStats.total + mediaStats.total + caseStats.total
}
}
});
} catch (error) {
console.error('Inbox stats error:', error);
res.status(500).json({
success: false,
error: 'Failed to load statistics'
});
}
}
/**
* Helper: Get media inquiry statistics
*/
async function getMediaStats() {
const [total, newCount, triagedCount, respondedCount, closedCount] = await Promise.all([
MediaInquiry.countByStatus('new'),
MediaInquiry.countByStatus('new'),
MediaInquiry.countByStatus('triaged'),
MediaInquiry.countByStatus('responded'),
MediaInquiry.countByStatus('closed')
]);
// Get total by adding all status counts
const totalSum = newCount + triagedCount + respondedCount + closedCount;
return {
total: totalSum,
by_status: {
new: newCount,
triaged: triagedCount,
responded: respondedCount,
closed: closedCount
}
};
}
/**
* Helper: Get case submission statistics
*/
async function getCaseStats() {
const [pending, approved, rejected, needsInfo] = await Promise.all([
CaseSubmission.countByStatus('pending'),
CaseSubmission.countByStatus('approved'),
CaseSubmission.countByStatus('rejected'),
CaseSubmission.countByStatus('needs_info')
]);
const total = pending + approved + rejected + needsInfo;
return {
total,
by_status: {
pending,
approved,
rejected,
needs_info: needsInfo
}
};
}
module.exports = {
getInbox,
getStats
};

View file

@ -18,7 +18,7 @@ function securityHeadersMiddleware(req, res, next) {
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self'",
"script-src 'self' https://cdn.jsdelivr.net", // Allow Chart.js CDN
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", // Tailwind + Google Fonts
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com",

View file

@ -0,0 +1,181 @@
/**
* 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;

View file

@ -0,0 +1,232 @@
/**
* 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;

View file

@ -0,0 +1,244 @@
/**
* ResponseTemplate Model
* Pre-written response templates for common inquiries
*/
const { ObjectId } = require('mongodb');
const { getCollection } = require('../utils/db.util');
class ResponseTemplate {
/**
* Create a new response template
*/
static async create(data) {
const collection = await getCollection('response_templates');
const template = {
// Template identification
name: data.name,
description: data.description || null,
// Template content
subject: data.subject || null, // For email templates
content: data.content, // Template text (supports {placeholders})
// Categorization
category: data.category || 'general', // general, media, technical, partnership, case, rejection
language: data.language || 'en',
// Usage metadata
projects: data.projects || ['tractatus'], // Which projects can use this template
tags: data.tags || [],
// Template variables (placeholders)
variables: data.variables || [], // [{name: 'contact_name', description: 'Name of the contact', required: true}]
// Who can use this template
visibility: data.visibility || 'public', // public, private, team
created_by: data.created_by ? new ObjectId(data.created_by) : null,
// Usage statistics
usage_stats: {
times_used: 0,
last_used: null
},
// Status
active: data.active !== undefined ? data.active : true,
created_at: new Date(),
updated_at: new Date()
};
const result = await collection.insertOne(template);
return { ...template, _id: result.insertedId };
}
/**
* Find template by ID
*/
static async findById(id) {
const collection = await getCollection('response_templates');
return await collection.findOne({ _id: new ObjectId(id) });
}
/**
* List templates with filtering
*/
static async list(filters = {}, options = {}) {
const collection = await getCollection('response_templates');
const { limit = 50, skip = 0 } = options;
const query = { active: true };
if (filters.category) query.category = filters.category;
if (filters.language) query.language = filters.language;
if (filters.project) query.projects = filters.project;
if (filters.tag) query.tags = filters.tag;
if (filters.visibility) query.visibility = filters.visibility;
return await collection
.find(query)
.sort({ name: 1 })
.skip(skip)
.limit(limit)
.toArray();
}
/**
* Search templates by name or content
*/
static async search(searchTerm, options = {}) {
const collection = await getCollection('response_templates');
const { limit = 20 } = options;
return await collection
.find({
active: true,
$or: [
{ name: { $regex: searchTerm, $options: 'i' } },
{ description: { $regex: searchTerm, $options: 'i' } },
{ content: { $regex: searchTerm, $options: 'i' } },
{ tags: { $regex: searchTerm, $options: 'i' } }
]
})
.limit(limit)
.toArray();
}
/**
* Render template with variables
*/
static async render(id, variables = {}) {
const template = await this.findById(id);
if (!template) {
throw new Error('Template not found');
}
let rendered = {
subject: template.subject,
content: template.content
};
// Replace placeholders with actual values
Object.keys(variables).forEach(key => {
const placeholder = new RegExp(`\\{${key}\\}`, 'g');
if (rendered.subject) {
rendered.subject = rendered.subject.replace(placeholder, variables[key]);
}
rendered.content = rendered.content.replace(placeholder, variables[key]);
});
// Track usage
await this.incrementUsage(id);
return rendered;
}
/**
* Increment usage counter
*/
static async incrementUsage(id) {
const collection = await getCollection('response_templates');
await collection.updateOne(
{ _id: new ObjectId(id) },
{
$inc: { 'usage_stats.times_used': 1 },
$set: {
'usage_stats.last_used': new Date(),
updated_at: new Date()
}
}
);
}
/**
* Update template
*/
static async update(id, data) {
const collection = await getCollection('response_templates');
const updateData = {
...data,
updated_at: new Date()
};
await collection.updateOne(
{ _id: new ObjectId(id) },
{ $set: updateData }
);
return await this.findById(id);
}
/**
* Deactivate template (soft delete)
*/
static async deactivate(id) {
return await this.update(id, { active: false });
}
/**
* Delete template
*/
static async delete(id) {
const collection = await getCollection('response_templates');
return await collection.deleteOne({ _id: new ObjectId(id) });
}
/**
* Get popular templates
*/
static async getPopular(limit = 10) {
const collection = await getCollection('response_templates');
return await collection
.find({ active: true })
.sort({ 'usage_stats.times_used': -1 })
.limit(limit)
.toArray();
}
/**
* Get statistics
*/
static async getStats() {
const collection = await getCollection('response_templates');
const [total, active, byCategory, byLanguage, topUsed] = await Promise.all([
collection.countDocuments(),
collection.countDocuments({ active: true }),
collection.aggregate([
{ $match: { active: true } },
{ $group: { _id: '$category', count: { $sum: 1 } } }
]).toArray(),
collection.aggregate([
{ $match: { active: true } },
{ $group: { _id: '$language', count: { $sum: 1 } } }
]).toArray(),
collection.find({ active: true })
.sort({ 'usage_stats.times_used': -1 })
.limit(5)
.toArray()
]);
return {
total,
active,
by_category: byCategory.reduce((acc, item) => {
acc[item._id] = item.count;
return acc;
}, {}),
by_language: byLanguage.reduce((acc, item) => {
acc[item._id] = item.count;
return acc;
}, {}),
top_used: topUsed.map(t => ({
name: t.name,
times_used: t.usage_stats.times_used
}))
};
}
}
module.exports = ResponseTemplate;

View file

@ -0,0 +1,296 @@
/**
* SLATracking Model
* Tracks Service Level Agreement compliance for response times
*/
const { ObjectId } = require('mongodb');
const { getCollection } = require('../utils/db.util');
class SLATracking {
/**
* Create SLA tracking entry
*/
static async create(data) {
const collection = await getCollection('sla_tracking');
const sla = {
// What this SLA is tracking
item_type: data.item_type, // contact, media, case
item_id: new ObjectId(data.item_id),
// SLA targets (hours)
sla_target: data.sla_target || 24, // Default 24 hours
sla_priority: data.sla_priority || 'normal', // low, normal, high, urgent
// Timestamps
received_at: data.received_at || new Date(),
due_at: data.due_at || this.calculateDueDate(data.sla_target || 24),
first_response_at: data.first_response_at || null,
resolved_at: data.resolved_at || null,
// Status
status: data.status || 'pending', // pending, responded, resolved, breached
breach: data.breach || false,
// Response time metrics (hours)
response_time: data.response_time || null,
resolution_time: data.resolution_time || null,
// Escalation tracking
escalated: data.escalated || false,
escalated_at: data.escalated_at || null,
escalated_to: data.escalated_to ? new ObjectId(data.escalated_to) : null,
// Metadata
project: data.project || 'tractatus',
assigned_to: data.assigned_to ? new ObjectId(data.assigned_to) : null,
created_at: new Date(),
updated_at: new Date()
};
const result = await collection.insertOne(sla);
return { ...sla, _id: result.insertedId };
}
/**
* Calculate due date based on SLA hours
*/
static calculateDueDate(slaHours, fromDate = new Date()) {
return new Date(fromDate.getTime() + (slaHours * 60 * 60 * 1000));
}
/**
* Find SLA by item
*/
static async findByItem(itemType, itemId) {
const collection = await getCollection('sla_tracking');
return await collection.findOne({
item_type: itemType,
item_id: new ObjectId(itemId)
});
}
/**
* Record first response
*/
static async recordResponse(itemType, itemId) {
const collection = await getCollection('sla_tracking');
const sla = await this.findByItem(itemType, itemId);
if (!sla || sla.first_response_at) {
return; // Already recorded
}
const responseTime = (new Date() - new Date(sla.received_at)) / (1000 * 60 * 60); // hours
const breach = responseTime > sla.sla_target;
await collection.updateOne(
{ _id: sla._id },
{
$set: {
first_response_at: new Date(),
response_time: responseTime,
status: 'responded',
breach: breach,
updated_at: new Date()
}
}
);
return await collection.findOne({ _id: sla._id });
}
/**
* Record resolution
*/
static async recordResolution(itemType, itemId) {
const collection = await getCollection('sla_tracking');
const sla = await this.findByItem(itemType, itemId);
if (!sla || sla.resolved_at) {
return; // Already recorded
}
const resolutionTime = (new Date() - new Date(sla.received_at)) / (1000 * 60 * 60); // hours
await collection.updateOne(
{ _id: sla._id },
{
$set: {
resolved_at: new Date(),
resolution_time: resolutionTime,
status: 'resolved',
updated_at: new Date()
}
}
);
return await collection.findOne({ _id: sla._id });
}
/**
* Escalate SLA
*/
static async escalate(itemType, itemId, escalatedTo) {
const collection = await getCollection('sla_tracking');
const sla = await this.findByItem(itemType, itemId);
if (!sla) return;
await collection.updateOne(
{ _id: sla._id },
{
$set: {
escalated: true,
escalated_at: new Date(),
escalated_to: new ObjectId(escalatedTo),
updated_at: new Date()
}
}
);
return await collection.findOne({ _id: sla._id });
}
/**
* Get items approaching SLA breach (within 2 hours)
*/
static async getApproachingBreach(options = {}) {
const collection = await getCollection('sla_tracking');
const { project = null } = options;
const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + (2 * 60 * 60 * 1000));
const query = {
status: 'pending',
breach: false,
due_at: {
$gte: now,
$lte: twoHoursFromNow
}
};
if (project) query.project = project;
return await collection
.find(query)
.sort({ due_at: 1 })
.toArray();
}
/**
* Get breached SLAs
*/
static async getBreached(options = {}) {
const collection = await getCollection('sla_tracking');
const { limit = 50, project = null } = options;
const query = { breach: true };
if (project) query.project = project;
return await collection
.find(query)
.sort({ due_at: 1 })
.limit(limit)
.toArray();
}
/**
* Get pending items (not yet responded)
*/
static async getPending(options = {}) {
const collection = await getCollection('sla_tracking');
const { limit = 50, project = null } = options;
const query = { status: 'pending' };
if (project) query.project = project;
return await collection
.find(query)
.sort({ due_at: 1 })
.limit(limit)
.toArray();
}
/**
* Check and update breached SLAs
*/
static async checkBreaches() {
const collection = await getCollection('sla_tracking');
const now = new Date();
// Find all pending items past their due date
const breached = await collection.find({
status: 'pending',
breach: false,
due_at: { $lt: now }
}).toArray();
if (breached.length > 0) {
// Mark as breached
await collection.updateMany(
{
status: 'pending',
breach: false,
due_at: { $lt: now }
},
{
$set: {
breach: true,
status: 'breached',
updated_at: new Date()
}
}
);
}
return breached.length;
}
/**
* Get SLA statistics
*/
static async getStats(filters = {}) {
const collection = await getCollection('sla_tracking');
const query = {};
if (filters.project) query.project = filters.project;
const [total, pending, responded, resolved, breached, avgResponseTime] = await Promise.all([
collection.countDocuments(query),
collection.countDocuments({ ...query, status: 'pending' }),
collection.countDocuments({ ...query, status: 'responded' }),
collection.countDocuments({ ...query, status: 'resolved' }),
collection.countDocuments({ ...query, breach: true }),
collection.aggregate([
{ $match: { ...query, response_time: { $exists: true, $ne: null } } },
{ $group: { _id: null, avg: { $avg: '$response_time' } } }
]).toArray()
]);
const complianceRate = total > 0 ? ((total - breached) / total) * 100 : 100;
return {
total,
pending,
responded,
resolved,
breached,
compliance_rate: Math.round(complianceRate * 100) / 100,
avg_response_time: avgResponseTime.length > 0 ? Math.round(avgResponseTime[0].avg * 100) / 100 : null
};
}
/**
* Delete SLA record
*/
static async delete(id) {
const collection = await getCollection('sla_tracking');
return await collection.deleteOne({ _id: new ObjectId(id) });
}
}
module.exports = SLATracking;

View file

@ -0,0 +1,354 @@
/**
* 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;

66
src/routes/crm.routes.js Normal file
View file

@ -0,0 +1,66 @@
/**
* CRM Routes
* Multi-project CRM system
*/
const express = require('express');
const router = express.Router();
const crmController = require('../controllers/crm.controller');
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
const { asyncHandler } = require('../middleware/error.middleware');
// Dashboard
router.get('/dashboard/stats',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(crmController.getDashboardStats)
);
// Contacts
router.get('/contacts',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(crmController.listContacts)
);
router.get('/contacts/:id',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(crmController.getContact)
);
// Organizations
router.get('/organizations',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(crmController.listOrganizations)
);
router.get('/organizations/:id',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(crmController.getOrganization)
);
// SLA Tracking
router.get('/sla/dashboard',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(crmController.getSLADashboard)
);
// Response Templates
router.get('/templates',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(crmController.listTemplates)
);
router.post('/templates/:id/render',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(crmController.renderTemplate)
);
module.exports = router;

View file

@ -0,0 +1,27 @@
/**
* Unified Inbox Routes
* Combined view of contacts, media inquiries, and case submissions
*/
const express = require('express');
const router = express.Router();
const inboxController = require('../controllers/inbox.controller');
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
const { asyncHandler } = require('../middleware/error.middleware');
// GET /api/inbox - Get unified inbox (requires admin auth)
router.get('/',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(inboxController.getInbox)
);
// GET /api/inbox/stats - Get unified statistics (requires admin auth)
router.get('/stats',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(inboxController.getStats)
);
module.exports = router;

View file

@ -28,6 +28,8 @@ const publicationsRoutes = require('./publications.routes');
const submissionsRoutes = require('./submissions.routes');
const relationshipsRoutes = require('./relationships.routes');
const contactRoutes = require('./contact.routes');
const inboxRoutes = require('./inbox.routes');
const crmRoutes = require('./crm.routes');
// Development/test routes (only in development)
if (process.env.NODE_ENV !== 'production') {
@ -57,6 +59,8 @@ router.use('/publications', publicationsRoutes);
router.use('/submissions', submissionsRoutes);
router.use('/relationships', relationshipsRoutes);
router.use('/contact', contactRoutes);
router.use('/inbox', inboxRoutes);
router.use('/crm', crmRoutes);
// API root endpoint - redirect browsers to documentation
router.get('/', (req, res) => {
@ -127,6 +131,30 @@ router.get('/', (req, res) => {
request_info: 'POST /api/cases/submissions/:id/request-info (admin)',
delete: 'DELETE /api/cases/submissions/:id (admin)'
},
contact: {
submit: 'POST /api/contact/submit',
stats: 'GET /api/contact/admin/stats (admin)',
list: 'GET /api/contact/admin/list (admin)',
get: 'GET /api/contact/admin/:id (admin)',
assign: 'POST /api/contact/admin/:id/assign (admin)',
respond: 'POST /api/contact/admin/:id/respond (admin)',
update: 'PUT /api/contact/admin/:id (admin)',
delete: 'DELETE /api/contact/admin/:id (admin)'
},
inbox: {
list: 'GET /api/inbox (admin)',
stats: 'GET /api/inbox/stats (admin)'
},
crm: {
dashboard: 'GET /api/crm/dashboard/stats (admin)',
contacts: 'GET /api/crm/contacts (admin)',
contact: 'GET /api/crm/contacts/:id (admin)',
organizations: 'GET /api/crm/organizations (admin)',
organization: 'GET /api/crm/organizations/:id (admin)',
sla_dashboard: 'GET /api/crm/sla/dashboard (admin)',
templates: 'GET /api/crm/templates (admin)',
render_template: 'POST /api/crm/templates/:id/render (admin)'
},
admin: {
moderation_queue: 'GET /api/admin/moderation',
moderation_item: 'GET /api/admin/moderation/:id',