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:
parent
fe3035913e
commit
edb1540631
21 changed files with 3267 additions and 6 deletions
|
|
@ -1 +0,0 @@
|
|||
,theflow,the-flow,24.10.2025 08:46,file:///home/theflow/.config/libreoffice/4;
|
||||
117
public/admin/contact-management.html
Normal file
117
public/admin/contact-management.html
Normal 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">×</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>
|
||||
144
public/admin/crm-dashboard.html
Normal file
144
public/admin/crm-dashboard.html
Normal 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>
|
||||
116
public/admin/unified-inbox.html
Normal file
116
public/admin/unified-inbox.html
Normal 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">×</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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
447
public/js/admin/contact-management.js
Normal file
447
public/js/admin/contact-management.js
Normal 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>
|
||||
\`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
521
public/js/admin/unified-inbox.js
Normal file
521
public/js/admin/unified-inbox.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
287
src/controllers/crm.controller.js
Normal file
287
src/controllers/crm.controller.js
Normal 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
|
||||
};
|
||||
189
src/controllers/inbox.controller.js
Normal file
189
src/controllers/inbox.controller.js
Normal 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
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
181
src/models/ActivityTimeline.model.js
Normal file
181
src/models/ActivityTimeline.model.js
Normal 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;
|
||||
232
src/models/Organization.model.js
Normal file
232
src/models/Organization.model.js
Normal 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;
|
||||
244
src/models/ResponseTemplate.model.js
Normal file
244
src/models/ResponseTemplate.model.js
Normal 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;
|
||||
296
src/models/SLATracking.model.js
Normal file
296
src/models/SLATracking.model.js
Normal 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;
|
||||
354
src/models/UnifiedContact.model.js
Normal file
354
src/models/UnifiedContact.model.js
Normal 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
66
src/routes/crm.routes.js
Normal 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;
|
||||
27
src/routes/inbox.routes.js
Normal file
27
src/routes/inbox.routes.js
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue