refactor: deep cleanup - remove all website code from framework repo
REMOVED: 77 website-specific files from src/ and public/
Website Models (9):
- Blog, CaseSubmission, Document, Donation, MediaInquiry,
ModerationQueue, NewsletterSubscription, Resource, User
Website Services (6):
- BlogCuration, MediaTriage, Koha, ClaudeAPI, ClaudeMdAnalyzer,
AdaptiveCommunicationOrchestrator
Website Controllers (9):
- blog, cases, documents, koha, media, newsletter, auth, admin, variables
Website Routes (10):
- blog, cases, documents, koha, media, newsletter, auth, admin, test, demo
Website Middleware (4):
- auth, csrf-protection, file-security, response-sanitization
Website Utils (3):
- document-section-parser, jwt, markdown
Website JS (36):
- Website components, docs viewers, page features, i18n, Koha
RETAINED Framework Code:
- 6 core services (Boundary, ContextPressure, CrossReference,
InstructionPersistence, Metacognitive, PluralisticDeliberation)
- 4 support services (AnthropicMemoryClient, MemoryProxy,
RuleOptimizer, VariableSubstitution)
- 9 framework models (governance, audit, deliberation, project state)
- 3 framework controllers (rules, projects, audit)
- 7 framework routes (rules, governance, projects, audit, hooks, sync)
- 6 framework middleware (error, validation, security, governance)
- Minimal admin UI (rule manager, dashboard, hooks dashboard)
- Framework demos and documentation
PURPOSE: Tractatus-framework repo is now PURELY framework code.
All website/project code remains in internal repo only.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
cd6e7bcd0b
commit
aab23e8c33
77 changed files with 0 additions and 21354 deletions
|
|
@ -1,36 +0,0 @@
|
||||||
# Security Policy
|
|
||||||
|
|
||||||
Contact: mailto:security@agenticgovernance.digital
|
|
||||||
Expires: 2026-10-09T00:00:00.000Z
|
|
||||||
Preferred-Languages: en
|
|
||||||
Canonical: https://agenticgovernance.digital/.well-known/security.txt
|
|
||||||
|
|
||||||
# Encryption
|
|
||||||
# Please use PGP encryption for sensitive security reports
|
|
||||||
# Public key available at: https://agenticgovernance.digital/.well-known/pgp-key.txt
|
|
||||||
|
|
||||||
# Policy
|
|
||||||
# We take security seriously and appreciate responsible disclosure
|
|
||||||
# Please allow up to 48 hours for initial response
|
|
||||||
# We aim to patch critical vulnerabilities within 7 days
|
|
||||||
|
|
||||||
# Scope
|
|
||||||
# In scope:
|
|
||||||
# - XSS, CSRF, SQL/NoSQL injection
|
|
||||||
# - Authentication/authorization bypass
|
|
||||||
# - Sensitive data exposure
|
|
||||||
# - Server-side vulnerabilities
|
|
||||||
|
|
||||||
# Out of scope:
|
|
||||||
# - Social engineering
|
|
||||||
# - Physical security
|
|
||||||
# - Denial of Service (DoS/DDoS)
|
|
||||||
# - Self-XSS
|
|
||||||
# - Clickjacking on pages without sensitive actions
|
|
||||||
|
|
||||||
# Acknowledgments
|
|
||||||
# https://agenticgovernance.digital/security-researchers
|
|
||||||
|
|
||||||
# Hall of Fame
|
|
||||||
# Security researchers who responsibly disclosed vulnerabilities:
|
|
||||||
# (None yet - be the first!)
|
|
||||||
Binary file not shown.
|
|
@ -1,107 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<!-- Central core gradient (shared with Passport - cyan to blue) -->
|
|
||||||
<radialGradient id="tractatusCore">
|
|
||||||
<stop offset="0%" style="stop-color:#64ffda;stop-opacity:1" />
|
|
||||||
<stop offset="70%" style="stop-color:#448aff;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#0ea5e9;stop-opacity:1" />
|
|
||||||
</radialGradient>
|
|
||||||
|
|
||||||
<!-- Service-specific gradients (6 governance services) -->
|
|
||||||
<!-- 1. BoundaryEnforcer - Green (safety, protection) -->
|
|
||||||
<linearGradient id="serviceBoundary" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#10b981;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#059669;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<!-- 2. InstructionPersistenceClassifier - Indigo (memory, persistence) -->
|
|
||||||
<linearGradient id="serviceInstruction" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#4f46e5;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<!-- 3. CrossReferenceValidator - Purple (verification) -->
|
|
||||||
<linearGradient id="serviceValidator" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<!-- 4. ContextPressureMonitor - Amber (alertness, monitoring) -->
|
|
||||||
<linearGradient id="servicePressure" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#f59e0b;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<!-- 5. MetacognitiveVerifier - Rose (reflection, thought) -->
|
|
||||||
<linearGradient id="serviceMetacognitive" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#ec4899;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#db2777;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<!-- 6. PluralisticDeliberationOrchestrator - Teal (balance, mediation) -->
|
|
||||||
<linearGradient id="serviceDeliberation" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#14b8a6;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#0d9488;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<!-- Connection lines gradient -->
|
|
||||||
<linearGradient id="connectionGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" style="stop-color:#64ffda;stop-opacity:0.2" />
|
|
||||||
<stop offset="50%" style="stop-color:#64ffda;stop-opacity:0.5" />
|
|
||||||
<stop offset="100%" style="stop-color:#64ffda;stop-opacity:0.2" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<!-- Drop shadow for depth -->
|
|
||||||
<filter id="dropShadow">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.3"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Subtle background for contrast -->
|
|
||||||
<circle cx="100" cy="100" r="95" fill="rgba(255,255,255,0.02)"/>
|
|
||||||
|
|
||||||
<!-- Orbital rings (3 layers - governance architecture) -->
|
|
||||||
<circle cx="100" cy="100" r="85" stroke="#64ffda" stroke-width="1" opacity="0.15" fill="none"/>
|
|
||||||
<circle cx="100" cy="100" r="70" stroke="#64ffda" stroke-width="1" opacity="0.25" fill="none"/>
|
|
||||||
<circle cx="100" cy="100" r="55" stroke="#64ffda" stroke-width="1" opacity="0.35" fill="none"/>
|
|
||||||
|
|
||||||
<!-- Connection lines from center to each service node (hexagonal pattern) -->
|
|
||||||
<g opacity="0.4">
|
|
||||||
<line x1="100" y1="100" x2="100" y2="35" stroke="url(#connectionGradient)" stroke-width="2"/>
|
|
||||||
<line x1="100" y1="100" x2="156" y2="67.5" stroke="url(#connectionGradient)" stroke-width="2"/>
|
|
||||||
<line x1="100" y1="100" x2="156" y2="132.5" stroke="url(#connectionGradient)" stroke-width="2"/>
|
|
||||||
<line x1="100" y1="100" x2="100" y2="165" stroke="url(#connectionGradient)" stroke-width="2"/>
|
|
||||||
<line x1="100" y1="100" x2="44" y2="132.5" stroke="url(#connectionGradient)" stroke-width="2"/>
|
|
||||||
<line x1="100" y1="100" x2="44" y2="67.5" stroke="url(#connectionGradient)" stroke-width="2"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Six governance service nodes in hexagonal arrangement -->
|
|
||||||
<!-- 1. BoundaryEnforcer (top) - Green -->
|
|
||||||
<circle cx="100" cy="35" r="18" fill="url(#serviceBoundary)" filter="url(#dropShadow)" opacity="0.9"/>
|
|
||||||
|
|
||||||
<!-- 2. InstructionPersistenceClassifier (top-right) - Indigo -->
|
|
||||||
<circle cx="156" cy="67.5" r="18" fill="url(#serviceInstruction)" filter="url(#dropShadow)" opacity="0.9"/>
|
|
||||||
|
|
||||||
<!-- 3. CrossReferenceValidator (bottom-right) - Purple -->
|
|
||||||
<circle cx="156" cy="132.5" r="18" fill="url(#serviceValidator)" filter="url(#dropShadow)" opacity="0.9"/>
|
|
||||||
|
|
||||||
<!-- 4. ContextPressureMonitor (bottom) - Amber -->
|
|
||||||
<circle cx="100" cy="165" r="18" fill="url(#servicePressure)" filter="url(#dropShadow)" opacity="0.9"/>
|
|
||||||
|
|
||||||
<!-- 5. MetacognitiveVerifier (bottom-left) - Rose -->
|
|
||||||
<circle cx="44" cy="132.5" r="18" fill="url(#serviceMetacognitive)" filter="url(#dropShadow)" opacity="0.9"/>
|
|
||||||
|
|
||||||
<!-- 6. PluralisticDeliberationOrchestrator (top-left) - Teal -->
|
|
||||||
<circle cx="44" cy="67.5" r="18" fill="url(#serviceDeliberation)" filter="url(#dropShadow)" opacity="0.9"/>
|
|
||||||
|
|
||||||
<!-- Central core (AI system being governed) -->
|
|
||||||
<circle cx="100" cy="100" r="35" fill="url(#tractatusCore)" filter="url(#dropShadow)"/>
|
|
||||||
|
|
||||||
<!-- Subtle outer glow -->
|
|
||||||
<circle cx="100" cy="100" r="38" fill="none" stroke="rgba(100,255,218,0.2)" stroke-width="2"/>
|
|
||||||
|
|
||||||
<!-- Center symbol - "T" for Tractatus -->
|
|
||||||
<circle cx="100" cy="100" r="28" fill="rgba(0,0,0,0.25)"/>
|
|
||||||
<text x="100" y="110" text-anchor="middle" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="white" opacity="0.95">T</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
|
@ -1,29 +0,0 @@
|
||||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" class="tractatus-icon">
|
|
||||||
<!-- Outer orbit -->
|
|
||||||
<circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="1" opacity="0.3" fill="none"/>
|
|
||||||
|
|
||||||
<!-- Middle orbit -->
|
|
||||||
<circle cx="24" cy="24" r="14" stroke="currentColor" stroke-width="1" opacity="0.4" fill="none"/>
|
|
||||||
|
|
||||||
<!-- Inner orbit -->
|
|
||||||
<circle cx="24" cy="24" r="8" stroke="currentColor" stroke-width="1" opacity="0.5" fill="none"/>
|
|
||||||
|
|
||||||
<!-- Center sphere with gradient for depth -->
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="centerGradient">
|
|
||||||
<stop offset="0%" stop-color="currentColor" stop-opacity="1"/>
|
|
||||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0.7"/>
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
<circle cx="24" cy="24" r="5" fill="url(#centerGradient)"/>
|
|
||||||
|
|
||||||
<!-- Orbital dots positioned strategically -->
|
|
||||||
<!-- Outer orbit dot (top-right) -->
|
|
||||||
<circle cx="38" cy="10" r="2" fill="currentColor" opacity="0.7"/>
|
|
||||||
|
|
||||||
<!-- Middle orbit dot (bottom-left) -->
|
|
||||||
<circle cx="14" cy="34" r="1.5" fill="currentColor" opacity="0.8"/>
|
|
||||||
|
|
||||||
<!-- Inner orbit dot (right) -->
|
|
||||||
<circle cx="32" cy="24" r="1.2" fill="currentColor" opacity="0.9"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
|
@ -1,286 +0,0 @@
|
||||||
/**
|
|
||||||
* Newsletter Management - Admin Interface
|
|
||||||
*/
|
|
||||||
|
|
||||||
let currentPage = 1;
|
|
||||||
const perPage = 50;
|
|
||||||
let currentFilters = {
|
|
||||||
status: 'active',
|
|
||||||
verified: 'all'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize page
|
|
||||||
*/
|
|
||||||
async function init() {
|
|
||||||
// Event listeners (navbar handles admin name and logout now)
|
|
||||||
document.getElementById('refresh-btn').addEventListener('click', () => loadAll());
|
|
||||||
document.getElementById('export-btn').addEventListener('click', exportSubscribers);
|
|
||||||
document.getElementById('filter-status').addEventListener('change', handleFilterChange);
|
|
||||||
document.getElementById('filter-verified').addEventListener('change', handleFilterChange);
|
|
||||||
document.getElementById('prev-page').addEventListener('click', () => changePage(-1));
|
|
||||||
document.getElementById('next-page').addEventListener('click', () => changePage(1));
|
|
||||||
|
|
||||||
// Load data
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all data
|
|
||||||
*/
|
|
||||||
async function loadAll() {
|
|
||||||
await Promise.all([
|
|
||||||
loadStats(),
|
|
||||||
loadSubscribers()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load statistics
|
|
||||||
*/
|
|
||||||
async function loadStats() {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('admin_token');
|
|
||||||
const response = await fetch('/api/newsletter/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 || 0;
|
|
||||||
document.getElementById('stat-active').textContent = stats.active || 0;
|
|
||||||
document.getElementById('stat-verified').textContent = stats.verified || 0;
|
|
||||||
document.getElementById('stat-recent').textContent = stats.recent_30_days || 0;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading stats:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load subscribers list
|
|
||||||
*/
|
|
||||||
async function loadSubscribers() {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('admin_token');
|
|
||||||
const skip = (currentPage - 1) * perPage;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
limit: perPage,
|
|
||||||
skip,
|
|
||||||
active: currentFilters.status === 'all' ? null : currentFilters.status === 'active',
|
|
||||||
verified: currentFilters.verified === 'all' ? null : currentFilters.verified === 'verified'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove null values
|
|
||||||
for (const [key, value] of [...params.entries()]) {
|
|
||||||
if (value === 'null' || value === null) {
|
|
||||||
params.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/newsletter/admin/subscriptions?${params}`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to load subscribers');
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
renderSubscribers(data.subscriptions);
|
|
||||||
updatePagination(data.pagination);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading subscribers:', error);
|
|
||||||
document.getElementById('subscribers-table').innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-6 py-8 text-center text-red-600">
|
|
||||||
Error loading subscribers. Please refresh the page.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render subscribers table
|
|
||||||
*/
|
|
||||||
function renderSubscribers(subscriptions) {
|
|
||||||
const tbody = document.getElementById('subscribers-table');
|
|
||||||
|
|
||||||
if (!subscriptions || subscriptions.length === 0) {
|
|
||||||
tbody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
|
|
||||||
No subscribers found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = subscriptions.map(sub => `
|
|
||||||
<tr>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
${escapeHtml(sub.email)}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
${escapeHtml(sub.name) || '-'}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
${escapeHtml(sub.source) || 'unknown'}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
|
||||||
${sub.active
|
|
||||||
? `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
|
||||||
${sub.verified ? '<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>' : ''}
|
|
||||||
${sub.verified ? 'Active ✓' : 'Active'}
|
|
||||||
</span>`
|
|
||||||
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">Inactive</span>'
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
${formatDate(sub.subscribed_at)}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<button class="view-details-btn text-blue-600 hover:text-blue-900 mr-3" data-id="${sub._id}">View</button>
|
|
||||||
<button class="delete-subscriber-btn text-red-600 hover:text-red-900" data-id="${sub._id}" data-email="${escapeHtml(sub.email)}">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Add event listeners to buttons
|
|
||||||
tbody.querySelectorAll('.view-details-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => viewDetails(btn.dataset.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
tbody.querySelectorAll('.delete-subscriber-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => deleteSubscriber(btn.dataset.id, btn.dataset.email));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update pagination UI
|
|
||||||
*/
|
|
||||||
function updatePagination(pagination) {
|
|
||||||
document.getElementById('showing-from').textContent = pagination.skip + 1;
|
|
||||||
document.getElementById('showing-to').textContent = Math.min(pagination.skip + pagination.limit, pagination.total);
|
|
||||||
document.getElementById('total-count').textContent = pagination.total;
|
|
||||||
|
|
||||||
document.getElementById('prev-page').disabled = currentPage === 1;
|
|
||||||
document.getElementById('next-page').disabled = !pagination.has_more;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle filter change
|
|
||||||
*/
|
|
||||||
function handleFilterChange() {
|
|
||||||
currentFilters.status = document.getElementById('filter-status').value;
|
|
||||||
currentFilters.verified = document.getElementById('filter-verified').value;
|
|
||||||
currentPage = 1;
|
|
||||||
loadSubscribers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change page
|
|
||||||
*/
|
|
||||||
function changePage(direction) {
|
|
||||||
currentPage += direction;
|
|
||||||
loadSubscribers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* View subscriber details
|
|
||||||
*/
|
|
||||||
async function viewDetails(id) {
|
|
||||||
alert(`Subscriber details for ID: ${id}\n(Full implementation would show a modal with complete subscriber information)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete subscriber
|
|
||||||
*/
|
|
||||||
async function deleteSubscriber(id, email) {
|
|
||||||
if (!confirm(`Are you sure you want to delete subscription for ${email}?\n\nThis action cannot be undone.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('admin_token');
|
|
||||||
const response = await fetch(`/api/newsletter/admin/subscriptions/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete subscriber');
|
|
||||||
|
|
||||||
alert('Subscriber deleted successfully');
|
|
||||||
await loadAll();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting subscriber:', error);
|
|
||||||
alert('Failed to delete subscriber. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export subscribers as CSV
|
|
||||||
*/
|
|
||||||
async function exportSubscribers() {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('admin_token');
|
|
||||||
const active = currentFilters.status === 'all' ? 'all' : 'true';
|
|
||||||
|
|
||||||
const response = await fetch(`/api/newsletter/admin/export?active=${active}`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to export subscribers');
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `newsletter-subscribers-${Date.now()}.csv`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error exporting subscribers:', error);
|
|
||||||
alert('Failed to export subscribers. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout handled by navbar component
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date
|
|
||||||
*/
|
|
||||||
function formatDate(dateString) {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML
|
|
||||||
*/
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
|
|
@ -1,433 +0,0 @@
|
||||||
/**
|
|
||||||
* Blog Post Page - Client-Side Logic
|
|
||||||
* Handles fetching and displaying individual blog posts with metadata, sharing, and related posts
|
|
||||||
*/
|
|
||||||
|
|
||||||
let currentPost = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the blog post page
|
|
||||||
*/
|
|
||||||
async function init() {
|
|
||||||
try {
|
|
||||||
// Get slug from URL parameter
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const slug = urlParams.get('slug');
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
showError('No blog post specified');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadPost(slug);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing blog post:', error);
|
|
||||||
showError('Failed to load blog post');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load blog post by slug
|
|
||||||
*/
|
|
||||||
async function loadPost(slug) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/blog/${slug}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
throw new Error(data.error || 'Post not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPost = data.post;
|
|
||||||
|
|
||||||
// Render post
|
|
||||||
renderPost();
|
|
||||||
|
|
||||||
// Load related posts
|
|
||||||
loadRelatedPosts();
|
|
||||||
|
|
||||||
// Attach event listeners
|
|
||||||
attachEventListeners();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading post:', error);
|
|
||||||
showError(error.message || 'Post not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Safely set element content
|
|
||||||
*/
|
|
||||||
function safeSetContent(elementId, content) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
element.textContent = content;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Safely set element attribute
|
|
||||||
*/
|
|
||||||
function safeSetAttribute(elementId, attribute, value) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
element.setAttribute(attribute, value);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the blog post
|
|
||||||
*/
|
|
||||||
function renderPost() {
|
|
||||||
// Hide loading state
|
|
||||||
safeSetClass('loading-state', 'add', 'hidden');
|
|
||||||
safeSetClass('error-state', 'add', 'hidden');
|
|
||||||
|
|
||||||
// Show post content
|
|
||||||
safeSetClass('post-content', 'remove', 'hidden');
|
|
||||||
|
|
||||||
// Update page title and meta description
|
|
||||||
safeSetContent('page-title', `${currentPost.title} | Tractatus Blog`);
|
|
||||||
safeSetAttribute('page-description', 'content', currentPost.excerpt || currentPost.title);
|
|
||||||
|
|
||||||
// Update social media meta tags
|
|
||||||
updateSocialMetaTags(currentPost);
|
|
||||||
|
|
||||||
// Update breadcrumb
|
|
||||||
safeSetContent('breadcrumb-title', truncate(currentPost.title, 50));
|
|
||||||
|
|
||||||
// Render post header
|
|
||||||
if (currentPost.category) {
|
|
||||||
safeSetContent('post-category', currentPost.category);
|
|
||||||
} else {
|
|
||||||
const categoryEl = document.getElementById('post-category');
|
|
||||||
if (categoryEl) categoryEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
safeSetContent('post-title', currentPost.title);
|
|
||||||
|
|
||||||
// Author - handle both flat (author_name) and nested (author.name) structures
|
|
||||||
const authorName = currentPost.author_name || currentPost.author?.name || 'Tractatus Team';
|
|
||||||
safeSetContent('post-author', authorName);
|
|
||||||
|
|
||||||
// Date
|
|
||||||
const publishedDate = new Date(currentPost.published_at);
|
|
||||||
const formattedDate = publishedDate.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
safeSetContent('post-date', formattedDate);
|
|
||||||
safeSetAttribute('post-date', 'datetime', currentPost.published_at);
|
|
||||||
|
|
||||||
// Read time
|
|
||||||
const wordCount = currentPost.content ? currentPost.content.split(/\s+/).length : 0;
|
|
||||||
const readTime = Math.max(1, Math.ceil(wordCount / 200));
|
|
||||||
safeSetContent('post-read-time', `${readTime} min read`);
|
|
||||||
|
|
||||||
// Tags
|
|
||||||
if (currentPost.tags && currentPost.tags.length > 0) {
|
|
||||||
const tagsHTML = currentPost.tags.map(tag => `
|
|
||||||
<span class="inline-block bg-gray-100 text-gray-700 text-sm px-3 py-1 rounded-full">
|
|
||||||
${escapeHtml(tag)}
|
|
||||||
</span>
|
|
||||||
`).join('');
|
|
||||||
const tagsEl = document.getElementById('post-tags');
|
|
||||||
if (tagsEl) tagsEl.innerHTML = tagsHTML;
|
|
||||||
safeSetClass('post-tags-container', 'remove', 'hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI disclosure (if AI-assisted)
|
|
||||||
if (currentPost.ai_assisted || currentPost.metadata?.ai_assisted) {
|
|
||||||
safeSetClass('ai-disclosure', 'remove', 'hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post body
|
|
||||||
const bodyHTML = currentPost.content_html || convertMarkdownToHTML(currentPost.content);
|
|
||||||
const bodyEl = document.getElementById('post-body');
|
|
||||||
if (bodyEl) bodyEl.innerHTML = bodyHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Safely add/remove class
|
|
||||||
*/
|
|
||||||
function safeSetClass(elementId, action, className) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
if (action === 'add') {
|
|
||||||
element.classList.add(className);
|
|
||||||
} else if (action === 'remove') {
|
|
||||||
element.classList.remove(className);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load related posts (same category or similar tags)
|
|
||||||
*/
|
|
||||||
async function loadRelatedPosts() {
|
|
||||||
try {
|
|
||||||
// Fetch all published posts
|
|
||||||
const response = await fetch('/api/blog');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) return;
|
|
||||||
|
|
||||||
let allPosts = data.posts || [];
|
|
||||||
|
|
||||||
// Filter out current post
|
|
||||||
allPosts = allPosts.filter(post => post._id !== currentPost._id);
|
|
||||||
|
|
||||||
// Find related posts (same category, or matching tags)
|
|
||||||
let relatedPosts = [];
|
|
||||||
|
|
||||||
// Priority 1: Same category
|
|
||||||
if (currentPost.category) {
|
|
||||||
relatedPosts = allPosts.filter(post => post.category === currentPost.category);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: Matching tags (if not enough from same category)
|
|
||||||
if (relatedPosts.length < 3 && currentPost.tags && currentPost.tags.length > 0) {
|
|
||||||
const tagMatches = allPosts.filter(post => {
|
|
||||||
if (!post.tags || post.tags.length === 0) return false;
|
|
||||||
return post.tags.some(tag => currentPost.tags.includes(tag));
|
|
||||||
});
|
|
||||||
relatedPosts = [...new Set([...relatedPosts, ...tagMatches])];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: Most recent posts (if still not enough)
|
|
||||||
if (relatedPosts.length < 3) {
|
|
||||||
const recentPosts = allPosts
|
|
||||||
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at))
|
|
||||||
.slice(0, 3);
|
|
||||||
relatedPosts = [...new Set([...relatedPosts, ...recentPosts])];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit to 2-3 related posts
|
|
||||||
relatedPosts = relatedPosts.slice(0, 2);
|
|
||||||
|
|
||||||
if (relatedPosts.length > 0) {
|
|
||||||
renderRelatedPosts(relatedPosts);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading related posts:', error);
|
|
||||||
// Silently fail - related posts are not critical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render related posts section
|
|
||||||
*/
|
|
||||||
function renderRelatedPosts(posts) {
|
|
||||||
const relatedPostsHTML = posts.map(post => {
|
|
||||||
const publishedDate = new Date(post.published_at);
|
|
||||||
const formattedDate = publishedDate.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
|
|
||||||
return `
|
|
||||||
<article class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition">
|
|
||||||
<a href="/blog-post.html?slug=${escapeHtml(post.slug)}" class="block">
|
|
||||||
${post.featured_image ? `
|
|
||||||
<div class="aspect-w-16 aspect-h-9 bg-gray-200">
|
|
||||||
<img src="${escapeHtml(post.featured_image)}" alt="${escapeHtml(post.title)}" class="object-cover w-full h-32">
|
|
||||||
</div>
|
|
||||||
` : `
|
|
||||||
<div class="h-32 bg-gradient-to-br from-indigo-400 to-indigo-600 flex items-center justify-center">
|
|
||||||
<svg class="h-12 w-12 text-white opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
<div class="p-4">
|
|
||||||
${post.category ? `
|
|
||||||
<span class="inline-block bg-indigo-100 text-indigo-800 text-xs font-semibold px-2 py-0.5 rounded mb-2">
|
|
||||||
${escapeHtml(post.category)}
|
|
||||||
</span>
|
|
||||||
` : ''}
|
|
||||||
<h3 class="text-lg font-bold text-gray-900 mb-2 line-clamp-2 hover:text-indigo-600">
|
|
||||||
${escapeHtml(post.title)}
|
|
||||||
</h3>
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
<time datetime="${post.published_at}">${formattedDate}</time>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
document.getElementById('related-posts').innerHTML = relatedPostsHTML;
|
|
||||||
document.getElementById('related-posts-section').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach event listeners for sharing and interactions
|
|
||||||
*/
|
|
||||||
function attachEventListeners() {
|
|
||||||
// Share on Twitter
|
|
||||||
const shareTwitterBtn = document.getElementById('share-twitter');
|
|
||||||
if (shareTwitterBtn) {
|
|
||||||
shareTwitterBtn.addEventListener('click', () => {
|
|
||||||
const url = encodeURIComponent(window.location.href);
|
|
||||||
const text = encodeURIComponent(currentPost.title);
|
|
||||||
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=550,height=420');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Share on LinkedIn
|
|
||||||
const shareLinkedInBtn = document.getElementById('share-linkedin');
|
|
||||||
if (shareLinkedInBtn) {
|
|
||||||
shareLinkedInBtn.addEventListener('click', () => {
|
|
||||||
const url = encodeURIComponent(window.location.href);
|
|
||||||
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=550,height=420');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy link
|
|
||||||
const copyLinkBtn = document.getElementById('copy-link');
|
|
||||||
if (copyLinkBtn) {
|
|
||||||
copyLinkBtn.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(window.location.href);
|
|
||||||
// Show temporary success message
|
|
||||||
const originalHTML = copyLinkBtn.innerHTML;
|
|
||||||
copyLinkBtn.innerHTML = `
|
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
|
||||||
</svg>
|
|
||||||
Copied!
|
|
||||||
`;
|
|
||||||
copyLinkBtn.classList.add('bg-green-600');
|
|
||||||
copyLinkBtn.classList.remove('bg-gray-600');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
copyLinkBtn.innerHTML = originalHTML;
|
|
||||||
copyLinkBtn.classList.remove('bg-green-600');
|
|
||||||
copyLinkBtn.classList.add('bg-gray-600');
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy link:', err);
|
|
||||||
// Show error in button
|
|
||||||
const originalHTML = copyLinkBtn.innerHTML;
|
|
||||||
copyLinkBtn.innerHTML = `
|
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
Failed
|
|
||||||
`;
|
|
||||||
copyLinkBtn.classList.add('bg-red-600');
|
|
||||||
copyLinkBtn.classList.remove('bg-gray-600');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
copyLinkBtn.innerHTML = originalHTML;
|
|
||||||
copyLinkBtn.classList.remove('bg-red-600');
|
|
||||||
copyLinkBtn.classList.add('bg-gray-600');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show error state
|
|
||||||
*/
|
|
||||||
function showError(message) {
|
|
||||||
document.getElementById('loading-state').classList.add('hidden');
|
|
||||||
document.getElementById('post-content').classList.add('hidden');
|
|
||||||
|
|
||||||
const errorStateEl = document.getElementById('error-state');
|
|
||||||
errorStateEl.classList.remove('hidden');
|
|
||||||
|
|
||||||
const errorMessageEl = document.getElementById('error-message');
|
|
||||||
if (errorMessageEl) {
|
|
||||||
errorMessageEl.textContent = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert markdown to HTML (basic implementation - can be enhanced with a library)
|
|
||||||
*/
|
|
||||||
function convertMarkdownToHTML(markdown) {
|
|
||||||
if (!markdown) return '';
|
|
||||||
|
|
||||||
let html = markdown;
|
|
||||||
|
|
||||||
// Headers
|
|
||||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
|
||||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
|
||||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
|
||||||
|
|
||||||
// Bold
|
|
||||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
|
||||||
|
|
||||||
// Italic
|
|
||||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
|
||||||
|
|
||||||
// Links
|
|
||||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
||||||
|
|
||||||
// Paragraphs
|
|
||||||
html = html.replace(/\n\n/g, '</p><p>');
|
|
||||||
html = `<p>${ html }</p>`;
|
|
||||||
|
|
||||||
// Line breaks
|
|
||||||
html = html.replace(/\n/g, '<br>');
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update social media meta tags for sharing
|
|
||||||
*/
|
|
||||||
function updateSocialMetaTags(post) {
|
|
||||||
const currentUrl = window.location.href;
|
|
||||||
const excerpt = post.excerpt || post.title;
|
|
||||||
const imageUrl = post.featured_image || 'https://agenticgovernance.digital/images/tractatus-icon.svg';
|
|
||||||
const authorName = post.author_name || post.author?.name || 'Tractatus Team';
|
|
||||||
|
|
||||||
// Open Graph tags
|
|
||||||
document.getElementById('og-title').setAttribute('content', post.title);
|
|
||||||
document.getElementById('og-description').setAttribute('content', excerpt);
|
|
||||||
document.getElementById('og-url').setAttribute('content', currentUrl);
|
|
||||||
document.getElementById('og-image').setAttribute('content', imageUrl);
|
|
||||||
|
|
||||||
if (post.published_at) {
|
|
||||||
document.getElementById('article-published-time').setAttribute('content', post.published_at);
|
|
||||||
}
|
|
||||||
document.getElementById('article-author').setAttribute('content', authorName);
|
|
||||||
|
|
||||||
// Twitter Card tags
|
|
||||||
document.getElementById('twitter-title').setAttribute('content', post.title);
|
|
||||||
document.getElementById('twitter-description').setAttribute('content', excerpt);
|
|
||||||
document.getElementById('twitter-image').setAttribute('content', imageUrl);
|
|
||||||
document.getElementById('twitter-image-alt').setAttribute('content', `${post.title} - Tractatus AI Safety Framework`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML to prevent XSS
|
|
||||||
*/
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate text to specified length
|
|
||||||
*/
|
|
||||||
function truncate(text, maxLength) {
|
|
||||||
if (!text || text.length <= maxLength) return text;
|
|
||||||
return `${text.substring(0, maxLength) }...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
|
|
@ -1,619 +0,0 @@
|
||||||
/**
|
|
||||||
* Blog Listing Page - Client-Side Logic
|
|
||||||
* Handles fetching, filtering, searching, sorting, and pagination of blog posts
|
|
||||||
*/
|
|
||||||
|
|
||||||
// State management
|
|
||||||
let allPosts = [];
|
|
||||||
let filteredPosts = [];
|
|
||||||
let currentPage = 1;
|
|
||||||
const postsPerPage = 9;
|
|
||||||
|
|
||||||
// Filter state
|
|
||||||
const activeFilters = {
|
|
||||||
search: '',
|
|
||||||
category: '',
|
|
||||||
sort: 'date-desc'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the blog page
|
|
||||||
*/
|
|
||||||
async function init() {
|
|
||||||
try {
|
|
||||||
await loadPosts();
|
|
||||||
attachEventListeners();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing blog:', error);
|
|
||||||
showError('Failed to load blog posts. Please refresh the page.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all published blog posts from API
|
|
||||||
*/
|
|
||||||
async function loadPosts() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/blog');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
throw new Error(data.error || 'Failed to load posts');
|
|
||||||
}
|
|
||||||
|
|
||||||
allPosts = data.posts || [];
|
|
||||||
filteredPosts = [...allPosts];
|
|
||||||
|
|
||||||
// Apply initial sorting
|
|
||||||
sortPosts();
|
|
||||||
|
|
||||||
// Render initial view
|
|
||||||
renderPosts();
|
|
||||||
updateResultsCount();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading posts:', error);
|
|
||||||
showError('Failed to load blog posts');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render blog posts grid
|
|
||||||
*/
|
|
||||||
function renderPosts() {
|
|
||||||
const gridEl = document.getElementById('blog-grid');
|
|
||||||
const emptyStateEl = document.getElementById('empty-state');
|
|
||||||
|
|
||||||
if (filteredPosts.length === 0) {
|
|
||||||
gridEl.innerHTML = '';
|
|
||||||
emptyStateEl.classList.remove('hidden');
|
|
||||||
document.getElementById('pagination').classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emptyStateEl.classList.add('hidden');
|
|
||||||
|
|
||||||
// Calculate pagination
|
|
||||||
const startIndex = (currentPage - 1) * postsPerPage;
|
|
||||||
const endIndex = startIndex + postsPerPage;
|
|
||||||
const postsToShow = filteredPosts.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
// Render posts
|
|
||||||
const postsHTML = postsToShow.map(post => renderPostCard(post)).join('');
|
|
||||||
gridEl.innerHTML = postsHTML;
|
|
||||||
|
|
||||||
// Render pagination
|
|
||||||
renderPagination();
|
|
||||||
|
|
||||||
// Scroll to top when changing pages (except initial load)
|
|
||||||
if (currentPage > 1) {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a single post card
|
|
||||||
*/
|
|
||||||
function renderPostCard(post) {
|
|
||||||
const publishedDate = new Date(post.published_at);
|
|
||||||
const formattedDate = publishedDate.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate read time (rough estimate: 200 words per minute)
|
|
||||||
const wordCount = post.content ? post.content.split(/\s+/).length : 0;
|
|
||||||
const readTime = Math.max(1, Math.ceil(wordCount / 200));
|
|
||||||
|
|
||||||
// Truncate excerpt to 150 characters
|
|
||||||
const excerpt = post.excerpt ?
|
|
||||||
(post.excerpt.length > 150 ? `${post.excerpt.substring(0, 150) }...` : post.excerpt) :
|
|
||||||
'Read more...';
|
|
||||||
|
|
||||||
// Get category color
|
|
||||||
const categoryColor = getCategoryColor(post.category);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<article class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300">
|
|
||||||
<a href="/blog-post.html?slug=${escapeHtml(post.slug)}" class="block">
|
|
||||||
${post.featured_image ? `
|
|
||||||
<div class="aspect-w-16 aspect-h-9 bg-gray-200">
|
|
||||||
<img src="${escapeHtml(post.featured_image)}" alt="${escapeHtml(post.title)}" class="object-cover w-full h-48">
|
|
||||||
</div>
|
|
||||||
` : `
|
|
||||||
<div class="h-48 bg-gradient-to-br ${categoryColor} flex items-center justify-center">
|
|
||||||
<svg class="h-16 w-16 text-white opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
|
|
||||||
<div class="p-6">
|
|
||||||
<!-- Category Badge -->
|
|
||||||
${post.category ? `
|
|
||||||
<span class="inline-block bg-indigo-100 text-indigo-800 text-xs font-semibold px-2.5 py-0.5 rounded mb-3">
|
|
||||||
${escapeHtml(post.category)}
|
|
||||||
</span>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<!-- Title -->
|
|
||||||
<h2 class="text-xl font-bold text-gray-900 mb-2 line-clamp-2 hover:text-indigo-600 transition">
|
|
||||||
${escapeHtml(post.title)}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- Excerpt -->
|
|
||||||
<p class="text-gray-600 mb-4 line-clamp-3">
|
|
||||||
${escapeHtml(excerpt)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Metadata -->
|
|
||||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
<time datetime="${post.published_at}">${formattedDate}</time>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<span>${readTime} min read</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tags -->
|
|
||||||
${post.tags && post.tags.length > 0 ? `
|
|
||||||
<div class="mt-4 flex flex-wrap gap-1">
|
|
||||||
${post.tags.slice(0, 3).map(tag => `
|
|
||||||
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
|
|
||||||
${escapeHtml(tag)}
|
|
||||||
</span>
|
|
||||||
`).join('')}
|
|
||||||
${post.tags.length > 3 ? `<span class="text-xs text-gray-500">+${post.tags.length - 3} more</span>` : ''}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get category color gradient
|
|
||||||
*/
|
|
||||||
function getCategoryColor(category) {
|
|
||||||
const colorMap = {
|
|
||||||
'Framework Updates': 'from-blue-400 to-blue-600',
|
|
||||||
'Case Studies': 'from-purple-400 to-purple-600',
|
|
||||||
'Research': 'from-green-400 to-green-600',
|
|
||||||
'Implementation': 'from-yellow-400 to-yellow-600',
|
|
||||||
'Community': 'from-pink-400 to-pink-600'
|
|
||||||
};
|
|
||||||
return colorMap[category] || 'from-gray-400 to-gray-600';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render pagination controls
|
|
||||||
*/
|
|
||||||
function renderPagination() {
|
|
||||||
const paginationEl = document.getElementById('pagination');
|
|
||||||
const totalPages = Math.ceil(filteredPosts.length / postsPerPage);
|
|
||||||
|
|
||||||
if (totalPages <= 1) {
|
|
||||||
paginationEl.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
paginationEl.classList.remove('hidden');
|
|
||||||
|
|
||||||
const prevBtn = document.getElementById('prev-page');
|
|
||||||
const nextBtn = document.getElementById('next-page');
|
|
||||||
const pageNumbersEl = document.getElementById('page-numbers');
|
|
||||||
|
|
||||||
// Update prev/next buttons
|
|
||||||
prevBtn.disabled = currentPage === 1;
|
|
||||||
nextBtn.disabled = currentPage === totalPages;
|
|
||||||
|
|
||||||
// Render page numbers
|
|
||||||
let pageNumbersHTML = '';
|
|
||||||
const maxVisiblePages = 5;
|
|
||||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
|
||||||
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
|
||||||
|
|
||||||
// Adjust start if we're near the end
|
|
||||||
if (endPage - startPage < maxVisiblePages - 1) {
|
|
||||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// First page + ellipsis
|
|
||||||
if (startPage > 1) {
|
|
||||||
pageNumbersHTML += `
|
|
||||||
<button class="page-number px-3 py-1 border border-gray-300 rounded text-sm" data-page="1">1</button>
|
|
||||||
${startPage > 2 ? '<span class="px-2 text-gray-500">...</span>' : ''}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visible page numbers
|
|
||||||
for (let i = startPage; i <= endPage; i++) {
|
|
||||||
const isActive = i === currentPage;
|
|
||||||
pageNumbersHTML += `
|
|
||||||
<button class="page-number px-3 py-1 border ${isActive ? 'bg-indigo-600 text-white border-indigo-600' : 'border-gray-300 text-gray-700 hover:bg-gray-50'} rounded text-sm" data-page="${i}">
|
|
||||||
${i}
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ellipsis + last page
|
|
||||||
if (endPage < totalPages) {
|
|
||||||
pageNumbersHTML += `
|
|
||||||
${endPage < totalPages - 1 ? '<span class="px-2 text-gray-500">...</span>' : ''}
|
|
||||||
<button class="page-number px-3 py-1 border border-gray-300 rounded text-sm" data-page="${totalPages}">${totalPages}</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
pageNumbersEl.innerHTML = pageNumbersHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply filters and search
|
|
||||||
*/
|
|
||||||
function applyFilters() {
|
|
||||||
// Reset to first page when filters change
|
|
||||||
currentPage = 1;
|
|
||||||
|
|
||||||
// Start with all posts
|
|
||||||
filteredPosts = [...allPosts];
|
|
||||||
|
|
||||||
// Apply search
|
|
||||||
if (activeFilters.search) {
|
|
||||||
const searchLower = activeFilters.search.toLowerCase();
|
|
||||||
filteredPosts = filteredPosts.filter(post => {
|
|
||||||
return (
|
|
||||||
post.title.toLowerCase().includes(searchLower) ||
|
|
||||||
(post.content && post.content.toLowerCase().includes(searchLower)) ||
|
|
||||||
(post.excerpt && post.excerpt.toLowerCase().includes(searchLower)) ||
|
|
||||||
(post.tags && post.tags.some(tag => tag.toLowerCase().includes(searchLower)))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply category filter
|
|
||||||
if (activeFilters.category) {
|
|
||||||
filteredPosts = filteredPosts.filter(post => post.category === activeFilters.category);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort
|
|
||||||
sortPosts();
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
renderPosts();
|
|
||||||
updateResultsCount();
|
|
||||||
updateActiveFiltersDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort posts based on active sort option
|
|
||||||
*/
|
|
||||||
function sortPosts() {
|
|
||||||
switch (activeFilters.sort) {
|
|
||||||
case 'date-desc':
|
|
||||||
filteredPosts.sort((a, b) => new Date(b.published_at) - new Date(a.published_at));
|
|
||||||
break;
|
|
||||||
case 'date-asc':
|
|
||||||
filteredPosts.sort((a, b) => new Date(a.published_at) - new Date(b.published_at));
|
|
||||||
break;
|
|
||||||
case 'title-asc':
|
|
||||||
filteredPosts.sort((a, b) => a.title.localeCompare(b.title));
|
|
||||||
break;
|
|
||||||
case 'title-desc':
|
|
||||||
filteredPosts.sort((a, b) => b.title.localeCompare(a.title));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update results count display
|
|
||||||
*/
|
|
||||||
function updateResultsCount() {
|
|
||||||
const countEl = document.getElementById('post-count');
|
|
||||||
if (countEl) {
|
|
||||||
countEl.textContent = filteredPosts.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update active filters display
|
|
||||||
*/
|
|
||||||
function updateActiveFiltersDisplay() {
|
|
||||||
const activeFiltersEl = document.getElementById('active-filters');
|
|
||||||
const filterTagsEl = document.getElementById('filter-tags');
|
|
||||||
|
|
||||||
const hasActiveFilters = activeFilters.search || activeFilters.category;
|
|
||||||
|
|
||||||
if (!hasActiveFilters) {
|
|
||||||
activeFiltersEl.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeFiltersEl.classList.remove('hidden');
|
|
||||||
|
|
||||||
let tagsHTML = '';
|
|
||||||
|
|
||||||
if (activeFilters.search) {
|
|
||||||
tagsHTML += `
|
|
||||||
<span class="inline-flex items-center gap-1 px-3 py-1 bg-indigo-100 text-indigo-800 rounded-full text-sm">
|
|
||||||
Search: "${escapeHtml(activeFilters.search)}"
|
|
||||||
<button class="ml-1 hover:text-indigo-900" data-remove-filter="search">
|
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeFilters.category) {
|
|
||||||
tagsHTML += `
|
|
||||||
<span class="inline-flex items-center gap-1 px-3 py-1 bg-indigo-100 text-indigo-800 rounded-full text-sm">
|
|
||||||
Category: ${escapeHtml(activeFilters.category)}
|
|
||||||
<button class="ml-1 hover:text-indigo-900" data-remove-filter="category">
|
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
filterTagsEl.innerHTML = tagsHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all filters
|
|
||||||
*/
|
|
||||||
function clearFilters() {
|
|
||||||
activeFilters.search = '';
|
|
||||||
activeFilters.category = '';
|
|
||||||
|
|
||||||
// Reset UI elements
|
|
||||||
document.getElementById('search-input').value = '';
|
|
||||||
document.getElementById('category-filter').value = '';
|
|
||||||
|
|
||||||
// Reapply filters
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove specific filter
|
|
||||||
*/
|
|
||||||
function removeFilter(filterType) {
|
|
||||||
if (filterType === 'search') {
|
|
||||||
activeFilters.search = '';
|
|
||||||
document.getElementById('search-input').value = '';
|
|
||||||
} else if (filterType === 'category') {
|
|
||||||
activeFilters.category = '';
|
|
||||||
document.getElementById('category-filter').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach event listeners
|
|
||||||
*/
|
|
||||||
function attachEventListeners() {
|
|
||||||
// Search input (debounced)
|
|
||||||
const searchInput = document.getElementById('search-input');
|
|
||||||
let searchTimeout;
|
|
||||||
searchInput.addEventListener('input', e => {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
activeFilters.search = e.target.value.trim();
|
|
||||||
applyFilters();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Category filter
|
|
||||||
const categoryFilter = document.getElementById('category-filter');
|
|
||||||
categoryFilter.addEventListener('change', e => {
|
|
||||||
activeFilters.category = e.target.value;
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort select
|
|
||||||
const sortSelect = document.getElementById('sort-select');
|
|
||||||
sortSelect.addEventListener('change', e => {
|
|
||||||
activeFilters.sort = e.target.value;
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear filters button
|
|
||||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
|
||||||
clearFiltersBtn.addEventListener('click', clearFilters);
|
|
||||||
|
|
||||||
// Pagination - prev/next buttons
|
|
||||||
const prevBtn = document.getElementById('prev-page');
|
|
||||||
const nextBtn = document.getElementById('next-page');
|
|
||||||
|
|
||||||
prevBtn.addEventListener('click', () => {
|
|
||||||
if (currentPage > 1) {
|
|
||||||
currentPage--;
|
|
||||||
renderPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nextBtn.addEventListener('click', () => {
|
|
||||||
const totalPages = Math.ceil(filteredPosts.length / postsPerPage);
|
|
||||||
if (currentPage < totalPages) {
|
|
||||||
currentPage++;
|
|
||||||
renderPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination - page numbers (event delegation)
|
|
||||||
const pageNumbersEl = document.getElementById('page-numbers');
|
|
||||||
pageNumbersEl.addEventListener('click', e => {
|
|
||||||
const pageBtn = e.target.closest('.page-number');
|
|
||||||
if (pageBtn) {
|
|
||||||
currentPage = parseInt(pageBtn.dataset.page, 10);
|
|
||||||
renderPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove filter tags (event delegation)
|
|
||||||
const filterTagsEl = document.getElementById('filter-tags');
|
|
||||||
filterTagsEl.addEventListener('click', e => {
|
|
||||||
const removeBtn = e.target.closest('[data-remove-filter]');
|
|
||||||
if (removeBtn) {
|
|
||||||
const filterType = removeBtn.dataset.removeFilter;
|
|
||||||
removeFilter(filterType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show error message
|
|
||||||
*/
|
|
||||||
function showError(message) {
|
|
||||||
const gridEl = document.getElementById('blog-grid');
|
|
||||||
gridEl.innerHTML = `
|
|
||||||
<div class="col-span-full bg-red-50 border border-red-200 rounded-lg p-6">
|
|
||||||
<div class="flex items-start">
|
|
||||||
<svg class="h-6 w-6 text-red-600 mr-3 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-red-900 mb-2">Error</h3>
|
|
||||||
<p class="text-red-700">${escapeHtml(message)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML to prevent XSS
|
|
||||||
*/
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Newsletter Modal Functionality
|
|
||||||
*/
|
|
||||||
function setupNewsletterModal() {
|
|
||||||
const modal = document.getElementById('newsletter-modal');
|
|
||||||
const openBtn = document.getElementById('open-newsletter-modal');
|
|
||||||
const closeBtn = document.getElementById('close-newsletter-modal');
|
|
||||||
const cancelBtn = document.getElementById('cancel-newsletter');
|
|
||||||
const form = document.getElementById('newsletter-form');
|
|
||||||
const successMsg = document.getElementById('newsletter-success');
|
|
||||||
const errorMsg = document.getElementById('newsletter-error');
|
|
||||||
const errorText = document.getElementById('newsletter-error-message');
|
|
||||||
const submitBtn = document.getElementById('newsletter-submit');
|
|
||||||
|
|
||||||
// Open modal
|
|
||||||
if (openBtn) {
|
|
||||||
openBtn.addEventListener('click', () => {
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
document.getElementById('newsletter-email').focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
function closeModal() {
|
|
||||||
modal.classList.add('hidden');
|
|
||||||
form.reset();
|
|
||||||
successMsg.classList.add('hidden');
|
|
||||||
errorMsg.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (closeBtn) {
|
|
||||||
closeBtn.addEventListener('click', closeModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelBtn) {
|
|
||||||
cancelBtn.addEventListener('click', closeModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close on backdrop click
|
|
||||||
modal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === modal) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close on Escape key
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
if (form) {
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Reset messages
|
|
||||||
successMsg.classList.add('hidden');
|
|
||||||
errorMsg.classList.add('hidden');
|
|
||||||
|
|
||||||
const email = document.getElementById('newsletter-email').value;
|
|
||||||
const name = document.getElementById('newsletter-name').value;
|
|
||||||
|
|
||||||
// Disable submit button
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = 'Subscribing...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/newsletter/subscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
name: name || null,
|
|
||||||
source: 'blog'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
|
||||||
// Show success message
|
|
||||||
successMsg.classList.remove('hidden');
|
|
||||||
form.reset();
|
|
||||||
|
|
||||||
// Close modal after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
closeModal();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
// Show error message
|
|
||||||
errorText.textContent = data.error || 'Failed to subscribe. Please try again.';
|
|
||||||
errorMsg.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Newsletter subscription error:', error);
|
|
||||||
errorText.textContent = 'Network error. Please check your connection and try again.';
|
|
||||||
errorMsg.classList.remove('hidden');
|
|
||||||
} finally {
|
|
||||||
// Re-enable submit button
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = 'Subscribe';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
init();
|
|
||||||
setupNewsletterModal();
|
|
||||||
});
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
/**
|
|
||||||
* Case Submission Form Handler
|
|
||||||
*/
|
|
||||||
|
|
||||||
const form = document.getElementById('case-submission-form');
|
|
||||||
const submitButton = document.getElementById('submit-button');
|
|
||||||
const successMessage = document.getElementById('success-message');
|
|
||||||
const errorMessage = document.getElementById('error-message');
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Hide previous messages
|
|
||||||
successMessage.style.display = 'none';
|
|
||||||
errorMessage.style.display = 'none';
|
|
||||||
|
|
||||||
// Disable submit button
|
|
||||||
submitButton.disabled = true;
|
|
||||||
submitButton.textContent = 'Submitting...';
|
|
||||||
|
|
||||||
// Collect form data
|
|
||||||
const evidenceText = document.getElementById('case-evidence').value;
|
|
||||||
const evidence = evidenceText
|
|
||||||
? evidenceText.split('\n').filter(line => line.trim())
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const formData = {
|
|
||||||
submitter: {
|
|
||||||
name: document.getElementById('submitter-name').value,
|
|
||||||
email: document.getElementById('submitter-email').value,
|
|
||||||
organization: document.getElementById('submitter-organization').value || null,
|
|
||||||
public: document.getElementById('submitter-public').checked
|
|
||||||
},
|
|
||||||
case_study: {
|
|
||||||
title: document.getElementById('case-title').value,
|
|
||||||
description: document.getElementById('case-description').value,
|
|
||||||
failure_mode: document.getElementById('case-failure-mode').value,
|
|
||||||
tractatus_applicability: document.getElementById('case-tractatus').value,
|
|
||||||
evidence: evidence,
|
|
||||||
attachments: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/cases/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Success
|
|
||||||
successMessage.textContent = data.message || 'Thank you for your submission. We will review it shortly.';
|
|
||||||
successMessage.style.display = 'block';
|
|
||||||
form.reset();
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
} else {
|
|
||||||
// Error
|
|
||||||
errorMessage.textContent = data.message || 'An error occurred. Please try again.';
|
|
||||||
errorMessage.style.display = 'block';
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Submit error:', error);
|
|
||||||
errorMessage.textContent = 'Network error. Please check your connection and try again.';
|
|
||||||
errorMessage.style.display = 'block';
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
} finally {
|
|
||||||
// Re-enable submit button
|
|
||||||
submitButton.disabled = false;
|
|
||||||
submitButton.textContent = 'Submit Case Study';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
/**
|
|
||||||
* Version Check Script
|
|
||||||
* Tests if browser is using cached JavaScript files
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Get the version from the main docs page
|
|
||||||
fetch('/docs.html?' + Date.now())
|
|
||||||
.then(r => r.text())
|
|
||||||
.then(html => {
|
|
||||||
const match = html.match(/docs-app\.js\?v=(\d+)/);
|
|
||||||
const version = match ? match[1] : 'NOT FOUND';
|
|
||||||
|
|
||||||
const expected = '1759828916';
|
|
||||||
const correct = version === expected;
|
|
||||||
|
|
||||||
// Now fetch the actual JavaScript
|
|
||||||
return fetch('/js/docs-app.js?v=' + version + '&' + Date.now())
|
|
||||||
.then(r => r.text())
|
|
||||||
.then(js => {
|
|
||||||
const hasNewHandler = js.includes('window.location.href=');
|
|
||||||
const hasOldHandler = js.includes('event.stopPropagation()');
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
if (correct && hasNewHandler) {
|
|
||||||
html = `
|
|
||||||
<div class="box good">
|
|
||||||
<h2>✅ Version is CORRECT</h2>
|
|
||||||
<p>JavaScript version: <code>${version}</code></p>
|
|
||||||
<p>Handler includes: <code>window.location.href</code></p>
|
|
||||||
<p><strong>Downloads should work now!</strong></p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
html = `
|
|
||||||
<div class="box bad">
|
|
||||||
<h2>❌ Version is WRONG</h2>
|
|
||||||
<p>JavaScript version loaded: <code>${version}</code></p>
|
|
||||||
<p>Expected: <code>${expected}</code></p>
|
|
||||||
<p>Has new handler: ${hasNewHandler ? '✅ YES' : '❌ NO'}</p>
|
|
||||||
<p><strong>Your browser is serving cached files!</strong></p>
|
|
||||||
</div>
|
|
||||||
<div class="box">
|
|
||||||
<h3>Cached JavaScript Snippet:</h3>
|
|
||||||
<pre>${js.substring(js.indexOf('onclick='), js.indexOf('onclick=') + 200).replace(/</g, '<').replace(/>/g, '>')}</pre>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('results').innerHTML = html;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
document.getElementById('results').innerHTML = `
|
|
||||||
<div class="box bad">
|
|
||||||
<h2>Error</h2>
|
|
||||||
<p>${err.message}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
/**
|
|
||||||
* Framework Activity Timeline
|
|
||||||
* Tractatus Framework - Phase 3: Data Visualization
|
|
||||||
*
|
|
||||||
* Visual timeline showing framework component interactions
|
|
||||||
* Color-coded by service
|
|
||||||
*/
|
|
||||||
|
|
||||||
class ActivityTimeline {
|
|
||||||
constructor(containerId) {
|
|
||||||
this.container = document.getElementById(containerId);
|
|
||||||
if (!this.container) {
|
|
||||||
console.error(`[ActivityTimeline] Container #${containerId} not found`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentPath = 'fast'; // Default to fast path
|
|
||||||
|
|
||||||
// Define three execution paths with realistic timings
|
|
||||||
this.pathProfiles = {
|
|
||||||
fast: {
|
|
||||||
name: 'Fast Path',
|
|
||||||
description: 'Simple request, all checks pass',
|
|
||||||
totalTime: '65ms',
|
|
||||||
events: [
|
|
||||||
{ time: '0ms', timeMs: 0, service: 'instruction', name: 'InstructionPersistence', action: 'Load cached instructions', color: '#4338ca' },
|
|
||||||
{ time: '5ms', timeMs: 5, service: 'validator', name: 'CrossReferenceValidator', action: 'Quick validation check', color: '#6d28d9' },
|
|
||||||
{ time: '15ms', timeMs: 15, service: 'boundary', name: 'BoundaryEnforcer', action: 'Auto-approved operation', color: '#047857' },
|
|
||||||
{ time: '25ms', timeMs: 25, service: 'pressure', name: 'ContextPressureMonitor', action: 'Normal pressure detected', color: '#b45309' },
|
|
||||||
{ time: '50ms', timeMs: 50, service: 'validator', name: 'CrossReferenceValidator', action: 'Final validation', color: '#6d28d9' },
|
|
||||||
{ time: '65ms', timeMs: 65, service: 'pressure', name: 'ContextPressureMonitor', action: 'Update metrics', color: '#b45309' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
standard: {
|
|
||||||
name: 'Standard Path',
|
|
||||||
description: 'Needs validation and verification',
|
|
||||||
totalTime: '135ms',
|
|
||||||
events: [
|
|
||||||
{ time: '0ms', timeMs: 0, service: 'instruction', name: 'InstructionPersistence', action: 'Load HIGH persistence instructions', color: '#4338ca' },
|
|
||||||
{ time: '8ms', timeMs: 8, service: 'validator', name: 'CrossReferenceValidator', action: 'Verify against instruction history', color: '#6d28d9' },
|
|
||||||
{ time: '30ms', timeMs: 30, service: 'boundary', name: 'BoundaryEnforcer', action: 'Check approval requirements', color: '#047857' },
|
|
||||||
{ time: '55ms', timeMs: 55, service: 'pressure', name: 'ContextPressureMonitor', action: 'Calculate pressure level', color: '#b45309' },
|
|
||||||
{ time: '95ms', timeMs: 95, service: 'metacognitive', name: 'MetacognitiveVerifier', action: 'Verify operation alignment', color: '#be185d' },
|
|
||||||
{ time: '120ms', timeMs: 120, service: 'validator', name: 'CrossReferenceValidator', action: 'Final validation check', color: '#6d28d9' },
|
|
||||||
{ time: '135ms', timeMs: 135, service: 'pressure', name: 'ContextPressureMonitor', action: 'Update pressure metrics', color: '#b45309' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
complex: {
|
|
||||||
name: 'Complex Path',
|
|
||||||
description: 'Requires deliberation and consensus',
|
|
||||||
totalTime: '285ms',
|
|
||||||
events: [
|
|
||||||
{ time: '0ms', timeMs: 0, service: 'instruction', name: 'InstructionPersistence', action: 'Load HIGH persistence instructions', color: '#4338ca' },
|
|
||||||
{ time: '8ms', timeMs: 8, service: 'validator', name: 'CrossReferenceValidator', action: 'Verify request against instruction history', color: '#6d28d9' },
|
|
||||||
{ time: '35ms', timeMs: 35, service: 'boundary', name: 'BoundaryEnforcer', action: 'Check if request requires human approval', color: '#047857' },
|
|
||||||
{ time: '60ms', timeMs: 60, service: 'pressure', name: 'ContextPressureMonitor', action: 'Calculate current pressure level', color: '#b45309' },
|
|
||||||
{ time: '105ms', timeMs: 105, service: 'metacognitive', name: 'MetacognitiveVerifier', action: 'Verify operation alignment', color: '#be185d' },
|
|
||||||
{ time: '160ms', timeMs: 160, service: 'deliberation', name: 'PluralisticDeliberation', action: 'Coordinate stakeholder perspectives', color: '#0f766e' },
|
|
||||||
{ time: '255ms', timeMs: 255, service: 'validator', name: 'CrossReferenceValidator', action: 'Final validation check', color: '#6d28d9' },
|
|
||||||
{ time: '285ms', timeMs: 285, service: 'pressure', name: 'ContextPressureMonitor', action: 'Update pressure metrics', color: '#b45309' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize with fast path by default
|
|
||||||
this.events = this.pathProfiles[this.currentPath].events;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.render();
|
|
||||||
this.isSimulating = false;
|
|
||||||
console.log('[ActivityTimeline] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const eventsHTML = this.events.map((event, index) => `
|
|
||||||
<div class="timeline-event flex items-start space-x-4 p-4 bg-white rounded-lg border-2 border-gray-200 hover:shadow-md cursor-pointer transition-all duration-300"
|
|
||||||
data-service="${event.service}"
|
|
||||||
data-event-index="${index}">
|
|
||||||
<div class="flex-shrink-0 w-16 text-right">
|
|
||||||
<span class="text-sm font-semibold text-gray-600 event-time">${event.time}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div class="w-3 h-3 rounded-full service-dot transition-all duration-300" data-color="${event.color}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="text-sm font-semibold text-gray-900 service-name transition-colors duration-300" data-color="${event.color}">
|
|
||||||
${event.name}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-600 mt-1 event-action">${event.action}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
const currentProfile = this.pathProfiles[this.currentPath];
|
|
||||||
|
|
||||||
this.container.innerHTML = `
|
|
||||||
<div class="activity-timeline-container">
|
|
||||||
<div class="mb-4">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Governance Flow</h2>
|
|
||||||
<p class="text-xs text-gray-500 mt-1 italic">Estimated timing based on current performance data</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Path Selection -->
|
|
||||||
<div class="mb-4 p-3 bg-gray-50 border border-gray-300 rounded-lg">
|
|
||||||
<div class="text-xs font-semibold text-gray-700 mb-2">Execution Path:</div>
|
|
||||||
<div class="flex flex-col sm:flex-row gap-2">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="radio" name="path" value="fast" ${this.currentPath === 'fast' ? 'checked' : ''} class="path-radio">
|
|
||||||
<span class="text-sm font-medium text-gray-900">Fast</span>
|
|
||||||
<span class="text-xs text-gray-600">(${this.pathProfiles.fast.totalTime})</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="radio" name="path" value="standard" ${this.currentPath === 'standard' ? 'checked' : ''} class="path-radio">
|
|
||||||
<span class="text-sm font-medium text-gray-900">Standard</span>
|
|
||||||
<span class="text-xs text-gray-600">(${this.pathProfiles.standard.totalTime})</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="radio" name="path" value="complex" ${this.currentPath === 'complex' ? 'checked' : ''} class="path-radio">
|
|
||||||
<span class="text-sm font-medium text-gray-900">Complex</span>
|
|
||||||
<span class="text-xs text-gray-600">(${this.pathProfiles.complex.totalTime})</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-600 mt-2">${currentProfile.description}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timeline Explanation -->
|
|
||||||
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<p class="text-sm text-gray-700 leading-relaxed mb-2">
|
|
||||||
This shows the framework's governance components working together to validate and process each request. Each component has a specific role in ensuring safe, values-aligned AI operation.
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-600 italic">
|
|
||||||
Note: Timing values are estimates based on current performance statistics and may vary in production.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
${eventsHTML}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 text-xs text-gray-500 text-center">
|
|
||||||
Total processing time: ${currentProfile.totalTime} | All services coordinated
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Apply colors via JavaScript (CSP-compliant)
|
|
||||||
this.applyColors();
|
|
||||||
|
|
||||||
// Attach event listeners to path radio buttons
|
|
||||||
this.attachPathListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
attachPathListeners() {
|
|
||||||
const radios = this.container.querySelectorAll('.path-radio');
|
|
||||||
radios.forEach(radio => {
|
|
||||||
radio.addEventListener('change', (e) => {
|
|
||||||
this.setPath(e.target.value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setPath(pathName) {
|
|
||||||
if (!this.pathProfiles[pathName]) {
|
|
||||||
console.error(`[ActivityTimeline] Unknown path: ${pathName}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[ActivityTimeline] Switching to ${pathName} path`);
|
|
||||||
this.currentPath = pathName;
|
|
||||||
this.events = this.pathProfiles[pathName].events;
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
applyColors() {
|
|
||||||
document.querySelectorAll('.service-dot').forEach(dot => {
|
|
||||||
const color = dot.getAttribute('data-color');
|
|
||||||
dot.style.backgroundColor = color;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.service-name').forEach(name => {
|
|
||||||
const color = name.getAttribute('data-color');
|
|
||||||
name.style.color = color;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activateEvent(index) {
|
|
||||||
const eventElement = this.container.querySelector(`[data-event-index="${index}"]`);
|
|
||||||
if (!eventElement) return;
|
|
||||||
|
|
||||||
const event = this.events[index];
|
|
||||||
|
|
||||||
// Highlight the event card
|
|
||||||
eventElement.style.borderColor = event.color;
|
|
||||||
eventElement.style.backgroundColor = `${event.color}10`; // 10% opacity
|
|
||||||
eventElement.style.boxShadow = `0 4px 12px ${event.color}40`;
|
|
||||||
|
|
||||||
// Enlarge and pulse the service dot
|
|
||||||
const dot = eventElement.querySelector('.service-dot');
|
|
||||||
if (dot) {
|
|
||||||
dot.style.width = '12px';
|
|
||||||
dot.style.height = '12px';
|
|
||||||
dot.style.boxShadow = `0 0 8px ${event.color}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[ActivityTimeline] Activated event ${index}: ${event.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivateEvent(index) {
|
|
||||||
const eventElement = this.container.querySelector(`[data-event-index="${index}"]`);
|
|
||||||
if (!eventElement) return;
|
|
||||||
|
|
||||||
// Reset to default styling
|
|
||||||
eventElement.style.borderColor = '#e5e7eb';
|
|
||||||
eventElement.style.backgroundColor = '#ffffff';
|
|
||||||
eventElement.style.boxShadow = '';
|
|
||||||
|
|
||||||
// Reset service dot
|
|
||||||
const dot = eventElement.querySelector('.service-dot');
|
|
||||||
if (dot) {
|
|
||||||
dot.style.width = '12px';
|
|
||||||
dot.style.height = '12px';
|
|
||||||
dot.style.boxShadow = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async simulateFlow() {
|
|
||||||
if (this.isSimulating) {
|
|
||||||
console.log('[ActivityTimeline] Already simulating, ignoring request');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isSimulating = true;
|
|
||||||
console.log('[ActivityTimeline] Starting governance flow simulation');
|
|
||||||
|
|
||||||
// Reset all events first
|
|
||||||
for (let i = 0; i < this.events.length; i++) {
|
|
||||||
this.deactivateEvent(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate each event activation with realistic timing
|
|
||||||
for (let i = 0; i < this.events.length; i++) {
|
|
||||||
const event = this.events[i];
|
|
||||||
const prevEvent = i > 0 ? this.events[i - 1] : null;
|
|
||||||
|
|
||||||
// Calculate actual delay based on event timing (scaled 2x for visibility)
|
|
||||||
const delay = prevEvent ? (event.timeMs - prevEvent.timeMs) * 2 : 0;
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
|
|
||||||
// Deactivate previous event
|
|
||||||
if (i > 0) {
|
|
||||||
this.deactivateEvent(i - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate current event
|
|
||||||
this.activateEvent(i);
|
|
||||||
console.log(`[ActivityTimeline] Event ${i} activated at ${event.time} (delay: ${delay}ms)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the last event active for a moment, then deactivate
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 800));
|
|
||||||
this.deactivateEvent(this.events.length - 1);
|
|
||||||
|
|
||||||
this.isSimulating = false;
|
|
||||||
console.log('[ActivityTimeline] Governance flow simulation complete');
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
console.log('[ActivityTimeline] Resetting timeline');
|
|
||||||
for (let i = 0; i < this.events.length; i++) {
|
|
||||||
this.deactivateEvent(i);
|
|
||||||
}
|
|
||||||
this.isSimulating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-initialize if container exists
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
function initActivityTimeline() {
|
|
||||||
console.log('[ActivityTimeline] Attempting to initialize, readyState:', document.readyState);
|
|
||||||
const container = document.getElementById('activity-timeline');
|
|
||||||
if (container) {
|
|
||||||
console.log('[ActivityTimeline] Container found, creating instance');
|
|
||||||
window.activityTimeline = new ActivityTimeline('activity-timeline');
|
|
||||||
} else {
|
|
||||||
console.error('[ActivityTimeline] Container #activity-timeline not found in DOM');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize immediately if DOM is already loaded, otherwise wait for DOMContentLoaded
|
|
||||||
console.log('[ActivityTimeline] Script loaded, readyState:', document.readyState);
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
console.log('[ActivityTimeline] Waiting for DOMContentLoaded');
|
|
||||||
document.addEventListener('DOMContentLoaded', initActivityTimeline);
|
|
||||||
} else {
|
|
||||||
console.log('[ActivityTimeline] DOM already loaded, initializing immediately');
|
|
||||||
initActivityTimeline();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export for module systems
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = ActivityTimeline;
|
|
||||||
}
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
/**
|
|
||||||
* Code Copy Button Component
|
|
||||||
* Tractatus Framework - Phase 3: Interactive Documentation
|
|
||||||
*
|
|
||||||
* Adds "Copy" buttons to all code blocks for easy copying
|
|
||||||
* Shows success feedback on copy
|
|
||||||
*/
|
|
||||||
|
|
||||||
class CodeCopyButtons {
|
|
||||||
constructor() {
|
|
||||||
this.buttonClass = 'code-copy-btn';
|
|
||||||
this.successClass = 'code-copy-success';
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Wait for DOM to be ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => this.addCopyButtons());
|
|
||||||
} else {
|
|
||||||
this.addCopyButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[CodeCopyButtons] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
addCopyButtons() {
|
|
||||||
// Find all code blocks (pre elements)
|
|
||||||
const codeBlocks = document.querySelectorAll('pre');
|
|
||||||
console.log(`[CodeCopyButtons] Found ${codeBlocks.length} code blocks`);
|
|
||||||
|
|
||||||
codeBlocks.forEach((pre, index) => {
|
|
||||||
// Skip if already has a copy button
|
|
||||||
if (pre.querySelector(`.${this.buttonClass}`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make pre relative positioned for absolute button
|
|
||||||
pre.style.position = 'relative';
|
|
||||||
|
|
||||||
// Create copy button
|
|
||||||
const button = this.createCopyButton(pre, index);
|
|
||||||
|
|
||||||
// Add button to pre element
|
|
||||||
pre.appendChild(button);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createCopyButton(pre, index) {
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.className = `${this.buttonClass} absolute top-2 right-2 px-3 py-1 text-xs font-medium rounded transition-all duration-200`;
|
|
||||||
button.style.cssText = `
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: #e5e7eb;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
`;
|
|
||||||
button.textContent = 'Copy';
|
|
||||||
button.setAttribute('aria-label', 'Copy code to clipboard');
|
|
||||||
button.setAttribute('data-code-index', index);
|
|
||||||
|
|
||||||
// Add hover styles via class
|
|
||||||
button.addEventListener('mouseenter', () => {
|
|
||||||
button.style.background = 'rgba(255, 255, 255, 0.2)';
|
|
||||||
});
|
|
||||||
|
|
||||||
button.addEventListener('mouseleave', () => {
|
|
||||||
if (!button.classList.contains(this.successClass)) {
|
|
||||||
button.style.background = 'rgba(255, 255, 255, 0.1)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add click handler
|
|
||||||
button.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.copyCode(pre, button);
|
|
||||||
});
|
|
||||||
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
async copyCode(pre, button) {
|
|
||||||
// Get code content (find code element inside pre)
|
|
||||||
const codeElement = pre.querySelector('code');
|
|
||||||
const code = codeElement ? codeElement.textContent : pre.textContent;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use Clipboard API
|
|
||||||
await navigator.clipboard.writeText(code);
|
|
||||||
|
|
||||||
// Show success feedback
|
|
||||||
this.showSuccess(button);
|
|
||||||
|
|
||||||
console.log('[CodeCopyButtons] Code copied to clipboard');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[CodeCopyButtons] Failed to copy code:', err);
|
|
||||||
|
|
||||||
// Fallback: try using execCommand
|
|
||||||
this.fallbackCopy(code, button);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fallbackCopy(text, button) {
|
|
||||||
// Create temporary textarea
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
|
|
||||||
try {
|
|
||||||
textarea.select();
|
|
||||||
const successful = document.execCommand('copy');
|
|
||||||
|
|
||||||
if (successful) {
|
|
||||||
this.showSuccess(button);
|
|
||||||
console.log('[CodeCopyButtons] Code copied using fallback method');
|
|
||||||
} else {
|
|
||||||
this.showError(button);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[CodeCopyButtons] Fallback copy failed:', err);
|
|
||||||
this.showError(button);
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showSuccess(button) {
|
|
||||||
button.classList.add(this.successClass);
|
|
||||||
button.textContent = '✓ Copied!';
|
|
||||||
button.style.background = 'rgba(16, 185, 129, 0.3)'; // Green
|
|
||||||
button.style.borderColor = 'rgba(16, 185, 129, 0.5)';
|
|
||||||
button.style.color = '#d1fae5';
|
|
||||||
|
|
||||||
// Reset after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
button.classList.remove(this.successClass);
|
|
||||||
button.textContent = 'Copy';
|
|
||||||
button.style.background = 'rgba(255, 255, 255, 0.1)';
|
|
||||||
button.style.borderColor = 'rgba(255, 255, 255, 0.2)';
|
|
||||||
button.style.color = '#e5e7eb';
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(button) {
|
|
||||||
button.textContent = '✗ Failed';
|
|
||||||
button.style.background = 'rgba(239, 68, 68, 0.3)'; // Red
|
|
||||||
button.style.borderColor = 'rgba(239, 68, 68, 0.5)';
|
|
||||||
|
|
||||||
// Reset after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
button.textContent = 'Copy';
|
|
||||||
button.style.background = 'rgba(255, 255, 255, 0.1)';
|
|
||||||
button.style.borderColor = 'rgba(255, 255, 255, 0.2)';
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public method to refresh buttons (useful for dynamically loaded content)
|
|
||||||
refresh() {
|
|
||||||
this.addCopyButtons();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-initialize when script loads
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.codeCopyButtons = new CodeCopyButtons();
|
|
||||||
|
|
||||||
// Listen for custom event from document viewer for dynamic content
|
|
||||||
document.addEventListener('documentLoaded', () => {
|
|
||||||
console.log('[CodeCopyButtons] Document loaded, refreshing buttons');
|
|
||||||
window.codeCopyButtons.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export for module usage
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = CodeCopyButtons;
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
/**
|
|
||||||
* Coming Soon Overlay
|
|
||||||
* Displays over Koha pages until Stripe is configured
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Check if we should show the overlay
|
|
||||||
const shouldShowOverlay = () => {
|
|
||||||
// Only show on Koha pages
|
|
||||||
const isKohaPage = window.location.pathname.includes('/koha');
|
|
||||||
return isKohaPage;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create and inject overlay
|
|
||||||
if (shouldShowOverlay()) {
|
|
||||||
const overlayHTML = `
|
|
||||||
<div id="coming-soon-overlay" class="coming-soon-overlay">
|
|
||||||
<div class="coming-soon-card">
|
|
||||||
<h1 class="coming-soon-title">
|
|
||||||
Koha Donation System
|
|
||||||
</h1>
|
|
||||||
<p class="coming-soon-subtitle">
|
|
||||||
Coming Soon
|
|
||||||
</p>
|
|
||||||
<div class="coming-soon-info-box">
|
|
||||||
<p class="coming-soon-info-title">
|
|
||||||
<strong>What is Koha?</strong>
|
|
||||||
</p>
|
|
||||||
<p class="coming-soon-info-text">
|
|
||||||
Koha (Māori for "gift") is our upcoming donation system to support the Tractatus Framework.
|
|
||||||
We're currently finalizing payment processing integration and will launch soon.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p class="coming-soon-status">
|
|
||||||
Infrastructure deployed and ready. Payment processing activation in progress.
|
|
||||||
</p>
|
|
||||||
<a href="/" class="coming-soon-button">
|
|
||||||
Return to Homepage
|
|
||||||
</a>
|
|
||||||
<p class="coming-soon-footer">
|
|
||||||
Questions? Contact <a href="mailto:support@agenticgovernance.digital">support@agenticgovernance.digital</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', overlayHTML);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
/**
|
|
||||||
* Currency Selector Component
|
|
||||||
* Dropdown for selecting donation currency
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Currency selector HTML
|
|
||||||
const selectorHTML = `
|
|
||||||
<div id="currency-selector" class="bg-white shadow rounded-lg p-4 mb-8">
|
|
||||||
<label for="currency-select" class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Select Currency
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="currency-select"
|
|
||||||
class="w-full md:w-64 px-4 py-2 border-2 border-gray-300 rounded-lg focus:border-blue-600 focus:outline-none text-base"
|
|
||||||
aria-label="Select your preferred currency"
|
|
||||||
>
|
|
||||||
<option value="NZD">🇳🇿 NZD - NZ Dollar</option>
|
|
||||||
<option value="USD">🇺🇸 USD - US Dollar</option>
|
|
||||||
<option value="EUR">🇪🇺 EUR - Euro</option>
|
|
||||||
<option value="GBP">🇬🇧 GBP - British Pound</option>
|
|
||||||
<option value="AUD">🇦🇺 AUD - Australian Dollar</option>
|
|
||||||
<option value="CAD">🇨🇦 CAD - Canadian Dollar</option>
|
|
||||||
<option value="JPY">🇯🇵 JPY - Japanese Yen</option>
|
|
||||||
<option value="CHF">🇨🇭 CHF - Swiss Franc</option>
|
|
||||||
<option value="SGD">🇸🇬 SGD - Singapore Dollar</option>
|
|
||||||
<option value="HKD">🇭🇰 HKD - Hong Kong Dollar</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
|
||||||
Prices are automatically converted from NZD. Your selection is saved for future visits.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Initialize currency selector
|
|
||||||
function initCurrencySelector() {
|
|
||||||
// Find container (should have id="currency-selector-container")
|
|
||||||
const container = document.getElementById('currency-selector-container');
|
|
||||||
if (!container) {
|
|
||||||
console.warn('Currency selector container not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert selector HTML
|
|
||||||
container.innerHTML = selectorHTML;
|
|
||||||
|
|
||||||
// Get select element
|
|
||||||
const select = document.getElementById('currency-select');
|
|
||||||
|
|
||||||
// Set initial value from detected currency
|
|
||||||
const detectedCurrency = detectUserCurrency();
|
|
||||||
select.value = detectedCurrency;
|
|
||||||
|
|
||||||
// Trigger initial price update
|
|
||||||
if (typeof window.updatePricesForCurrency === 'function') {
|
|
||||||
window.updatePricesForCurrency(detectedCurrency);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for changes
|
|
||||||
select.addEventListener('change', function(e) {
|
|
||||||
const newCurrency = e.target.value;
|
|
||||||
|
|
||||||
// Save preference
|
|
||||||
saveCurrencyPreference(newCurrency);
|
|
||||||
|
|
||||||
// Update prices
|
|
||||||
if (typeof window.updatePricesForCurrency === 'function') {
|
|
||||||
window.updatePricesForCurrency(newCurrency);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-initialize when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initCurrencySelector);
|
|
||||||
} else {
|
|
||||||
initCurrencySelector();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose init function globally
|
|
||||||
window.initCurrencySelector = initCurrencySelector;
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@ -1,422 +0,0 @@
|
||||||
/**
|
|
||||||
* Document Cards Component
|
|
||||||
* Renders document sections as interactive cards
|
|
||||||
*/
|
|
||||||
|
|
||||||
class DocumentCards {
|
|
||||||
constructor(containerId) {
|
|
||||||
this.container = document.getElementById(containerId);
|
|
||||||
this.currentDocument = null;
|
|
||||||
this.modalViewer = new ModalViewer();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render document as card grid
|
|
||||||
*/
|
|
||||||
render(document) {
|
|
||||||
if (!document || !document.sections || document.sections.length === 0) {
|
|
||||||
this.renderTraditionalView(document);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentDocument = document;
|
|
||||||
|
|
||||||
// Create document header
|
|
||||||
const headerHtml = this.renderHeader(document);
|
|
||||||
|
|
||||||
// Group sections by category
|
|
||||||
const sectionsByCategory = this.groupByCategory(document.sections);
|
|
||||||
|
|
||||||
// Render card grid
|
|
||||||
const cardsHtml = this.renderCardGrid(sectionsByCategory);
|
|
||||||
|
|
||||||
this.container.innerHTML = `
|
|
||||||
${headerHtml}
|
|
||||||
${cardsHtml}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add event listeners after a brief delay to ensure DOM is ready
|
|
||||||
setTimeout(() => {
|
|
||||||
this.attachEventListeners();
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render document header
|
|
||||||
*/
|
|
||||||
renderHeader(document) {
|
|
||||||
const version = document.metadata?.version || '';
|
|
||||||
const dateUpdated = document.metadata?.date_updated
|
|
||||||
? new Date(document.metadata.date_updated).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short'
|
|
||||||
})
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const versionText = version ? `v${version}` : '';
|
|
||||||
const metaText = [versionText, dateUpdated ? `Updated ${dateUpdated}` : '']
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' | ');
|
|
||||||
|
|
||||||
const hasToC = document.toc && document.toc.length > 0;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="flex items-center justify-between mb-6 pb-4 border-b border-gray-200">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">${document.title}</h1>
|
|
||||||
${metaText ? `<p class="text-sm text-gray-500">${metaText}</p>` : ''}
|
|
||||||
${document.sections ? `<p class="text-sm text-gray-600 mt-1">${document.sections.length} sections</p>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${hasToC ? `
|
|
||||||
<button id="toc-button"
|
|
||||||
class="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded transition"
|
|
||||||
title="Table of Contents"
|
|
||||||
aria-label="Show table of contents">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
<a href="/downloads/${document.slug}.pdf"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded transition"
|
|
||||||
title="Download PDF"
|
|
||||||
aria-label="Download PDF">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group sections by category
|
|
||||||
*/
|
|
||||||
groupByCategory(sections) {
|
|
||||||
const groups = {
|
|
||||||
conceptual: [],
|
|
||||||
practical: [],
|
|
||||||
technical: [],
|
|
||||||
reference: [],
|
|
||||||
critical: []
|
|
||||||
};
|
|
||||||
|
|
||||||
sections.forEach(section => {
|
|
||||||
const category = section.category || 'conceptual';
|
|
||||||
if (groups[category]) {
|
|
||||||
groups[category].push(section);
|
|
||||||
} else {
|
|
||||||
groups.conceptual.push(section);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render card grid
|
|
||||||
*/
|
|
||||||
renderCardGrid(sectionsByCategory) {
|
|
||||||
const categoryConfig = {
|
|
||||||
conceptual: { icon: '📘', label: 'Conceptual', color: 'blue' },
|
|
||||||
practical: { icon: '✨', label: 'Practical', color: 'green' },
|
|
||||||
technical: { icon: '🔧', label: 'Technical', color: 'purple' },
|
|
||||||
reference: { icon: '📋', label: 'Reference', color: 'gray' },
|
|
||||||
critical: { icon: '⚠️', label: 'Critical', color: 'amber' }
|
|
||||||
};
|
|
||||||
|
|
||||||
let html = '<div class="card-grid-container">';
|
|
||||||
|
|
||||||
// Render each category that has sections
|
|
||||||
for (const [category, sections] of Object.entries(sectionsByCategory)) {
|
|
||||||
if (sections.length === 0) continue;
|
|
||||||
|
|
||||||
const config = categoryConfig[category];
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="category-section mb-8">
|
|
||||||
<h2 class="category-header text-lg font-semibold text-gray-700 mb-4 flex items-center">
|
|
||||||
<span class="text-2xl mr-2">${config.icon}</span>
|
|
||||||
${config.label}
|
|
||||||
</h2>
|
|
||||||
<div class="card-grid grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
${sections.map(section => this.renderCard(section, config.color)).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render individual card
|
|
||||||
*/
|
|
||||||
renderCard(section, color) {
|
|
||||||
const levelIcons = {
|
|
||||||
basic: '○',
|
|
||||||
intermediate: '◐',
|
|
||||||
advanced: '●'
|
|
||||||
};
|
|
||||||
|
|
||||||
const levelIcon = levelIcons[section.technicalLevel] || '○';
|
|
||||||
const levelLabel = section.technicalLevel.charAt(0).toUpperCase() + section.technicalLevel.slice(1);
|
|
||||||
|
|
||||||
const borderColor = {
|
|
||||||
blue: 'border-blue-400',
|
|
||||||
green: 'border-green-400',
|
|
||||||
purple: 'border-purple-400',
|
|
||||||
gray: 'border-gray-400',
|
|
||||||
amber: 'border-amber-400'
|
|
||||||
}[color] || 'border-blue-400';
|
|
||||||
|
|
||||||
const hoverColor = {
|
|
||||||
blue: 'hover:border-blue-600 hover:shadow-blue-100',
|
|
||||||
green: 'hover:border-green-600 hover:shadow-green-100',
|
|
||||||
purple: 'hover:border-purple-600 hover:shadow-purple-100',
|
|
||||||
gray: 'hover:border-gray-600 hover:shadow-gray-100',
|
|
||||||
amber: 'hover:border-amber-600 hover:shadow-amber-100'
|
|
||||||
}[color] || 'hover:border-blue-600';
|
|
||||||
|
|
||||||
const bgColor = {
|
|
||||||
blue: 'bg-blue-50',
|
|
||||||
green: 'bg-green-50',
|
|
||||||
purple: 'bg-purple-50',
|
|
||||||
gray: 'bg-gray-50',
|
|
||||||
amber: 'bg-amber-50'
|
|
||||||
}[color] || 'bg-blue-50';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="doc-card ${bgColor} border-2 ${borderColor} rounded-lg p-5 cursor-pointer transition-all duration-200 ${hoverColor} hover:shadow-lg"
|
|
||||||
data-section-slug="${section.slug}">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">${section.title}</h3>
|
|
||||||
<p class="text-sm text-gray-700 mb-4 line-clamp-3">${section.excerpt}</p>
|
|
||||||
<div class="flex items-center justify-between text-xs text-gray-600">
|
|
||||||
<span>${section.readingTime} min read</span>
|
|
||||||
<span title="${levelLabel}">${levelIcon} ${levelLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback: render traditional view for documents without sections
|
|
||||||
*/
|
|
||||||
renderTraditionalView(document) {
|
|
||||||
if (!document) return;
|
|
||||||
|
|
||||||
this.container.innerHTML = `
|
|
||||||
<div class="prose max-w-none">
|
|
||||||
${document.content_html}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach event listeners to cards
|
|
||||||
*/
|
|
||||||
attachEventListeners() {
|
|
||||||
const cards = this.container.querySelectorAll('.doc-card');
|
|
||||||
|
|
||||||
cards.forEach(card => {
|
|
||||||
card.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const sectionSlug = card.dataset.sectionSlug;
|
|
||||||
const section = this.currentDocument.sections.find(s => s.slug === sectionSlug);
|
|
||||||
|
|
||||||
if (section) {
|
|
||||||
this.modalViewer.show(section, this.currentDocument.sections);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach ToC button listener
|
|
||||||
const tocButton = document.getElementById('toc-button');
|
|
||||||
if (tocButton) {
|
|
||||||
tocButton.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (typeof openToCModal === 'function') {
|
|
||||||
openToCModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modal Viewer Component
|
|
||||||
* Displays section content in a modal
|
|
||||||
*/
|
|
||||||
class ModalViewer {
|
|
||||||
constructor() {
|
|
||||||
this.modal = null;
|
|
||||||
this.currentSection = null;
|
|
||||||
this.allSections = [];
|
|
||||||
this.currentIndex = 0;
|
|
||||||
this.createModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create modal structure
|
|
||||||
*/
|
|
||||||
createModal() {
|
|
||||||
const modalHtml = `
|
|
||||||
<div id="section-modal">
|
|
||||||
<div class="bg-white rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
|
||||||
<h2 id="modal-title" class="text-2xl font-bold text-gray-900">Document</h2>
|
|
||||||
<button id="modal-close" class="text-gray-400 hover:text-gray-600 text-3xl leading-none" aria-label="Close document">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div id="modal-content" class="flex-1 overflow-y-auto p-6 prose max-w-none">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer Navigation -->
|
|
||||||
<div class="flex items-center justify-between p-4 border-t border-gray-200 bg-gray-50">
|
|
||||||
<button id="modal-prev" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
← Previous
|
|
||||||
</button>
|
|
||||||
<span id="modal-progress" class="text-sm text-gray-600"></span>
|
|
||||||
<button id="modal-next" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
Next →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
||||||
|
|
||||||
this.modal = document.getElementById('section-modal');
|
|
||||||
this.attachModalListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show modal with section content
|
|
||||||
*/
|
|
||||||
show(section, allSections) {
|
|
||||||
this.currentSection = section;
|
|
||||||
this.allSections = allSections;
|
|
||||||
this.currentIndex = allSections.findIndex(s => s.slug === section.slug);
|
|
||||||
|
|
||||||
// Update content
|
|
||||||
const titleEl = document.getElementById('modal-title');
|
|
||||||
const contentEl = document.getElementById('modal-content');
|
|
||||||
|
|
||||||
if (!titleEl || !contentEl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
titleEl.textContent = section.title;
|
|
||||||
|
|
||||||
// Remove duplicate title (H1 or H2) from content (it's already in modal header)
|
|
||||||
let contentHtml = section.content_html;
|
|
||||||
|
|
||||||
// Try removing h1 first, then h2
|
|
||||||
const firstH1Match = contentHtml.match(/<h1[^>]*>.*?<\/h1>/);
|
|
||||||
if (firstH1Match) {
|
|
||||||
contentHtml = contentHtml.replace(firstH1Match[0], '');
|
|
||||||
} else {
|
|
||||||
const firstH2Match = contentHtml.match(/<h2[^>]*>.*?<\/h2>/);
|
|
||||||
if (firstH2Match) {
|
|
||||||
contentHtml = contentHtml.replace(firstH2Match[0], '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentEl.innerHTML = contentHtml;
|
|
||||||
|
|
||||||
// Update navigation
|
|
||||||
this.updateNavigation();
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
this.modal.style.display = 'flex';
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
// Scroll to top of content
|
|
||||||
contentEl.scrollTop = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide modal
|
|
||||||
*/
|
|
||||||
hide() {
|
|
||||||
this.modal.style.display = 'none';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update navigation buttons
|
|
||||||
*/
|
|
||||||
updateNavigation() {
|
|
||||||
const prevBtn = document.getElementById('modal-prev');
|
|
||||||
const nextBtn = document.getElementById('modal-next');
|
|
||||||
const progress = document.getElementById('modal-progress');
|
|
||||||
|
|
||||||
prevBtn.disabled = this.currentIndex === 0;
|
|
||||||
nextBtn.disabled = this.currentIndex === this.allSections.length - 1;
|
|
||||||
|
|
||||||
progress.textContent = `${this.currentIndex + 1} of ${this.allSections.length}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to previous section
|
|
||||||
*/
|
|
||||||
showPrevious() {
|
|
||||||
if (this.currentIndex > 0) {
|
|
||||||
this.show(this.allSections[this.currentIndex - 1], this.allSections);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to next section
|
|
||||||
*/
|
|
||||||
showNext() {
|
|
||||||
if (this.currentIndex < this.allSections.length - 1) {
|
|
||||||
this.show(this.allSections[this.currentIndex + 1], this.allSections);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach modal event listeners
|
|
||||||
*/
|
|
||||||
attachModalListeners() {
|
|
||||||
// Close button
|
|
||||||
document.getElementById('modal-close').addEventListener('click', () => this.hide());
|
|
||||||
|
|
||||||
// Navigation buttons
|
|
||||||
document.getElementById('modal-prev').addEventListener('click', () => this.showPrevious());
|
|
||||||
document.getElementById('modal-next').addEventListener('click', () => this.showNext());
|
|
||||||
|
|
||||||
// Close on background click
|
|
||||||
this.modal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === this.modal) {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
// Check if modal is visible using display style instead of hidden class
|
|
||||||
if (this.modal.style.display === 'flex') {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
this.hide();
|
|
||||||
} else if (e.key === 'ArrowLeft') {
|
|
||||||
this.showPrevious();
|
|
||||||
} else if (e.key === 'ArrowRight') {
|
|
||||||
this.showNext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
/**
|
|
||||||
* Document Viewer Component
|
|
||||||
* Displays framework documentation with TOC and navigation
|
|
||||||
*/
|
|
||||||
|
|
||||||
class DocumentViewer {
|
|
||||||
constructor(containerId = 'document-viewer') {
|
|
||||||
this.container = document.getElementById(containerId);
|
|
||||||
this.currentDocument = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render document
|
|
||||||
*/
|
|
||||||
async render(documentSlug) {
|
|
||||||
if (!this.container) {
|
|
||||||
console.error('Document viewer container not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Show loading state
|
|
||||||
this.showLoading();
|
|
||||||
|
|
||||||
// Fetch document
|
|
||||||
const response = await API.Documents.get(documentSlug);
|
|
||||||
|
|
||||||
if (!response.success) {
|
|
||||||
throw new Error('Document not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentDocument = response.document;
|
|
||||||
this.showDocument();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.showError(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show loading state
|
|
||||||
*/
|
|
||||||
showLoading() {
|
|
||||||
this.container.innerHTML = `
|
|
||||||
<div class="flex items-center justify-center py-20">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
||||||
<p class="text-gray-600">Loading document...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show document content
|
|
||||||
*/
|
|
||||||
showDocument() {
|
|
||||||
const doc = this.currentDocument;
|
|
||||||
|
|
||||||
this.container.innerHTML = `
|
|
||||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-8">
|
|
||||||
${doc.quadrant ? `
|
|
||||||
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded mb-2">
|
|
||||||
${doc.quadrant}
|
|
||||||
</span>
|
|
||||||
` : ''}
|
|
||||||
<h1 class="text-4xl font-bold text-gray-900 mb-2">${this.escapeHtml(doc.title)}</h1>
|
|
||||||
${doc.metadata?.version ? `
|
|
||||||
<p class="text-sm text-gray-500">Version ${doc.metadata.version}</p>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table of Contents -->
|
|
||||||
${doc.toc && doc.toc.length > 0 ? this.renderTOC(doc.toc) : ''}
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="prose prose-lg max-w-none">
|
|
||||||
${doc.content_html}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Metadata -->
|
|
||||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
${doc.created_at ? `<p>Created: ${new Date(doc.created_at).toLocaleDateString()}</p>` : ''}
|
|
||||||
${doc.updated_at ? `<p>Updated: ${new Date(doc.updated_at).toLocaleDateString()}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add smooth scroll to TOC links
|
|
||||||
this.initializeTOCLinks();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render table of contents
|
|
||||||
*/
|
|
||||||
renderTOC(toc) {
|
|
||||||
return `
|
|
||||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-8">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Table of Contents</h2>
|
|
||||||
<nav>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
${toc.map(item => {
|
|
||||||
// Generate Tailwind margin class based on level
|
|
||||||
const marginClass = item.level === 1 ? '' :
|
|
||||||
item.level === 2 ? 'ml-4' :
|
|
||||||
item.level === 3 ? 'ml-8' :
|
|
||||||
item.level === 4 ? 'ml-12' :
|
|
||||||
'ml-16';
|
|
||||||
return `
|
|
||||||
<li class="${marginClass}">
|
|
||||||
<a href="#${item.id}"
|
|
||||||
class="text-blue-600 hover:text-blue-700 hover:underline">
|
|
||||||
${this.escapeHtml(item.text)}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
}).join('')}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize TOC links for smooth scrolling
|
|
||||||
*/
|
|
||||||
initializeTOCLinks() {
|
|
||||||
this.container.querySelectorAll('a[href^="#"]').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const id = link.getAttribute('href').slice(1);
|
|
||||||
const target = document.getElementById(id);
|
|
||||||
if (target) {
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show error state
|
|
||||||
*/
|
|
||||||
showError(message) {
|
|
||||||
this.container.innerHTML = `
|
|
||||||
<div class="max-w-2xl mx-auto px-4 py-20 text-center">
|
|
||||||
<div class="text-red-600 mb-4">
|
|
||||||
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Document Not Found</h2>
|
|
||||||
<p class="text-gray-600 mb-6">${this.escapeHtml(message)}</p>
|
|
||||||
<a href="/docs" class="text-blue-600 hover:text-blue-700 font-semibold">
|
|
||||||
← Browse all documents
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML to prevent XSS
|
|
||||||
*/
|
|
||||||
escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export as global
|
|
||||||
window.DocumentViewer = DocumentViewer;
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
/**
|
|
||||||
* Footer Component - i18n-enabled
|
|
||||||
* Shared footer for all Tractatus pages with language persistence
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
class TractatusFooter {
|
|
||||||
constructor() {
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Wait for I18n to be ready before rendering
|
|
||||||
if (window.I18n && window.I18n.translations && Object.keys(window.I18n.translations).length > 0) {
|
|
||||||
this.render();
|
|
||||||
this.attachEventListeners();
|
|
||||||
} else {
|
|
||||||
// If I18n not ready, wait for it
|
|
||||||
const checkI18n = setInterval(() => {
|
|
||||||
if (window.I18n && window.I18n.translations && Object.keys(window.I18n.translations).length > 0) {
|
|
||||||
clearInterval(checkI18n);
|
|
||||||
this.render();
|
|
||||||
this.attachEventListeners();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Fallback timeout - render without i18n after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
clearInterval(checkI18n);
|
|
||||||
if (!document.querySelector('footer[role="contentinfo"]')) {
|
|
||||||
this.render();
|
|
||||||
this.attachEventListeners();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
// Create footer HTML with data-i18n attributes
|
|
||||||
const footerHTML = `
|
|
||||||
<footer class="bg-gray-900 text-gray-300 mt-16" role="contentinfo">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
|
|
||||||
<!-- Main Footer Content -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
|
|
||||||
|
|
||||||
<!-- About -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-white font-semibold mb-4" data-i18n="footer.about_heading">Tractatus Framework</h3>
|
|
||||||
<p class="text-sm text-gray-400" data-i18n="footer.about_text">
|
|
||||||
Architectural constraints for AI safety that preserve human agency through structural, not aspirational, enforcement.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Documentation -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-white font-semibold mb-4" data-i18n="footer.documentation_heading">Documentation</h3>
|
|
||||||
<ul class="space-y-2 text-sm">
|
|
||||||
<li><a href="/docs.html" class="hover:text-white transition" data-i18n="footer.documentation_links.framework_docs">Framework Docs</a></li>
|
|
||||||
<li><a href="/about.html" class="hover:text-white transition" data-i18n="footer.documentation_links.about">About</a></li>
|
|
||||||
<li><a href="/about/values.html" class="hover:text-white transition" data-i18n="footer.documentation_links.core_values">Core Values</a></li>
|
|
||||||
<li><a href="/demos/27027-demo.html" class="hover:text-white transition" data-i18n="footer.documentation_links.interactive_demo">Interactive Demo</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Support -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-white font-semibold mb-4" data-i18n="footer.support_heading">Support</h3>
|
|
||||||
<ul class="space-y-2 text-sm">
|
|
||||||
<li><a href="/koha.html" class="hover:text-white transition" data-i18n="footer.support_links.koha">Support (Koha)</a></li>
|
|
||||||
<li><a href="/koha/transparency.html" class="hover:text-white transition" data-i18n="footer.support_links.transparency">Transparency</a></li>
|
|
||||||
<li><a href="/media-inquiry.html" class="hover:text-white transition" data-i18n="footer.support_links.media_inquiries">Media Inquiries</a></li>
|
|
||||||
<li><a href="/case-submission.html" class="hover:text-white transition" data-i18n="footer.support_links.submit_case">Submit Case Study</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Legal & Contact -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-white font-semibold mb-4" data-i18n="footer.legal_heading">Legal</h3>
|
|
||||||
<ul class="space-y-2 text-sm">
|
|
||||||
<li><a href="/privacy.html" class="hover:text-white transition" data-i18n="footer.legal_links.privacy">Privacy Policy</a></li>
|
|
||||||
<li><a href="mailto:hello@agenticgovernance.digital" class="hover:text-white transition" data-i18n="footer.legal_links.contact">Contact Us</a></li>
|
|
||||||
<li><a href="https://github.com/AgenticGovernance/tractatus-framework" class="hover:text-white transition" target="_blank" rel="noopener">GitHub</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="border-t border-gray-800 pt-8">
|
|
||||||
|
|
||||||
<!-- Te Tiriti Acknowledgement -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<p class="text-sm text-gray-400">
|
|
||||||
<strong class="text-gray-300" data-i18n="footer.te_tiriti_label">Te Tiriti o Waitangi:</strong>
|
|
||||||
<span data-i18n="footer.te_tiriti_text">We acknowledge Te Tiriti o Waitangi and our commitment to partnership, protection, and participation. This project respects Māori data sovereignty (rangatiratanga) and collective guardianship (kaitiakitanga).</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom Row -->
|
|
||||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4 text-sm">
|
|
||||||
<p class="text-gray-400">
|
|
||||||
© ${currentYear} <span data-i18n="footer.copyright">John G Stroh. Licensed under</span> <a href="https://www.apache.org/licenses/LICENSE-2.0" class="text-blue-400 hover:text-blue-300 transition" target="_blank" rel="noopener"><span data-i18n="footer.license">Apache 2.0</span></a>.
|
|
||||||
</p>
|
|
||||||
<p class="text-gray-400" data-i18n="footer.location">
|
|
||||||
Made in Aotearoa New Zealand 🇳🇿
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Insert footer at end of body
|
|
||||||
const existingFooter = document.querySelector('footer[role="contentinfo"]');
|
|
||||||
if (existingFooter) {
|
|
||||||
existingFooter.outerHTML = footerHTML;
|
|
||||||
} else if (document.body) {
|
|
||||||
document.body.insertAdjacentHTML('beforeend', footerHTML);
|
|
||||||
} else {
|
|
||||||
// If body not ready, wait for DOM
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
document.body.insertAdjacentHTML('beforeend', footerHTML);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply translations if I18n is available
|
|
||||||
if (window.I18n && window.I18n.applyTranslations) {
|
|
||||||
window.I18n.applyTranslations();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attachEventListeners() {
|
|
||||||
// Listen for language changes and re-render footer
|
|
||||||
window.addEventListener('languageChanged', (event) => {
|
|
||||||
console.log('[Footer] Language changed to:', event.detail.language);
|
|
||||||
this.render();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-initialize when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => new TractatusFooter());
|
|
||||||
} else {
|
|
||||||
new TractatusFooter();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
/**
|
|
||||||
* Language Selector Component
|
|
||||||
* Simple icon-based selector for all devices
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
const supportedLanguages = [
|
|
||||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
|
||||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
|
||||||
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
|
|
||||||
{ code: 'mi', name: 'Te Reo Māori', flag: '🇳🇿', disabled: true, tooltip: 'Planned' }
|
|
||||||
];
|
|
||||||
|
|
||||||
function createLanguageSelector() {
|
|
||||||
const container = document.getElementById('language-selector-container');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const currentLang = (window.I18n && window.I18n.currentLang) || 'en';
|
|
||||||
|
|
||||||
const selectorHTML = `
|
|
||||||
<!-- Language icon buttons (all devices) -->
|
|
||||||
<div class="flex gap-1">
|
|
||||||
${supportedLanguages.map(lang => `
|
|
||||||
<button
|
|
||||||
data-lang="${lang.code}"
|
|
||||||
class="language-icon-btn w-11 h-11 flex items-center justify-center rounded-lg border-2 transition-all ${
|
|
||||||
lang.code === currentLang
|
|
||||||
? 'border-blue-600 bg-blue-50'
|
|
||||||
: 'border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50'
|
|
||||||
} ${lang.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
|
|
||||||
aria-label="${lang.disabled ? lang.name + ' (' + lang.tooltip + ')' : 'Switch to ' + lang.name}"
|
|
||||||
title="${lang.name}${lang.disabled ? ' (' + lang.tooltip + ')' : ''}"
|
|
||||||
${lang.disabled ? 'disabled' : ''}
|
|
||||||
>
|
|
||||||
<span class="text-2xl" role="img" aria-label="${lang.name} flag">${lang.flag}</span>
|
|
||||||
</button>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.innerHTML = selectorHTML;
|
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
attachEventListeners(currentLang);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachEventListeners(currentLang) {
|
|
||||||
// Icon buttons
|
|
||||||
const iconButtons = document.querySelectorAll('.language-icon-btn:not([disabled])');
|
|
||||||
iconButtons.forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const selectedLang = this.getAttribute('data-lang');
|
|
||||||
if (window.I18n) {
|
|
||||||
window.I18n.setLanguage(selectedLang);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update active state
|
|
||||||
document.querySelectorAll('.language-icon-btn').forEach(btn => {
|
|
||||||
if (!btn.disabled) {
|
|
||||||
btn.classList.remove('border-blue-600', 'bg-blue-50');
|
|
||||||
btn.classList.add('border-gray-300', 'bg-white');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.classList.remove('border-gray-300', 'bg-white');
|
|
||||||
this.classList.add('border-blue-600', 'bg-blue-50');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', createLanguageSelector);
|
|
||||||
} else {
|
|
||||||
createLanguageSelector();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-initialize when language changes (to update active state)
|
|
||||||
if (window.I18n) {
|
|
||||||
const originalSetLanguage = window.I18n.setLanguage;
|
|
||||||
window.I18n.setLanguage = function(lang) {
|
|
||||||
originalSetLanguage.call(window.I18n, lang);
|
|
||||||
createLanguageSelector(); // Refresh selector with new active language
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
/**
|
|
||||||
* Tractatus Framework - Responsive Navbar Component
|
|
||||||
* Consistent, mobile-friendly navigation across all pages
|
|
||||||
*/
|
|
||||||
|
|
||||||
class TractatusNavbar {
|
|
||||||
constructor() {
|
|
||||||
this.mobileMenuOpen = false;
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.render();
|
|
||||||
this.attachEventListeners();
|
|
||||||
this.setActivePageIndicator();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const navHTML = `
|
|
||||||
<nav class="bg-white border-b border-gray-200 sticky top-0 z-50 shadow-sm">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between h-16">
|
|
||||||
|
|
||||||
<!-- Left: Logo + Brand -->
|
|
||||||
<div class="flex items-center">
|
|
||||||
<a href="/"
|
|
||||||
class="flex items-center space-x-3 px-3 py-2 -ml-3 rounded-lg hover:bg-blue-50 transition-all duration-200 group"
|
|
||||||
title="Return to homepage">
|
|
||||||
<img src="/images/tractatus-icon-new.svg" alt="Tractatus Icon" class="w-8 h-8">
|
|
||||||
<span class="text-xl font-bold text-gray-900 group-hover:text-blue-700 transition-colors hidden sm:inline">Tractatus Framework</span>
|
|
||||||
<span class="text-xl font-bold text-gray-900 group-hover:text-blue-700 transition-colors sm:hidden">Tractatus</span>
|
|
||||||
<svg class="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity ml-1 hidden sm:block"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Language Selector + Menu Button -->
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<!-- Language Selector Container -->
|
|
||||||
<div id="language-selector-container"></div>
|
|
||||||
|
|
||||||
<button id="mobile-menu-btn" class="text-gray-600 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-2" aria-label="Toggle menu">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation Drawer (overlay, doesn't push content) -->
|
|
||||||
<div id="mobile-menu" class="hidden fixed inset-0 z-[9999]">
|
|
||||||
<!-- Backdrop with blur -->
|
|
||||||
<div id="mobile-menu-backdrop" class="absolute inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity"></div>
|
|
||||||
|
|
||||||
<!-- Menu Panel (slides from right) -->
|
|
||||||
<div id="mobile-menu-panel" class="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white shadow-2xl transform transition-transform duration-300 ease-out">
|
|
||||||
<div class="flex justify-between items-center px-5 h-16 border-b border-gray-200">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<img src="/images/tractatus-icon-new.svg" alt="Tractatus Icon" class="w-6 h-6">
|
|
||||||
<span class="font-bold text-gray-900">Navigation</span>
|
|
||||||
</div>
|
|
||||||
<button id="mobile-menu-close-btn" class="text-gray-600 hover:text-gray-900 p-2 rounded hover:bg-gray-100 transition" aria-label="Close menu">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<nav class="p-5 space-y-3">
|
|
||||||
<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">Audiences</p>
|
|
||||||
<a href="/researcher.html" class="block px-3 py-2.5 text-gray-700 hover:bg-blue-50 hover:text-blue-700 rounded-lg transition font-medium">
|
|
||||||
<span class="text-sm">🔬 Researcher</span>
|
|
||||||
</a>
|
|
||||||
<a href="/implementer.html" class="block px-3 py-2.5 text-gray-700 hover:bg-blue-50 hover:text-blue-700 rounded-lg transition font-medium">
|
|
||||||
<span class="text-sm">⚙️ Implementer</span>
|
|
||||||
</a>
|
|
||||||
<a href="/leader.html" class="block px-3 py-2.5 text-gray-700 hover:bg-blue-50 hover:text-blue-700 rounded-lg transition font-medium">
|
|
||||||
<span class="text-sm">💼 Leader</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<a href="/docs.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 font-semibold">📚 Documentation</span>
|
|
||||||
</a>
|
|
||||||
<a href="/blog.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 font-semibold">📝 Blog</span>
|
|
||||||
</a>
|
|
||||||
<a href="/faq.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 font-semibold">❓ FAQ</span>
|
|
||||||
</a>
|
|
||||||
<a href="/about.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 font-semibold">ℹ️ About</span>
|
|
||||||
</a>
|
|
||||||
<a href="/koha.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 font-semibold">🤝 Support (Koha)</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</nav>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Always insert navbar at the very beginning of body
|
|
||||||
// Check if there's already a tractatus navbar (to avoid duplicates)
|
|
||||||
const existingNavbar = document.querySelector('nav.bg-white.border-b.border-gray-200.sticky');
|
|
||||||
if (existingNavbar) {
|
|
||||||
existingNavbar.outerHTML = navHTML;
|
|
||||||
} else {
|
|
||||||
const placeholder = document.getElementById('navbar-placeholder');
|
|
||||||
if (placeholder) {
|
|
||||||
placeholder.outerHTML = navHTML;
|
|
||||||
} else {
|
|
||||||
document.body.insertAdjacentHTML('afterbegin', navHTML);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attachEventListeners() {
|
|
||||||
// Mobile Menu (Navigation Drawer)
|
|
||||||
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
|
||||||
const mobileMenuCloseBtn = document.getElementById('mobile-menu-close-btn');
|
|
||||||
const mobileMenu = document.getElementById('mobile-menu');
|
|
||||||
const mobileMenuPanel = document.getElementById('mobile-menu-panel');
|
|
||||||
const mobileMenuBackdrop = document.getElementById('mobile-menu-backdrop');
|
|
||||||
|
|
||||||
const toggleMobileMenu = () => {
|
|
||||||
this.mobileMenuOpen = !this.mobileMenuOpen;
|
|
||||||
|
|
||||||
if (this.mobileMenuOpen) {
|
|
||||||
// Open: Show menu and slide panel in from right
|
|
||||||
mobileMenu.classList.remove('hidden');
|
|
||||||
// Use setTimeout to ensure display change happens before animation
|
|
||||||
setTimeout(() => {
|
|
||||||
mobileMenuPanel.classList.remove('translate-x-full');
|
|
||||||
mobileMenuPanel.classList.add('translate-x-0');
|
|
||||||
}, 10);
|
|
||||||
document.body.style.overflow = 'hidden'; // Prevent scrolling when menu is open
|
|
||||||
} else {
|
|
||||||
// Close: Slide panel out to right
|
|
||||||
mobileMenuPanel.classList.remove('translate-x-0');
|
|
||||||
mobileMenuPanel.classList.add('translate-x-full');
|
|
||||||
// Hide menu after animation completes (300ms)
|
|
||||||
setTimeout(() => {
|
|
||||||
mobileMenu.classList.add('hidden');
|
|
||||||
}, 300);
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize panel in hidden state (off-screen to the right)
|
|
||||||
if (mobileMenuPanel) {
|
|
||||||
mobileMenuPanel.classList.add('translate-x-full');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mobileMenuBtn) {
|
|
||||||
mobileMenuBtn.addEventListener('click', toggleMobileMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mobileMenuCloseBtn) {
|
|
||||||
mobileMenuCloseBtn.addEventListener('click', toggleMobileMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mobileMenuBackdrop) {
|
|
||||||
mobileMenuBackdrop.addEventListener('click', toggleMobileMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close mobile menu on navigation
|
|
||||||
const mobileLinks = document.querySelectorAll('#mobile-menu a');
|
|
||||||
mobileLinks.forEach(link => {
|
|
||||||
link.addEventListener('click', () => {
|
|
||||||
if (this.mobileMenuOpen) {
|
|
||||||
toggleMobileMenu();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setActivePageIndicator() {
|
|
||||||
// Get current page path
|
|
||||||
const currentPath = window.location.pathname;
|
|
||||||
|
|
||||||
// Normalize paths (handle both /page.html and /page)
|
|
||||||
const normalizePath = (path) => {
|
|
||||||
if (path === '/' || path === '/index.html') return '/';
|
|
||||||
return path.replace('.html', '').replace(/\/$/, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizedCurrent = normalizePath(currentPath);
|
|
||||||
|
|
||||||
// Find all navigation links in mobile menu
|
|
||||||
const mobileLinks = document.querySelectorAll('#mobile-menu a');
|
|
||||||
|
|
||||||
mobileLinks.forEach(link => {
|
|
||||||
const linkPath = link.getAttribute('href');
|
|
||||||
const normalizedLink = normalizePath(linkPath);
|
|
||||||
|
|
||||||
if (normalizedLink === normalizedCurrent) {
|
|
||||||
// Add active styling with brand colors
|
|
||||||
link.classList.add('border-l-4', 'bg-sky-50');
|
|
||||||
link.style.borderLeftColor = 'var(--tractatus-core-end)';
|
|
||||||
link.style.color = 'var(--tractatus-core-end)';
|
|
||||||
link.classList.remove('text-gray-700');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-initialize when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => new TractatusNavbar());
|
|
||||||
} else {
|
|
||||||
new TractatusNavbar();
|
|
||||||
}
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
/**
|
|
||||||
* Table of Contents Component
|
|
||||||
* Tractatus Framework - Phase 3: Interactive Documentation
|
|
||||||
*
|
|
||||||
* Creates sticky TOC sidebar on desktop, collapsible on mobile
|
|
||||||
* Highlights current section on scroll
|
|
||||||
* Smooth scroll to sections
|
|
||||||
*/
|
|
||||||
|
|
||||||
class TableOfContents {
|
|
||||||
constructor(options = {}) {
|
|
||||||
this.contentSelector = options.contentSelector || '#document-viewer';
|
|
||||||
this.tocSelector = options.tocSelector || '#table-of-contents';
|
|
||||||
this.headingSelector = options.headingSelector || 'h1, h2, h3';
|
|
||||||
this.activeClass = 'toc-active';
|
|
||||||
this.collapsedClass = 'toc-collapsed';
|
|
||||||
|
|
||||||
this.headings = [];
|
|
||||||
this.tocLinks = [];
|
|
||||||
this.currentActiveIndex = -1;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Wait for DOM to be ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => this.build());
|
|
||||||
} else {
|
|
||||||
this.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[TOC] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
|
||||||
const tocContainer = document.querySelector(this.tocSelector);
|
|
||||||
if (!tocContainer) {
|
|
||||||
console.warn('[TOC] TOC container not found:', this.tocSelector);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = document.querySelector(this.contentSelector);
|
|
||||||
if (!content) {
|
|
||||||
console.warn('[TOC] Content container not found:', this.contentSelector);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all headings in content
|
|
||||||
this.headings = Array.from(content.querySelectorAll(this.headingSelector));
|
|
||||||
|
|
||||||
if (this.headings.length === 0) {
|
|
||||||
console.log('[TOC] No headings found, hiding TOC');
|
|
||||||
tocContainer.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[TOC] Found ${this.headings.length} headings`);
|
|
||||||
|
|
||||||
// Generate IDs for headings if they don't have them
|
|
||||||
this.headings.forEach((heading, index) => {
|
|
||||||
if (!heading.id) {
|
|
||||||
heading.id = `toc-heading-${index}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build TOC HTML
|
|
||||||
const tocHTML = this.buildTOCHTML();
|
|
||||||
tocContainer.innerHTML = tocHTML;
|
|
||||||
|
|
||||||
// Store TOC links
|
|
||||||
this.tocLinks = Array.from(tocContainer.querySelectorAll('a'));
|
|
||||||
|
|
||||||
// Add scroll spy
|
|
||||||
this.initScrollSpy();
|
|
||||||
|
|
||||||
// Add smooth scroll
|
|
||||||
this.initSmoothScroll();
|
|
||||||
|
|
||||||
// Add mobile toggle functionality
|
|
||||||
this.initMobileToggle();
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTOCHTML() {
|
|
||||||
let html = '<nav class="toc-nav" aria-label="Table of Contents">';
|
|
||||||
html += '<div class="toc-header flex items-center justify-between mb-4 md:mb-6">';
|
|
||||||
html += '<h2 class="text-sm font-semibold text-gray-900 uppercase">On This Page</h2>';
|
|
||||||
html += '<button id="toc-toggle" class="md:hidden p-2 text-gray-600 hover:text-gray-900" aria-label="Toggle table of contents">';
|
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
|
||||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>';
|
|
||||||
html += '</svg>';
|
|
||||||
html += '</button>';
|
|
||||||
html += '</div>';
|
|
||||||
html += '<ul class="toc-list space-y-2" id="toc-list">';
|
|
||||||
|
|
||||||
this.headings.forEach((heading) => {
|
|
||||||
const level = parseInt(heading.tagName.substring(1)); // h1 -> 1, h2 -> 2, etc.
|
|
||||||
const text = heading.textContent.trim();
|
|
||||||
const id = heading.id;
|
|
||||||
|
|
||||||
// Different indentation for different levels
|
|
||||||
const indent = level === 1 ? '' : level === 2 ? 'ml-3' : 'ml-6';
|
|
||||||
const fontSize = level === 1 ? 'text-sm' : level === 2 ? 'text-sm' : 'text-xs';
|
|
||||||
const fontWeight = level === 1 ? 'font-semibold' : level === 2 ? 'font-medium' : 'font-normal';
|
|
||||||
|
|
||||||
html += `<li class="${indent}">`;
|
|
||||||
html += `<a href="#${id}" class="toc-link block py-1 ${fontSize} ${fontWeight} text-gray-700 hover:text-blue-600 transition-colors border-l-2 border-transparent hover:border-blue-600 pl-2" data-target="${id}">`;
|
|
||||||
html += text;
|
|
||||||
html += '</a>';
|
|
||||||
html += '</li>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</ul>';
|
|
||||||
html += '</nav>';
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
initScrollSpy() {
|
|
||||||
// Use Intersection Observer for better performance
|
|
||||||
const observerOptions = {
|
|
||||||
rootMargin: '-20% 0px -35% 0px',
|
|
||||||
threshold: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
const id = entry.target.id;
|
|
||||||
this.setActiveLink(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, observerOptions);
|
|
||||||
|
|
||||||
// Observe all headings
|
|
||||||
this.headings.forEach(heading => {
|
|
||||||
observer.observe(heading);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[TOC] Scroll spy initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveLink(targetId) {
|
|
||||||
// Remove active class from all links
|
|
||||||
this.tocLinks.forEach(link => {
|
|
||||||
link.classList.remove(this.activeClass);
|
|
||||||
link.classList.remove('border-blue-600', 'text-blue-600', 'font-semibold');
|
|
||||||
link.classList.add('border-transparent', 'text-gray-700');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add active class to current link
|
|
||||||
const activeLink = this.tocLinks.find(link => link.dataset.target === targetId);
|
|
||||||
if (activeLink) {
|
|
||||||
activeLink.classList.add(this.activeClass);
|
|
||||||
activeLink.classList.remove('border-transparent', 'text-gray-700');
|
|
||||||
activeLink.classList.add('border-blue-600', 'text-blue-600', 'font-semibold');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initSmoothScroll() {
|
|
||||||
this.tocLinks.forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const targetId = link.dataset.target;
|
|
||||||
const targetElement = document.getElementById(targetId);
|
|
||||||
|
|
||||||
if (targetElement) {
|
|
||||||
// Smooth scroll to target
|
|
||||||
targetElement.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'start'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update URL hash without jumping
|
|
||||||
if (history.pushState) {
|
|
||||||
history.pushState(null, null, `#${targetId}`);
|
|
||||||
} else {
|
|
||||||
window.location.hash = targetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// On mobile, collapse TOC after clicking
|
|
||||||
if (window.innerWidth < 768) {
|
|
||||||
this.collapseTOC();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[TOC] Smooth scroll initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
initMobileToggle() {
|
|
||||||
const toggleButton = document.getElementById('toc-toggle');
|
|
||||||
const tocList = document.getElementById('toc-list');
|
|
||||||
|
|
||||||
if (!toggleButton || !tocList) return;
|
|
||||||
|
|
||||||
toggleButton.addEventListener('click', () => {
|
|
||||||
const isCollapsed = tocList.classList.contains('hidden');
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
|
||||||
this.expandTOC();
|
|
||||||
} else {
|
|
||||||
this.collapseTOC();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start collapsed on mobile
|
|
||||||
if (window.innerWidth < 768) {
|
|
||||||
this.collapseTOC();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[TOC] Mobile toggle initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
collapseTOC() {
|
|
||||||
const tocList = document.getElementById('toc-list');
|
|
||||||
const toggleButton = document.getElementById('toc-toggle');
|
|
||||||
|
|
||||||
if (tocList) {
|
|
||||||
tocList.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toggleButton) {
|
|
||||||
const svg = toggleButton.querySelector('svg');
|
|
||||||
if (svg) {
|
|
||||||
svg.style.transform = 'rotate(0deg)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expandTOC() {
|
|
||||||
const tocList = document.getElementById('toc-list');
|
|
||||||
const toggleButton = document.getElementById('toc-toggle');
|
|
||||||
|
|
||||||
if (tocList) {
|
|
||||||
tocList.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toggleButton) {
|
|
||||||
const svg = toggleButton.querySelector('svg');
|
|
||||||
if (svg) {
|
|
||||||
svg.style.transform = 'rotate(180deg)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public method to rebuild TOC (useful for dynamically loaded content)
|
|
||||||
rebuild() {
|
|
||||||
this.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-initialize when script loads (if TOC container exists)
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.tocInstance = new TableOfContents();
|
|
||||||
|
|
||||||
// Listen for custom event from document viewer for dynamic content
|
|
||||||
document.addEventListener('documentLoaded', () => {
|
|
||||||
console.log('[TOC] Document loaded, rebuilding TOC');
|
|
||||||
window.tocInstance.rebuild();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export for module usage
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = TableOfContents;
|
|
||||||
}
|
|
||||||
|
|
@ -1,625 +0,0 @@
|
||||||
let documents = [];
|
|
||||||
let currentDocument = null;
|
|
||||||
let documentCards = null;
|
|
||||||
|
|
||||||
// Initialize card-based viewer
|
|
||||||
if (typeof DocumentCards !== 'undefined') {
|
|
||||||
documentCards = new DocumentCards('document-content');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Document categorization - Granular categories for better organization
|
|
||||||
const CATEGORIES = {
|
|
||||||
'getting-started': {
|
|
||||||
label: '🚀 Getting Started',
|
|
||||||
icon: '🚀',
|
|
||||||
description: 'Introduction, core concepts, and quick start guides',
|
|
||||||
order: 1,
|
|
||||||
color: 'blue',
|
|
||||||
bgColor: 'bg-blue-50',
|
|
||||||
borderColor: 'border-l-4 border-blue-500',
|
|
||||||
textColor: 'text-blue-700',
|
|
||||||
collapsed: false
|
|
||||||
},
|
|
||||||
'technical-reference': {
|
|
||||||
label: '🔌 Technical Reference',
|
|
||||||
icon: '🔌',
|
|
||||||
description: 'API docs, implementation guides, code examples',
|
|
||||||
order: 2,
|
|
||||||
color: 'green',
|
|
||||||
bgColor: 'bg-green-50',
|
|
||||||
borderColor: 'border-l-4 border-green-500',
|
|
||||||
textColor: 'text-green-700',
|
|
||||||
collapsed: true
|
|
||||||
},
|
|
||||||
'research-theory': {
|
|
||||||
label: '🔬 Theory & Research',
|
|
||||||
icon: '🔬',
|
|
||||||
description: 'Research papers, theoretical foundations, academic content',
|
|
||||||
order: 3,
|
|
||||||
color: 'purple',
|
|
||||||
bgColor: 'bg-purple-50',
|
|
||||||
borderColor: 'border-l-4 border-purple-500',
|
|
||||||
textColor: 'text-purple-700',
|
|
||||||
collapsed: true
|
|
||||||
},
|
|
||||||
'advanced-topics': {
|
|
||||||
label: '🎓 Advanced Topics',
|
|
||||||
icon: '🎓',
|
|
||||||
description: 'Value pluralism, deep dives, comparative analysis',
|
|
||||||
order: 4,
|
|
||||||
color: 'teal',
|
|
||||||
bgColor: 'bg-teal-50',
|
|
||||||
borderColor: 'border-l-4 border-teal-500',
|
|
||||||
textColor: 'text-teal-700',
|
|
||||||
collapsed: true
|
|
||||||
},
|
|
||||||
'case-studies': {
|
|
||||||
label: '📊 Case Studies',
|
|
||||||
icon: '📊',
|
|
||||||
description: 'Real-world examples, failure modes, success stories',
|
|
||||||
order: 5,
|
|
||||||
color: 'amber',
|
|
||||||
bgColor: 'bg-amber-50',
|
|
||||||
borderColor: 'border-l-4 border-amber-500',
|
|
||||||
textColor: 'text-amber-700',
|
|
||||||
collapsed: true
|
|
||||||
},
|
|
||||||
'business-leadership': {
|
|
||||||
label: '💼 Business & Leadership',
|
|
||||||
icon: '💼',
|
|
||||||
description: 'Business cases, executive briefs, ROI analysis',
|
|
||||||
order: 6,
|
|
||||||
color: 'pink',
|
|
||||||
bgColor: 'bg-pink-50',
|
|
||||||
borderColor: 'border-l-4 border-pink-500',
|
|
||||||
textColor: 'text-pink-700',
|
|
||||||
collapsed: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Documents to hide (internal/confidential)
|
|
||||||
const HIDDEN_DOCS = [
|
|
||||||
'security-audit-report',
|
|
||||||
'koha-production-deployment',
|
|
||||||
'koha-stripe-payment',
|
|
||||||
'appendix-e-contact',
|
|
||||||
'cover-letter'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Categorize a document using database category field
|
|
||||||
// New granular category system for better document organization
|
|
||||||
function categorizeDocument(doc) {
|
|
||||||
const slug = doc.slug.toLowerCase();
|
|
||||||
|
|
||||||
// Skip hidden documents
|
|
||||||
if (HIDDEN_DOCS.some(hidden => slug.includes(hidden))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use category from database
|
|
||||||
const category = doc.category || 'downloads-resources';
|
|
||||||
|
|
||||||
// Validate category exists in CATEGORIES constant
|
|
||||||
if (CATEGORIES[category]) {
|
|
||||||
return category;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to downloads-resources for uncategorized
|
|
||||||
console.warn(`Document "${doc.title}" has invalid category "${category}", using fallback`);
|
|
||||||
return 'downloads-resources';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group documents by category
|
|
||||||
function groupDocuments(docs) {
|
|
||||||
const grouped = {};
|
|
||||||
|
|
||||||
// Initialize all categories
|
|
||||||
Object.keys(CATEGORIES).forEach(key => {
|
|
||||||
grouped[key] = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Categorize each document (already sorted by order from API)
|
|
||||||
docs.forEach(doc => {
|
|
||||||
const category = categorizeDocument(doc);
|
|
||||||
if (category && grouped[category]) {
|
|
||||||
grouped[category].push(doc);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return grouped;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render document link with download button
|
|
||||||
function renderDocLink(doc, isHighlighted = false) {
|
|
||||||
const highlightClass = isHighlighted ? 'text-blue-700 bg-blue-50 border border-blue-200' : '';
|
|
||||||
|
|
||||||
// Determine if PDF download is available and get PDF path
|
|
||||||
// First check if document has explicit download_formats.pdf
|
|
||||||
let pdfPath = null;
|
|
||||||
let hasPDF = false;
|
|
||||||
|
|
||||||
if (doc.download_formats && doc.download_formats.pdf) {
|
|
||||||
pdfPath = doc.download_formats.pdf;
|
|
||||||
hasPDF = true;
|
|
||||||
} else if (!doc.slug.includes('api-reference-complete') &&
|
|
||||||
!doc.slug.includes('openapi-specification') &&
|
|
||||||
!doc.slug.includes('api-javascript-examples') &&
|
|
||||||
!doc.slug.includes('api-python-examples') &&
|
|
||||||
!doc.slug.includes('technical-architecture-diagram')) {
|
|
||||||
// Fallback to default /downloads/ path for documents that typically have PDFs
|
|
||||||
pdfPath = `/downloads/${doc.slug}.pdf`;
|
|
||||||
hasPDF = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add download button styling
|
|
||||||
const paddingClass = hasPDF ? 'pr-10' : 'pr-3';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="relative mb-1">
|
|
||||||
<button class="doc-link w-full text-left px-3 py-2 ${paddingClass} rounded text-sm hover:bg-blue-50 transition ${highlightClass}"
|
|
||||||
data-slug="${doc.slug}">
|
|
||||||
<div class="font-medium text-gray-900">${doc.title}</div>
|
|
||||||
</button>
|
|
||||||
${hasPDF ? `
|
|
||||||
<a href="${pdfPath}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="doc-download-link"
|
|
||||||
title="Download PDF (opens in new tab)">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load document list
|
|
||||||
async function loadDocuments() {
|
|
||||||
try {
|
|
||||||
// Fetch public documents
|
|
||||||
const response = await fetch('/api/documents');
|
|
||||||
const data = await response.json();
|
|
||||||
documents = data.documents || [];
|
|
||||||
|
|
||||||
// Fetch archived documents
|
|
||||||
const archivedResponse = await fetch('/api/documents/archived');
|
|
||||||
const archivedData = await archivedResponse.json();
|
|
||||||
const archivedDocuments = archivedData.documents || [];
|
|
||||||
|
|
||||||
const listEl = document.getElementById('document-list');
|
|
||||||
if (documents.length === 0 && archivedDocuments.length === 0) {
|
|
||||||
listEl.innerHTML = '<div class="text-sm text-gray-500">No documents available</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group documents by category
|
|
||||||
const grouped = groupDocuments(documents);
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
// Render categories in order
|
|
||||||
const sortedCategories = Object.entries(CATEGORIES)
|
|
||||||
.sort((a, b) => a[1].order - b[1].order);
|
|
||||||
|
|
||||||
sortedCategories.forEach(([categoryId, category]) => {
|
|
||||||
const docs = grouped[categoryId] || [];
|
|
||||||
if (docs.length === 0) return;
|
|
||||||
|
|
||||||
const isCollapsed = category.collapsed || false;
|
|
||||||
|
|
||||||
// Category header
|
|
||||||
html += `
|
|
||||||
<div class="category-section mb-4" data-category="${categoryId}">
|
|
||||||
<button class="category-toggle w-full flex items-center justify-between px-3 py-3 text-sm font-bold ${category.textColor} ${category.bgColor} ${category.borderColor} rounded-r hover:shadow-md transition-all"
|
|
||||||
data-category="${categoryId}"
|
|
||||||
data-collapsed="${isCollapsed}">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<span class="category-icon">${category.icon}</span>
|
|
||||||
<span>${category.label.replace(category.icon, '').trim()}</span>
|
|
||||||
</span>
|
|
||||||
<svg class="category-arrow w-5 h-5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="category-docs mt-2 pl-2" data-category="${categoryId}" data-collapsed="${isCollapsed}">
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Render documents in category
|
|
||||||
docs.forEach(doc => {
|
|
||||||
// Highlight the first document in Getting Started category
|
|
||||||
const isHighlighted = categoryId === 'getting-started' && doc.order === 1;
|
|
||||||
html += renderDocLink(doc, isHighlighted);
|
|
||||||
});
|
|
||||||
|
|
||||||
html += `
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add Archives section if there are archived documents
|
|
||||||
if (archivedDocuments.length > 0) {
|
|
||||||
html += `
|
|
||||||
<div class="category-section mb-4 mt-8" data-category="archives">
|
|
||||||
<button class="category-toggle w-full flex items-center justify-between px-3 py-3 text-sm font-bold text-gray-600 bg-gray-50 border-l-4 border-gray-400 rounded-r hover:shadow-md transition-all"
|
|
||||||
data-category="archives"
|
|
||||||
data-collapsed="true">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<span class="category-icon">📦</span>
|
|
||||||
<span>Archives</span>
|
|
||||||
<span class="text-xs font-normal text-gray-500">(${archivedDocuments.length} documents)</span>
|
|
||||||
</span>
|
|
||||||
<svg class="category-arrow w-5 h-5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="category-docs mt-2 pl-2" data-category="archives" data-collapsed="true">
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Render archived documents
|
|
||||||
archivedDocuments.forEach(doc => {
|
|
||||||
html += renderDocLink(doc, false);
|
|
||||||
// Add archive note if available
|
|
||||||
if (doc.archiveNote) {
|
|
||||||
html += `<div class="text-xs text-gray-500 italic pl-6 mb-2">${doc.archiveNote}</div>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
html += `
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
listEl.innerHTML = html;
|
|
||||||
|
|
||||||
// Apply collapsed state to categories (CSP-compliant - no inline styles)
|
|
||||||
listEl.querySelectorAll('.category-docs[data-collapsed="true"]').forEach(docsEl => {
|
|
||||||
docsEl.style.display = 'none';
|
|
||||||
});
|
|
||||||
listEl.querySelectorAll('.category-toggle[data-collapsed="true"] .category-arrow').forEach(arrowEl => {
|
|
||||||
arrowEl.style.transform = 'rotate(-90deg)';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add event delegation for document links
|
|
||||||
listEl.addEventListener('click', function(e) {
|
|
||||||
// Check for download link first (prevent document load when clicking download)
|
|
||||||
const downloadLink = e.target.closest('.doc-download-link');
|
|
||||||
if (downloadLink) {
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const button = e.target.closest('.doc-link');
|
|
||||||
if (button && button.dataset.slug) {
|
|
||||||
e.preventDefault();
|
|
||||||
loadDocument(button.dataset.slug);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category toggle
|
|
||||||
const toggle = e.target.closest('.category-toggle');
|
|
||||||
if (toggle) {
|
|
||||||
const categoryId = toggle.dataset.category;
|
|
||||||
const docsEl = listEl.querySelector(`.category-docs[data-category="${categoryId}"]`);
|
|
||||||
const arrowEl = toggle.querySelector('.category-arrow');
|
|
||||||
|
|
||||||
if (docsEl.style.display === 'none') {
|
|
||||||
docsEl.style.display = 'block';
|
|
||||||
arrowEl.style.transform = 'rotate(0deg)';
|
|
||||||
} else {
|
|
||||||
docsEl.style.display = 'none';
|
|
||||||
arrowEl.style.transform = 'rotate(-90deg)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for URL parameter to auto-load document or category
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const docParam = urlParams.get('doc');
|
|
||||||
const categoryParam = urlParams.get('category');
|
|
||||||
|
|
||||||
// Priority 1: Load specific document by slug if provided
|
|
||||||
if (docParam) {
|
|
||||||
const doc = documents.find(d => d.slug === docParam);
|
|
||||||
if (doc) {
|
|
||||||
// Find and expand the category containing this document
|
|
||||||
const docCategory = categorizeDocument(doc);
|
|
||||||
if (docCategory) {
|
|
||||||
const categoryDocsEl = listEl.querySelector(`.category-docs[data-category="${docCategory}"]`);
|
|
||||||
const categoryArrowEl = listEl.querySelector(`.category-toggle[data-category="${docCategory}"] .category-arrow`);
|
|
||||||
|
|
||||||
if (categoryDocsEl) {
|
|
||||||
categoryDocsEl.style.display = 'block';
|
|
||||||
if (categoryArrowEl) {
|
|
||||||
categoryArrowEl.style.transform = 'rotate(0deg)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the requested document
|
|
||||||
loadDocument(docParam);
|
|
||||||
} else {
|
|
||||||
console.warn(`Document with slug "${docParam}" not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Priority 2: Load category if provided but no specific document
|
|
||||||
else if (categoryParam && grouped[categoryParam] && grouped[categoryParam].length > 0) {
|
|
||||||
// Expand the specified category
|
|
||||||
const categoryDocsEl = listEl.querySelector(`.category-docs[data-category="${categoryParam}"]`);
|
|
||||||
const categoryArrowEl = listEl.querySelector(`.category-toggle[data-category="${categoryParam}"] .category-arrow`);
|
|
||||||
|
|
||||||
if (categoryDocsEl) {
|
|
||||||
categoryDocsEl.style.display = 'block';
|
|
||||||
if (categoryArrowEl) {
|
|
||||||
categoryArrowEl.style.transform = 'rotate(0deg)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load first document in the category
|
|
||||||
const firstDoc = grouped[categoryParam][0];
|
|
||||||
if (firstDoc) {
|
|
||||||
loadDocument(firstDoc.slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Priority 3: Default behavior
|
|
||||||
else {
|
|
||||||
// Default: Auto-load first document in "Getting Started" category (order: 1)
|
|
||||||
const gettingStartedDocs = grouped['getting-started'] || [];
|
|
||||||
if (gettingStartedDocs.length > 0) {
|
|
||||||
// Load the first document (order: 1) if available
|
|
||||||
const firstDoc = gettingStartedDocs.find(d => d.order === 1);
|
|
||||||
if (firstDoc) {
|
|
||||||
loadDocument(firstDoc.slug);
|
|
||||||
} else {
|
|
||||||
loadDocument(gettingStartedDocs[0].slug);
|
|
||||||
}
|
|
||||||
} else if (documents.length > 0) {
|
|
||||||
// Fallback to first available document in any category
|
|
||||||
const firstCategory = sortedCategories.find(([catId]) => grouped[catId] && grouped[catId].length > 0);
|
|
||||||
if (firstCategory) {
|
|
||||||
loadDocument(grouped[firstCategory[0]][0].slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading documents:', error);
|
|
||||||
document.getElementById('document-list').innerHTML =
|
|
||||||
'<div class="text-sm text-red-600">Error loading documents</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load specific document
|
|
||||||
let isLoading = false;
|
|
||||||
|
|
||||||
async function loadDocument(slug) {
|
|
||||||
// Prevent multiple simultaneous loads
|
|
||||||
if (isLoading) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
isLoading = true;
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
const contentEl = document.getElementById('document-content');
|
|
||||||
contentEl.innerHTML = `
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<svg class="animate-spin h-8 w-8 text-blue-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-600">Loading document...</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const response = await fetch(`/api/documents/${slug}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
throw new Error(data.error || 'Failed to load document');
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDocument = data.document;
|
|
||||||
|
|
||||||
// Update active state
|
|
||||||
document.querySelectorAll('.doc-link').forEach(el => {
|
|
||||||
if (el.dataset.slug === slug) {
|
|
||||||
el.classList.add('bg-blue-100', 'text-blue-900');
|
|
||||||
} else {
|
|
||||||
el.classList.remove('bg-blue-100', 'text-blue-900');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render with card-based viewer if available and document has sections
|
|
||||||
if (documentCards && currentDocument.sections && currentDocument.sections.length > 0) {
|
|
||||||
documentCards.render(currentDocument);
|
|
||||||
} else {
|
|
||||||
// Fallback to traditional view with header
|
|
||||||
const hasToC = currentDocument.toc && currentDocument.toc.length > 0;
|
|
||||||
|
|
||||||
// Check if PDF is available and get PDF path
|
|
||||||
let pdfPath = null;
|
|
||||||
let hasPDF = false;
|
|
||||||
|
|
||||||
if (currentDocument.download_formats && currentDocument.download_formats.pdf) {
|
|
||||||
pdfPath = currentDocument.download_formats.pdf;
|
|
||||||
hasPDF = true;
|
|
||||||
} else if (!currentDocument.slug.includes('api-reference-complete') &&
|
|
||||||
!currentDocument.slug.includes('openapi-specification') &&
|
|
||||||
!currentDocument.slug.includes('api-javascript-examples') &&
|
|
||||||
!currentDocument.slug.includes('api-python-examples') &&
|
|
||||||
!currentDocument.slug.includes('technical-architecture-diagram')) {
|
|
||||||
pdfPath = `/downloads/${currentDocument.slug}.pdf`;
|
|
||||||
hasPDF = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let headerHTML = `
|
|
||||||
<div class="flex items-center justify-between mb-6 pb-4 border-b border-gray-200">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">${currentDocument.title}</h1>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${hasToC ? `
|
|
||||||
<button id="toc-button"
|
|
||||||
class="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded transition"
|
|
||||||
title="Table of Contents"
|
|
||||||
aria-label="Show table of contents">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
${hasPDF ? `
|
|
||||||
<a href="${pdfPath}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded transition"
|
|
||||||
title="Download PDF"
|
|
||||||
aria-label="Download PDF">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Remove duplicate title H1 from content (it's already in header)
|
|
||||||
let contentHtml = currentDocument.content_html;
|
|
||||||
const firstH1Match = contentHtml.match(/<h1[^>]*>.*?<\/h1>/);
|
|
||||||
if (firstH1Match) {
|
|
||||||
contentHtml = contentHtml.replace(firstH1Match[0], '');
|
|
||||||
}
|
|
||||||
|
|
||||||
contentEl.innerHTML = headerHTML + `
|
|
||||||
<div class="prose max-w-none">
|
|
||||||
${contentHtml}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add ToC button event listener (works for both card and traditional views)
|
|
||||||
setTimeout(() => {
|
|
||||||
const tocButton = document.getElementById('toc-button');
|
|
||||||
if (tocButton) {
|
|
||||||
tocButton.addEventListener('click', () => openToCModal());
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Mobile navigation: Add document-active class to show document view
|
|
||||||
document.body.classList.add('document-active');
|
|
||||||
|
|
||||||
// Scroll to top
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading document:', error);
|
|
||||||
document.getElementById('document-content').innerHTML = `
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<svg class="h-12 w-12 text-red-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Error loading document</h3>
|
|
||||||
<p class="text-sm text-gray-600">${error.message}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open ToC modal
|
|
||||||
function openToCModal() {
|
|
||||||
if (!currentDocument || !currentDocument.toc || currentDocument.toc.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.getElementById('toc-modal');
|
|
||||||
if (!modal) return;
|
|
||||||
|
|
||||||
// Render ToC content
|
|
||||||
const tocContent = document.getElementById('toc-modal-content');
|
|
||||||
|
|
||||||
const tocHTML = currentDocument.toc
|
|
||||||
.filter(item => item.level <= 3) // Only show H1, H2, H3
|
|
||||||
.map(item => {
|
|
||||||
return `
|
|
||||||
<a href="#${item.slug}"
|
|
||||||
class="block py-2 px-3 text-gray-700 hover:bg-blue-50 hover:text-blue-700 rounded transition toc-link toc-indent-${item.level}"
|
|
||||||
data-slug="${item.slug}">
|
|
||||||
${item.title}
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
tocContent.innerHTML = tocHTML;
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
modal.classList.add('show');
|
|
||||||
|
|
||||||
// Prevent body scroll and reset modal content scroll
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
tocContent.scrollTop = 0;
|
|
||||||
|
|
||||||
// Add event listeners to ToC links
|
|
||||||
tocContent.querySelectorAll('.toc-link').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const targetId = link.getAttribute('href').substring(1);
|
|
||||||
const targetEl = document.getElementById(targetId);
|
|
||||||
if (targetEl) {
|
|
||||||
closeToCModal();
|
|
||||||
setTimeout(() => {
|
|
||||||
targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close ToC modal
|
|
||||||
function closeToCModal() {
|
|
||||||
const modal = document.getElementById('toc-modal');
|
|
||||||
if (modal) {
|
|
||||||
modal.classList.remove('show');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
loadDocuments();
|
|
||||||
|
|
||||||
// Add ESC key listener for closing modal
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeToCModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add close button listener for ToC modal (script loads after DOM, so elements exist)
|
|
||||||
const closeButton = document.getElementById('toc-close-button');
|
|
||||||
if (closeButton) {
|
|
||||||
closeButton.addEventListener('click', closeToCModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click outside modal to close
|
|
||||||
const modal = document.getElementById('toc-modal');
|
|
||||||
if (modal) {
|
|
||||||
modal.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeToCModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mobile navigation: Back to documents button
|
|
||||||
const backButton = document.getElementById('back-to-docs-btn');
|
|
||||||
if (backButton) {
|
|
||||||
backButton.addEventListener('click', function() {
|
|
||||||
// Remove document-active class to show sidebar
|
|
||||||
document.body.classList.remove('document-active');
|
|
||||||
|
|
||||||
// Scroll to top
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,650 +0,0 @@
|
||||||
/**
|
|
||||||
* Docs Search Enhancement Module
|
|
||||||
* Provides faceted search, filters, history, and keyboard navigation
|
|
||||||
* CSP Compliant - No inline scripts or event handlers
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const CONFIG = {
|
|
||||||
DEBOUNCE_DELAY: 300,
|
|
||||||
MAX_SEARCH_HISTORY: 10,
|
|
||||||
SEARCH_HISTORY_KEY: 'tractatus_search_history',
|
|
||||||
MIN_QUERY_LENGTH: 2
|
|
||||||
};
|
|
||||||
|
|
||||||
// State
|
|
||||||
let searchTimeout = null;
|
|
||||||
let currentFilters = {
|
|
||||||
query: '',
|
|
||||||
quadrant: '',
|
|
||||||
persistence: '',
|
|
||||||
audience: ''
|
|
||||||
};
|
|
||||||
let searchHistory = [];
|
|
||||||
let selectedResultIndex = -1;
|
|
||||||
let searchResults = [];
|
|
||||||
|
|
||||||
// DOM Elements
|
|
||||||
const elements = {
|
|
||||||
searchInput: null,
|
|
||||||
quadrantFilter: null,
|
|
||||||
persistenceFilter: null,
|
|
||||||
audienceFilter: null,
|
|
||||||
clearFiltersBtn: null,
|
|
||||||
searchTipsBtn: null,
|
|
||||||
searchTipsModal: null,
|
|
||||||
searchTipsCloseBtn: null,
|
|
||||||
searchResultsPanel: null,
|
|
||||||
searchResultsList: null,
|
|
||||||
closeSearchResults: null,
|
|
||||||
searchResultsSummary: null,
|
|
||||||
searchResultsCount: null,
|
|
||||||
searchHistoryContainer: null,
|
|
||||||
searchHistory: null,
|
|
||||||
searchModal: null,
|
|
||||||
openSearchModalBtn: null,
|
|
||||||
searchModalCloseBtn: null,
|
|
||||||
searchResultsModal: null,
|
|
||||||
searchResultsListModal: null
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the search enhancement module
|
|
||||||
*/
|
|
||||||
function init() {
|
|
||||||
// Get DOM elements
|
|
||||||
elements.searchInput = document.getElementById('docs-search-input');
|
|
||||||
elements.quadrantFilter = document.getElementById('filter-quadrant');
|
|
||||||
elements.persistenceFilter = document.getElementById('filter-persistence');
|
|
||||||
elements.audienceFilter = document.getElementById('filter-audience');
|
|
||||||
elements.clearFiltersBtn = document.getElementById('clear-filters-btn');
|
|
||||||
elements.searchTipsBtn = document.getElementById('search-tips-btn');
|
|
||||||
elements.searchTipsModal = document.getElementById('search-tips-modal');
|
|
||||||
elements.searchTipsCloseBtn = document.getElementById('search-tips-close-btn');
|
|
||||||
elements.searchResultsPanel = document.getElementById('search-results-panel');
|
|
||||||
elements.searchResultsList = document.getElementById('search-results-list');
|
|
||||||
elements.closeSearchResults = document.getElementById('close-search-results');
|
|
||||||
elements.searchResultsSummary = document.getElementById('search-results-summary');
|
|
||||||
elements.searchResultsCount = document.getElementById('search-results-count');
|
|
||||||
elements.searchHistoryContainer = document.getElementById('search-history-container');
|
|
||||||
elements.searchHistory = document.getElementById('search-history');
|
|
||||||
elements.searchModal = document.getElementById('search-modal');
|
|
||||||
elements.openSearchModalBtn = document.getElementById('open-search-modal-btn');
|
|
||||||
elements.searchModalCloseBtn = document.getElementById('search-modal-close-btn');
|
|
||||||
elements.searchResultsModal = document.getElementById('search-results-modal');
|
|
||||||
elements.searchResultsListModal = document.getElementById('search-results-list-modal');
|
|
||||||
|
|
||||||
// Check if essential elements exist
|
|
||||||
if (!elements.searchInput || !elements.searchModal) {
|
|
||||||
console.warn('Search elements not found - search enhancement disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load search history from localStorage
|
|
||||||
loadSearchHistory();
|
|
||||||
|
|
||||||
// Attach event listeners
|
|
||||||
attachEventListeners();
|
|
||||||
|
|
||||||
// Display search history if available
|
|
||||||
renderSearchHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach event listeners (CSP compliant - no inline handlers)
|
|
||||||
*/
|
|
||||||
function attachEventListeners() {
|
|
||||||
// Search modal open/close
|
|
||||||
if (elements.openSearchModalBtn) {
|
|
||||||
elements.openSearchModalBtn.addEventListener('click', openSearchModal);
|
|
||||||
}
|
|
||||||
if (elements.searchModalCloseBtn) {
|
|
||||||
elements.searchModalCloseBtn.addEventListener('click', closeSearchModal);
|
|
||||||
}
|
|
||||||
if (elements.searchModal) {
|
|
||||||
elements.searchModal.addEventListener('click', function(e) {
|
|
||||||
if (e.target === elements.searchModal) {
|
|
||||||
closeSearchModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search input - debounced
|
|
||||||
if (elements.searchInput) {
|
|
||||||
elements.searchInput.addEventListener('input', handleSearchInput);
|
|
||||||
elements.searchInput.addEventListener('keydown', handleSearchKeydown);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter dropdowns
|
|
||||||
if (elements.quadrantFilter) {
|
|
||||||
elements.quadrantFilter.addEventListener('change', handleFilterChange);
|
|
||||||
}
|
|
||||||
if (elements.persistenceFilter) {
|
|
||||||
elements.persistenceFilter.addEventListener('change', handleFilterChange);
|
|
||||||
}
|
|
||||||
if (elements.audienceFilter) {
|
|
||||||
elements.audienceFilter.addEventListener('change', handleFilterChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear filters button
|
|
||||||
if (elements.clearFiltersBtn) {
|
|
||||||
elements.clearFiltersBtn.addEventListener('click', clearFilters);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search tips button
|
|
||||||
if (elements.searchTipsBtn) {
|
|
||||||
elements.searchTipsBtn.addEventListener('click', openSearchTipsModal);
|
|
||||||
}
|
|
||||||
if (elements.searchTipsCloseBtn) {
|
|
||||||
elements.searchTipsCloseBtn.addEventListener('click', closeSearchTipsModal);
|
|
||||||
}
|
|
||||||
if (elements.searchTipsModal) {
|
|
||||||
elements.searchTipsModal.addEventListener('click', function(e) {
|
|
||||||
if (e.target === elements.searchTipsModal) {
|
|
||||||
closeSearchTipsModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close search results
|
|
||||||
if (elements.closeSearchResults) {
|
|
||||||
elements.closeSearchResults.addEventListener('click', closeSearchResultsPanel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', handleGlobalKeydown);
|
|
||||||
|
|
||||||
// Escape key to close modals
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeSearchTipsModal();
|
|
||||||
closeSearchModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle search input with debounce
|
|
||||||
*/
|
|
||||||
function handleSearchInput(e) {
|
|
||||||
const query = e.target.value.trim();
|
|
||||||
|
|
||||||
// Clear previous timeout
|
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce search
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentFilters.query = query;
|
|
||||||
performSearch();
|
|
||||||
}, CONFIG.DEBOUNCE_DELAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle keyboard navigation in search input
|
|
||||||
*/
|
|
||||||
function handleSearchKeydown(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const query = e.target.value.trim();
|
|
||||||
if (query) {
|
|
||||||
currentFilters.query = query;
|
|
||||||
performSearch();
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
closeSearchResultsPanel();
|
|
||||||
closeSearchModal();
|
|
||||||
e.target.blur();
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateResults('down');
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateResults('up');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle filter changes
|
|
||||||
*/
|
|
||||||
function handleFilterChange() {
|
|
||||||
currentFilters.quadrant = elements.quadrantFilter ? elements.quadrantFilter.value : '';
|
|
||||||
currentFilters.persistence = elements.persistenceFilter ? elements.persistenceFilter.value : '';
|
|
||||||
currentFilters.audience = elements.audienceFilter ? elements.audienceFilter.value : '';
|
|
||||||
|
|
||||||
performSearch();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all filters
|
|
||||||
*/
|
|
||||||
function clearFilters() {
|
|
||||||
if (elements.searchInput) elements.searchInput.value = '';
|
|
||||||
if (elements.quadrantFilter) elements.quadrantFilter.value = '';
|
|
||||||
if (elements.persistenceFilter) elements.persistenceFilter.value = '';
|
|
||||||
if (elements.audienceFilter) elements.audienceFilter.value = '';
|
|
||||||
|
|
||||||
currentFilters = {
|
|
||||||
query: '',
|
|
||||||
quadrant: '',
|
|
||||||
persistence: '',
|
|
||||||
audience: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
closeSearchResultsPanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform search with current filters
|
|
||||||
*/
|
|
||||||
async function performSearch() {
|
|
||||||
const { query, quadrant, persistence, audience } = currentFilters;
|
|
||||||
|
|
||||||
// If no query and no filters, don't search
|
|
||||||
if (!query && !quadrant && !persistence && !audience) {
|
|
||||||
closeSearchResultsPanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build query params
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (query) params.append('q', query);
|
|
||||||
if (quadrant) params.append('quadrant', quadrant);
|
|
||||||
if (persistence) params.append('persistence', persistence);
|
|
||||||
if (audience) params.append('audience', audience);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startTime = performance.now();
|
|
||||||
const response = await fetch(`/api/documents/search?${params.toString()}`);
|
|
||||||
const endTime = performance.now();
|
|
||||||
const duration = Math.round(endTime - startTime);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
searchResults = data.documents || [];
|
|
||||||
renderSearchResults(data, duration);
|
|
||||||
|
|
||||||
// Save to search history if query exists
|
|
||||||
if (query) {
|
|
||||||
addToSearchHistory(query);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showError('Search failed. Please try again.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search error:', error);
|
|
||||||
showError('Search failed. Please check your connection.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render search results
|
|
||||||
*/
|
|
||||||
function renderSearchResults(data, duration) {
|
|
||||||
// Use modal list if available, otherwise fall back to panel list
|
|
||||||
const targetList = elements.searchResultsListModal || elements.searchResultsList;
|
|
||||||
const targetContainer = elements.searchResultsModal || elements.searchResultsPanel;
|
|
||||||
|
|
||||||
if (!targetList) return;
|
|
||||||
|
|
||||||
const { documents, count, total, filters } = data;
|
|
||||||
|
|
||||||
// Show results container
|
|
||||||
if (targetContainer) {
|
|
||||||
targetContainer.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update summary
|
|
||||||
if (elements.searchResultsSummary && elements.searchResultsCount) {
|
|
||||||
elements.searchResultsSummary.classList.remove('hidden');
|
|
||||||
|
|
||||||
let summaryText = `Found ${total} document${total !== 1 ? 's' : ''}`;
|
|
||||||
if (duration) {
|
|
||||||
summaryText += ` (${duration}ms)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeFilters = [];
|
|
||||||
if (filters.quadrant) activeFilters.push(`Quadrant: ${filters.quadrant}`);
|
|
||||||
if (filters.persistence) activeFilters.push(`Persistence: ${filters.persistence}`);
|
|
||||||
if (filters.audience) activeFilters.push(`Audience: ${filters.audience}`);
|
|
||||||
|
|
||||||
if (activeFilters.length > 0) {
|
|
||||||
summaryText += ` • Filters: ${activeFilters.join(', ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.searchResultsCount.textContent = summaryText;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render results
|
|
||||||
if (documents.length === 0) {
|
|
||||||
targetList.innerHTML = `
|
|
||||||
<div class="text-center py-8 text-gray-500">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-lg font-medium">No documents found</p>
|
|
||||||
<p class="text-sm mt-2">Try adjusting your search terms or filters</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultsHTML = documents.map((doc, index) => {
|
|
||||||
const badges = [];
|
|
||||||
if (doc.quadrant) badges.push(`<span class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium">${doc.quadrant}</span>`);
|
|
||||||
if (doc.persistence) badges.push(`<span class="px-2 py-1 bg-green-100 text-green-700 rounded text-xs font-medium">${doc.persistence}</span>`);
|
|
||||||
if (doc.audience) badges.push(`<span class="px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs font-medium">${doc.audience}</span>`);
|
|
||||||
|
|
||||||
// Highlight query terms in title (simple highlighting)
|
|
||||||
let highlightedTitle = doc.title;
|
|
||||||
if (currentFilters.query) {
|
|
||||||
const regex = new RegExp(`(${escapeRegex(currentFilters.query)})`, 'gi');
|
|
||||||
highlightedTitle = doc.title.replace(regex, '<mark class="bg-yellow-200">$1</mark>');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="search-result-item p-4 border border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition cursor-pointer ${index === selectedResultIndex ? 'border-blue-500 bg-blue-50' : ''}"
|
|
||||||
data-slug="${doc.slug}"
|
|
||||||
data-index="${index}">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">${highlightedTitle}</h3>
|
|
||||||
<div class="flex flex-wrap gap-2 mb-3">
|
|
||||||
${badges.join('')}
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 line-clamp-2">${doc.metadata?.description || 'Framework documentation'}</p>
|
|
||||||
</div>
|
|
||||||
<a href="/downloads/${doc.slug}.pdf"
|
|
||||||
class="flex-shrink-0 p-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded transition"
|
|
||||||
title="Download PDF"
|
|
||||||
aria-label="Download PDF">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
targetList.innerHTML = resultsHTML;
|
|
||||||
|
|
||||||
// Attach click handlers to results
|
|
||||||
document.querySelectorAll('.search-result-item').forEach(item => {
|
|
||||||
item.addEventListener('click', function(e) {
|
|
||||||
// Don't navigate if clicking download link
|
|
||||||
if (e.target.closest('a[href*="/downloads/"]')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = this.dataset.slug;
|
|
||||||
if (slug && typeof loadDocument === 'function') {
|
|
||||||
loadDocument(slug);
|
|
||||||
closeSearchResultsPanel();
|
|
||||||
closeSearchModal();
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset selected index
|
|
||||||
selectedResultIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate search results with keyboard
|
|
||||||
*/
|
|
||||||
function navigateResults(direction) {
|
|
||||||
if (searchResults.length === 0) return;
|
|
||||||
|
|
||||||
if (direction === 'down') {
|
|
||||||
selectedResultIndex = Math.min(selectedResultIndex + 1, searchResults.length - 1);
|
|
||||||
} else if (direction === 'up') {
|
|
||||||
selectedResultIndex = Math.max(selectedResultIndex - 1, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render to show selection
|
|
||||||
if (searchResults.length > 0) {
|
|
||||||
const items = document.querySelectorAll('.search-result-item');
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
if (index === selectedResultIndex) {
|
|
||||||
item.classList.add('border-blue-500', 'bg-blue-50');
|
|
||||||
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
} else {
|
|
||||||
item.classList.remove('border-blue-500', 'bg-blue-50');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If Enter key pressed on selected result
|
|
||||||
if (selectedResultIndex >= 0) {
|
|
||||||
const selectedSlug = searchResults[selectedResultIndex].slug;
|
|
||||||
// Store for Enter key handler
|
|
||||||
document.addEventListener('keydown', function enterHandler(e) {
|
|
||||||
if (e.key === 'Enter' && selectedResultIndex >= 0) {
|
|
||||||
if (typeof loadDocument === 'function') {
|
|
||||||
loadDocument(selectedSlug);
|
|
||||||
closeSearchResultsPanel();
|
|
||||||
}
|
|
||||||
document.removeEventListener('keydown', enterHandler);
|
|
||||||
}
|
|
||||||
}, { once: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close search results panel
|
|
||||||
*/
|
|
||||||
function closeSearchResultsPanel() {
|
|
||||||
if (elements.searchResultsPanel) {
|
|
||||||
elements.searchResultsPanel.classList.add('hidden');
|
|
||||||
}
|
|
||||||
if (elements.searchResultsSummary) {
|
|
||||||
elements.searchResultsSummary.classList.add('hidden');
|
|
||||||
}
|
|
||||||
selectedResultIndex = -1;
|
|
||||||
searchResults = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show error message
|
|
||||||
*/
|
|
||||||
function showError(message) {
|
|
||||||
if (elements.searchResultsList) {
|
|
||||||
elements.searchResultsList.innerHTML = `
|
|
||||||
<div class="text-center py-8 text-red-500">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-lg font-medium">${message}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open search tips modal
|
|
||||||
*/
|
|
||||||
function openSearchTipsModal() {
|
|
||||||
if (elements.searchTipsModal) {
|
|
||||||
elements.searchTipsModal.classList.remove('hidden');
|
|
||||||
elements.searchTipsModal.classList.add('flex');
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close search tips modal
|
|
||||||
*/
|
|
||||||
function closeSearchTipsModal() {
|
|
||||||
if (elements.searchTipsModal) {
|
|
||||||
elements.searchTipsModal.classList.add('hidden');
|
|
||||||
elements.searchTipsModal.classList.remove('flex');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open search modal
|
|
||||||
*/
|
|
||||||
function openSearchModal() {
|
|
||||||
if (elements.searchModal) {
|
|
||||||
elements.searchModal.classList.remove('hidden');
|
|
||||||
elements.searchModal.classList.add('show');
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
// Focus search input after modal opens
|
|
||||||
setTimeout(() => {
|
|
||||||
if (elements.searchInput) {
|
|
||||||
elements.searchInput.focus();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close search modal
|
|
||||||
*/
|
|
||||||
function closeSearchModal() {
|
|
||||||
if (elements.searchModal) {
|
|
||||||
elements.searchModal.classList.remove('show');
|
|
||||||
elements.searchModal.classList.add('hidden');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
|
|
||||||
// Clear search when closing
|
|
||||||
clearFilters();
|
|
||||||
|
|
||||||
// Hide results
|
|
||||||
if (elements.searchResultsModal) {
|
|
||||||
elements.searchResultsModal.classList.add('hidden');
|
|
||||||
}
|
|
||||||
if (elements.searchResultsSummary) {
|
|
||||||
elements.searchResultsSummary.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle global keyboard shortcuts
|
|
||||||
*/
|
|
||||||
function handleGlobalKeydown(e) {
|
|
||||||
// Ctrl+K or Cmd+K to open search modal
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
openSearchModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load search history from localStorage
|
|
||||||
*/
|
|
||||||
function loadSearchHistory() {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(CONFIG.SEARCH_HISTORY_KEY);
|
|
||||||
if (stored) {
|
|
||||||
searchHistory = JSON.parse(stored);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to load search history:', error);
|
|
||||||
searchHistory = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save search history to localStorage
|
|
||||||
*/
|
|
||||||
function saveSearchHistory() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(CONFIG.SEARCH_HISTORY_KEY, JSON.stringify(searchHistory));
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to save search history:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add query to search history
|
|
||||||
*/
|
|
||||||
function addToSearchHistory(query) {
|
|
||||||
if (!query || query.length < CONFIG.MIN_QUERY_LENGTH) return;
|
|
||||||
|
|
||||||
// Remove duplicates
|
|
||||||
searchHistory = searchHistory.filter(item => item !== query);
|
|
||||||
|
|
||||||
// Add to beginning
|
|
||||||
searchHistory.unshift(query);
|
|
||||||
|
|
||||||
// Limit size
|
|
||||||
if (searchHistory.length > CONFIG.MAX_SEARCH_HISTORY) {
|
|
||||||
searchHistory = searchHistory.slice(0, CONFIG.MAX_SEARCH_HISTORY);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSearchHistory();
|
|
||||||
renderSearchHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render search history
|
|
||||||
*/
|
|
||||||
function renderSearchHistory() {
|
|
||||||
if (!elements.searchHistory || !elements.searchHistoryContainer) return;
|
|
||||||
|
|
||||||
if (searchHistory.length === 0) {
|
|
||||||
elements.searchHistoryContainer.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.searchHistoryContainer.classList.remove('hidden');
|
|
||||||
|
|
||||||
const historyHTML = searchHistory.slice(0, 5).map(query => {
|
|
||||||
return `
|
|
||||||
<button class="search-history-item px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-full hover:bg-gray-50 hover:border-gray-400 transition"
|
|
||||||
data-query="${escapeHtml(query)}">
|
|
||||||
${escapeHtml(query)}
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
elements.searchHistory.innerHTML = historyHTML;
|
|
||||||
|
|
||||||
// Attach click handlers
|
|
||||||
document.querySelectorAll('.search-history-item').forEach(item => {
|
|
||||||
item.addEventListener('click', function() {
|
|
||||||
const query = this.dataset.query;
|
|
||||||
if (elements.searchInput) {
|
|
||||||
elements.searchInput.value = query;
|
|
||||||
currentFilters.query = query;
|
|
||||||
performSearch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML to prevent XSS
|
|
||||||
*/
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape regex special characters
|
|
||||||
*/
|
|
||||||
function escapeRegex(text) {
|
|
||||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
// Initialize document viewer
|
|
||||||
const viewer = new DocumentViewer('document-viewer');
|
|
||||||
|
|
||||||
// Load navigation
|
|
||||||
async function loadNavigation() {
|
|
||||||
try {
|
|
||||||
const response = await API.Documents.list({ limit: 50 });
|
|
||||||
const nav = document.getElementById('doc-navigation');
|
|
||||||
|
|
||||||
if (response.success && response.documents) {
|
|
||||||
nav.innerHTML = response.documents.map(doc => `
|
|
||||||
<a href="/docs/${doc.slug}"
|
|
||||||
data-route="/docs/${doc.slug}"
|
|
||||||
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md">
|
|
||||||
${doc.title}
|
|
||||||
</a>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load navigation:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup routing
|
|
||||||
router
|
|
||||||
.on('/docs-viewer.html', async () => {
|
|
||||||
// Show default document
|
|
||||||
await viewer.render('introduction-to-the-tractatus-framework');
|
|
||||||
})
|
|
||||||
.on('/docs/:slug', async (params) => {
|
|
||||||
await viewer.render(params.slug);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
loadNavigation();
|
|
||||||
3352
public/js/faq.js
3352
public/js/faq.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,225 +0,0 @@
|
||||||
/**
|
|
||||||
* Simple i18n system for Tractatus
|
|
||||||
* Supports: en (English), de (German), fr (French), mi (Te Reo Māori - coming soon)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const I18n = {
|
|
||||||
currentLang: 'en',
|
|
||||||
translations: {},
|
|
||||||
supportedLanguages: ['en', 'de', 'fr'],
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
// 1. Detect language preference
|
|
||||||
this.currentLang = this.detectLanguage();
|
|
||||||
|
|
||||||
// 2. Load translations
|
|
||||||
await this.loadTranslations(this.currentLang);
|
|
||||||
|
|
||||||
// 3. Apply to page
|
|
||||||
this.applyTranslations();
|
|
||||||
|
|
||||||
// 4. Update language selector if present
|
|
||||||
this.updateLanguageSelector();
|
|
||||||
|
|
||||||
console.log(`[i18n] Initialized with language: ${this.currentLang}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
detectLanguage() {
|
|
||||||
// Priority order:
|
|
||||||
// 1. User's manual selection (localStorage) - allows override via flag clicks
|
|
||||||
// 2. Browser's language setting (automatic detection)
|
|
||||||
// 3. Default to English (fallback)
|
|
||||||
|
|
||||||
// 1. Check localStorage first (user override)
|
|
||||||
const saved = localStorage.getItem('tractatus-lang');
|
|
||||||
if (saved && this.supportedLanguages.includes(saved)) {
|
|
||||||
console.log(`[i18n] Language detected from user preference: ${saved}`);
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check browser language (automatic detection)
|
|
||||||
const browserLang = (navigator.language || navigator.userLanguage).split('-')[0];
|
|
||||||
if (this.supportedLanguages.includes(browserLang)) {
|
|
||||||
console.log(`[i18n] Language detected from browser: ${browserLang} (from ${navigator.language})`);
|
|
||||||
return browserLang;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Default to English
|
|
||||||
console.log(`[i18n] Language defaulted to: en (browser language '${navigator.language}' not supported)`);
|
|
||||||
return 'en';
|
|
||||||
},
|
|
||||||
|
|
||||||
detectPageName() {
|
|
||||||
// Try to get page name from data attribute first
|
|
||||||
const pageAttr = document.documentElement.getAttribute('data-page');
|
|
||||||
if (pageAttr) {
|
|
||||||
return pageAttr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect from URL path
|
|
||||||
const path = window.location.pathname;
|
|
||||||
|
|
||||||
// Map paths to translation file names
|
|
||||||
const pageMap = {
|
|
||||||
'/': 'homepage',
|
|
||||||
'/index.html': 'homepage',
|
|
||||||
'/researcher.html': 'researcher',
|
|
||||||
'/leader.html': 'leader',
|
|
||||||
'/implementer.html': 'implementer',
|
|
||||||
'/about.html': 'about',
|
|
||||||
'/about/values.html': 'values',
|
|
||||||
'/about/values': 'values',
|
|
||||||
'/faq.html': 'faq',
|
|
||||||
'/koha.html': 'koha',
|
|
||||||
'/koha/transparency.html': 'transparency',
|
|
||||||
'/koha/transparency': 'transparency',
|
|
||||||
'/privacy.html': 'privacy',
|
|
||||||
'/privacy': 'privacy'
|
|
||||||
};
|
|
||||||
|
|
||||||
return pageMap[path] || 'homepage';
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadTranslations(lang) {
|
|
||||||
try {
|
|
||||||
// Always load common translations (footer, navbar, etc.)
|
|
||||||
const commonResponse = await fetch(`/locales/${lang}/common.json`);
|
|
||||||
let commonTranslations = {};
|
|
||||||
if (commonResponse.ok) {
|
|
||||||
commonTranslations = await commonResponse.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load page-specific translations
|
|
||||||
const pageName = this.detectPageName();
|
|
||||||
const pageResponse = await fetch(`/locales/${lang}/${pageName}.json`);
|
|
||||||
let pageTranslations = {};
|
|
||||||
if (pageResponse.ok) {
|
|
||||||
pageTranslations = await pageResponse.json();
|
|
||||||
} else if (pageName !== 'homepage') {
|
|
||||||
// If page-specific translations don't exist, that's okay for some pages
|
|
||||||
console.warn(`[i18n] No translations found for ${lang}/${pageName}, using common only`);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Failed to load translations for ${lang}/${pageName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge common and page-specific translations (page-specific takes precedence)
|
|
||||||
this.translations = { ...commonTranslations, ...pageTranslations };
|
|
||||||
console.log(`[i18n] Loaded translations: common + ${pageName}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[i18n] Error loading translations:`, error);
|
|
||||||
// Fallback to English if loading fails
|
|
||||||
if (lang !== 'en') {
|
|
||||||
this.currentLang = 'en';
|
|
||||||
await this.loadTranslations('en');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
t(key) {
|
|
||||||
const keys = key.split('.');
|
|
||||||
let value = this.translations;
|
|
||||||
|
|
||||||
for (const k of keys) {
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
value = value[k];
|
|
||||||
} else {
|
|
||||||
return key; // Return key if translation not found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return value || key;
|
|
||||||
},
|
|
||||||
|
|
||||||
applyTranslations() {
|
|
||||||
// Find all elements with data-i18n attribute
|
|
||||||
// Using innerHTML to preserve formatting like <em>, <strong>, <a> tags in translations
|
|
||||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
||||||
const key = el.dataset.i18n;
|
|
||||||
const translation = this.t(key);
|
|
||||||
|
|
||||||
if (typeof translation === 'string') {
|
|
||||||
el.innerHTML = translation;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle data-i18n-html for HTML content (kept for backward compatibility)
|
|
||||||
document.querySelectorAll('[data-i18n-html]').forEach(el => {
|
|
||||||
const key = el.dataset.i18nHtml;
|
|
||||||
const translation = this.t(key);
|
|
||||||
|
|
||||||
if (typeof translation === 'string') {
|
|
||||||
el.innerHTML = translation;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle data-i18n-placeholder for input placeholders
|
|
||||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
||||||
const key = el.dataset.i18nPlaceholder;
|
|
||||||
const translation = this.t(key);
|
|
||||||
|
|
||||||
if (typeof translation === 'string') {
|
|
||||||
el.placeholder = translation;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async setLanguage(lang) {
|
|
||||||
if (!this.supportedLanguages.includes(lang)) {
|
|
||||||
console.error(`[i18n] Unsupported language: ${lang}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save preference (overrides browser language detection)
|
|
||||||
localStorage.setItem('tractatus-lang', lang);
|
|
||||||
console.log(`[i18n] User manually selected language: ${lang} (saved to localStorage)`);
|
|
||||||
|
|
||||||
// Update current language
|
|
||||||
this.currentLang = lang;
|
|
||||||
|
|
||||||
// Reload translations
|
|
||||||
await this.loadTranslations(lang);
|
|
||||||
|
|
||||||
// Reapply to page
|
|
||||||
this.applyTranslations();
|
|
||||||
|
|
||||||
// Update selector
|
|
||||||
this.updateLanguageSelector();
|
|
||||||
|
|
||||||
// Update HTML lang attribute
|
|
||||||
document.documentElement.lang = lang;
|
|
||||||
|
|
||||||
// Dispatch event for language change
|
|
||||||
window.dispatchEvent(new CustomEvent('languageChanged', {
|
|
||||||
detail: { language: lang }
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`[i18n] Language changed to: ${lang} (will persist across pages)`);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateLanguageSelector() {
|
|
||||||
const selector = document.getElementById('language-selector');
|
|
||||||
if (selector) {
|
|
||||||
selector.value = this.currentLang;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getLanguageName(code) {
|
|
||||||
const names = {
|
|
||||||
'en': 'English',
|
|
||||||
'de': 'Deutsch',
|
|
||||||
'fr': 'Français',
|
|
||||||
'mi': 'Te Reo Māori'
|
|
||||||
};
|
|
||||||
return names[code] || code;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-initialize when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => I18n.init());
|
|
||||||
} else {
|
|
||||||
I18n.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make available globally
|
|
||||||
window.I18n = I18n;
|
|
||||||
|
|
@ -1,415 +0,0 @@
|
||||||
/**
|
|
||||||
* Koha Donation System
|
|
||||||
* Handles donation form functionality with CSP compliance
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
let selectedFrequency = 'monthly';
|
|
||||||
let selectedTier = '15';
|
|
||||||
let selectedAmount = 1500; // in cents (NZD)
|
|
||||||
let currentCurrency = typeof detectUserCurrency === 'function' ? detectUserCurrency() : 'NZD';
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize event listeners
|
|
||||||
initializeFrequencyButtons();
|
|
||||||
initializeTierCards();
|
|
||||||
initializePublicAcknowledgement();
|
|
||||||
initializeDonationForm();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize frequency selection buttons
|
|
||||||
*/
|
|
||||||
function initializeFrequencyButtons() {
|
|
||||||
const monthlyBtn = document.getElementById('freq-monthly');
|
|
||||||
const onetimeBtn = document.getElementById('freq-onetime');
|
|
||||||
|
|
||||||
if (monthlyBtn) {
|
|
||||||
monthlyBtn.addEventListener('click', function() {
|
|
||||||
selectFrequency('monthly');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onetimeBtn) {
|
|
||||||
onetimeBtn.addEventListener('click', function() {
|
|
||||||
selectFrequency('one_time');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize tier card click handlers
|
|
||||||
*/
|
|
||||||
function initializeTierCards() {
|
|
||||||
const tierCards = document.querySelectorAll('[data-tier]');
|
|
||||||
|
|
||||||
tierCards.forEach(card => {
|
|
||||||
card.addEventListener('click', function() {
|
|
||||||
const tier = this.dataset.tier;
|
|
||||||
const amount = parseInt(this.dataset.amount);
|
|
||||||
selectTier(tier, amount);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize public acknowledgement checkbox
|
|
||||||
*/
|
|
||||||
function initializePublicAcknowledgement() {
|
|
||||||
const checkbox = document.getElementById('public-acknowledgement');
|
|
||||||
|
|
||||||
if (checkbox) {
|
|
||||||
checkbox.addEventListener('change', togglePublicName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize donation form submission
|
|
||||||
*/
|
|
||||||
function initializeDonationForm() {
|
|
||||||
const form = document.getElementById('donation-form');
|
|
||||||
|
|
||||||
if (form) {
|
|
||||||
form.addEventListener('submit', handleFormSubmit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update prices when currency changes
|
|
||||||
*/
|
|
||||||
window.updatePricesForCurrency = function(currency) {
|
|
||||||
currentCurrency = currency;
|
|
||||||
|
|
||||||
if (typeof getTierPrices !== 'function' || typeof formatCurrency !== 'function') {
|
|
||||||
console.warn('Currency utilities not loaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prices = getTierPrices(currency);
|
|
||||||
|
|
||||||
// Update tier card prices
|
|
||||||
const tierCards = document.querySelectorAll('.tier-card');
|
|
||||||
if (tierCards[0]) {
|
|
||||||
const priceEl = tierCards[0].querySelector('.text-4xl');
|
|
||||||
const currencyEl = tierCards[0].querySelector('.text-sm.text-gray-500');
|
|
||||||
if (priceEl) priceEl.textContent = formatCurrency(prices.tier_5, currency).replace(/\.\d+$/, '');
|
|
||||||
if (currencyEl) currencyEl.textContent = `${currency} / month`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tierCards[1]) {
|
|
||||||
const priceEl = tierCards[1].querySelector('.text-4xl');
|
|
||||||
const currencyEl = tierCards[1].querySelector('.text-sm.text-gray-500');
|
|
||||||
if (priceEl) priceEl.textContent = formatCurrency(prices.tier_15, currency).replace(/\.\d+$/, '');
|
|
||||||
if (currencyEl) currencyEl.textContent = `${currency} / month`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tierCards[2]) {
|
|
||||||
const priceEl = tierCards[2].querySelector('.text-4xl');
|
|
||||||
const currencyEl = tierCards[2].querySelector('.text-sm.text-gray-500');
|
|
||||||
if (priceEl) priceEl.textContent = formatCurrency(prices.tier_50, currency).replace(/\.\d+$/, '');
|
|
||||||
if (currencyEl) currencyEl.textContent = `${currency} / month`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update custom amount placeholder
|
|
||||||
const amountInput = document.getElementById('amount-input');
|
|
||||||
if (amountInput) {
|
|
||||||
amountInput.placeholder = 'Enter amount';
|
|
||||||
const amountCurrencyLabel = amountInput.nextElementSibling;
|
|
||||||
if (amountCurrencyLabel) {
|
|
||||||
amountCurrencyLabel.textContent = currency;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update help text
|
|
||||||
const amountHelp = document.getElementById('amount-help');
|
|
||||||
if (amountHelp && typeof formatCurrency === 'function') {
|
|
||||||
amountHelp.textContent = `Minimum donation: ${formatCurrency(100, currency)}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select donation frequency (monthly or one-time)
|
|
||||||
*/
|
|
||||||
function selectFrequency(freq) {
|
|
||||||
selectedFrequency = freq;
|
|
||||||
|
|
||||||
// Update button styles
|
|
||||||
const monthlyBtn = document.getElementById('freq-monthly');
|
|
||||||
const onetimeBtn = document.getElementById('freq-onetime');
|
|
||||||
|
|
||||||
if (freq === 'monthly') {
|
|
||||||
monthlyBtn.className = 'flex-1 py-3 px-6 border-2 border-blue-600 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition';
|
|
||||||
onetimeBtn.className = 'flex-1 py-3 px-6 border-2 border-gray-300 bg-white text-gray-700 rounded-lg font-semibold hover:border-blue-600 transition';
|
|
||||||
|
|
||||||
// Show tier selection, hide custom amount
|
|
||||||
const tierSelection = document.getElementById('tier-selection');
|
|
||||||
const customAmount = document.getElementById('custom-amount');
|
|
||||||
if (tierSelection) tierSelection.classList.remove('hidden');
|
|
||||||
if (customAmount) customAmount.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
monthlyBtn.className = 'flex-1 py-3 px-6 border-2 border-gray-300 bg-white text-gray-700 rounded-lg font-semibold hover:border-blue-600 transition';
|
|
||||||
onetimeBtn.className = 'flex-1 py-3 px-6 border-2 border-blue-600 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition';
|
|
||||||
|
|
||||||
// Hide tier selection, show custom amount
|
|
||||||
const tierSelection = document.getElementById('tier-selection');
|
|
||||||
const customAmount = document.getElementById('custom-amount');
|
|
||||||
if (tierSelection) tierSelection.classList.add('hidden');
|
|
||||||
if (customAmount) customAmount.classList.remove('hidden');
|
|
||||||
|
|
||||||
const amountInput = document.getElementById('amount-input');
|
|
||||||
if (amountInput) amountInput.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select tier amount
|
|
||||||
*/
|
|
||||||
function selectTier(tier, amountNZDCents) {
|
|
||||||
selectedTier = tier;
|
|
||||||
|
|
||||||
// Calculate amount in current currency
|
|
||||||
if (typeof getTierPrices === 'function') {
|
|
||||||
const prices = getTierPrices(currentCurrency);
|
|
||||||
selectedAmount = prices[`tier_${tier}`];
|
|
||||||
} else {
|
|
||||||
selectedAmount = amountNZDCents;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update card styles
|
|
||||||
const cards = document.querySelectorAll('.tier-card');
|
|
||||||
cards.forEach(card => {
|
|
||||||
card.classList.remove('selected');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add selected class to clicked card
|
|
||||||
const selectedCard = document.querySelector(`[data-tier="${tier}"]`);
|
|
||||||
if (selectedCard) {
|
|
||||||
selectedCard.classList.add('selected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle public name field visibility
|
|
||||||
*/
|
|
||||||
function togglePublicName() {
|
|
||||||
const checkbox = document.getElementById('public-acknowledgement');
|
|
||||||
const nameField = document.getElementById('public-name-field');
|
|
||||||
|
|
||||||
if (checkbox && nameField) {
|
|
||||||
if (checkbox.checked) {
|
|
||||||
nameField.classList.remove('hidden');
|
|
||||||
const publicNameInput = document.getElementById('public-name');
|
|
||||||
if (publicNameInput) publicNameInput.focus();
|
|
||||||
} else {
|
|
||||||
nameField.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle form submission
|
|
||||||
*/
|
|
||||||
async function handleFormSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = 'Processing...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get form data
|
|
||||||
const donorName = document.getElementById('donor-name').value.trim() || 'Anonymous';
|
|
||||||
const donorEmail = document.getElementById('donor-email').value.trim();
|
|
||||||
const donorCountry = document.getElementById('donor-country').value.trim();
|
|
||||||
const publicAck = document.getElementById('public-acknowledgement').checked;
|
|
||||||
const publicName = document.getElementById('public-name').value.trim();
|
|
||||||
|
|
||||||
// Validate email
|
|
||||||
if (!donorEmail) {
|
|
||||||
alert('Please enter your email address.');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = 'Proceed to Secure Payment';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get amount
|
|
||||||
let amount;
|
|
||||||
if (selectedFrequency === 'monthly') {
|
|
||||||
amount = selectedAmount;
|
|
||||||
} else {
|
|
||||||
const customAmount = parseFloat(document.getElementById('amount-input').value);
|
|
||||||
if (!customAmount || customAmount < 1) {
|
|
||||||
const minAmount = typeof formatCurrency === 'function'
|
|
||||||
? formatCurrency(100, currentCurrency)
|
|
||||||
: `${currentCurrency} 1.00`;
|
|
||||||
alert(`Please enter a donation amount of at least ${minAmount}.`);
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = 'Proceed to Secure Payment';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
amount = Math.round(customAmount * 100); // Convert to cents
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create checkout session
|
|
||||||
const response = await fetch('/api/koha/checkout', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
amount: amount,
|
|
||||||
currency: currentCurrency,
|
|
||||||
frequency: selectedFrequency,
|
|
||||||
tier: selectedFrequency === 'monthly' ? selectedTier : 'custom',
|
|
||||||
donor: {
|
|
||||||
name: donorName,
|
|
||||||
email: donorEmail,
|
|
||||||
country: donorCountry
|
|
||||||
},
|
|
||||||
public_acknowledgement: publicAck,
|
|
||||||
public_name: publicAck ? (publicName || donorName) : null
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.data.checkoutUrl) {
|
|
||||||
// Redirect to Stripe Checkout
|
|
||||||
window.location.href = data.data.checkoutUrl;
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || 'Failed to create checkout session');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Donation error:', error);
|
|
||||||
alert('An error occurred while processing your donation. Please try again or contact support.');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = 'Proceed to Secure Payment';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize manage subscription functionality
|
|
||||||
*/
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const manageBtn = document.getElementById('manage-subscription-btn');
|
|
||||||
const emailInput = document.getElementById('manage-email');
|
|
||||||
|
|
||||||
if (manageBtn && emailInput) {
|
|
||||||
manageBtn.addEventListener('click', handleManageSubscription);
|
|
||||||
emailInput.addEventListener('keypress', function(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleManageSubscription();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle manage subscription button click
|
|
||||||
*/
|
|
||||||
async function handleManageSubscription() {
|
|
||||||
const emailInput = document.getElementById('manage-email');
|
|
||||||
const manageBtn = document.getElementById('manage-subscription-btn');
|
|
||||||
const errorDiv = document.getElementById('manage-error');
|
|
||||||
const loadingDiv = document.getElementById('manage-loading');
|
|
||||||
|
|
||||||
const email = emailInput.value.trim();
|
|
||||||
|
|
||||||
// Validate email
|
|
||||||
if (!email) {
|
|
||||||
showManageError('Please enter your email address.');
|
|
||||||
emailInput.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidEmail(email)) {
|
|
||||||
showManageError('Please enter a valid email address.');
|
|
||||||
emailInput.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide error, show loading
|
|
||||||
hideManageError();
|
|
||||||
showManageLoading();
|
|
||||||
manageBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/koha/portal', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.data.url) {
|
|
||||||
// Redirect to Stripe Customer Portal
|
|
||||||
window.location.href = data.data.url;
|
|
||||||
} else if (response.status === 404) {
|
|
||||||
showManageError('No subscription found for this email address. Please check your email and try again.');
|
|
||||||
manageBtn.disabled = false;
|
|
||||||
hideManageLoading();
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || 'Failed to access subscription portal');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Manage subscription error:', error);
|
|
||||||
showManageError('An error occurred. Please try again or contact support@agenticgovernance.digital');
|
|
||||||
manageBtn.disabled = false;
|
|
||||||
hideManageLoading();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate email format
|
|
||||||
*/
|
|
||||||
function isValidEmail(email) {
|
|
||||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
return re.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show error message
|
|
||||||
*/
|
|
||||||
function showManageError(message) {
|
|
||||||
const errorDiv = document.getElementById('manage-error');
|
|
||||||
if (errorDiv) {
|
|
||||||
errorDiv.textContent = message;
|
|
||||||
errorDiv.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide error message
|
|
||||||
*/
|
|
||||||
function hideManageError() {
|
|
||||||
const errorDiv = document.getElementById('manage-error');
|
|
||||||
if (errorDiv) {
|
|
||||||
errorDiv.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show loading indicator
|
|
||||||
*/
|
|
||||||
function showManageLoading() {
|
|
||||||
const loadingDiv = document.getElementById('manage-loading');
|
|
||||||
if (loadingDiv) {
|
|
||||||
loadingDiv.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide loading indicator
|
|
||||||
*/
|
|
||||||
function hideManageLoading() {
|
|
||||||
const loadingDiv = document.getElementById('manage-loading');
|
|
||||||
if (loadingDiv) {
|
|
||||||
loadingDiv.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/**
|
|
||||||
* Koha Success Page - Donation Verification
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Get session ID from URL
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const sessionId = urlParams.get('session_id');
|
|
||||||
|
|
||||||
// Verify donation
|
|
||||||
async function verifyDonation() {
|
|
||||||
if (!sessionId) {
|
|
||||||
// No session ID - just show success message
|
|
||||||
document.getElementById('loading-content').classList.add('hidden');
|
|
||||||
document.getElementById('success-content').classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/koha/verify/${sessionId}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.data.isSuccessful) {
|
|
||||||
// Show success content
|
|
||||||
document.getElementById('loading-content').classList.add('hidden');
|
|
||||||
document.getElementById('success-content').classList.remove('hidden');
|
|
||||||
|
|
||||||
// Update details
|
|
||||||
document.getElementById('amount').textContent = `$${data.data.amount.toFixed(2)} ${data.data.currency.toUpperCase()}`;
|
|
||||||
|
|
||||||
const frequencyText = data.data.frequency === 'monthly' ? 'Monthly Donation' : 'One-Time Donation';
|
|
||||||
document.getElementById('frequency').textContent = frequencyText;
|
|
||||||
|
|
||||||
// Show monthly section if applicable
|
|
||||||
if (data.data.frequency === 'monthly') {
|
|
||||||
document.getElementById('monthly-section').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
throw new Error('Donation not successful');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Verification error:', error);
|
|
||||||
document.getElementById('loading-content').classList.add('hidden');
|
|
||||||
document.getElementById('error-content').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify on page load
|
|
||||||
if (sessionId) {
|
|
||||||
document.getElementById('success-content').classList.add('hidden');
|
|
||||||
document.getElementById('loading-content').classList.remove('hidden');
|
|
||||||
verifyDonation();
|
|
||||||
}
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
/**
|
|
||||||
* Koha Transparency Dashboard
|
|
||||||
* Real-time donation metrics with privacy-preserving analytics
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Chart instances (global for updates)
|
|
||||||
let allocationChart = null;
|
|
||||||
let trendChart = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and display transparency metrics
|
|
||||||
*/
|
|
||||||
async function loadMetrics() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/koha/transparency');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.data) {
|
|
||||||
const metrics = data.data;
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
updateStats(metrics);
|
|
||||||
|
|
||||||
// Update allocation chart
|
|
||||||
updateAllocationChart(metrics);
|
|
||||||
|
|
||||||
// Update progress bars (legacy display)
|
|
||||||
animateProgressBars();
|
|
||||||
|
|
||||||
// Display recent donors
|
|
||||||
displayRecentDonors(metrics.recent_donors || []);
|
|
||||||
|
|
||||||
// Update last updated time
|
|
||||||
updateLastUpdated(metrics.last_updated);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to load metrics');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading transparency metrics:', error);
|
|
||||||
document.getElementById('recent-donors').innerHTML = `
|
|
||||||
<div class="text-center py-8 text-red-600">
|
|
||||||
Failed to load transparency data. Please try again later.
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update stats display
|
|
||||||
*/
|
|
||||||
function updateStats(metrics) {
|
|
||||||
document.getElementById('total-received').textContent = `$${metrics.total_received.toFixed(2)}`;
|
|
||||||
document.getElementById('monthly-supporters').textContent = metrics.monthly_supporters;
|
|
||||||
document.getElementById('monthly-revenue').textContent = `$${metrics.monthly_recurring_revenue.toFixed(2)}`;
|
|
||||||
document.getElementById('onetime-count').textContent = metrics.one_time_donations || 0;
|
|
||||||
|
|
||||||
// Calculate average
|
|
||||||
const totalCount = metrics.monthly_supporters + (metrics.one_time_donations || 0);
|
|
||||||
const avgDonation = totalCount > 0 ? metrics.total_received / totalCount : 0;
|
|
||||||
document.getElementById('average-donation').textContent = `$${avgDonation.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update allocation pie chart
|
|
||||||
*/
|
|
||||||
function updateAllocationChart(metrics) {
|
|
||||||
const ctx = document.getElementById('allocation-chart');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const allocation = metrics.allocation || {
|
|
||||||
development: 0.4,
|
|
||||||
hosting: 0.3,
|
|
||||||
research: 0.2,
|
|
||||||
community: 0.1
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
labels: ['Development (40%)', 'Hosting & Infrastructure (30%)', 'Research (20%)', 'Community (10%)'],
|
|
||||||
datasets: [{
|
|
||||||
data: [
|
|
||||||
allocation.development * 100,
|
|
||||||
allocation.hosting * 100,
|
|
||||||
allocation.research * 100,
|
|
||||||
allocation.community * 100
|
|
||||||
],
|
|
||||||
backgroundColor: [
|
|
||||||
'#3B82F6', // blue-600
|
|
||||||
'#10B981', // green-600
|
|
||||||
'#A855F7', // purple-600
|
|
||||||
'#F59E0B' // orange-600
|
|
||||||
],
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: '#FFFFFF'
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
type: 'doughnut',
|
|
||||||
data: data,
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: true,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'bottom',
|
|
||||||
labels: {
|
|
||||||
padding: 15,
|
|
||||||
font: {
|
|
||||||
size: 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: function(context) {
|
|
||||||
const label = context.label || '';
|
|
||||||
const value = context.parsed;
|
|
||||||
return `${label}: ${value.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (allocationChart) {
|
|
||||||
allocationChart.data = data;
|
|
||||||
allocationChart.update();
|
|
||||||
} else {
|
|
||||||
allocationChart = new Chart(ctx, config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Animate progress bars (legacy display)
|
|
||||||
*/
|
|
||||||
function animateProgressBars() {
|
|
||||||
setTimeout(() => {
|
|
||||||
document.querySelectorAll('.progress-bar').forEach(bar => {
|
|
||||||
const width = bar.getAttribute('data-width');
|
|
||||||
bar.style.width = width + '%';
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display recent donors
|
|
||||||
*/
|
|
||||||
function displayRecentDonors(donors) {
|
|
||||||
const donorsContainer = document.getElementById('recent-donors');
|
|
||||||
const noDonorsMessage = document.getElementById('no-donors');
|
|
||||||
|
|
||||||
if (donors.length > 0) {
|
|
||||||
const donorsHtml = donors.map(donor => {
|
|
||||||
const date = new Date(donor.date);
|
|
||||||
const dateStr = date.toLocaleDateString('en-NZ', { year: 'numeric', month: 'short' });
|
|
||||||
const freqBadge = donor.frequency === 'monthly'
|
|
||||||
? '<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">Monthly</span>'
|
|
||||||
: '<span class="inline-block bg-green-100 text-green-800 text-xs px-2 py-1 rounded">One-time</span>';
|
|
||||||
|
|
||||||
// Format currency display
|
|
||||||
const currency = (donor.currency || 'nzd').toUpperCase();
|
|
||||||
const amountDisplay = `$${donor.amount.toFixed(2)} ${currency}`;
|
|
||||||
|
|
||||||
// Show NZD equivalent if different currency
|
|
||||||
const nzdEquivalent = currency !== 'NZD'
|
|
||||||
? `<div class="text-xs text-gray-500">≈ $${donor.amount_nzd.toFixed(2)} NZD</div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="flex items-center justify-between py-3 border-b border-gray-200 last:border-0">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
|
||||||
<span class="text-blue-600 font-semibold">${donor.name.charAt(0).toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-gray-900">${donor.name}</div>
|
|
||||||
<div class="text-sm text-gray-500">${dateStr}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<div class="font-semibold text-gray-900">${amountDisplay}</div>
|
|
||||||
${nzdEquivalent}
|
|
||||||
${freqBadge}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
donorsContainer.innerHTML = donorsHtml;
|
|
||||||
donorsContainer.style.display = 'block';
|
|
||||||
if (noDonorsMessage) noDonorsMessage.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
donorsContainer.style.display = 'none';
|
|
||||||
if (noDonorsMessage) noDonorsMessage.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update last updated timestamp
|
|
||||||
*/
|
|
||||||
function updateLastUpdated(timestamp) {
|
|
||||||
const lastUpdated = new Date(timestamp);
|
|
||||||
const elem = document.getElementById('last-updated');
|
|
||||||
if (elem) {
|
|
||||||
elem.textContent = `Last updated: ${lastUpdated.toLocaleString()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export transparency data as CSV
|
|
||||||
*/
|
|
||||||
async function exportCSV() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/koha/transparency');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success || !data.data) {
|
|
||||||
throw new Error('Failed to load metrics for export');
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = data.data;
|
|
||||||
|
|
||||||
// Build CSV content
|
|
||||||
let csv = 'Tractatus Koha Transparency Report\\n';
|
|
||||||
csv += `Generated: ${new Date().toISOString()}\\n\\n`;
|
|
||||||
|
|
||||||
csv += 'Metric,Value\\n';
|
|
||||||
csv += `Total Received,${metrics.total_received}\\n`;
|
|
||||||
csv += `Monthly Supporters,${metrics.monthly_supporters}\\n`;
|
|
||||||
csv += `One-Time Donations,${metrics.one_time_donations || 0}\\n`;
|
|
||||||
csv += `Monthly Recurring Revenue,${metrics.monthly_recurring_revenue}\\n\\n`;
|
|
||||||
|
|
||||||
csv += 'Allocation Category,Percentage\\n';
|
|
||||||
csv += `Development,${(metrics.allocation.development * 100).toFixed(1)}%\\n`;
|
|
||||||
csv += `Hosting & Infrastructure,${(metrics.allocation.hosting * 100).toFixed(1)}%\\n`;
|
|
||||||
csv += `Research,${(metrics.allocation.research * 100).toFixed(1)}%\\n`;
|
|
||||||
csv += `Community,${(metrics.allocation.community * 100).toFixed(1)}%\\n\\n`;
|
|
||||||
|
|
||||||
if (metrics.recent_donors && metrics.recent_donors.length > 0) {
|
|
||||||
csv += 'Recent Public Supporters\\n';
|
|
||||||
csv += 'Name,Date,Amount,Currency,Frequency\\n';
|
|
||||||
metrics.recent_donors.forEach(donor => {
|
|
||||||
csv += `"${donor.name}",${donor.date},${donor.amount},${donor.currency || 'NZD'},${donor.frequency}\\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create download
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `tractatus-transparency-${new Date().toISOString().split('T')[0]}.csv`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error exporting CSV:', error);
|
|
||||||
alert('Failed to export transparency data. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Load metrics
|
|
||||||
loadMetrics();
|
|
||||||
|
|
||||||
// Refresh every 5 minutes
|
|
||||||
setInterval(loadMetrics, 5 * 60 * 1000);
|
|
||||||
|
|
||||||
// Setup CSV export button
|
|
||||||
const exportBtn = document.getElementById('export-csv');
|
|
||||||
if (exportBtn) {
|
|
||||||
exportBtn.addEventListener('click', exportCSV);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
/**
|
|
||||||
* Leader Page - Accordion Functionality
|
|
||||||
* Handles expandable/collapsible sections for leadership content
|
|
||||||
*/
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Get all accordion buttons
|
|
||||||
const accordionButtons = document.querySelectorAll('[data-accordion]');
|
|
||||||
|
|
||||||
accordionButtons.forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const accordionId = this.dataset.accordion;
|
|
||||||
toggleAccordion(accordionId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle accordion section open/closed
|
|
||||||
* @param {string} id - Accordion section ID
|
|
||||||
*/
|
|
||||||
function toggleAccordion(id) {
|
|
||||||
const content = document.getElementById(id + '-content');
|
|
||||||
const icon = document.getElementById(id + '-icon');
|
|
||||||
|
|
||||||
if (content && icon) {
|
|
||||||
content.classList.toggle('active');
|
|
||||||
icon.classList.toggle('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
/**
|
|
||||||
* Media Inquiry Form Handler
|
|
||||||
*/
|
|
||||||
|
|
||||||
const form = document.getElementById('media-inquiry-form');
|
|
||||||
const submitButton = document.getElementById('submit-button');
|
|
||||||
const successMessage = document.getElementById('success-message');
|
|
||||||
const errorMessage = document.getElementById('error-message');
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Hide previous messages
|
|
||||||
successMessage.style.display = 'none';
|
|
||||||
errorMessage.style.display = 'none';
|
|
||||||
|
|
||||||
// Disable submit button
|
|
||||||
submitButton.disabled = true;
|
|
||||||
submitButton.textContent = 'Submitting...';
|
|
||||||
|
|
||||||
// Collect form data
|
|
||||||
const formData = {
|
|
||||||
contact: {
|
|
||||||
name: document.getElementById('contact-name').value,
|
|
||||||
email: document.getElementById('contact-email').value,
|
|
||||||
outlet: document.getElementById('contact-outlet').value,
|
|
||||||
phone: document.getElementById('contact-phone').value || null
|
|
||||||
},
|
|
||||||
inquiry: {
|
|
||||||
subject: document.getElementById('inquiry-subject').value,
|
|
||||||
message: document.getElementById('inquiry-message').value,
|
|
||||||
deadline: document.getElementById('inquiry-deadline').value || null,
|
|
||||||
topic_areas: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/media/inquiries', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Success
|
|
||||||
successMessage.textContent = data.message || 'Thank you for your inquiry. We will review and respond shortly.';
|
|
||||||
successMessage.style.display = 'block';
|
|
||||||
form.reset();
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
} else {
|
|
||||||
// Error
|
|
||||||
errorMessage.textContent = data.message || 'An error occurred. Please try again.';
|
|
||||||
errorMessage.style.display = 'block';
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Submit error:', error);
|
|
||||||
errorMessage.textContent = 'Network error. Please check your connection and try again.';
|
|
||||||
errorMessage.style.display = 'block';
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
} finally {
|
|
||||||
// Re-enable submit button
|
|
||||||
submitButton.disabled = false;
|
|
||||||
submitButton.textContent = 'Submit Inquiry';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
/**
|
|
||||||
* Media Triage Transparency - Public Statistics Display
|
|
||||||
* Demonstrates AI governance in practice with measurable transparency
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Fetch and display triage statistics
|
|
||||||
async function loadTriageStats() {
|
|
||||||
const loadingState = document.getElementById('loading-state');
|
|
||||||
const statsContent = document.getElementById('stats-content');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/media/triage-stats');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
throw new Error('Failed to load statistics');
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = data.statistics;
|
|
||||||
|
|
||||||
// Hide loading, show content
|
|
||||||
loadingState.classList.add('hidden');
|
|
||||||
statsContent.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Update key metrics
|
|
||||||
document.getElementById('stat-total').textContent = stats.total_triaged || 0;
|
|
||||||
document.getElementById('stat-values').textContent = stats.involves_values_count || 0;
|
|
||||||
|
|
||||||
// Update urgency distribution
|
|
||||||
const totalUrgency = stats.by_urgency.high + stats.by_urgency.medium + stats.by_urgency.low || 1;
|
|
||||||
|
|
||||||
const highPct = Math.round((stats.by_urgency.high / totalUrgency) * 100);
|
|
||||||
const mediumPct = Math.round((stats.by_urgency.medium / totalUrgency) * 100);
|
|
||||||
const lowPct = Math.round((stats.by_urgency.low / totalUrgency) * 100);
|
|
||||||
|
|
||||||
document.getElementById('urgency-high-count').textContent = `${stats.by_urgency.high} inquiries (${highPct}%)`;
|
|
||||||
document.getElementById('urgency-high-bar').style.width = `${highPct}%`;
|
|
||||||
|
|
||||||
document.getElementById('urgency-medium-count').textContent = `${stats.by_urgency.medium} inquiries (${mediumPct}%)`;
|
|
||||||
document.getElementById('urgency-medium-bar').style.width = `${mediumPct}%`;
|
|
||||||
|
|
||||||
document.getElementById('urgency-low-count').textContent = `${stats.by_urgency.low} inquiries (${lowPct}%)`;
|
|
||||||
document.getElementById('urgency-low-bar').style.width = `${lowPct}%`;
|
|
||||||
|
|
||||||
// Update sensitivity distribution
|
|
||||||
const totalSensitivity = stats.by_sensitivity.high + stats.by_sensitivity.medium + stats.by_sensitivity.low || 1;
|
|
||||||
|
|
||||||
const sensHighPct = Math.round((stats.by_sensitivity.high / totalSensitivity) * 100);
|
|
||||||
const sensMediumPct = Math.round((stats.by_sensitivity.medium / totalSensitivity) * 100);
|
|
||||||
const sensLowPct = Math.round((stats.by_sensitivity.low / totalSensitivity) * 100);
|
|
||||||
|
|
||||||
document.getElementById('sensitivity-high-count').textContent = `${stats.by_sensitivity.high} inquiries (${sensHighPct}%)`;
|
|
||||||
document.getElementById('sensitivity-high-bar').style.width = `${sensHighPct}%`;
|
|
||||||
|
|
||||||
document.getElementById('sensitivity-medium-count').textContent = `${stats.by_sensitivity.medium} inquiries (${sensMediumPct}%)`;
|
|
||||||
document.getElementById('sensitivity-medium-bar').style.width = `${sensMediumPct}%`;
|
|
||||||
|
|
||||||
document.getElementById('sensitivity-low-count').textContent = `${stats.by_sensitivity.low} inquiries (${sensLowPct}%)`;
|
|
||||||
document.getElementById('sensitivity-low-bar').style.width = `${sensLowPct}%`;
|
|
||||||
|
|
||||||
// Update framework compliance metrics
|
|
||||||
document.getElementById('boundary-enforcements').textContent = stats.boundary_enforcements || 0;
|
|
||||||
document.getElementById('avg-response-time').textContent = stats.avg_response_time_hours || 0;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load triage statistics:', error);
|
|
||||||
|
|
||||||
// Show error message
|
|
||||||
loadingState.innerHTML = `
|
|
||||||
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
|
||||||
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-red-800 font-medium mb-2">Failed to load statistics</p>
|
|
||||||
<p class="text-sm text-red-600">Please try again later or contact support if the problem persists.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load stats on page load
|
|
||||||
loadTriageStats();
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
/**
|
|
||||||
* Page Transitions
|
|
||||||
* Tractatus Framework - Phase 3: Page Transitions
|
|
||||||
*
|
|
||||||
* Provides smooth fade transitions between pages for better UX
|
|
||||||
*/
|
|
||||||
|
|
||||||
class PageTransitions {
|
|
||||||
constructor() {
|
|
||||||
this.transitionDuration = 300; // milliseconds
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Add fade-in class to body on page load
|
|
||||||
document.body.classList.add('page-fade-in');
|
|
||||||
|
|
||||||
// Attach click handlers to internal links
|
|
||||||
this.attachLinkHandlers();
|
|
||||||
|
|
||||||
console.log('[PageTransitions] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
attachLinkHandlers() {
|
|
||||||
// Get all internal links (href starts with / or is relative)
|
|
||||||
const links = document.querySelectorAll('a[href^="/"], a[href^="./"], a[href^="../"]');
|
|
||||||
|
|
||||||
links.forEach(link => {
|
|
||||||
// Skip if link has target="_blank" or download attribute
|
|
||||||
if (link.getAttribute('target') === '_blank' || link.hasAttribute('download')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
// Allow Ctrl/Cmd+click to open in new tab
|
|
||||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if link has hash (same-page navigation)
|
|
||||||
const href = link.getAttribute('href');
|
|
||||||
if (href && href.startsWith('#')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
this.transitionToPage(link.href);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[PageTransitions] Attached handlers to ${links.length} links`);
|
|
||||||
}
|
|
||||||
|
|
||||||
transitionToPage(url) {
|
|
||||||
// Remove fade-in, add fade-out
|
|
||||||
document.body.classList.remove('page-fade-in');
|
|
||||||
document.body.classList.add('page-fade-out');
|
|
||||||
|
|
||||||
// Navigate after fade-out completes
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = url;
|
|
||||||
}, this.transitionDuration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
new PageTransitions();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
new PageTransitions();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export for module systems
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = PageTransitions;
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
/**
|
|
||||||
* Researcher Page - Accordion Functionality
|
|
||||||
* Handles expandable/collapsible sections for research content
|
|
||||||
*/
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Get all accordion buttons
|
|
||||||
const accordionButtons = document.querySelectorAll('[data-accordion]');
|
|
||||||
|
|
||||||
accordionButtons.forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const accordionId = this.dataset.accordion;
|
|
||||||
toggleAccordion(accordionId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle accordion section open/closed
|
|
||||||
* @param {string} id - Accordion section ID
|
|
||||||
*/
|
|
||||||
function toggleAccordion(id) {
|
|
||||||
const content = document.getElementById(id + '-content');
|
|
||||||
const icon = document.getElementById(id + '-icon');
|
|
||||||
|
|
||||||
if (content && icon) {
|
|
||||||
content.classList.toggle('active');
|
|
||||||
icon.classList.toggle('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
/**
|
|
||||||
* Scroll Animations using Intersection Observer
|
|
||||||
* Tractatus Framework - Phase 3: Engagement & Interactive Features
|
|
||||||
*
|
|
||||||
* Provides smooth scroll-triggered animations for elements marked with .animate-on-scroll
|
|
||||||
* Supports multiple animation types via data-animation attribute
|
|
||||||
*/
|
|
||||||
|
|
||||||
class ScrollAnimations {
|
|
||||||
constructor(options = {}) {
|
|
||||||
this.observerOptions = {
|
|
||||||
threshold: options.threshold || 0.1,
|
|
||||||
rootMargin: options.rootMargin || '0px 0px -100px 0px'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.animations = {
|
|
||||||
'fade-in': 'opacity-0 animate-fade-in',
|
|
||||||
'slide-up': 'opacity-0 translate-y-8 animate-slide-up',
|
|
||||||
'slide-down': 'opacity-0 -translate-y-8 animate-slide-down',
|
|
||||||
'slide-left': 'opacity-0 translate-x-8 animate-slide-left',
|
|
||||||
'slide-right': 'opacity-0 -translate-x-8 animate-slide-right',
|
|
||||||
'scale-in': 'opacity-0 scale-95 animate-scale-in',
|
|
||||||
'rotate-in': 'opacity-0 rotate-12 animate-rotate-in'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Wait for DOM to be ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => this.observe());
|
|
||||||
} else {
|
|
||||||
this.observe();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[ScrollAnimations] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
observe() {
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
this.animateElement(entry.target);
|
|
||||||
|
|
||||||
// Optional: unobserve after animation to improve performance
|
|
||||||
// Only do this for elements without data-animate-repeat attribute
|
|
||||||
if (!entry.target.hasAttribute('data-animate-repeat')) {
|
|
||||||
observer.unobserve(entry.target);
|
|
||||||
}
|
|
||||||
} else if (entry.target.hasAttribute('data-animate-repeat')) {
|
|
||||||
// Reset animation for repeatable elements
|
|
||||||
this.resetElement(entry.target);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, this.observerOptions);
|
|
||||||
|
|
||||||
// Find all elements with .animate-on-scroll class
|
|
||||||
const elements = document.querySelectorAll('.animate-on-scroll');
|
|
||||||
console.log(`[ScrollAnimations] Observing ${elements.length} elements`);
|
|
||||||
|
|
||||||
elements.forEach((el, index) => {
|
|
||||||
// Add stagger delay if data-stagger attribute is present
|
|
||||||
if (el.hasAttribute('data-stagger')) {
|
|
||||||
const delay = parseInt(el.getAttribute('data-stagger')) || (index * 100);
|
|
||||||
el.style.animationDelay = `${delay}ms`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply initial animation classes based on data-animation attribute
|
|
||||||
const animationType = el.getAttribute('data-animation') || 'fade-in';
|
|
||||||
if (this.animations[animationType]) {
|
|
||||||
// Remove any existing animation classes
|
|
||||||
Object.values(this.animations).forEach(classes => {
|
|
||||||
classes.split(' ').forEach(cls => el.classList.remove(cls));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add initial state classes (will be removed when visible)
|
|
||||||
const initialClasses = this.getInitialClasses(animationType);
|
|
||||||
initialClasses.forEach(cls => el.classList.add(cls));
|
|
||||||
}
|
|
||||||
|
|
||||||
observer.observe(el);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getInitialClasses(animationType) {
|
|
||||||
// Return classes that represent the "before animation" state
|
|
||||||
const map = {
|
|
||||||
'fade-in': ['opacity-0'],
|
|
||||||
'slide-up': ['opacity-0', 'translate-y-8'],
|
|
||||||
'slide-down': ['opacity-0', '-translate-y-8'],
|
|
||||||
'slide-left': ['opacity-0', 'translate-x-8'],
|
|
||||||
'slide-right': ['opacity-0', '-translate-x-8'],
|
|
||||||
'scale-in': ['opacity-0', 'scale-95'],
|
|
||||||
'rotate-in': ['opacity-0', 'rotate-12']
|
|
||||||
};
|
|
||||||
|
|
||||||
return map[animationType] || ['opacity-0'];
|
|
||||||
}
|
|
||||||
|
|
||||||
animateElement(element) {
|
|
||||||
// Remove initial state classes
|
|
||||||
element.classList.remove('opacity-0', 'translate-y-8', '-translate-y-8', 'translate-x-8', '-translate-x-8', 'scale-95', 'rotate-12');
|
|
||||||
|
|
||||||
// Add visible state
|
|
||||||
element.classList.add('is-visible');
|
|
||||||
|
|
||||||
// Trigger custom event for other components to listen to
|
|
||||||
element.dispatchEvent(new CustomEvent('scroll-animated', {
|
|
||||||
bubbles: true,
|
|
||||||
detail: { element }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
resetElement(element) {
|
|
||||||
// Remove visible state
|
|
||||||
element.classList.remove('is-visible');
|
|
||||||
|
|
||||||
// Re-apply initial animation classes
|
|
||||||
const animationType = element.getAttribute('data-animation') || 'fade-in';
|
|
||||||
const initialClasses = this.getInitialClasses(animationType);
|
|
||||||
initialClasses.forEach(cls => element.classList.add(cls));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-initialize when script loads
|
|
||||||
// Can be disabled by setting window.DISABLE_AUTO_SCROLL_ANIMATIONS = true before loading this script
|
|
||||||
if (typeof window !== 'undefined' && !window.DISABLE_AUTO_SCROLL_ANIMATIONS) {
|
|
||||||
window.scrollAnimations = new ScrollAnimations();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export for module usage
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = ScrollAnimations;
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
/**
|
|
||||||
* Currency Utilities (Client-Side)
|
|
||||||
* Multi-currency support for Koha donation form
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Base prices in NZD (in cents)
|
|
||||||
const BASE_PRICES_NZD = {
|
|
||||||
tier_5: 500, // $5 NZD
|
|
||||||
tier_15: 1500, // $15 NZD
|
|
||||||
tier_50: 5000 // $50 NZD
|
|
||||||
};
|
|
||||||
|
|
||||||
// Exchange rates: 1 NZD = X currency
|
|
||||||
const EXCHANGE_RATES = {
|
|
||||||
NZD: 1.0,
|
|
||||||
USD: 0.60,
|
|
||||||
EUR: 0.55,
|
|
||||||
GBP: 0.47,
|
|
||||||
AUD: 0.93,
|
|
||||||
CAD: 0.82,
|
|
||||||
JPY: 94.0,
|
|
||||||
CHF: 0.53,
|
|
||||||
SGD: 0.81,
|
|
||||||
HKD: 4.68
|
|
||||||
};
|
|
||||||
|
|
||||||
// Currency metadata
|
|
||||||
const CURRENCY_CONFIG = {
|
|
||||||
NZD: { symbol: '$', code: 'NZD', name: 'NZ Dollar', decimals: 2, flag: '🇳🇿' },
|
|
||||||
USD: { symbol: '$', code: 'USD', name: 'US Dollar', decimals: 2, flag: '🇺🇸' },
|
|
||||||
EUR: { symbol: '€', code: 'EUR', name: 'Euro', decimals: 2, flag: '🇪🇺' },
|
|
||||||
GBP: { symbol: '£', code: 'GBP', name: 'British Pound', decimals: 2, flag: '🇬🇧' },
|
|
||||||
AUD: { symbol: '$', code: 'AUD', name: 'Australian Dollar', decimals: 2, flag: '🇦🇺' },
|
|
||||||
CAD: { symbol: '$', code: 'CAD', name: 'Canadian Dollar', decimals: 2, flag: '🇨🇦' },
|
|
||||||
JPY: { symbol: '¥', code: 'JPY', name: 'Japanese Yen', decimals: 0, flag: '🇯🇵' },
|
|
||||||
CHF: { symbol: 'CHF', code: 'CHF', name: 'Swiss Franc', decimals: 2, flag: '🇨🇭' },
|
|
||||||
SGD: { symbol: '$', code: 'SGD', name: 'Singapore Dollar', decimals: 2, flag: '🇸🇬' },
|
|
||||||
HKD: { symbol: '$', code: 'HKD', name: 'Hong Kong Dollar', decimals: 2, flag: '🇭🇰' }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Supported currencies
|
|
||||||
const SUPPORTED_CURRENCIES = ['NZD', 'USD', 'EUR', 'GBP', 'AUD', 'CAD', 'JPY', 'CHF', 'SGD', 'HKD'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert NZD amount to target currency
|
|
||||||
*/
|
|
||||||
function convertFromNZD(amountNZD, targetCurrency) {
|
|
||||||
const rate = EXCHANGE_RATES[targetCurrency];
|
|
||||||
return Math.round(amountNZD * rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tier prices for a currency
|
|
||||||
*/
|
|
||||||
function getTierPrices(currency) {
|
|
||||||
return {
|
|
||||||
tier_5: convertFromNZD(BASE_PRICES_NZD.tier_5, currency),
|
|
||||||
tier_15: convertFromNZD(BASE_PRICES_NZD.tier_15, currency),
|
|
||||||
tier_50: convertFromNZD(BASE_PRICES_NZD.tier_50, currency)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format currency amount for display
|
|
||||||
*/
|
|
||||||
function formatCurrency(amountCents, currency) {
|
|
||||||
const config = CURRENCY_CONFIG[currency];
|
|
||||||
const amount = amountCents / 100;
|
|
||||||
|
|
||||||
// For currencies with symbols that should come after (none in our list currently)
|
|
||||||
// we could customize here, but Intl.NumberFormat handles it well
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat('en-NZ', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
minimumFractionDigits: config.decimals,
|
|
||||||
maximumFractionDigits: config.decimals
|
|
||||||
}).format(amount);
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback if Intl fails
|
|
||||||
return `${config.symbol}${amount.toFixed(config.decimals)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get currency display name with flag
|
|
||||||
*/
|
|
||||||
function getCurrencyDisplayName(currency) {
|
|
||||||
const config = CURRENCY_CONFIG[currency];
|
|
||||||
return `${config.flag} ${config.code} - ${config.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect user's currency from browser/location
|
|
||||||
*/
|
|
||||||
function detectUserCurrency() {
|
|
||||||
// Try localStorage first
|
|
||||||
const saved = localStorage.getItem('tractatus_currency');
|
|
||||||
if (saved && SUPPORTED_CURRENCIES.includes(saved)) {
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to detect from browser language
|
|
||||||
const lang = navigator.language || navigator.userLanguage || 'en-NZ';
|
|
||||||
const langMap = {
|
|
||||||
'en-US': 'USD',
|
|
||||||
'en-GB': 'GBP',
|
|
||||||
'en-AU': 'AUD',
|
|
||||||
'en-CA': 'CAD',
|
|
||||||
'en-NZ': 'NZD',
|
|
||||||
'ja': 'JPY',
|
|
||||||
'ja-JP': 'JPY',
|
|
||||||
'de': 'EUR',
|
|
||||||
'de-DE': 'EUR',
|
|
||||||
'fr': 'EUR',
|
|
||||||
'fr-FR': 'EUR',
|
|
||||||
'de-CH': 'CHF',
|
|
||||||
'en-SG': 'SGD',
|
|
||||||
'zh-HK': 'HKD'
|
|
||||||
};
|
|
||||||
|
|
||||||
return langMap[lang] || langMap[lang.substring(0, 2)] || 'NZD';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save user's currency preference
|
|
||||||
*/
|
|
||||||
function saveCurrencyPreference(currency) {
|
|
||||||
localStorage.setItem('tractatus_currency', currency);
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
/**
|
|
||||||
* Simple client-side router for three audience paths
|
|
||||||
*/
|
|
||||||
|
|
||||||
class Router {
|
|
||||||
constructor() {
|
|
||||||
this.routes = new Map();
|
|
||||||
this.currentPath = null;
|
|
||||||
|
|
||||||
// Initialize router
|
|
||||||
window.addEventListener('popstate', () => this.handleRoute());
|
|
||||||
document.addEventListener('DOMContentLoaded', () => this.handleRoute());
|
|
||||||
|
|
||||||
// Handle link clicks
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (e.target.matches('[data-route]')) {
|
|
||||||
e.preventDefault();
|
|
||||||
const path = e.target.getAttribute('data-route') || e.target.getAttribute('href');
|
|
||||||
this.navigateTo(path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a route
|
|
||||||
*/
|
|
||||||
on(path, handler) {
|
|
||||||
this.routes.set(path, handler);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to a path
|
|
||||||
*/
|
|
||||||
navigateTo(path) {
|
|
||||||
if (path === this.currentPath) return;
|
|
||||||
|
|
||||||
history.pushState(null, '', path);
|
|
||||||
this.handleRoute();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle current route
|
|
||||||
*/
|
|
||||||
async handleRoute() {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
this.currentPath = path;
|
|
||||||
|
|
||||||
// Try exact match
|
|
||||||
if (this.routes.has(path)) {
|
|
||||||
await this.routes.get(path)();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try pattern match
|
|
||||||
for (const [pattern, handler] of this.routes) {
|
|
||||||
const match = this.matchRoute(pattern, path);
|
|
||||||
if (match) {
|
|
||||||
await handler(match.params);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No match, show 404
|
|
||||||
this.show404();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match route pattern
|
|
||||||
*/
|
|
||||||
matchRoute(pattern, path) {
|
|
||||||
const patternParts = pattern.split('/');
|
|
||||||
const pathParts = path.split('/');
|
|
||||||
|
|
||||||
if (patternParts.length !== pathParts.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {};
|
|
||||||
for (let i = 0; i < patternParts.length; i++) {
|
|
||||||
if (patternParts[i].startsWith(':')) {
|
|
||||||
const paramName = patternParts[i].slice(1);
|
|
||||||
params[paramName] = pathParts[i];
|
|
||||||
} else if (patternParts[i] !== pathParts[i]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { params };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show 404 page
|
|
||||||
*/
|
|
||||||
show404() {
|
|
||||||
const container = document.getElementById('app') || document.body;
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-50">
|
|
||||||
<div class="text-center">
|
|
||||||
<h1 class="text-6xl font-bold text-gray-900 mb-4">404</h1>
|
|
||||||
<p class="text-xl text-gray-600 mb-8">Page not found</p>
|
|
||||||
<a href="/" class="text-blue-600 hover:text-blue-700 font-semibold">
|
|
||||||
← Return to homepage
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create global router instance
|
|
||||||
window.router = new Router();
|
|
||||||
|
|
@ -1,421 +0,0 @@
|
||||||
/**
|
|
||||||
* Tractatus Version Manager
|
|
||||||
* - Registers service worker
|
|
||||||
* - Checks for updates every hour
|
|
||||||
* - Shows update notifications
|
|
||||||
* - Manages PWA install prompts
|
|
||||||
*/
|
|
||||||
|
|
||||||
class VersionManager {
|
|
||||||
constructor() {
|
|
||||||
this.serviceWorker = null;
|
|
||||||
this.deferredInstallPrompt = null;
|
|
||||||
this.updateCheckInterval = null;
|
|
||||||
this.currentVersion = null;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
// Only run in browsers that support service workers
|
|
||||||
if (!('serviceWorker' in navigator)) {
|
|
||||||
console.log('[VersionManager] Service workers not supported');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Register service worker
|
|
||||||
await this.registerServiceWorker();
|
|
||||||
|
|
||||||
// Check for updates immediately
|
|
||||||
await this.checkForUpdates();
|
|
||||||
|
|
||||||
// Check for updates every hour
|
|
||||||
this.updateCheckInterval = setInterval(() => {
|
|
||||||
this.checkForUpdates();
|
|
||||||
}, 3600000); // 1 hour
|
|
||||||
|
|
||||||
// Listen for PWA install prompt
|
|
||||||
this.setupInstallPrompt();
|
|
||||||
|
|
||||||
// Listen for service worker messages
|
|
||||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
||||||
if (event.data.type === 'UPDATE_AVAILABLE') {
|
|
||||||
this.showUpdateNotification(event.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[VersionManager] Initialization failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async registerServiceWorker() {
|
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.register('/service-worker.js');
|
|
||||||
this.serviceWorker = registration;
|
|
||||||
console.log('[VersionManager] Service worker registered');
|
|
||||||
|
|
||||||
// Check for updates when service worker updates
|
|
||||||
registration.addEventListener('updatefound', () => {
|
|
||||||
const newWorker = registration.installing;
|
|
||||||
newWorker.addEventListener('statechange', () => {
|
|
||||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
|
||||||
// New service worker available
|
|
||||||
this.showUpdateNotification({
|
|
||||||
updateAvailable: true,
|
|
||||||
currentVersion: this.currentVersion,
|
|
||||||
serverVersion: 'latest'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[VersionManager] Service worker registration failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkForUpdates() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/version.json', { cache: 'no-store' });
|
|
||||||
const versionInfo = await response.json();
|
|
||||||
|
|
||||||
// Get current version from localStorage or default
|
|
||||||
const storedVersion = localStorage.getItem('tractatus_version') || '0.0.0';
|
|
||||||
this.currentVersion = storedVersion;
|
|
||||||
|
|
||||||
if (storedVersion !== versionInfo.version) {
|
|
||||||
console.log('[VersionManager] Update available:', versionInfo.version);
|
|
||||||
this.showUpdateNotification({
|
|
||||||
updateAvailable: true,
|
|
||||||
currentVersion: storedVersion,
|
|
||||||
serverVersion: versionInfo.version,
|
|
||||||
changelog: versionInfo.changelog,
|
|
||||||
forceUpdate: versionInfo.forceUpdate
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[VersionManager] Version check failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showUpdateNotification(versionInfo) {
|
|
||||||
// Don't show if notification already visible
|
|
||||||
if (document.getElementById('tractatus-update-notification')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.id = 'tractatus-update-notification';
|
|
||||||
notification.className = 'fixed bottom-0 left-0 right-0 bg-blue-600 text-white px-4 py-3 shadow-lg z-50 transform transition-transform duration-300 translate-y-full';
|
|
||||||
notification.innerHTML = `
|
|
||||||
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
|
|
||||||
<div class="flex items-start flex-1">
|
|
||||||
<svg class="w-6 h-6 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold">Update Available</p>
|
|
||||||
<p class="text-sm text-blue-100">
|
|
||||||
A new version of Tractatus Framework is available
|
|
||||||
${versionInfo.serverVersion ? `(v${versionInfo.serverVersion})` : ''}
|
|
||||||
</p>
|
|
||||||
${versionInfo.changelog ? `
|
|
||||||
<details class="mt-2 text-sm">
|
|
||||||
<summary class="cursor-pointer text-blue-100 hover:text-white">What's new?</summary>
|
|
||||||
<ul class="mt-2 ml-4 list-disc text-blue-100">
|
|
||||||
${versionInfo.changelog.map(item => `<li>${item}</li>`).join('')}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
${versionInfo.forceUpdate ? `
|
|
||||||
<button id="update-now-btn" class="bg-white text-blue-600 px-6 py-2 rounded-lg font-semibold hover:bg-blue-50 transition">
|
|
||||||
Update Now
|
|
||||||
</button>
|
|
||||||
` : `
|
|
||||||
<button id="update-later-btn" class="text-white hover:text-blue-100 transition px-3">
|
|
||||||
Later
|
|
||||||
</button>
|
|
||||||
<button id="update-reload-btn" class="bg-white text-blue-600 px-6 py-2 rounded-lg font-semibold hover:bg-blue-50 transition">
|
|
||||||
Reload
|
|
||||||
</button>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
|
|
||||||
// Add event listeners (CSP compliant)
|
|
||||||
const updateNowBtn = document.getElementById('update-now-btn');
|
|
||||||
const updateLaterBtn = document.getElementById('update-later-btn');
|
|
||||||
const updateReloadBtn = document.getElementById('update-reload-btn');
|
|
||||||
|
|
||||||
if (updateNowBtn) {
|
|
||||||
updateNowBtn.addEventListener('click', () => this.applyUpdate());
|
|
||||||
}
|
|
||||||
if (updateLaterBtn) {
|
|
||||||
updateLaterBtn.addEventListener('click', () => this.dismissUpdate());
|
|
||||||
}
|
|
||||||
if (updateReloadBtn) {
|
|
||||||
updateReloadBtn.addEventListener('click', () => this.applyUpdate());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate in
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.classList.remove('translate-y-full');
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Auto-reload for forced updates after 10 seconds
|
|
||||||
if (versionInfo.forceUpdate) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.applyUpdate();
|
|
||||||
}, 10000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyUpdate() {
|
|
||||||
// Store new version
|
|
||||||
fetch('/version.json', { cache: 'no-store' })
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(versionInfo => {
|
|
||||||
localStorage.setItem('tractatus_version', versionInfo.version);
|
|
||||||
|
|
||||||
// Tell service worker to skip waiting
|
|
||||||
if (this.serviceWorker) {
|
|
||||||
this.serviceWorker.waiting?.postMessage({ type: 'SKIP_WAITING' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload page
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
dismissUpdate() {
|
|
||||||
const notification = document.getElementById('tractatus-update-notification');
|
|
||||||
if (notification) {
|
|
||||||
notification.classList.add('translate-y-full');
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.remove();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupInstallPrompt() {
|
|
||||||
// Listen for beforeinstallprompt event
|
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
|
||||||
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Stash the event so it can be triggered later
|
|
||||||
this.deferredInstallPrompt = e;
|
|
||||||
|
|
||||||
// Check if user has dismissed install prompt before
|
|
||||||
const dismissed = sessionStorage.getItem('install_prompt_dismissed');
|
|
||||||
if (!dismissed) {
|
|
||||||
// Show install prompt after 30 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
this.showInstallPrompt();
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Detect if app was installed
|
|
||||||
window.addEventListener('appinstalled', () => {
|
|
||||||
console.log('[VersionManager] PWA installed');
|
|
||||||
this.deferredInstallPrompt = null;
|
|
||||||
|
|
||||||
// Hide install prompt if visible
|
|
||||||
const prompt = document.getElementById('tractatus-install-prompt');
|
|
||||||
if (prompt) {
|
|
||||||
prompt.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showInstallPrompt() {
|
|
||||||
if (!this.deferredInstallPrompt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't show if already installed or on iOS Safari (handles differently)
|
|
||||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = document.createElement('div');
|
|
||||||
prompt.id = 'tractatus-install-prompt';
|
|
||||||
prompt.className = 'fixed bottom-0 left-0 right-0 bg-gradient-to-r from-purple-600 to-blue-600 text-white px-4 py-3 shadow-lg z-50 transform transition-transform duration-300 translate-y-full';
|
|
||||||
prompt.innerHTML = `
|
|
||||||
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
|
|
||||||
<div class="flex items-start flex-1">
|
|
||||||
<svg class="w-6 h-6 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold">Install Tractatus App</p>
|
|
||||||
<p class="text-sm text-purple-100">
|
|
||||||
Add to your home screen for quick access and offline support
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button id="dismiss-install-btn" class="text-white hover:text-purple-100 transition px-3">
|
|
||||||
Not Now
|
|
||||||
</button>
|
|
||||||
<button id="install-app-btn" class="bg-white text-purple-600 px-6 py-2 rounded-lg font-semibold hover:bg-purple-50 transition">
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(prompt);
|
|
||||||
|
|
||||||
// Add event listeners (CSP compliant)
|
|
||||||
const dismissBtn = document.getElementById('dismiss-install-btn');
|
|
||||||
const installBtn = document.getElementById('install-app-btn');
|
|
||||||
|
|
||||||
if (dismissBtn) {
|
|
||||||
dismissBtn.addEventListener('click', () => this.dismissInstallPrompt());
|
|
||||||
}
|
|
||||||
if (installBtn) {
|
|
||||||
installBtn.addEventListener('click', () => this.installApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate in
|
|
||||||
setTimeout(() => {
|
|
||||||
prompt.classList.remove('translate-y-full');
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
async installApp() {
|
|
||||||
if (!this.deferredInstallPrompt) {
|
|
||||||
// Show helpful feedback if installation isn't available
|
|
||||||
this.showInstallUnavailableMessage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the install prompt
|
|
||||||
this.deferredInstallPrompt.prompt();
|
|
||||||
|
|
||||||
// Wait for the user to respond to the prompt
|
|
||||||
const { outcome } = await this.deferredInstallPrompt.userChoice;
|
|
||||||
console.log(`[VersionManager] User response: ${outcome}`);
|
|
||||||
|
|
||||||
// Clear the deferredInstallPrompt
|
|
||||||
this.deferredInstallPrompt = null;
|
|
||||||
|
|
||||||
// Hide the prompt
|
|
||||||
this.dismissInstallPrompt();
|
|
||||||
}
|
|
||||||
|
|
||||||
showInstallUnavailableMessage() {
|
|
||||||
// Check if app is already installed
|
|
||||||
const isInstalled = window.matchMedia('(display-mode: standalone)').matches;
|
|
||||||
|
|
||||||
// Don't show message if it already exists
|
|
||||||
if (document.getElementById('tractatus-install-unavailable')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = document.createElement('div');
|
|
||||||
message.id = 'tractatus-install-unavailable';
|
|
||||||
message.className = 'fixed bottom-0 left-0 right-0 bg-gray-800 text-white px-4 py-3 shadow-lg z-50 transform transition-transform duration-300 translate-y-full';
|
|
||||||
|
|
||||||
if (isInstalled) {
|
|
||||||
message.innerHTML = `
|
|
||||||
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
|
|
||||||
<div class="flex items-start flex-1">
|
|
||||||
<svg class="w-6 h-6 mr-3 flex-shrink-0 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold">Already Installed</p>
|
|
||||||
<p class="text-sm text-gray-300">
|
|
||||||
Tractatus is already installed on your device. You're using it right now!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button id="dismiss-unavailable-btn" class="text-white hover:text-gray-300 transition px-3">
|
|
||||||
Okay
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
message.innerHTML = `
|
|
||||||
<div class="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-4">
|
|
||||||
<div class="flex items-start flex-1">
|
|
||||||
<svg class="w-6 h-6 mr-3 flex-shrink-0 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold">Installation Not Available</p>
|
|
||||||
<p class="text-sm text-gray-300">
|
|
||||||
Your browser doesn't currently support app installation. Try using Chrome, Edge, or Safari on a supported device.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button id="dismiss-unavailable-btn" class="text-white hover:text-gray-300 transition px-3">
|
|
||||||
Okay
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(message);
|
|
||||||
|
|
||||||
// Add event listener for dismiss button
|
|
||||||
const dismissBtn = document.getElementById('dismiss-unavailable-btn');
|
|
||||||
if (dismissBtn) {
|
|
||||||
dismissBtn.addEventListener('click', () => {
|
|
||||||
message.classList.add('translate-y-full');
|
|
||||||
setTimeout(() => {
|
|
||||||
message.remove();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate in
|
|
||||||
setTimeout(() => {
|
|
||||||
message.classList.remove('translate-y-full');
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Auto-dismiss after 8 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (message.parentElement) {
|
|
||||||
message.classList.add('translate-y-full');
|
|
||||||
setTimeout(() => {
|
|
||||||
message.remove();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}, 8000);
|
|
||||||
}
|
|
||||||
|
|
||||||
dismissInstallPrompt() {
|
|
||||||
const prompt = document.getElementById('tractatus-install-prompt');
|
|
||||||
if (prompt) {
|
|
||||||
prompt.classList.add('translate-y-full');
|
|
||||||
setTimeout(() => {
|
|
||||||
prompt.remove();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remember dismissal for this session
|
|
||||||
sessionStorage.setItem('install_prompt_dismissed', 'true');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize version manager on page load
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
window.versionManager = new VersionManager();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
window.versionManager = new VersionManager();
|
|
||||||
}
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin Controller
|
|
||||||
* Moderation queue and system statistics
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ModerationQueue = require('../models/ModerationQueue.model');
|
|
||||||
const Document = require('../models/Document.model');
|
|
||||||
const BlogPost = require('../models/BlogPost.model');
|
|
||||||
const User = require('../models/User.model');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get moderation queue dashboard
|
|
||||||
* GET /api/admin/moderation
|
|
||||||
*/
|
|
||||||
async function getModerationQueue(req, res) {
|
|
||||||
try {
|
|
||||||
const { limit = 20, skip = 0, priority, quadrant, item_type, type } = req.query;
|
|
||||||
|
|
||||||
let items;
|
|
||||||
let total;
|
|
||||||
|
|
||||||
// Support both new 'type' and legacy 'item_type' fields
|
|
||||||
// Treat 'all' as no filter (same as not providing a type)
|
|
||||||
const filterType = (type && type !== 'all') ? type : (item_type && item_type !== 'all' ? item_type : null);
|
|
||||||
|
|
||||||
if (quadrant) {
|
|
||||||
items = await ModerationQueue.findByQuadrant(quadrant, {
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip)
|
|
||||||
});
|
|
||||||
total = await ModerationQueue.countPending({ quadrant });
|
|
||||||
} else if (filterType) {
|
|
||||||
// Filter by new 'type' field (preferred) or legacy 'item_type' field
|
|
||||||
const collection = await require('../utils/db.util').getCollection('moderation_queue');
|
|
||||||
items = await collection
|
|
||||||
.find({
|
|
||||||
status: 'pending',
|
|
||||||
$or: [
|
|
||||||
{ type: filterType },
|
|
||||||
{ item_type: filterType }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.sort({ priority: -1, created_at: 1 })
|
|
||||||
.skip(parseInt(skip))
|
|
||||||
.limit(parseInt(limit))
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
total = await collection.countDocuments({
|
|
||||||
status: 'pending',
|
|
||||||
$or: [
|
|
||||||
{ type: filterType },
|
|
||||||
{ item_type: filterType }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
items = await ModerationQueue.findPending({
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
priority
|
|
||||||
});
|
|
||||||
total = await ModerationQueue.countPending(priority ? { priority } : {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get stats by quadrant
|
|
||||||
const stats = await ModerationQueue.getStatsByQuadrant();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
items,
|
|
||||||
queue: items, // Alias for backward compatibility
|
|
||||||
stats: stats.reduce((acc, stat) => {
|
|
||||||
acc[stat._id] = {
|
|
||||||
total: stat.count,
|
|
||||||
high_priority: stat.high_priority
|
|
||||||
};
|
|
||||||
return acc;
|
|
||||||
}, {}),
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get moderation queue error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get single moderation item
|
|
||||||
* GET /api/admin/moderation/:id
|
|
||||||
*/
|
|
||||||
async function getModerationItem(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const item = await ModerationQueue.findById(id);
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Moderation item not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
item
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get moderation item error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Review moderation item (approve/reject/escalate)
|
|
||||||
* POST /api/admin/moderation/:id/review
|
|
||||||
*/
|
|
||||||
async function reviewModerationItem(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { action, notes } = req.body;
|
|
||||||
|
|
||||||
const item = await ModerationQueue.findById(id);
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Moderation item not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let success;
|
|
||||||
let createdPost = null;
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'approve':
|
|
||||||
success = await ModerationQueue.approve(id, req.userId, notes);
|
|
||||||
|
|
||||||
// Blog-specific handling: Create BlogPost from approved draft
|
|
||||||
if (success && item.type === 'BLOG_POST_DRAFT' && item.data?.draft) {
|
|
||||||
try {
|
|
||||||
const draft = item.data.draft;
|
|
||||||
const slug = generateSlug(draft.title);
|
|
||||||
|
|
||||||
// Create and publish blog post
|
|
||||||
createdPost = await BlogPost.create({
|
|
||||||
title: draft.title,
|
|
||||||
slug,
|
|
||||||
content: draft.content,
|
|
||||||
excerpt: draft.excerpt,
|
|
||||||
tags: draft.tags || [],
|
|
||||||
author: {
|
|
||||||
type: 'ai_curated',
|
|
||||||
name: req.user.name || req.user.email,
|
|
||||||
claude_version: item.metadata?.model_info?.model || 'claude-3-5-sonnet'
|
|
||||||
},
|
|
||||||
author_name: req.user.name || req.user.email, // Flattened for frontend
|
|
||||||
ai_assisted: true, // Flag for AI disclosure
|
|
||||||
tractatus_classification: {
|
|
||||||
quadrant: item.quadrant || 'OPERATIONAL',
|
|
||||||
values_sensitive: false,
|
|
||||||
requires_strategic_review: false
|
|
||||||
},
|
|
||||||
moderation: {
|
|
||||||
ai_analysis: item.data.validation,
|
|
||||||
human_reviewer: req.userId,
|
|
||||||
review_notes: notes,
|
|
||||||
approved_at: new Date()
|
|
||||||
},
|
|
||||||
status: 'published',
|
|
||||||
published_at: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log governance action
|
|
||||||
const GovernanceLog = require('../models/GovernanceLog.model');
|
|
||||||
await GovernanceLog.create({
|
|
||||||
action: 'BLOG_POST_PUBLISHED',
|
|
||||||
user_id: req.userId,
|
|
||||||
user_email: req.user.email,
|
|
||||||
timestamp: new Date(),
|
|
||||||
outcome: 'APPROVED',
|
|
||||||
details: {
|
|
||||||
post_id: createdPost._id,
|
|
||||||
slug,
|
|
||||||
title: draft.title,
|
|
||||||
queue_id: id,
|
|
||||||
validation_result: item.data.validation?.recommendation
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Blog post created from approved draft: ${createdPost._id} (${slug}) by ${req.user.email}`);
|
|
||||||
} catch (blogError) {
|
|
||||||
logger.error('Failed to create blog post from approved draft:', blogError);
|
|
||||||
// Don't fail the entire approval if blog creation fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'reject':
|
|
||||||
success = await ModerationQueue.reject(id, req.userId, notes);
|
|
||||||
break;
|
|
||||||
case 'escalate':
|
|
||||||
success = await ModerationQueue.escalate(id, req.userId, notes);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Invalid action. Must be: approve, reject, or escalate'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Failed to update moderation item'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedItem = await ModerationQueue.findById(id);
|
|
||||||
|
|
||||||
logger.info(`Moderation item ${action}: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
item: updatedItem,
|
|
||||||
message: `Item ${action}d successfully`,
|
|
||||||
blog_post: createdPost ? {
|
|
||||||
id: createdPost._id,
|
|
||||||
slug: createdPost.slug,
|
|
||||||
title: createdPost.title,
|
|
||||||
url: `/blog-post.html?slug=${createdPost.slug}`
|
|
||||||
} : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Review moderation item error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate URL-friendly slug from title
|
|
||||||
*/
|
|
||||||
function generateSlug(title) {
|
|
||||||
return title
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
.substring(0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get system statistics
|
|
||||||
* GET /api/admin/stats
|
|
||||||
*/
|
|
||||||
async function getSystemStats(req, res) {
|
|
||||||
try {
|
|
||||||
// Document stats
|
|
||||||
const totalDocuments = await Document.count();
|
|
||||||
const documentsByQuadrant = await Promise.all([
|
|
||||||
Document.count({ quadrant: 'STRATEGIC' }),
|
|
||||||
Document.count({ quadrant: 'OPERATIONAL' }),
|
|
||||||
Document.count({ quadrant: 'TACTICAL' }),
|
|
||||||
Document.count({ quadrant: 'SYSTEM' }),
|
|
||||||
Document.count({ quadrant: 'STOCHASTIC' })
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Blog stats
|
|
||||||
const blogStats = await Promise.all([
|
|
||||||
BlogPost.countByStatus('published'),
|
|
||||||
BlogPost.countByStatus('draft'),
|
|
||||||
BlogPost.countByStatus('pending')
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Moderation queue stats
|
|
||||||
const moderationStats = await ModerationQueue.getStatsByQuadrant();
|
|
||||||
const totalPending = await ModerationQueue.countPending();
|
|
||||||
|
|
||||||
// User stats
|
|
||||||
const totalUsers = await User.count();
|
|
||||||
const activeUsers = await User.count({ active: true });
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
stats: {
|
|
||||||
documents: {
|
|
||||||
total: totalDocuments,
|
|
||||||
by_quadrant: {
|
|
||||||
STRATEGIC: documentsByQuadrant[0],
|
|
||||||
OPERATIONAL: documentsByQuadrant[1],
|
|
||||||
TACTICAL: documentsByQuadrant[2],
|
|
||||||
SYSTEM: documentsByQuadrant[3],
|
|
||||||
STOCHASTIC: documentsByQuadrant[4]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
blog: {
|
|
||||||
published: blogStats[0],
|
|
||||||
draft: blogStats[1],
|
|
||||||
pending: blogStats[2],
|
|
||||||
total: blogStats[0] + blogStats[1] + blogStats[2]
|
|
||||||
},
|
|
||||||
moderation: {
|
|
||||||
total_pending: totalPending,
|
|
||||||
by_quadrant: moderationStats.reduce((acc, stat) => {
|
|
||||||
acc[stat._id] = {
|
|
||||||
total: stat.count,
|
|
||||||
high_priority: stat.high_priority
|
|
||||||
};
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
total: totalUsers,
|
|
||||||
active: activeUsers,
|
|
||||||
inactive: totalUsers - activeUsers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get system stats error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recent activity log
|
|
||||||
* GET /api/admin/activity
|
|
||||||
*/
|
|
||||||
async function getActivityLog(req, res) {
|
|
||||||
try {
|
|
||||||
// This would typically read from a dedicated activity log
|
|
||||||
// For now, return recent moderation reviews as example
|
|
||||||
const { limit = 50 } = req.query;
|
|
||||||
|
|
||||||
const collection = await require('../utils/db.util').getCollection('moderation_queue');
|
|
||||||
|
|
||||||
const recentActivity = await collection
|
|
||||||
.find({ status: 'reviewed' })
|
|
||||||
.sort({ reviewed_at: -1 })
|
|
||||||
.limit(parseInt(limit))
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
activity: recentActivity.map(item => ({
|
|
||||||
timestamp: item.reviewed_at,
|
|
||||||
action: item.review_decision?.action,
|
|
||||||
item_type: item.item_type,
|
|
||||||
item_id: item.item_id,
|
|
||||||
reviewer: item.review_decision?.reviewer,
|
|
||||||
notes: item.review_decision?.notes
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get activity log error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getModerationQueue,
|
|
||||||
getModerationItem,
|
|
||||||
reviewModerationItem,
|
|
||||||
getSystemStats,
|
|
||||||
getActivityLog
|
|
||||||
};
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
/**
|
|
||||||
* Authentication Controller
|
|
||||||
* Handles user login and token verification
|
|
||||||
*/
|
|
||||||
|
|
||||||
const User = require('../models/User.model');
|
|
||||||
const { generateToken } = require('../utils/jwt.util');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Login user
|
|
||||||
* POST /api/auth/login
|
|
||||||
*/
|
|
||||||
async function login(req, res) {
|
|
||||||
try {
|
|
||||||
const { email, password } = req.body;
|
|
||||||
|
|
||||||
// Authenticate user
|
|
||||||
const user = await User.authenticate(email, password);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
logger.warn(`Failed login attempt for email: ${email}`);
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'Authentication failed',
|
|
||||||
message: 'Invalid email or password'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
const token = generateToken({
|
|
||||||
userId: user._id.toString(),
|
|
||||||
email: user.email,
|
|
||||||
role: user.role
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`User logged in: ${user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
accessToken: token,
|
|
||||||
user: {
|
|
||||||
id: user._id.toString(),
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Login error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred during login'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify token and get current user
|
|
||||||
* GET /api/auth/me
|
|
||||||
*/
|
|
||||||
async function getCurrentUser(req, res) {
|
|
||||||
try {
|
|
||||||
// User is already attached to req by auth middleware
|
|
||||||
const user = await User.findById(req.userId);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'User not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
user: {
|
|
||||||
id: user._id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
created_at: user.created_at,
|
|
||||||
last_login: user.last_login
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get current user error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout (client-side token removal, server logs it)
|
|
||||||
* POST /api/auth/logout
|
|
||||||
*/
|
|
||||||
async function logout(req, res) {
|
|
||||||
try {
|
|
||||||
logger.info(`User logged out: ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Logged out successfully'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Logout error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
login,
|
|
||||||
getCurrentUser,
|
|
||||||
logout
|
|
||||||
};
|
|
||||||
|
|
@ -1,764 +0,0 @@
|
||||||
/**
|
|
||||||
* Blog Controller
|
|
||||||
* AI-curated blog with human oversight
|
|
||||||
*/
|
|
||||||
|
|
||||||
const BlogPost = require('../models/BlogPost.model');
|
|
||||||
const ModerationQueue = require('../models/ModerationQueue.model');
|
|
||||||
const GovernanceLog = require('../models/GovernanceLog.model');
|
|
||||||
const { markdownToHtml } = require('../utils/markdown.util');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
const claudeAPI = require('../services/ClaudeAPI.service');
|
|
||||||
const BoundaryEnforcer = require('../services/BoundaryEnforcer.service');
|
|
||||||
const BlogCuration = require('../services/BlogCuration.service');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List published blog posts (public)
|
|
||||||
* GET /api/blog
|
|
||||||
*/
|
|
||||||
async function listPublishedPosts(req, res) {
|
|
||||||
try {
|
|
||||||
const { limit = 10, skip = 0 } = req.query;
|
|
||||||
|
|
||||||
const posts = await BlogPost.findPublished({
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip)
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = await BlogPost.countByStatus('published');
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
posts,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
hasMore: parseInt(skip) + posts.length < total
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('List published posts error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get single blog post by slug (public, published only)
|
|
||||||
* GET /api/blog/:slug
|
|
||||||
*/
|
|
||||||
async function getPublishedPost(req, res) {
|
|
||||||
try {
|
|
||||||
const { slug } = req.params;
|
|
||||||
|
|
||||||
const post = await BlogPost.findBySlug(slug);
|
|
||||||
|
|
||||||
if (!post || post.status !== 'published') {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Blog post not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment view count
|
|
||||||
await BlogPost.incrementViews(post._id);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
post
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get published post error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List posts by status (admin only)
|
|
||||||
* GET /api/blog/admin/posts?status=draft
|
|
||||||
*/
|
|
||||||
async function listPostsByStatus(req, res) {
|
|
||||||
try {
|
|
||||||
const { status = 'draft', limit = 20, skip = 0 } = req.query;
|
|
||||||
|
|
||||||
const posts = await BlogPost.findByStatus(status, {
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip)
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = await BlogPost.countByStatus(status);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
status,
|
|
||||||
posts,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('List posts by status error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get any post by ID (admin only)
|
|
||||||
* GET /api/blog/admin/:id
|
|
||||||
*/
|
|
||||||
async function getPostById(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const post = await BlogPost.findById(id);
|
|
||||||
|
|
||||||
if (!post) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Blog post not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
post
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get post by ID error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create blog post (admin only)
|
|
||||||
* POST /api/blog
|
|
||||||
*/
|
|
||||||
async function createPost(req, res) {
|
|
||||||
try {
|
|
||||||
const { title, slug, content, excerpt, tags, author, tractatus_classification } = req.body;
|
|
||||||
|
|
||||||
// Convert markdown content to HTML if needed
|
|
||||||
const content_html = content.includes('# ') ? markdownToHtml(content) : content;
|
|
||||||
|
|
||||||
const post = await BlogPost.create({
|
|
||||||
title,
|
|
||||||
slug,
|
|
||||||
content: content_html,
|
|
||||||
excerpt,
|
|
||||||
tags,
|
|
||||||
author: {
|
|
||||||
...author,
|
|
||||||
name: author?.name || req.user.name || req.user.email
|
|
||||||
},
|
|
||||||
tractatus_classification,
|
|
||||||
status: 'draft'
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Blog post created: ${slug} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
success: true,
|
|
||||||
post
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Create post error:', error);
|
|
||||||
|
|
||||||
// Handle duplicate slug
|
|
||||||
if (error.code === 11000) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: 'Conflict',
|
|
||||||
message: 'A post with this slug already exists'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update blog post (admin only)
|
|
||||||
* PUT /api/blog/:id
|
|
||||||
*/
|
|
||||||
async function updatePost(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const updates = { ...req.body };
|
|
||||||
|
|
||||||
// If content is updated and looks like markdown, convert to HTML
|
|
||||||
if (updates.content && updates.content.includes('# ')) {
|
|
||||||
updates.content = markdownToHtml(updates.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await BlogPost.update(id, updates);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Blog post not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const post = await BlogPost.findById(id);
|
|
||||||
|
|
||||||
logger.info(`Blog post updated: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
post
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Update post error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish blog post (admin only)
|
|
||||||
* POST /api/blog/:id/publish
|
|
||||||
*/
|
|
||||||
async function publishPost(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { review_notes } = req.body;
|
|
||||||
|
|
||||||
const post = await BlogPost.findById(id);
|
|
||||||
|
|
||||||
if (!post) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Blog post not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (post.status === 'published') {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Post is already published'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update with review notes if provided
|
|
||||||
if (review_notes) {
|
|
||||||
await BlogPost.update(id, {
|
|
||||||
'moderation.review_notes': review_notes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish the post
|
|
||||||
await BlogPost.publish(id, req.userId);
|
|
||||||
|
|
||||||
const updatedPost = await BlogPost.findById(id);
|
|
||||||
|
|
||||||
logger.info(`Blog post published: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
post: updatedPost,
|
|
||||||
message: 'Post published successfully'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Publish post error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete blog post (admin only)
|
|
||||||
* DELETE /api/blog/:id
|
|
||||||
*/
|
|
||||||
async function deletePost(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const success = await BlogPost.delete(id);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Blog post not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Blog post deleted: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Post deleted successfully'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Delete post error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suggest blog topics using AI (admin only)
|
|
||||||
* POST /api/blog/suggest-topics
|
|
||||||
*
|
|
||||||
* TRA-OPS-0002: AI suggests topics, human writes and approves posts
|
|
||||||
*/
|
|
||||||
async function suggestTopics(req, res) {
|
|
||||||
try {
|
|
||||||
const { audience, theme } = req.body;
|
|
||||||
|
|
||||||
// Validate audience
|
|
||||||
const validAudiences = ['researcher', 'implementer', 'advocate', 'general'];
|
|
||||||
if (!audience || !validAudiences.includes(audience)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: `Audience must be one of: ${validAudiences.join(', ')}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Blog topic suggestion requested: audience=${audience}, theme=${theme || 'none'}`);
|
|
||||||
|
|
||||||
// 1. Boundary check (TRA-OPS-0002: Editorial decisions require human oversight)
|
|
||||||
const boundaryCheck = BoundaryEnforcer.enforce({
|
|
||||||
description: 'Suggest blog topics for editorial calendar',
|
|
||||||
text: 'AI provides suggestions, human makes final editorial decisions',
|
|
||||||
classification: { quadrant: 'OPERATIONAL' },
|
|
||||||
type: 'content_suggestion'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log boundary check
|
|
||||||
await GovernanceLog.create({
|
|
||||||
action: 'BLOG_TOPIC_SUGGESTION',
|
|
||||||
user_id: req.user._id,
|
|
||||||
user_email: req.user.email,
|
|
||||||
timestamp: new Date(),
|
|
||||||
boundary_check: boundaryCheck,
|
|
||||||
outcome: boundaryCheck.allowed ? 'QUEUED_FOR_APPROVAL' : 'BLOCKED',
|
|
||||||
details: {
|
|
||||||
audience,
|
|
||||||
theme
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!boundaryCheck.allowed) {
|
|
||||||
logger.warn(`Blog topic suggestion blocked by BoundaryEnforcer: ${boundaryCheck.section}`);
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Boundary Violation',
|
|
||||||
message: boundaryCheck.reasoning,
|
|
||||||
section: boundaryCheck.section,
|
|
||||||
details: 'This action requires human judgment in values territory'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Claude API call for topic suggestions
|
|
||||||
const suggestions = await claudeAPI.generateBlogTopics(audience, theme);
|
|
||||||
|
|
||||||
logger.info(`Claude API returned ${suggestions.length} topic suggestions`);
|
|
||||||
|
|
||||||
// 3. Create moderation queue entry (human approval required)
|
|
||||||
const queueEntry = await ModerationQueue.create({
|
|
||||||
type: 'BLOG_TOPIC_SUGGESTION',
|
|
||||||
reference_collection: 'blog_posts',
|
|
||||||
data: {
|
|
||||||
audience,
|
|
||||||
theme,
|
|
||||||
suggestions,
|
|
||||||
requested_by: req.user.email
|
|
||||||
},
|
|
||||||
status: 'PENDING_APPROVAL',
|
|
||||||
ai_generated: true,
|
|
||||||
requires_human_approval: true,
|
|
||||||
created_by: req.user._id,
|
|
||||||
created_at: new Date(),
|
|
||||||
metadata: {
|
|
||||||
boundary_check: boundaryCheck,
|
|
||||||
governance_policy: 'TRA-OPS-0002'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Created moderation queue entry: ${queueEntry._id}`);
|
|
||||||
|
|
||||||
// 4. Return response (suggestions queued for human review)
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Blog topic suggestions generated. Awaiting human review and approval.',
|
|
||||||
queue_id: queueEntry._id,
|
|
||||||
suggestions,
|
|
||||||
governance: {
|
|
||||||
policy: 'TRA-OPS-0002',
|
|
||||||
boundary_check: boundaryCheck,
|
|
||||||
requires_approval: true,
|
|
||||||
note: 'Topics are suggestions only. Human must write all blog posts.'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Suggest topics error:', error);
|
|
||||||
|
|
||||||
// Handle Claude API errors specifically
|
|
||||||
if (error.message.includes('Claude API')) {
|
|
||||||
return res.status(502).json({
|
|
||||||
error: 'AI Service Error',
|
|
||||||
message: 'Failed to generate topic suggestions. Please try again.',
|
|
||||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draft a full blog post using AI (admin only)
|
|
||||||
* POST /api/blog/draft-post
|
|
||||||
*
|
|
||||||
* TRA-OPS-0002: AI drafts content, human reviews and approves before publication
|
|
||||||
* Enforces inst_016, inst_017, inst_018 via BlogCuration service
|
|
||||||
*/
|
|
||||||
async function draftBlogPost(req, res) {
|
|
||||||
try {
|
|
||||||
const { topic, audience, length = 'medium', focus } = req.body;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!topic || !audience) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'topic and audience are required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const validAudiences = ['researcher', 'implementer', 'advocate', 'general'];
|
|
||||||
if (!validAudiences.includes(audience)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: `audience must be one of: ${validAudiences.join(', ')}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const validLengths = ['short', 'medium', 'long'];
|
|
||||||
if (!validLengths.includes(length)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: `length must be one of: ${validLengths.join(', ')}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Blog post draft requested: topic="${topic}", audience=${audience}, length=${length}`);
|
|
||||||
|
|
||||||
// Generate draft using BlogCuration service (includes boundary checks and validation)
|
|
||||||
const result = await BlogCuration.draftBlogPost({
|
|
||||||
topic,
|
|
||||||
audience,
|
|
||||||
length,
|
|
||||||
focus
|
|
||||||
});
|
|
||||||
|
|
||||||
const { draft, validation, boundary_check, metadata } = result;
|
|
||||||
|
|
||||||
// Log governance action
|
|
||||||
await GovernanceLog.create({
|
|
||||||
action: 'BLOG_POST_DRAFT',
|
|
||||||
user_id: req.user._id,
|
|
||||||
user_email: req.user.email,
|
|
||||||
timestamp: new Date(),
|
|
||||||
boundary_check,
|
|
||||||
outcome: 'QUEUED_FOR_APPROVAL',
|
|
||||||
details: {
|
|
||||||
topic,
|
|
||||||
audience,
|
|
||||||
length,
|
|
||||||
validation_result: validation.recommendation,
|
|
||||||
violations: validation.violations.length,
|
|
||||||
warnings: validation.warnings.length
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create moderation queue entry (human approval required)
|
|
||||||
const queueEntry = await ModerationQueue.create({
|
|
||||||
type: 'BLOG_POST_DRAFT',
|
|
||||||
reference_collection: 'blog_posts',
|
|
||||||
data: {
|
|
||||||
topic,
|
|
||||||
audience,
|
|
||||||
length,
|
|
||||||
focus,
|
|
||||||
draft,
|
|
||||||
validation,
|
|
||||||
requested_by: req.user.email
|
|
||||||
},
|
|
||||||
status: 'PENDING_APPROVAL',
|
|
||||||
ai_generated: true,
|
|
||||||
requires_human_approval: true,
|
|
||||||
created_by: req.user._id,
|
|
||||||
created_at: new Date(),
|
|
||||||
priority: validation.violations.length > 0 ? 'high' : 'medium',
|
|
||||||
metadata: {
|
|
||||||
boundary_check,
|
|
||||||
governance_policy: 'TRA-OPS-0002',
|
|
||||||
tractatus_instructions: ['inst_016', 'inst_017', 'inst_018'],
|
|
||||||
model_info: metadata
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Created blog draft queue entry: ${queueEntry._id}, validation: ${validation.recommendation}`);
|
|
||||||
|
|
||||||
// Return response
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Blog post draft generated. Awaiting human review and approval.',
|
|
||||||
queue_id: queueEntry._id,
|
|
||||||
draft,
|
|
||||||
validation,
|
|
||||||
governance: {
|
|
||||||
policy: 'TRA-OPS-0002',
|
|
||||||
boundary_check,
|
|
||||||
requires_approval: true,
|
|
||||||
tractatus_enforcement: {
|
|
||||||
inst_016: 'No fabricated statistics or unverifiable claims',
|
|
||||||
inst_017: 'No absolute assurance terms (guarantee, 100%, etc.)',
|
|
||||||
inst_018: 'No unverified production-ready claims'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Draft blog post error:', error);
|
|
||||||
|
|
||||||
// Handle boundary violations
|
|
||||||
if (error.message.includes('Boundary violation')) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Boundary Violation',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Claude API errors
|
|
||||||
if (error.message.includes('Claude API') || error.message.includes('Blog draft generation failed')) {
|
|
||||||
return res.status(502).json({
|
|
||||||
error: 'AI Service Error',
|
|
||||||
message: 'Failed to generate blog draft. Please try again.',
|
|
||||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze blog content for Tractatus compliance (admin only)
|
|
||||||
* POST /api/blog/analyze-content
|
|
||||||
*
|
|
||||||
* Validates content against inst_016, inst_017, inst_018
|
|
||||||
*/
|
|
||||||
async function analyzeContent(req, res) {
|
|
||||||
try {
|
|
||||||
const { title, body } = req.body;
|
|
||||||
|
|
||||||
if (!title || !body) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'title and body are required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Content compliance analysis requested: "${title}"`);
|
|
||||||
|
|
||||||
const analysis = await BlogCuration.analyzeContentCompliance({
|
|
||||||
title,
|
|
||||||
body
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Compliance analysis complete: ${analysis.recommendation}, score: ${analysis.overall_score}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
analysis,
|
|
||||||
tractatus_enforcement: {
|
|
||||||
inst_016: 'No fabricated statistics',
|
|
||||||
inst_017: 'No absolute guarantees',
|
|
||||||
inst_018: 'No unverified production claims'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Analyze content error:', error);
|
|
||||||
|
|
||||||
if (error.message.includes('Claude API') || error.message.includes('Compliance analysis failed')) {
|
|
||||||
return res.status(502).json({
|
|
||||||
error: 'AI Service Error',
|
|
||||||
message: 'Failed to analyze content. Please try again.',
|
|
||||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get editorial guidelines (admin only)
|
|
||||||
* GET /api/blog/editorial-guidelines
|
|
||||||
*/
|
|
||||||
async function getEditorialGuidelines(req, res) {
|
|
||||||
try {
|
|
||||||
const guidelines = BlogCuration.getEditorialGuidelines();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
guidelines
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get editorial guidelines error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate RSS feed for published blog posts (public)
|
|
||||||
* GET /api/blog/rss
|
|
||||||
*/
|
|
||||||
async function generateRSSFeed(req, res) {
|
|
||||||
try {
|
|
||||||
// Fetch recent published posts (limit to 50 most recent)
|
|
||||||
const posts = await BlogPost.findPublished({
|
|
||||||
limit: 50,
|
|
||||||
skip: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// RSS 2.0 feed structure
|
|
||||||
const baseUrl = process.env.FRONTEND_URL || 'https://agenticgovernance.digital';
|
|
||||||
const buildDate = new Date().toUTCString();
|
|
||||||
|
|
||||||
// Start RSS XML
|
|
||||||
let rss = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
||||||
<channel>
|
|
||||||
<title>Tractatus AI Safety Framework Blog</title>
|
|
||||||
<link>${baseUrl}/blog.html</link>
|
|
||||||
<description>Insights, updates, and analysis on AI governance, safety frameworks, and the Tractatus boundary enforcement approach.</description>
|
|
||||||
<language>en-us</language>
|
|
||||||
<lastBuildDate>${buildDate}</lastBuildDate>
|
|
||||||
<atom:link href="${baseUrl}/api/blog/rss" rel="self" type="application/rss+xml" />
|
|
||||||
<image>
|
|
||||||
<url>${baseUrl}/images/tractatus-icon.svg</url>
|
|
||||||
<title>Tractatus AI Safety Framework</title>
|
|
||||||
<link>${baseUrl}/blog.html</link>
|
|
||||||
</image>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add items for each post
|
|
||||||
for (const post of posts) {
|
|
||||||
const postUrl = `${baseUrl}/blog-post.html?slug=${post.slug}`;
|
|
||||||
const pubDate = new Date(post.published_at || post.created_at).toUTCString();
|
|
||||||
const author = post.author_name || post.author?.name || 'Tractatus Team';
|
|
||||||
|
|
||||||
// Strip HTML tags from excerpt for RSS description
|
|
||||||
const description = (post.excerpt || post.content)
|
|
||||||
.replace(/<[^>]*>/g, '')
|
|
||||||
.substring(0, 500);
|
|
||||||
|
|
||||||
// Tags as categories
|
|
||||||
const categories = (post.tags || []).map(tag =>
|
|
||||||
` <category>${escapeXml(tag)}</category>`
|
|
||||||
).join('\n');
|
|
||||||
|
|
||||||
rss += ` <item>
|
|
||||||
<title>${escapeXml(post.title)}</title>
|
|
||||||
<link>${postUrl}</link>
|
|
||||||
<guid isPermaLink="true">${postUrl}</guid>
|
|
||||||
<description>${escapeXml(description)}</description>
|
|
||||||
<author>${escapeXml(author)}</author>
|
|
||||||
<pubDate>${pubDate}</pubDate>
|
|
||||||
${categories ? categories + '\n' : ''} </item>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close RSS XML
|
|
||||||
rss += ` </channel>
|
|
||||||
</rss>`;
|
|
||||||
|
|
||||||
// Set RSS content-type and send
|
|
||||||
res.set('Content-Type', 'application/rss+xml; charset=UTF-8');
|
|
||||||
res.send(rss);
|
|
||||||
|
|
||||||
logger.info(`RSS feed generated: ${posts.length} posts`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Generate RSS feed error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Failed to generate RSS feed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Escape XML special characters
|
|
||||||
*/
|
|
||||||
function escapeXml(unsafe) {
|
|
||||||
if (!unsafe) return '';
|
|
||||||
return unsafe
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
listPublishedPosts,
|
|
||||||
getPublishedPost,
|
|
||||||
listPostsByStatus,
|
|
||||||
getPostById,
|
|
||||||
createPost,
|
|
||||||
updatePost,
|
|
||||||
publishPost,
|
|
||||||
deletePost,
|
|
||||||
suggestTopics,
|
|
||||||
draftBlogPost,
|
|
||||||
analyzeContent,
|
|
||||||
getEditorialGuidelines,
|
|
||||||
generateRSSFeed
|
|
||||||
};
|
|
||||||
|
|
@ -1,453 +0,0 @@
|
||||||
/**
|
|
||||||
* Case Study Controller
|
|
||||||
* Community case study submissions with AI review
|
|
||||||
*/
|
|
||||||
|
|
||||||
const CaseSubmission = require('../models/CaseSubmission.model');
|
|
||||||
const ModerationQueue = require('../models/ModerationQueue.model');
|
|
||||||
const GovernanceLog = require('../models/GovernanceLog.model');
|
|
||||||
const BoundaryEnforcer = require('../services/BoundaryEnforcer.service');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit case study (public)
|
|
||||||
* POST /api/cases/submit
|
|
||||||
*
|
|
||||||
* Phase 1: Manual review (no AI)
|
|
||||||
* Phase 2: Add AI categorization with claudeAPI.reviewCaseStudy()
|
|
||||||
*/
|
|
||||||
async function submitCase(req, res) {
|
|
||||||
try {
|
|
||||||
const { submitter, case_study } = req.body;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!submitter?.name || !submitter?.email) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Missing required submitter information'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!case_study?.title || !case_study?.description || !case_study?.failure_mode) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Missing required case study information'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Case study submitted: ${case_study.title} by ${submitter.name}`);
|
|
||||||
|
|
||||||
// Create submission (Phase 1: no AI review yet)
|
|
||||||
const submission = await CaseSubmission.create({
|
|
||||||
submitter,
|
|
||||||
case_study,
|
|
||||||
ai_review: {
|
|
||||||
relevance_score: 0.5, // Default, will be AI-assessed in Phase 2
|
|
||||||
completeness_score: 0.5,
|
|
||||||
recommended_category: 'uncategorized'
|
|
||||||
},
|
|
||||||
moderation: {
|
|
||||||
status: 'pending'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add to moderation queue for human review
|
|
||||||
await ModerationQueue.create({
|
|
||||||
type: 'CASE_SUBMISSION',
|
|
||||||
reference_collection: 'case_submissions',
|
|
||||||
reference_id: submission._id,
|
|
||||||
quadrant: 'OPERATIONAL',
|
|
||||||
data: {
|
|
||||||
submitter,
|
|
||||||
case_study
|
|
||||||
},
|
|
||||||
priority: 'medium',
|
|
||||||
status: 'PENDING_APPROVAL',
|
|
||||||
requires_human_approval: true,
|
|
||||||
human_required_reason: 'All case submissions require human review and approval'
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Case submission created: ${submission._id}`);
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
success: true,
|
|
||||||
message: 'Thank you for your submission. We will review it shortly.',
|
|
||||||
submission_id: submission._id,
|
|
||||||
governance: {
|
|
||||||
human_review: true,
|
|
||||||
note: 'All case studies are reviewed by humans before publication'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Submit case error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred while submitting your case study'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get case submission statistics (admin)
|
|
||||||
* GET /api/cases/submissions/stats
|
|
||||||
*/
|
|
||||||
async function getStats(req, res) {
|
|
||||||
try {
|
|
||||||
const total = await CaseSubmission.countDocuments({});
|
|
||||||
const pending = await CaseSubmission.countDocuments({ 'moderation.status': 'pending' });
|
|
||||||
const approved = await CaseSubmission.countDocuments({ 'moderation.status': 'approved' });
|
|
||||||
const rejected = await CaseSubmission.countDocuments({ 'moderation.status': 'rejected' });
|
|
||||||
const needsInfo = await CaseSubmission.countDocuments({ 'moderation.status': 'needs_info' });
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
stats: {
|
|
||||||
total,
|
|
||||||
pending,
|
|
||||||
approved,
|
|
||||||
rejected,
|
|
||||||
needs_info: needsInfo
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get stats error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all case submissions (admin)
|
|
||||||
* GET /api/cases/submissions?status=pending&failure_mode=pattern_bias&score=high&sort=relevance_score
|
|
||||||
*/
|
|
||||||
async function listSubmissions(req, res) {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
status,
|
|
||||||
failure_mode,
|
|
||||||
score,
|
|
||||||
sort = 'submitted_at',
|
|
||||||
limit = 20,
|
|
||||||
skip = 0
|
|
||||||
} = req.query;
|
|
||||||
|
|
||||||
// Build query filter
|
|
||||||
const filter = {};
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
filter['moderation.status'] = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failure_mode) {
|
|
||||||
filter['case_study.failure_mode'] = failure_mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI score filtering
|
|
||||||
if (score) {
|
|
||||||
if (score === 'high') {
|
|
||||||
filter['ai_review.relevance_score'] = { $gte: 0.7 };
|
|
||||||
} else if (score === 'medium') {
|
|
||||||
filter['ai_review.relevance_score'] = { $gte: 0.4, $lt: 0.7 };
|
|
||||||
} else if (score === 'low') {
|
|
||||||
filter['ai_review.relevance_score'] = { $lt: 0.4 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build sort options
|
|
||||||
const sortOptions = {};
|
|
||||||
if (sort === 'submitted_at') {
|
|
||||||
sortOptions.submitted_at = -1; // Newest first
|
|
||||||
} else if (sort === 'relevance_score') {
|
|
||||||
sortOptions['ai_review.relevance_score'] = -1; // Highest first
|
|
||||||
} else if (sort === 'completeness_score') {
|
|
||||||
sortOptions['ai_review.completeness_score'] = -1; // Highest first
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query database
|
|
||||||
const submissions = await CaseSubmission.find(filter)
|
|
||||||
.sort(sortOptions)
|
|
||||||
.limit(parseInt(limit))
|
|
||||||
.skip(parseInt(skip))
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
const total = await CaseSubmission.countDocuments(filter);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
submissions,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
hasMore: parseInt(skip) + submissions.length < total
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('List submissions error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List high-relevance pending submissions (admin)
|
|
||||||
* GET /api/cases/submissions/high-relevance
|
|
||||||
*/
|
|
||||||
async function listHighRelevance(req, res) {
|
|
||||||
try {
|
|
||||||
const { limit = 10 } = req.query;
|
|
||||||
|
|
||||||
const submissions = await CaseSubmission.findHighRelevance({
|
|
||||||
limit: parseInt(limit)
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
count: submissions.length,
|
|
||||||
submissions
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('List high relevance error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get case submission by ID (admin)
|
|
||||||
* GET /api/cases/submissions/:id
|
|
||||||
*/
|
|
||||||
async function getSubmission(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const submission = await CaseSubmission.findById(id);
|
|
||||||
|
|
||||||
if (!submission) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Case submission not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
submission
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get submission error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Approve case submission (admin)
|
|
||||||
* POST /api/cases/submissions/:id/approve
|
|
||||||
*/
|
|
||||||
async function approveSubmission(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { notes } = req.body;
|
|
||||||
|
|
||||||
const submission = await CaseSubmission.findById(id);
|
|
||||||
|
|
||||||
if (!submission) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Case submission not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submission.moderation.status === 'approved') {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Submission is already approved'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await CaseSubmission.approve(id, req.user._id, notes || '');
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Failed to approve submission'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Case submission approved: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Case submission approved successfully',
|
|
||||||
note: 'You can now publish this as a case study document'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Approve submission error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject case submission (admin)
|
|
||||||
* POST /api/cases/submissions/:id/reject
|
|
||||||
*/
|
|
||||||
async function rejectSubmission(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { reason } = req.body;
|
|
||||||
|
|
||||||
if (!reason) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Rejection reason is required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const submission = await CaseSubmission.findById(id);
|
|
||||||
|
|
||||||
if (!submission) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Case submission not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await CaseSubmission.reject(id, req.user._id, reason);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Failed to reject submission'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Case submission rejected: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Case submission rejected',
|
|
||||||
note: 'Consider notifying the submitter with feedback'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Reject submission error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request more information (admin)
|
|
||||||
* POST /api/cases/submissions/:id/request-info
|
|
||||||
*/
|
|
||||||
async function requestMoreInfo(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { requested_info } = req.body;
|
|
||||||
|
|
||||||
if (!requested_info) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Requested information must be specified'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const submission = await CaseSubmission.findById(id);
|
|
||||||
|
|
||||||
if (!submission) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Case submission not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await CaseSubmission.requestInfo(id, req.user._id, requested_info);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Failed to update submission'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`More info requested for case ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Information request recorded',
|
|
||||||
note: 'Remember to contact submitter separately to request additional information'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Request info error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete case submission (admin)
|
|
||||||
* DELETE /api/cases/submissions/:id
|
|
||||||
*/
|
|
||||||
async function deleteSubmission(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const success = await CaseSubmission.delete(id);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Case submission not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Case submission deleted: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Case submission deleted successfully'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Delete submission error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
submitCase,
|
|
||||||
getStats,
|
|
||||||
listSubmissions,
|
|
||||||
listHighRelevance,
|
|
||||||
getSubmission,
|
|
||||||
approveSubmission,
|
|
||||||
rejectSubmission,
|
|
||||||
requestMoreInfo,
|
|
||||||
deleteSubmission
|
|
||||||
};
|
|
||||||
|
|
@ -1,480 +0,0 @@
|
||||||
/**
|
|
||||||
* Documents Controller
|
|
||||||
* Handles framework documentation CRUD operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
const Document = require('../models/Document.model');
|
|
||||||
const { markdownToHtml, extractTOC } = require('../utils/markdown.util');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all documents
|
|
||||||
* GET /api/documents
|
|
||||||
*/
|
|
||||||
async function listDocuments(req, res) {
|
|
||||||
try {
|
|
||||||
const { limit = 50, skip = 0, quadrant, audience } = req.query;
|
|
||||||
|
|
||||||
let documents;
|
|
||||||
let total;
|
|
||||||
|
|
||||||
// Build filter - only show public documents (not internal/confidential)
|
|
||||||
const filter = {
|
|
||||||
visibility: 'public'
|
|
||||||
};
|
|
||||||
if (quadrant) {
|
|
||||||
filter.quadrant = quadrant;
|
|
||||||
}
|
|
||||||
if (audience) {
|
|
||||||
filter.audience = audience;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quadrant && !audience) {
|
|
||||||
documents = await Document.findByQuadrant(quadrant, {
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
publicOnly: true
|
|
||||||
});
|
|
||||||
total = await Document.count(filter);
|
|
||||||
} else if (audience && !quadrant) {
|
|
||||||
documents = await Document.findByAudience(audience, {
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
publicOnly: true
|
|
||||||
});
|
|
||||||
total = await Document.count(filter);
|
|
||||||
} else {
|
|
||||||
documents = await Document.list({
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
filter
|
|
||||||
});
|
|
||||||
total = await Document.count(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
documents,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
hasMore: parseInt(skip) + documents.length < total
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('List documents error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get document by ID or slug
|
|
||||||
* GET /api/documents/:identifier
|
|
||||||
*/
|
|
||||||
async function getDocument(req, res) {
|
|
||||||
try {
|
|
||||||
const { identifier } = req.params;
|
|
||||||
|
|
||||||
// Try to find by ID first, then by slug
|
|
||||||
let document;
|
|
||||||
if (identifier.match(/^[0-9a-fA-F]{24}$/)) {
|
|
||||||
document = await Document.findById(identifier);
|
|
||||||
} else {
|
|
||||||
document = await Document.findBySlug(identifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!document) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Document not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
document
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get document error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search documents with faceted filtering
|
|
||||||
* GET /api/documents/search?q=...&quadrant=...&persistence=...&audience=...
|
|
||||||
*/
|
|
||||||
async function searchDocuments(req, res) {
|
|
||||||
try {
|
|
||||||
const { q, quadrant, persistence, audience, limit = 20, skip = 0 } = req.query;
|
|
||||||
|
|
||||||
// Build filter for faceted search
|
|
||||||
const filter = {
|
|
||||||
visibility: 'public'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add facet filters
|
|
||||||
if (quadrant) {
|
|
||||||
filter.quadrant = quadrant.toUpperCase();
|
|
||||||
}
|
|
||||||
if (persistence) {
|
|
||||||
filter.persistence = persistence.toUpperCase();
|
|
||||||
}
|
|
||||||
if (audience) {
|
|
||||||
filter.audience = audience.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
let documents;
|
|
||||||
|
|
||||||
// If text query provided, use full-text search with filters
|
|
||||||
if (q && q.trim()) {
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
|
|
||||||
// Add text search to filter
|
|
||||||
filter.$text = { $search: q };
|
|
||||||
|
|
||||||
documents = await collection
|
|
||||||
.find(filter, { score: { $meta: 'textScore' } })
|
|
||||||
.sort({ score: { $meta: 'textScore' } })
|
|
||||||
.skip(parseInt(skip))
|
|
||||||
.limit(parseInt(limit))
|
|
||||||
.toArray();
|
|
||||||
} else {
|
|
||||||
// No text query - just filter by facets
|
|
||||||
documents = await Document.list({
|
|
||||||
filter,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
sort: { order: 1, 'metadata.date_created': -1 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count total matching documents
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
const total = await collection.countDocuments(filter);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
query: q || null,
|
|
||||||
filters: {
|
|
||||||
quadrant: quadrant || null,
|
|
||||||
persistence: persistence || null,
|
|
||||||
audience: audience || null
|
|
||||||
},
|
|
||||||
documents,
|
|
||||||
count: documents.length,
|
|
||||||
total,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
hasMore: parseInt(skip) + documents.length < total
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Search documents error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create document (admin only)
|
|
||||||
* POST /api/documents
|
|
||||||
*/
|
|
||||||
async function createDocument(req, res) {
|
|
||||||
try {
|
|
||||||
const { title, slug, quadrant, persistence, audience, content_markdown, metadata } = req.body;
|
|
||||||
|
|
||||||
// Convert markdown to HTML
|
|
||||||
const content_html = markdownToHtml(content_markdown);
|
|
||||||
|
|
||||||
// Extract table of contents
|
|
||||||
const toc = extractTOC(content_markdown);
|
|
||||||
|
|
||||||
// Create search index from content
|
|
||||||
const search_index = `${title} ${content_markdown}`.toLowerCase();
|
|
||||||
|
|
||||||
const document = await Document.create({
|
|
||||||
title,
|
|
||||||
slug,
|
|
||||||
quadrant,
|
|
||||||
persistence,
|
|
||||||
audience: audience || 'general',
|
|
||||||
content_html,
|
|
||||||
content_markdown,
|
|
||||||
toc,
|
|
||||||
metadata,
|
|
||||||
search_index
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Document created: ${slug} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
success: true,
|
|
||||||
document
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Create document error:', error);
|
|
||||||
|
|
||||||
// Handle duplicate slug
|
|
||||||
if (error.code === 11000) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: 'Conflict',
|
|
||||||
message: 'A document with this slug already exists'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update document (admin only)
|
|
||||||
* PUT /api/documents/:id
|
|
||||||
*/
|
|
||||||
async function updateDocument(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const updates = { ...req.body };
|
|
||||||
|
|
||||||
// If content_markdown is updated, regenerate HTML and TOC
|
|
||||||
if (updates.content_markdown) {
|
|
||||||
updates.content_html = markdownToHtml(updates.content_markdown);
|
|
||||||
updates.toc = extractTOC(updates.content_markdown);
|
|
||||||
updates.search_index = `${updates.title || ''} ${updates.content_markdown}`.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await Document.update(id, updates);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Document not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const document = await Document.findById(id);
|
|
||||||
|
|
||||||
logger.info(`Document updated: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
document
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Update document error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete document (admin only)
|
|
||||||
* DELETE /api/documents/:id
|
|
||||||
*/
|
|
||||||
async function deleteDocument(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const success = await Document.delete(id);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Document not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Document deleted: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Document deleted successfully'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Delete document error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List archived documents
|
|
||||||
* GET /api/documents/archived
|
|
||||||
*/
|
|
||||||
async function listArchivedDocuments(req, res) {
|
|
||||||
try {
|
|
||||||
const { limit = 50, skip = 0 } = req.query;
|
|
||||||
|
|
||||||
const documents = await Document.listArchived({
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip)
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = await Document.count({ visibility: 'archived' });
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
documents,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
hasMore: parseInt(skip) + documents.length < total
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('List archived documents error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a document (admin only)
|
|
||||||
* POST /api/documents/:id/publish
|
|
||||||
*
|
|
||||||
* SECURITY: Explicit publish workflow prevents accidental exposure
|
|
||||||
* World-class UX: Clear validation messages guide admins
|
|
||||||
*/
|
|
||||||
async function publishDocument(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { category, order } = req.body;
|
|
||||||
|
|
||||||
const result = await Document.publish(id, {
|
|
||||||
category,
|
|
||||||
order,
|
|
||||||
publishedBy: req.user?.email || 'admin'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: result.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Document published: ${id} by ${req.user?.email || 'admin'} (category: ${category})`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: result.message,
|
|
||||||
document: result.document
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Publish document error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: error.message || 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unpublish a document (admin only)
|
|
||||||
* POST /api/documents/:id/unpublish
|
|
||||||
*/
|
|
||||||
async function unpublishDocument(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { reason } = req.body;
|
|
||||||
|
|
||||||
const result = await Document.unpublish(id, reason);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: result.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Document unpublished: ${id} by ${req.user?.email || 'admin'} (reason: ${reason || 'none'})`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: result.message
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Unpublish document error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List draft documents (admin only)
|
|
||||||
* GET /api/documents/drafts
|
|
||||||
*/
|
|
||||||
async function listDraftDocuments(req, res) {
|
|
||||||
try {
|
|
||||||
const { limit = 50, skip = 0 } = req.query;
|
|
||||||
|
|
||||||
const documents = await Document.listByWorkflowStatus('draft', {
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip)
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
documents,
|
|
||||||
pagination: {
|
|
||||||
total: documents.length,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('List draft documents error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
listDocuments,
|
|
||||||
getDocument,
|
|
||||||
searchDocuments,
|
|
||||||
createDocument,
|
|
||||||
updateDocument,
|
|
||||||
deleteDocument,
|
|
||||||
listArchivedDocuments,
|
|
||||||
publishDocument,
|
|
||||||
unpublishDocument,
|
|
||||||
listDraftDocuments
|
|
||||||
};
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
/**
|
|
||||||
* Koha Controller
|
|
||||||
* Handles donation-related HTTP requests
|
|
||||||
*/
|
|
||||||
|
|
||||||
const kohaService = require('../services/koha.service');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create checkout session for donation
|
|
||||||
* POST /api/koha/checkout
|
|
||||||
*/
|
|
||||||
exports.createCheckout = async (req, res) => {
|
|
||||||
try {
|
|
||||||
// Check if Stripe is configured (not placeholder)
|
|
||||||
if (!process.env.STRIPE_SECRET_KEY ||
|
|
||||||
process.env.STRIPE_SECRET_KEY.includes('PLACEHOLDER')) {
|
|
||||||
return res.status(503).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Donation system not yet active',
|
|
||||||
message: 'The Koha donation system is currently being configured. Please check back soon.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { amount, frequency, tier, donor, public_acknowledgement, public_name } = req.body;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!amount || !frequency || !donor?.email) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Missing required fields: amount, frequency, donor.email'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate amount
|
|
||||||
if (amount < 100) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Minimum donation amount is NZD $1.00'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate frequency
|
|
||||||
if (!['monthly', 'one_time'].includes(frequency)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid frequency. Must be "monthly" or "one_time"'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate tier for monthly donations
|
|
||||||
if (frequency === 'monthly' && !['5', '15', '50'].includes(tier)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid tier for monthly donations. Must be "5", "15", or "50"'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create checkout session
|
|
||||||
const session = await kohaService.createCheckoutSession({
|
|
||||||
amount,
|
|
||||||
frequency,
|
|
||||||
tier,
|
|
||||||
donor,
|
|
||||||
public_acknowledgement: public_acknowledgement || false,
|
|
||||||
public_name: public_name || null
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`[KOHA] Checkout session created: ${session.sessionId}`);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: session
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Create checkout error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message || 'Failed to create checkout session'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle Stripe webhook events
|
|
||||||
* POST /api/koha/webhook
|
|
||||||
*/
|
|
||||||
exports.handleWebhook = async (req, res) => {
|
|
||||||
const signature = req.headers['stripe-signature'];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify webhook signature and construct event
|
|
||||||
const event = kohaService.verifyWebhookSignature(req.rawBody, signature);
|
|
||||||
|
|
||||||
// Process webhook event
|
|
||||||
await kohaService.handleWebhook(event);
|
|
||||||
|
|
||||||
res.status(200).json({ received: true });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Webhook error:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message || 'Webhook processing failed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get public transparency metrics
|
|
||||||
* GET /api/koha/transparency
|
|
||||||
*/
|
|
||||||
exports.getTransparency = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const metrics = await kohaService.getTransparencyMetrics();
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: metrics
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Get transparency error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to fetch transparency metrics'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel recurring donation
|
|
||||||
* POST /api/koha/cancel
|
|
||||||
* Requires email verification to prevent unauthorized cancellations
|
|
||||||
*/
|
|
||||||
exports.cancelDonation = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { subscriptionId, email } = req.body;
|
|
||||||
|
|
||||||
if (!subscriptionId || !email) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Subscription ID and email are required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify donor owns this subscription by checking email
|
|
||||||
const donation = await require('../models/Donation.model').findBySubscriptionId(subscriptionId);
|
|
||||||
|
|
||||||
if (!donation) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Subscription not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify email matches the donor's email
|
|
||||||
if (donation.donor.email.toLowerCase() !== email.toLowerCase()) {
|
|
||||||
logger.warn(`[KOHA SECURITY] Failed cancellation attempt: subscription ${subscriptionId} with wrong email ${email}`);
|
|
||||||
return res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Email does not match subscription owner'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email verified, proceed with cancellation
|
|
||||||
const result = await kohaService.cancelRecurringDonation(subscriptionId);
|
|
||||||
|
|
||||||
logger.info(`[KOHA] Subscription cancelled: ${subscriptionId} by ${email}`);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: result
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Cancel donation error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message || 'Failed to cancel donation'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get donation statistics (ADMIN ONLY)
|
|
||||||
* GET /api/koha/statistics
|
|
||||||
* Authentication enforced in routes layer (requireAdmin middleware)
|
|
||||||
*/
|
|
||||||
exports.getStatistics = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { startDate, endDate } = req.query;
|
|
||||||
|
|
||||||
const statistics = await kohaService.getStatistics(startDate, endDate);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: statistics
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Get statistics error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to fetch statistics'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify donation session (after redirect from Stripe)
|
|
||||||
* GET /api/koha/verify/:sessionId
|
|
||||||
*/
|
|
||||||
exports.verifySession = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { sessionId } = req.params;
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Session ID is required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve session from Stripe
|
|
||||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
|
||||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
|
||||||
|
|
||||||
// Check if payment was successful
|
|
||||||
const isSuccessful = session.payment_status === 'paid';
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
status: session.payment_status,
|
|
||||||
amount: session.amount_total / 100,
|
|
||||||
currency: session.currency,
|
|
||||||
frequency: session.metadata.frequency,
|
|
||||||
isSuccessful: isSuccessful
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Verify session error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to verify session'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Stripe Customer Portal session
|
|
||||||
* POST /api/koha/portal
|
|
||||||
* Allows donors to manage their subscription (update payment, cancel, etc.)
|
|
||||||
*/
|
|
||||||
exports.createPortalSession = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { email } = req.body;
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Email is required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find customer by email
|
|
||||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
|
||||||
const customers = await stripe.customers.list({
|
|
||||||
email: email,
|
|
||||||
limit: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (customers.data.length === 0) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'No subscription found for this email address'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = customers.data[0];
|
|
||||||
|
|
||||||
// Create portal session
|
|
||||||
const session = await stripe.billingPortal.sessions.create({
|
|
||||||
customer: customer.id,
|
|
||||||
return_url: `${process.env.FRONTEND_URL || 'https://agenticgovernance.digital'}/koha.html`,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`[KOHA] Customer portal session created for ${email}`);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
url: session.url
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Create portal session error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message || 'Failed to create portal session'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,447 +0,0 @@
|
||||||
/**
|
|
||||||
* Media Inquiry Controller
|
|
||||||
* Press/media inquiry submission and AI triage
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MediaInquiry = require('../models/MediaInquiry.model');
|
|
||||||
const ModerationQueue = require('../models/ModerationQueue.model');
|
|
||||||
const GovernanceLog = require('../models/GovernanceLog.model');
|
|
||||||
const BoundaryEnforcer = require('../services/BoundaryEnforcer.service');
|
|
||||||
const MediaTriageService = require('../services/MediaTriage.service');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit media inquiry (public)
|
|
||||||
* POST /api/media/inquiries
|
|
||||||
*
|
|
||||||
* Phase 1: Manual triage (no AI)
|
|
||||||
* Phase 2: Add AI triage with claudeAPI.triageMediaInquiry()
|
|
||||||
*/
|
|
||||||
async function submitInquiry(req, res) {
|
|
||||||
try {
|
|
||||||
const { contact, inquiry } = req.body;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!contact?.name || !contact?.email || !contact?.outlet) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Missing required contact information'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inquiry?.subject || !inquiry?.message) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Missing required inquiry information'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Media inquiry submitted: ${contact.outlet} - ${inquiry.subject}`);
|
|
||||||
|
|
||||||
// Create inquiry (Phase 1: no AI triage yet)
|
|
||||||
const mediaInquiry = await MediaInquiry.create({
|
|
||||||
contact,
|
|
||||||
inquiry,
|
|
||||||
status: 'new',
|
|
||||||
ai_triage: {
|
|
||||||
urgency: 'medium', // Default, will be AI-assessed in Phase 2
|
|
||||||
topic_sensitivity: 'standard',
|
|
||||||
involves_values: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add to moderation queue for human review
|
|
||||||
await ModerationQueue.create({
|
|
||||||
type: 'MEDIA_INQUIRY',
|
|
||||||
reference_collection: 'media_inquiries',
|
|
||||||
reference_id: mediaInquiry._id,
|
|
||||||
quadrant: 'OPERATIONAL',
|
|
||||||
data: {
|
|
||||||
contact,
|
|
||||||
inquiry
|
|
||||||
},
|
|
||||||
priority: 'medium',
|
|
||||||
status: 'PENDING_APPROVAL',
|
|
||||||
requires_human_approval: true,
|
|
||||||
human_required_reason: 'All media inquiries require human review and response'
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Media inquiry created: ${mediaInquiry._id}`);
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
success: true,
|
|
||||||
message: 'Thank you for your inquiry. We will review and respond shortly.',
|
|
||||||
inquiry_id: mediaInquiry._id,
|
|
||||||
governance: {
|
|
||||||
human_review: true,
|
|
||||||
note: 'All media inquiries are reviewed by humans before response'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Submit inquiry error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred while submitting your inquiry'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all media inquiries (admin)
|
|
||||||
* GET /api/media/inquiries?status=new
|
|
||||||
*/
|
|
||||||
async function listInquiries(req, res) {
|
|
||||||
try {
|
|
||||||
const { status = 'new', limit = 20, skip = 0 } = req.query;
|
|
||||||
|
|
||||||
const inquiries = await MediaInquiry.findByStatus(status, {
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip)
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = await MediaInquiry.countByStatus(status);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
status,
|
|
||||||
inquiries,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
hasMore: parseInt(skip) + inquiries.length < total
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('List inquiries error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List urgent media inquiries (admin)
|
|
||||||
* GET /api/media/inquiries/urgent
|
|
||||||
*/
|
|
||||||
async function listUrgentInquiries(req, res) {
|
|
||||||
try {
|
|
||||||
const { limit = 10 } = req.query;
|
|
||||||
|
|
||||||
const inquiries = await MediaInquiry.findUrgent({
|
|
||||||
limit: parseInt(limit)
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
count: inquiries.length,
|
|
||||||
inquiries
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('List urgent inquiries error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get media inquiry by ID (admin)
|
|
||||||
* GET /api/media/inquiries/:id
|
|
||||||
*/
|
|
||||||
async function getInquiry(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const inquiry = await MediaInquiry.findById(id);
|
|
||||||
|
|
||||||
if (!inquiry) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Media inquiry not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
inquiry
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get inquiry error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign inquiry to user (admin)
|
|
||||||
* POST /api/media/inquiries/:id/assign
|
|
||||||
*/
|
|
||||||
async function assignInquiry(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { user_id } = req.body;
|
|
||||||
|
|
||||||
const userId = user_id || req.user._id;
|
|
||||||
|
|
||||||
const success = await MediaInquiry.assign(id, userId);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Media inquiry not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Media inquiry ${id} assigned to ${userId} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Inquiry assigned successfully'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Assign inquiry error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Respond to inquiry (admin)
|
|
||||||
* POST /api/media/inquiries/:id/respond
|
|
||||||
*/
|
|
||||||
async function respondToInquiry(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { content } = req.body;
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'Response content is required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const inquiry = await MediaInquiry.findById(id);
|
|
||||||
|
|
||||||
if (!inquiry) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Media inquiry not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await MediaInquiry.respond(id, {
|
|
||||||
content,
|
|
||||||
responder: req.user.email
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Failed to update inquiry'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Media inquiry ${id} responded to by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Response recorded successfully',
|
|
||||||
note: 'Remember to send actual email to media contact separately'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Respond to inquiry error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete media inquiry (admin)
|
|
||||||
* DELETE /api/media/inquiries/:id
|
|
||||||
*/
|
|
||||||
async function deleteInquiry(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const success = await MediaInquiry.delete(id);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Media inquiry not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Media inquiry deleted: ${id} by ${req.user.email}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Inquiry deleted successfully'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Delete inquiry error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'An error occurred'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run AI triage on inquiry (admin)
|
|
||||||
* POST /api/media/inquiries/:id/triage
|
|
||||||
*
|
|
||||||
* Demonstrates Tractatus dogfooding: AI assists, human decides
|
|
||||||
*/
|
|
||||||
async function triageInquiry(req, res) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const inquiry = await MediaInquiry.findById(id);
|
|
||||||
|
|
||||||
if (!inquiry) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Media inquiry not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Running AI triage on inquiry ${id}`);
|
|
||||||
|
|
||||||
// Run AI triage (MediaTriage service handles all analysis)
|
|
||||||
const triageResult = await MediaTriageService.triageInquiry(inquiry);
|
|
||||||
|
|
||||||
// Update inquiry with triage results
|
|
||||||
await MediaInquiry.update(id, {
|
|
||||||
'ai_triage.urgency': triageResult.urgency,
|
|
||||||
'ai_triage.urgency_score': triageResult.urgency_score,
|
|
||||||
'ai_triage.urgency_reasoning': triageResult.urgency_reasoning,
|
|
||||||
'ai_triage.topic_sensitivity': triageResult.topic_sensitivity,
|
|
||||||
'ai_triage.sensitivity_reasoning': triageResult.sensitivity_reasoning,
|
|
||||||
'ai_triage.involves_values': triageResult.involves_values,
|
|
||||||
'ai_triage.values_reasoning': triageResult.values_reasoning,
|
|
||||||
'ai_triage.boundary_enforcement': triageResult.boundary_enforcement,
|
|
||||||
'ai_triage.suggested_response_time': triageResult.suggested_response_time,
|
|
||||||
'ai_triage.suggested_talking_points': triageResult.suggested_talking_points,
|
|
||||||
'ai_triage.draft_response': triageResult.draft_response,
|
|
||||||
'ai_triage.draft_response_reasoning': triageResult.draft_response_reasoning,
|
|
||||||
'ai_triage.triaged_at': triageResult.triaged_at,
|
|
||||||
'ai_triage.ai_model': triageResult.ai_model,
|
|
||||||
status: 'triaged'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log governance action
|
|
||||||
await GovernanceLog.create({
|
|
||||||
action: 'AI_TRIAGE',
|
|
||||||
entity_type: 'media_inquiry',
|
|
||||||
entity_id: id,
|
|
||||||
actor: req.user.email,
|
|
||||||
quadrant: triageResult.involves_values ? 'STRATEGIC' : 'OPERATIONAL',
|
|
||||||
tractatus_component: 'BoundaryEnforcer',
|
|
||||||
reasoning: triageResult.values_reasoning,
|
|
||||||
outcome: 'success',
|
|
||||||
metadata: {
|
|
||||||
urgency: triageResult.urgency,
|
|
||||||
urgency_score: triageResult.urgency_score,
|
|
||||||
involves_values: triageResult.involves_values,
|
|
||||||
boundary_enforced: triageResult.involves_values,
|
|
||||||
human_approval_required: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`AI triage complete for inquiry ${id}: urgency=${triageResult.urgency}, values=${triageResult.involves_values}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'AI triage completed',
|
|
||||||
triage: triageResult,
|
|
||||||
governance: {
|
|
||||||
human_approval_required: true,
|
|
||||||
boundary_enforcer_active: triageResult.involves_values,
|
|
||||||
transparency_note: 'All AI reasoning is visible for human review'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Triage inquiry error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'AI triage failed',
|
|
||||||
details: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get triage statistics for public transparency
|
|
||||||
* GET /api/media/triage-stats
|
|
||||||
*/
|
|
||||||
async function getTriageStats(req, res) {
|
|
||||||
try {
|
|
||||||
// Get all triaged inquiries (public stats, no sensitive data)
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
|
|
||||||
const inquiries = await collection.find({
|
|
||||||
'ai_triage.triaged_at': { $exists: true }
|
|
||||||
}).toArray();
|
|
||||||
|
|
||||||
const stats = await MediaTriageService.getTriageStats(inquiries);
|
|
||||||
|
|
||||||
// Add transparency metrics
|
|
||||||
const transparencyMetrics = {
|
|
||||||
...stats,
|
|
||||||
human_review_rate: '100%', // All inquiries require human review
|
|
||||||
ai_auto_response_rate: '0%', // No auto-responses allowed
|
|
||||||
boundary_enforcement_active: stats.boundary_enforcements > 0,
|
|
||||||
framework_compliance: {
|
|
||||||
human_approval_required: true,
|
|
||||||
ai_reasoning_transparent: true,
|
|
||||||
values_decisions_escalated: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
period: 'all_time',
|
|
||||||
statistics: transparencyMetrics,
|
|
||||||
note: 'All media inquiries require human review before response. AI assists with triage only.'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Get triage stats error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Failed to retrieve statistics'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
submitInquiry,
|
|
||||||
listInquiries,
|
|
||||||
listUrgentInquiries,
|
|
||||||
getInquiry,
|
|
||||||
assignInquiry,
|
|
||||||
respondToInquiry,
|
|
||||||
deleteInquiry,
|
|
||||||
triageInquiry,
|
|
||||||
getTriageStats
|
|
||||||
};
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
/**
|
|
||||||
* Newsletter Controller
|
|
||||||
* Handles newsletter subscriptions and management
|
|
||||||
*/
|
|
||||||
|
|
||||||
const NewsletterSubscription = require('../models/NewsletterSubscription.model');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to newsletter (public)
|
|
||||||
* POST /api/newsletter/subscribe
|
|
||||||
*/
|
|
||||||
exports.subscribe = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { email, name, source, interests } = req.body;
|
|
||||||
|
|
||||||
// Validate email
|
|
||||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Valid email address is required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture metadata
|
|
||||||
const metadata = {
|
|
||||||
ip_address: req.ip || req.connection.remoteAddress,
|
|
||||||
user_agent: req.get('user-agent'),
|
|
||||||
referrer: req.get('referer')
|
|
||||||
};
|
|
||||||
|
|
||||||
const subscription = await NewsletterSubscription.subscribe({
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
source: source || 'blog',
|
|
||||||
interests: interests || [],
|
|
||||||
...metadata
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('Newsletter subscription created', {
|
|
||||||
email: subscription.email,
|
|
||||||
source: subscription.source,
|
|
||||||
verified: subscription.verified
|
|
||||||
});
|
|
||||||
|
|
||||||
// In a real system, send verification email here
|
|
||||||
// For now, we'll auto-verify (remove this in production)
|
|
||||||
await NewsletterSubscription.verify(subscription.verification_token);
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
success: true,
|
|
||||||
message: 'Successfully subscribed to newsletter',
|
|
||||||
subscription: {
|
|
||||||
email: subscription.email,
|
|
||||||
verified: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (subscribeError) {
|
|
||||||
console.error('Newsletter subscription error:', subscribeError);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to subscribe to newsletter',
|
|
||||||
details: process.env.NODE_ENV === 'development' ? subscribeError.message : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify email subscription
|
|
||||||
* GET /api/newsletter/verify/:token
|
|
||||||
*/
|
|
||||||
exports.verify = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { token } = req.params;
|
|
||||||
|
|
||||||
const verified = await NewsletterSubscription.verify(token);
|
|
||||||
|
|
||||||
if (!verified) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid or expired verification token'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Email verified successfully'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Newsletter verification error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to verify email'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsubscribe from newsletter (public)
|
|
||||||
* POST /api/newsletter/unsubscribe
|
|
||||||
*/
|
|
||||||
exports.unsubscribe = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { email, token } = req.body;
|
|
||||||
|
|
||||||
if (!email && !token) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Email or token is required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribed = await NewsletterSubscription.unsubscribe(email, token);
|
|
||||||
|
|
||||||
if (!unsubscribed) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Subscription not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Newsletter unsubscribe', { email: email || 'via token' });
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Successfully unsubscribed from newsletter'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Newsletter unsubscribe error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to unsubscribe'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update subscription preferences (public)
|
|
||||||
* PUT /api/newsletter/preferences
|
|
||||||
*/
|
|
||||||
exports.updatePreferences = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { email, name, interests } = req.body;
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Email is required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await NewsletterSubscription.updatePreferences(email, {
|
|
||||||
name,
|
|
||||||
interests
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Active subscription not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Preferences updated successfully'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Newsletter preferences update error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to update preferences'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get newsletter statistics (admin only)
|
|
||||||
* GET /api/admin/newsletter/stats
|
|
||||||
*/
|
|
||||||
exports.getStats = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const stats = await NewsletterSubscription.getStats();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
stats
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Newsletter stats error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to retrieve statistics'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all subscriptions (admin only)
|
|
||||||
* GET /api/admin/newsletter/subscriptions
|
|
||||||
*/
|
|
||||||
exports.listSubscriptions = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
limit = 100,
|
|
||||||
skip = 0,
|
|
||||||
active = 'true',
|
|
||||||
verified = null,
|
|
||||||
source = null
|
|
||||||
} = req.query;
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
active: active === 'true',
|
|
||||||
verified: verified === null ? null : verified === 'true',
|
|
||||||
source: source || null
|
|
||||||
};
|
|
||||||
|
|
||||||
const subscriptions = await NewsletterSubscription.list(options);
|
|
||||||
const total = await NewsletterSubscription.count({
|
|
||||||
active: options.active,
|
|
||||||
...(options.verified !== null && { verified: options.verified }),
|
|
||||||
...(options.source && { source: options.source })
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
subscriptions,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
limit: options.limit,
|
|
||||||
skip: options.skip,
|
|
||||||
has_more: skip + subscriptions.length < total
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Newsletter list error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to list subscriptions'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export subscriptions as CSV (admin only)
|
|
||||||
* GET /api/admin/newsletter/export
|
|
||||||
*/
|
|
||||||
exports.exportSubscriptions = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { active = 'true' } = req.query;
|
|
||||||
|
|
||||||
const subscriptions = await NewsletterSubscription.list({
|
|
||||||
active: active === 'true',
|
|
||||||
limit: 10000
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate CSV
|
|
||||||
const csv = [
|
|
||||||
'Email,Name,Source,Verified,Subscribed At',
|
|
||||||
...subscriptions.map(sub =>
|
|
||||||
`${sub.email},"${sub.name || ''}",${sub.source},${sub.verified},${sub.subscribed_at.toISOString()}`
|
|
||||||
)
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="newsletter-subscriptions-${Date.now()}.csv"`);
|
|
||||||
res.send(csv);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Newsletter export error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to export subscriptions'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete subscription (admin only)
|
|
||||||
* DELETE /api/admin/newsletter/subscriptions/:id
|
|
||||||
*/
|
|
||||||
exports.deleteSubscription = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const deleted = await NewsletterSubscription.delete(id);
|
|
||||||
|
|
||||||
if (!deleted) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Subscription not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Newsletter subscription deleted', { id });
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Subscription deleted successfully'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Newsletter delete error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to delete subscription'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,436 +0,0 @@
|
||||||
/**
|
|
||||||
* Variables Controller
|
|
||||||
*
|
|
||||||
* Handles CRUD operations for project-specific variable values.
|
|
||||||
* Variables enable context-aware rendering of governance rules.
|
|
||||||
*
|
|
||||||
* Endpoints:
|
|
||||||
* - GET /api/admin/projects/:projectId/variables - List variables for project
|
|
||||||
* - GET /api/admin/variables/global - Get all unique variable names
|
|
||||||
* - POST /api/admin/projects/:projectId/variables - Create/update variable
|
|
||||||
* - PUT /api/admin/projects/:projectId/variables/:name - Update variable value
|
|
||||||
* - DELETE /api/admin/projects/:projectId/variables/:name - Delete variable
|
|
||||||
*/
|
|
||||||
|
|
||||||
const VariableValue = require('../models/VariableValue.model');
|
|
||||||
const Project = require('../models/Project.model');
|
|
||||||
const VariableSubstitutionService = require('../services/VariableSubstitution.service');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all variables for a project
|
|
||||||
* @route GET /api/admin/projects/:projectId/variables
|
|
||||||
* @param {string} projectId - Project identifier
|
|
||||||
* @query {string} category - Filter by category (optional)
|
|
||||||
*/
|
|
||||||
async function getProjectVariables(req, res) {
|
|
||||||
try {
|
|
||||||
const { projectId } = req.params;
|
|
||||||
const { category } = req.query;
|
|
||||||
|
|
||||||
// Verify project exists
|
|
||||||
const project = await Project.findByProjectId(projectId);
|
|
||||||
if (!project) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Project not found',
|
|
||||||
message: `No project found with ID: ${projectId}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch variables
|
|
||||||
const variables = await VariableValue.findByProject(projectId, { category });
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
projectId,
|
|
||||||
projectName: project.name,
|
|
||||||
variables,
|
|
||||||
total: variables.length
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching project variables:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to fetch variables',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all unique variable names across all rules
|
|
||||||
* @route GET /api/admin/variables/global
|
|
||||||
*/
|
|
||||||
async function getGlobalVariables(req, res) {
|
|
||||||
try {
|
|
||||||
// Get all unique variables from rules
|
|
||||||
const ruleVariables = await VariableSubstitutionService.getAllVariables();
|
|
||||||
|
|
||||||
// Get all unique variables currently defined
|
|
||||||
const definedVariables = await VariableValue.getAllVariableNames();
|
|
||||||
|
|
||||||
// Merge and add metadata
|
|
||||||
const variableMap = new Map();
|
|
||||||
|
|
||||||
// Add variables from rules
|
|
||||||
ruleVariables.forEach(v => {
|
|
||||||
variableMap.set(v.name, {
|
|
||||||
name: v.name,
|
|
||||||
usageCount: v.usageCount,
|
|
||||||
rules: v.rules,
|
|
||||||
isDefined: definedVariables.includes(v.name)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add variables that are defined but not used in any rules
|
|
||||||
definedVariables.forEach(name => {
|
|
||||||
if (!variableMap.has(name)) {
|
|
||||||
variableMap.set(name, {
|
|
||||||
name,
|
|
||||||
usageCount: 0,
|
|
||||||
rules: [],
|
|
||||||
isDefined: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const allVariables = Array.from(variableMap.values())
|
|
||||||
.sort((a, b) => b.usageCount - a.usageCount);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
variables: allVariables,
|
|
||||||
total: allVariables.length,
|
|
||||||
statistics: {
|
|
||||||
totalVariables: allVariables.length,
|
|
||||||
usedInRules: ruleVariables.length,
|
|
||||||
definedButUnused: allVariables.filter(v => v.usageCount === 0).length
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching global variables:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to fetch global variables',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create or update variable value for project (upsert)
|
|
||||||
* @route POST /api/admin/projects/:projectId/variables
|
|
||||||
* @param {string} projectId - Project identifier
|
|
||||||
* @body {string} variableName - Variable name (UPPER_SNAKE_CASE)
|
|
||||||
* @body {string} value - Variable value
|
|
||||||
* @body {string} description - Description (optional)
|
|
||||||
* @body {string} category - Category (optional)
|
|
||||||
* @body {string} dataType - Data type (optional)
|
|
||||||
*/
|
|
||||||
async function createOrUpdateVariable(req, res) {
|
|
||||||
try {
|
|
||||||
const { projectId } = req.params;
|
|
||||||
const { variableName, value, description, category, dataType, validationRules } = req.body;
|
|
||||||
|
|
||||||
// Verify project exists
|
|
||||||
const project = await Project.findByProjectId(projectId);
|
|
||||||
if (!project) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Project not found',
|
|
||||||
message: `No project found with ID: ${projectId}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate variable name format
|
|
||||||
if (!/^[A-Z][A-Z0-9_]*$/.test(variableName)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid variable name',
|
|
||||||
message: 'Variable name must be UPPER_SNAKE_CASE (e.g., DB_NAME, API_KEY_2)'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert variable
|
|
||||||
const variable = await VariableValue.upsertValue(projectId, variableName, {
|
|
||||||
value,
|
|
||||||
description,
|
|
||||||
category,
|
|
||||||
dataType,
|
|
||||||
validationRules,
|
|
||||||
updatedBy: req.user?.email || 'system'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate the value against rules
|
|
||||||
const validation = variable.validateValue();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
variable: variable.toObject(),
|
|
||||||
validation,
|
|
||||||
message: `Variable "${variableName}" ${variable.isNew ? 'created' : 'updated'} successfully for project "${project.name}"`
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating/updating variable:', error);
|
|
||||||
|
|
||||||
// Handle validation errors
|
|
||||||
if (error.name === 'ValidationError') {
|
|
||||||
const errors = Object.values(error.errors).map(e => e.message);
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Validation failed',
|
|
||||||
message: errors.join(', '),
|
|
||||||
details: error.errors
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to create/update variable',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update existing variable value
|
|
||||||
* @route PUT /api/admin/projects/:projectId/variables/:variableName
|
|
||||||
* @param {string} projectId - Project identifier
|
|
||||||
* @param {string} variableName - Variable name
|
|
||||||
* @body {Object} updates - Fields to update
|
|
||||||
*/
|
|
||||||
async function updateVariable(req, res) {
|
|
||||||
try {
|
|
||||||
const { projectId, variableName } = req.params;
|
|
||||||
const updates = req.body;
|
|
||||||
|
|
||||||
// Find existing variable
|
|
||||||
const variable = await VariableValue.findValue(projectId, variableName);
|
|
||||||
|
|
||||||
if (!variable) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Variable not found',
|
|
||||||
message: `No variable "${variableName}" found for project "${projectId}"`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply updates
|
|
||||||
const allowedFields = ['value', 'description', 'category', 'dataType', 'validationRules'];
|
|
||||||
allowedFields.forEach(field => {
|
|
||||||
if (updates[field] !== undefined) {
|
|
||||||
variable[field] = updates[field];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
variable.updatedBy = req.user?.email || 'system';
|
|
||||||
await variable.save();
|
|
||||||
|
|
||||||
// Validate the new value
|
|
||||||
const validation = variable.validateValue();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
variable: variable.toObject(),
|
|
||||||
validation,
|
|
||||||
message: `Variable "${variableName}" updated successfully`
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating variable:', error);
|
|
||||||
|
|
||||||
if (error.name === 'ValidationError') {
|
|
||||||
const errors = Object.values(error.errors).map(e => e.message);
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Validation failed',
|
|
||||||
message: errors.join(', '),
|
|
||||||
details: error.errors
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to update variable',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete variable
|
|
||||||
* @route DELETE /api/admin/projects/:projectId/variables/:variableName
|
|
||||||
* @param {string} projectId - Project identifier
|
|
||||||
* @param {string} variableName - Variable name
|
|
||||||
* @query {boolean} hard - If true, permanently delete; otherwise soft delete
|
|
||||||
*/
|
|
||||||
async function deleteVariable(req, res) {
|
|
||||||
try {
|
|
||||||
const { projectId, variableName } = req.params;
|
|
||||||
const { hard } = req.query;
|
|
||||||
|
|
||||||
const variable = await VariableValue.findValue(projectId, variableName);
|
|
||||||
|
|
||||||
if (!variable) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Variable not found',
|
|
||||||
message: `No variable "${variableName}" found for project "${projectId}"`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hard === 'true') {
|
|
||||||
// Hard delete - permanently remove
|
|
||||||
await VariableValue.deleteOne({ projectId, variableName: variableName.toUpperCase() });
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Variable "${variableName}" permanently deleted`
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Soft delete - set active to false
|
|
||||||
await variable.deactivate();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Variable "${variableName}" deactivated. Use ?hard=true to permanently delete.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting variable:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to delete variable',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate project variables (check for missing required variables)
|
|
||||||
* @route GET /api/admin/projects/:projectId/variables/validate
|
|
||||||
* @param {string} projectId - Project identifier
|
|
||||||
*/
|
|
||||||
async function validateProjectVariables(req, res) {
|
|
||||||
try {
|
|
||||||
const { projectId } = req.params;
|
|
||||||
|
|
||||||
// Verify project exists
|
|
||||||
const project = await Project.findByProjectId(projectId);
|
|
||||||
if (!project) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Project not found',
|
|
||||||
message: `No project found with ID: ${projectId}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate variables
|
|
||||||
const validation = await VariableSubstitutionService.validateProjectVariables(projectId);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
projectId,
|
|
||||||
projectName: project.name,
|
|
||||||
validation,
|
|
||||||
message: validation.complete
|
|
||||||
? `All required variables are defined for project "${project.name}"`
|
|
||||||
: `Missing ${validation.missing.length} required variable(s) for project "${project.name}"`
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error validating project variables:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to validate variables',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch create/update variables from array
|
|
||||||
* @route POST /api/admin/projects/:projectId/variables/batch
|
|
||||||
* @param {string} projectId - Project identifier
|
|
||||||
* @body {Array} variables - Array of variable objects
|
|
||||||
*/
|
|
||||||
async function batchUpsertVariables(req, res) {
|
|
||||||
try {
|
|
||||||
const { projectId } = req.params;
|
|
||||||
const { variables } = req.body;
|
|
||||||
|
|
||||||
if (!Array.isArray(variables)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid request',
|
|
||||||
message: 'variables must be an array'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify project exists
|
|
||||||
const project = await Project.findByProjectId(projectId);
|
|
||||||
if (!project) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Project not found',
|
|
||||||
message: `No project found with ID: ${projectId}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = {
|
|
||||||
created: [],
|
|
||||||
updated: [],
|
|
||||||
failed: []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Process each variable
|
|
||||||
for (const varData of variables) {
|
|
||||||
try {
|
|
||||||
const variable = await VariableValue.upsertValue(projectId, varData.variableName, {
|
|
||||||
...varData,
|
|
||||||
updatedBy: req.user?.email || 'system'
|
|
||||||
});
|
|
||||||
|
|
||||||
const action = variable.isNew ? 'created' : 'updated';
|
|
||||||
results[action].push({
|
|
||||||
variableName: varData.variableName,
|
|
||||||
value: varData.value
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
results.failed.push({
|
|
||||||
variableName: varData.variableName,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
results,
|
|
||||||
message: `Batch operation complete: ${results.created.length} created, ${results.updated.length} updated, ${results.failed.length} failed`
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error batch upserting variables:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to batch upsert variables',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getProjectVariables,
|
|
||||||
getGlobalVariables,
|
|
||||||
createOrUpdateVariable,
|
|
||||||
updateVariable,
|
|
||||||
deleteVariable,
|
|
||||||
validateProjectVariables,
|
|
||||||
batchUpsertVariables
|
|
||||||
};
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
/**
|
|
||||||
* Authentication Middleware
|
|
||||||
* JWT-based authentication for admin routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { verifyToken, extractTokenFromHeader } = require('../utils/jwt.util');
|
|
||||||
const { User } = require('../models');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify JWT token and attach user to request
|
|
||||||
*/
|
|
||||||
async function authenticateToken(req, res, next) {
|
|
||||||
try {
|
|
||||||
const token = extractTokenFromHeader(req.headers.authorization);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'Authentication required',
|
|
||||||
message: 'No token provided'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify token
|
|
||||||
const decoded = verifyToken(token);
|
|
||||||
|
|
||||||
// Get user from database
|
|
||||||
const user = await User.findById(decoded.userId);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'Authentication failed',
|
|
||||||
message: 'User not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.active) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'Authentication failed',
|
|
||||||
message: 'User account is inactive'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach user to request
|
|
||||||
req.user = user;
|
|
||||||
req.userId = user._id;
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Authentication error:', error);
|
|
||||||
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'Authentication failed',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user has required role
|
|
||||||
*/
|
|
||||||
function requireRole(...roles) {
|
|
||||||
return (req, res, next) => {
|
|
||||||
if (!req.user) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'Authentication required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!roles.includes(req.user.role)) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Insufficient permissions',
|
|
||||||
message: `Required role: ${roles.join(' or ')}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional authentication (attach user if token present, continue if not)
|
|
||||||
*/
|
|
||||||
async function optionalAuth(req, res, next) {
|
|
||||||
try {
|
|
||||||
const token = extractTokenFromHeader(req.headers.authorization);
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
const decoded = verifyToken(token);
|
|
||||||
const user = await User.findById(decoded.userId);
|
|
||||||
|
|
||||||
if (user && user.active) {
|
|
||||||
req.user = user;
|
|
||||||
req.userId = user._id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - authentication is optional
|
|
||||||
logger.debug('Optional auth failed:', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require admin role (convenience function)
|
|
||||||
*/
|
|
||||||
const requireAdmin = requireRole('admin');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
authenticateToken,
|
|
||||||
requireRole,
|
|
||||||
requireAdmin,
|
|
||||||
optionalAuth
|
|
||||||
};
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
/**
|
|
||||||
* CSRF Protection Middleware (Modern Approach)
|
|
||||||
*
|
|
||||||
* Uses SameSite cookies + double-submit cookie pattern
|
|
||||||
* Replaces deprecated csurf package
|
|
||||||
*
|
|
||||||
* Reference: OWASP CSRF Prevention Cheat Sheet
|
|
||||||
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
|
||||||
*/
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const { logSecurityEvent, getClientIp } = require('../utils/security-logger');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate CSRF token
|
|
||||||
*/
|
|
||||||
function generateCsrfToken() {
|
|
||||||
return crypto.randomBytes(32).toString('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSRF Protection Middleware
|
|
||||||
*
|
|
||||||
* Uses double-submit cookie pattern:
|
|
||||||
* 1. Server sets CSRF token in secure, SameSite cookie
|
|
||||||
* 2. Client must send same token in custom header (X-CSRF-Token)
|
|
||||||
* 3. Server validates cookie matches header
|
|
||||||
*/
|
|
||||||
function csrfProtection(req, res, next) {
|
|
||||||
// Skip GET, HEAD, OPTIONS (safe methods)
|
|
||||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get CSRF token from cookie
|
|
||||||
const cookieToken = req.cookies['csrf-token'];
|
|
||||||
|
|
||||||
// Get CSRF token from header
|
|
||||||
const headerToken = req.headers['x-csrf-token'] || req.headers['csrf-token'];
|
|
||||||
|
|
||||||
// Validate tokens exist and match
|
|
||||||
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
|
||||||
logSecurityEvent({
|
|
||||||
type: 'csrf_violation',
|
|
||||||
sourceIp: getClientIp(req),
|
|
||||||
userId: req.user?.id,
|
|
||||||
endpoint: req.path,
|
|
||||||
userAgent: req.get('user-agent'),
|
|
||||||
details: {
|
|
||||||
method: req.method,
|
|
||||||
hasCookie: !!cookieToken,
|
|
||||||
hasHeader: !!headerToken,
|
|
||||||
tokensMatch: cookieToken === headerToken
|
|
||||||
},
|
|
||||||
action: 'blocked',
|
|
||||||
severity: 'high'
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Forbidden',
|
|
||||||
message: 'Invalid CSRF token',
|
|
||||||
code: 'CSRF_VALIDATION_FAILED'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to set CSRF token cookie
|
|
||||||
* Apply this globally or on routes that need CSRF protection
|
|
||||||
*/
|
|
||||||
function setCsrfToken(req, res, next) {
|
|
||||||
// Only set cookie if it doesn't exist
|
|
||||||
if (!req.cookies['csrf-token']) {
|
|
||||||
const token = generateCsrfToken();
|
|
||||||
|
|
||||||
//Check if we're behind a proxy (X-Forwarded-Proto header)
|
|
||||||
const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
||||||
|
|
||||||
res.cookie('csrf-token', token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isSecure && process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'strict',
|
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Endpoint to get CSRF token for client-side usage
|
|
||||||
* GET /api/csrf-token
|
|
||||||
*
|
|
||||||
* Returns the CSRF token from the cookie so client can include it in requests
|
|
||||||
*/
|
|
||||||
function getCsrfToken(req, res) {
|
|
||||||
const token = req.cookies['csrf-token'];
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'No CSRF token found. Visit the site first to receive a token.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
csrfToken: token
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
csrfProtection,
|
|
||||||
setCsrfToken,
|
|
||||||
getCsrfToken,
|
|
||||||
generateCsrfToken
|
|
||||||
};
|
|
||||||
|
|
@ -1,440 +0,0 @@
|
||||||
/**
|
|
||||||
* File Security Middleware (inst_041 Implementation)
|
|
||||||
*
|
|
||||||
* Multi-layer file upload validation:
|
|
||||||
* 1. File type validation (magic number check)
|
|
||||||
* 2. ClamAV malware scanning
|
|
||||||
* 3. Size limits enforcement
|
|
||||||
* 4. Quarantine system for suspicious files
|
|
||||||
* 5. Comprehensive security logging
|
|
||||||
*
|
|
||||||
* Reference: docs/plans/security-implementation-roadmap.md Phase 2
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const { exec } = require('child_process');
|
|
||||||
const { promisify } = require('util');
|
|
||||||
const multer = require('multer');
|
|
||||||
const { logSecurityEvent, getClientIp } = require('../utils/security-logger');
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/tmp/tractatus-uploads';
|
|
||||||
const QUARANTINE_DIR = process.env.QUARANTINE_DIR ||
|
|
||||||
(process.env.HOME ? `${process.env.HOME}/var/quarantine/tractatus` : '/var/quarantine/tractatus');
|
|
||||||
const MAX_FILE_SIZE = {
|
|
||||||
document: 10 * 1024 * 1024, // 10MB
|
|
||||||
media: 50 * 1024 * 1024, // 50MB
|
|
||||||
default: 5 * 1024 * 1024 // 5MB
|
|
||||||
};
|
|
||||||
|
|
||||||
// Allowed MIME types (whitelist approach)
|
|
||||||
const ALLOWED_MIME_TYPES = {
|
|
||||||
document: [
|
|
||||||
'application/pdf',
|
|
||||||
'application/msword',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
'text/plain',
|
|
||||||
'text/markdown'
|
|
||||||
],
|
|
||||||
media: [
|
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
'image/gif',
|
|
||||||
'image/webp',
|
|
||||||
'video/mp4',
|
|
||||||
'video/webm'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure upload and quarantine directories exist
|
|
||||||
*/
|
|
||||||
async function ensureDirectories() {
|
|
||||||
try {
|
|
||||||
await fs.mkdir(UPLOAD_DIR, { recursive: true, mode: 0o750 });
|
|
||||||
await fs.mkdir(QUARANTINE_DIR, { recursive: true, mode: 0o750 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[FILE SECURITY] Failed to create directories:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize directories on module load
|
|
||||||
ensureDirectories();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate file type using magic number (file command)
|
|
||||||
* Prevents MIME type spoofing
|
|
||||||
*/
|
|
||||||
async function validateFileType(filePath, expectedMimeType) {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync(`file --mime-type -b "${filePath}"`);
|
|
||||||
const actualMimeType = stdout.trim();
|
|
||||||
|
|
||||||
if (actualMimeType !== expectedMimeType) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
actualType: actualMimeType,
|
|
||||||
expectedType: expectedMimeType,
|
|
||||||
message: 'File type mismatch (possible MIME spoofing)'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true, actualType: actualMimeType };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
message: `File type validation failed: ${error.message}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan file with ClamAV
|
|
||||||
* Tries clamdscan first (fast, requires daemon), falls back to clamscan (slower, no daemon)
|
|
||||||
*/
|
|
||||||
async function scanWithClamAV(filePath) {
|
|
||||||
try {
|
|
||||||
// Try clamdscan first (fast with daemon)
|
|
||||||
await execAsync(`clamdscan --no-summary "${filePath}"`);
|
|
||||||
return { clean: true, threat: null, scanner: 'clamdscan' };
|
|
||||||
} catch (error) {
|
|
||||||
const output = error.stdout || error.stderr || '';
|
|
||||||
|
|
||||||
// Check if virus found
|
|
||||||
if (output.includes('FOUND')) {
|
|
||||||
const match = output.match(/(.+): (.+) FOUND/);
|
|
||||||
const threat = match ? match[2] : 'Unknown threat';
|
|
||||||
return { clean: false, threat, scanner: 'clamdscan' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// If daemon not available, fallback to clamscan
|
|
||||||
if (output.includes('Could not connect')) {
|
|
||||||
console.log('[FILE SECURITY] clamdscan daemon unavailable, using clamscan fallback');
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync(`clamscan --no-summary "${filePath}"`);
|
|
||||||
|
|
||||||
if (stdout.includes('FOUND')) {
|
|
||||||
const match = stdout.match(/(.+): (.+) FOUND/);
|
|
||||||
const threat = match ? match[2] : 'Unknown threat';
|
|
||||||
return { clean: false, threat, scanner: 'clamscan' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { clean: true, threat: null, scanner: 'clamscan' };
|
|
||||||
} catch (clamscanError) {
|
|
||||||
const clamscanOutput = clamscanError.stdout || clamscanError.stderr || '';
|
|
||||||
|
|
||||||
if (clamscanOutput.includes('FOUND')) {
|
|
||||||
const match = clamscanOutput.match(/(.+): (.+) FOUND/);
|
|
||||||
const threat = match ? match[2] : 'Unknown threat';
|
|
||||||
return { clean: false, threat, scanner: 'clamscan' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
clean: false,
|
|
||||||
threat: null,
|
|
||||||
error: `ClamAV scan failed: ${clamscanError.message}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other error
|
|
||||||
return {
|
|
||||||
clean: false,
|
|
||||||
threat: null,
|
|
||||||
error: `ClamAV scan failed: ${error.message}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move file to quarantine
|
|
||||||
* Handles cross-filesystem moves (copy + delete instead of rename)
|
|
||||||
*/
|
|
||||||
async function quarantineFile(filePath, reason, metadata) {
|
|
||||||
try {
|
|
||||||
const filename = path.basename(filePath);
|
|
||||||
const timestamp = new Date().toISOString().replace(/:/g, '-');
|
|
||||||
const quarantinePath = path.join(QUARANTINE_DIR, `${timestamp}_${filename}`);
|
|
||||||
|
|
||||||
// Ensure quarantine directory exists
|
|
||||||
await ensureDirectories();
|
|
||||||
|
|
||||||
// Use copyFile + unlink to handle cross-filesystem moves
|
|
||||||
// (fs.rename fails with EXDEV when source and dest are on different filesystems)
|
|
||||||
await fs.copyFile(filePath, quarantinePath);
|
|
||||||
await fs.unlink(filePath);
|
|
||||||
|
|
||||||
// Create metadata file
|
|
||||||
const metadataPath = `${quarantinePath}.json`;
|
|
||||||
await fs.writeFile(metadataPath, JSON.stringify({
|
|
||||||
original_path: filePath,
|
|
||||||
original_name: filename,
|
|
||||||
quarantine_reason: reason,
|
|
||||||
quarantine_time: new Date().toISOString(),
|
|
||||||
...metadata
|
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
console.log(`[FILE SECURITY] File quarantined: ${filename} → ${quarantinePath}`);
|
|
||||||
return quarantinePath;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[FILE SECURITY] Quarantine failed:', error.message);
|
|
||||||
// Try to delete file if quarantine fails
|
|
||||||
try {
|
|
||||||
await fs.unlink(filePath);
|
|
||||||
} catch (unlinkError) {
|
|
||||||
console.error('[FILE SECURITY] Failed to delete file after quarantine failure:', unlinkError.message);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File security validation middleware
|
|
||||||
* Use after multer upload
|
|
||||||
*/
|
|
||||||
function createFileSecurityMiddleware(options = {}) {
|
|
||||||
const {
|
|
||||||
fileType = 'default',
|
|
||||||
allowedMimeTypes = ALLOWED_MIME_TYPES.document
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
return async (req, res, next) => {
|
|
||||||
// Skip if no file uploaded
|
|
||||||
if (!req.file && !req.files) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = req.files || [req.file];
|
|
||||||
const clientIp = getClientIp(req);
|
|
||||||
const userId = req.user?.id || 'anonymous';
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file) continue;
|
|
||||||
|
|
||||||
const filePath = file.path;
|
|
||||||
const filename = file.originalname;
|
|
||||||
|
|
||||||
// 1. Check MIME type is allowed
|
|
||||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
|
||||||
await fs.unlink(filePath);
|
|
||||||
|
|
||||||
await logSecurityEvent({
|
|
||||||
type: 'file_upload_rejected',
|
|
||||||
sourceIp: clientIp,
|
|
||||||
userId,
|
|
||||||
endpoint: req.path,
|
|
||||||
userAgent: req.get('user-agent'),
|
|
||||||
details: {
|
|
||||||
filename,
|
|
||||||
mime_type: file.mimetype,
|
|
||||||
reason: 'MIME type not allowed'
|
|
||||||
},
|
|
||||||
action: 'rejected',
|
|
||||||
severity: 'medium'
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: `File type ${file.mimetype} is not allowed`,
|
|
||||||
allowed_types: allowedMimeTypes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Validate file type with magic number
|
|
||||||
const typeValidation = await validateFileType(filePath, file.mimetype);
|
|
||||||
if (!typeValidation.valid) {
|
|
||||||
await quarantineFile(filePath, 'MIME_TYPE_MISMATCH', {
|
|
||||||
reported_mime: file.mimetype,
|
|
||||||
actual_mime: typeValidation.actualType,
|
|
||||||
user_id: userId,
|
|
||||||
source_ip: clientIp
|
|
||||||
});
|
|
||||||
|
|
||||||
await logSecurityEvent({
|
|
||||||
type: 'file_upload_quarantined',
|
|
||||||
sourceIp: clientIp,
|
|
||||||
userId,
|
|
||||||
endpoint: req.path,
|
|
||||||
userAgent: req.get('user-agent'),
|
|
||||||
details: {
|
|
||||||
filename,
|
|
||||||
reason: 'MIME type mismatch',
|
|
||||||
reported: file.mimetype,
|
|
||||||
actual: typeValidation.actualType
|
|
||||||
},
|
|
||||||
action: 'quarantined',
|
|
||||||
severity: 'high'
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Forbidden',
|
|
||||||
message: 'File rejected due to security concerns',
|
|
||||||
code: 'FILE_TYPE_MISMATCH'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Scan with ClamAV
|
|
||||||
const scanResult = await scanWithClamAV(filePath);
|
|
||||||
|
|
||||||
if (!scanResult.clean) {
|
|
||||||
if (scanResult.threat) {
|
|
||||||
// Malware detected
|
|
||||||
await quarantineFile(filePath, 'MALWARE_DETECTED', {
|
|
||||||
threat: scanResult.threat,
|
|
||||||
user_id: userId,
|
|
||||||
source_ip: clientIp
|
|
||||||
});
|
|
||||||
|
|
||||||
await logSecurityEvent({
|
|
||||||
type: 'malware_detected',
|
|
||||||
sourceIp: clientIp,
|
|
||||||
userId,
|
|
||||||
endpoint: req.path,
|
|
||||||
userAgent: req.get('user-agent'),
|
|
||||||
details: {
|
|
||||||
filename,
|
|
||||||
threat: scanResult.threat,
|
|
||||||
mime_type: file.mimetype
|
|
||||||
},
|
|
||||||
action: 'quarantined',
|
|
||||||
severity: 'critical'
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Forbidden',
|
|
||||||
message: 'File rejected: Security threat detected',
|
|
||||||
code: 'MALWARE_DETECTED'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Scan failed
|
|
||||||
await logSecurityEvent({
|
|
||||||
type: 'file_scan_failed',
|
|
||||||
sourceIp: clientIp,
|
|
||||||
userId,
|
|
||||||
endpoint: req.path,
|
|
||||||
userAgent: req.get('user-agent'),
|
|
||||||
details: {
|
|
||||||
filename,
|
|
||||||
error: scanResult.error
|
|
||||||
},
|
|
||||||
action: 'blocked',
|
|
||||||
severity: 'high'
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(503).json({
|
|
||||||
error: 'Service Unavailable',
|
|
||||||
message: 'File security scan unavailable. Please try again later.',
|
|
||||||
code: 'SCAN_FAILED'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// File passed all checks
|
|
||||||
await logSecurityEvent({
|
|
||||||
type: 'file_upload_validated',
|
|
||||||
sourceIp: clientIp,
|
|
||||||
userId,
|
|
||||||
endpoint: req.path,
|
|
||||||
userAgent: req.get('user-agent'),
|
|
||||||
details: {
|
|
||||||
filename,
|
|
||||||
mime_type: file.mimetype,
|
|
||||||
size: file.size
|
|
||||||
},
|
|
||||||
action: 'allowed',
|
|
||||||
severity: 'low'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// All files passed validation
|
|
||||||
next();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[FILE SECURITY] Validation error:', error);
|
|
||||||
|
|
||||||
await logSecurityEvent({
|
|
||||||
type: 'file_validation_error',
|
|
||||||
sourceIp: clientIp,
|
|
||||||
userId,
|
|
||||||
endpoint: req.path,
|
|
||||||
userAgent: req.get('user-agent'),
|
|
||||||
details: {
|
|
||||||
error: error.message
|
|
||||||
},
|
|
||||||
action: 'error',
|
|
||||||
severity: 'high'
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'File validation failed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create multer storage configuration
|
|
||||||
*/
|
|
||||||
function createUploadStorage() {
|
|
||||||
return multer.diskStorage({
|
|
||||||
destination: async (req, file, cb) => {
|
|
||||||
await ensureDirectories();
|
|
||||||
cb(null, UPLOAD_DIR);
|
|
||||||
},
|
|
||||||
filename: (req, file, cb) => {
|
|
||||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`;
|
|
||||||
const ext = path.extname(file.originalname);
|
|
||||||
const basename = path.basename(file.originalname, ext);
|
|
||||||
const sanitized = basename.replace(/[^a-zA-Z0-9-_]/g, '_');
|
|
||||||
cb(null, `${sanitized}-${uniqueSuffix}${ext}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create multer upload middleware with security
|
|
||||||
*/
|
|
||||||
function createSecureUpload(options = {}) {
|
|
||||||
const {
|
|
||||||
fileType = 'default',
|
|
||||||
maxFileSize = MAX_FILE_SIZE.default,
|
|
||||||
allowedMimeTypes = ALLOWED_MIME_TYPES.document,
|
|
||||||
fieldName = 'file'
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const upload = multer({
|
|
||||||
storage: createUploadStorage(),
|
|
||||||
limits: {
|
|
||||||
fileSize: maxFileSize,
|
|
||||||
files: 1 // Single file upload by default
|
|
||||||
},
|
|
||||||
fileFilter: (req, file, cb) => {
|
|
||||||
// Basic MIME type check (will be verified again with magic number)
|
|
||||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
|
||||||
cb(null, true);
|
|
||||||
} else {
|
|
||||||
cb(new Error(`File type ${file.mimetype} not allowed`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
upload.single(fieldName),
|
|
||||||
createFileSecurityMiddleware({ fileType, allowedMimeTypes })
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createFileSecurityMiddleware,
|
|
||||||
createSecureUpload,
|
|
||||||
validateFileType,
|
|
||||||
scanWithClamAV,
|
|
||||||
quarantineFile,
|
|
||||||
ALLOWED_MIME_TYPES,
|
|
||||||
MAX_FILE_SIZE
|
|
||||||
};
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
/**
|
|
||||||
* Response Sanitization Middleware (inst_013, inst_045)
|
|
||||||
* Prevents information disclosure in error responses
|
|
||||||
*
|
|
||||||
* QUICK WIN: Hide stack traces and sensitive data in production
|
|
||||||
* NEVER expose: stack traces, internal paths, environment details
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize error responses
|
|
||||||
* Production: Generic error messages only
|
|
||||||
* Development: More detailed errors for debugging
|
|
||||||
*/
|
|
||||||
function sanitizeErrorResponse(err, req, res, next) {
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
// Log full error details internally (always)
|
|
||||||
console.error('[ERROR]', {
|
|
||||||
message: err.message,
|
|
||||||
stack: err.stack,
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip,
|
|
||||||
user: req.user?.id || req.user?.userId,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine status code
|
|
||||||
const statusCode = err.statusCode || err.status || 500;
|
|
||||||
|
|
||||||
// Generic error messages for common status codes
|
|
||||||
const genericErrors = {
|
|
||||||
400: 'Bad Request',
|
|
||||||
401: 'Unauthorized',
|
|
||||||
403: 'Forbidden',
|
|
||||||
404: 'Not Found',
|
|
||||||
405: 'Method Not Allowed',
|
|
||||||
413: 'Payload Too Large',
|
|
||||||
429: 'Too Many Requests',
|
|
||||||
500: 'Internal Server Error',
|
|
||||||
502: 'Bad Gateway',
|
|
||||||
503: 'Service Unavailable'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Production: Generic error messages only
|
|
||||||
if (isProduction) {
|
|
||||||
return res.status(statusCode).json({
|
|
||||||
error: genericErrors[statusCode] || 'Error',
|
|
||||||
message: err.message || 'An error occurred',
|
|
||||||
// NEVER include in production: stack, file paths, internal details
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Development: More detailed errors (but still sanitized)
|
|
||||||
res.status(statusCode).json({
|
|
||||||
error: err.name || 'Error',
|
|
||||||
message: err.message,
|
|
||||||
statusCode,
|
|
||||||
// Stack trace only in development
|
|
||||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove sensitive fields from objects
|
|
||||||
* Useful for sanitizing database results before sending to client
|
|
||||||
*/
|
|
||||||
function removeSensitiveFields(data, sensitiveFields = ['password', 'passwordHash', 'apiKey', 'secret', 'token']) {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data.map(item => removeSensitiveFields(item, sensitiveFields));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data === 'object' && data !== null) {
|
|
||||||
const sanitized = { ...data };
|
|
||||||
|
|
||||||
// Remove sensitive fields
|
|
||||||
for (const field of sensitiveFields) {
|
|
||||||
delete sanitized[field];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively sanitize nested objects
|
|
||||||
for (const key in sanitized) {
|
|
||||||
if (typeof sanitized[key] === 'object' && sanitized[key] !== null) {
|
|
||||||
sanitized[key] = removeSensitiveFields(sanitized[key], sensitiveFields);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to sanitize response data
|
|
||||||
* Apply before sending responses with user/database data
|
|
||||||
*/
|
|
||||||
function sanitizeResponseData(req, res, next) {
|
|
||||||
// Store original json method
|
|
||||||
const originalJson = res.json.bind(res);
|
|
||||||
|
|
||||||
// Override json method to sanitize data
|
|
||||||
res.json = function(data) {
|
|
||||||
const sanitized = removeSensitiveFields(data);
|
|
||||||
return originalJson(sanitized);
|
|
||||||
};
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
sanitizeErrorResponse,
|
|
||||||
removeSensitiveFields,
|
|
||||||
sanitizeResponseData
|
|
||||||
};
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
/**
|
|
||||||
* BlogPost Model
|
|
||||||
* AI-curated blog with human oversight
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
|
|
||||||
class BlogPost {
|
|
||||||
/**
|
|
||||||
* Create a new blog post
|
|
||||||
*/
|
|
||||||
static async create(data) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
|
|
||||||
const post = {
|
|
||||||
title: data.title,
|
|
||||||
slug: data.slug,
|
|
||||||
author: {
|
|
||||||
type: data.author?.type || 'human', // 'human' or 'ai_curated'
|
|
||||||
name: data.author?.name || 'John Stroh',
|
|
||||||
claude_version: data.author?.claude_version
|
|
||||||
},
|
|
||||||
content: data.content,
|
|
||||||
excerpt: data.excerpt,
|
|
||||||
featured_image: data.featured_image,
|
|
||||||
status: data.status || 'draft', // draft/pending/published/archived
|
|
||||||
moderation: {
|
|
||||||
ai_analysis: data.moderation?.ai_analysis,
|
|
||||||
human_reviewer: data.moderation?.human_reviewer,
|
|
||||||
review_notes: data.moderation?.review_notes,
|
|
||||||
approved_at: data.moderation?.approved_at
|
|
||||||
},
|
|
||||||
tractatus_classification: {
|
|
||||||
quadrant: data.tractatus_classification?.quadrant || 'OPERATIONAL',
|
|
||||||
values_sensitive: data.tractatus_classification?.values_sensitive || false,
|
|
||||||
requires_strategic_review: data.tractatus_classification?.requires_strategic_review || false
|
|
||||||
},
|
|
||||||
published_at: data.published_at,
|
|
||||||
tags: data.tags || [],
|
|
||||||
view_count: 0,
|
|
||||||
engagement: {
|
|
||||||
shares: 0,
|
|
||||||
comments: 0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.insertOne(post);
|
|
||||||
return { ...post, _id: result.insertedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find post by ID
|
|
||||||
*/
|
|
||||||
static async findById(id) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
return await collection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find post by slug
|
|
||||||
*/
|
|
||||||
static async findBySlug(slug) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
return await collection.findOne({ slug });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find published posts
|
|
||||||
*/
|
|
||||||
static async findPublished(options = {}) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
const { limit = 10, skip = 0, sort = { published_at: -1 } } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ status: 'published' })
|
|
||||||
.sort(sort)
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find posts by status
|
|
||||||
*/
|
|
||||||
static async findByStatus(status, options = {}) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
const { limit = 20, skip = 0 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ status })
|
|
||||||
.sort({ _id: -1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update post
|
|
||||||
*/
|
|
||||||
static async update(id, updates) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: updates }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish post (change status + set published_at)
|
|
||||||
*/
|
|
||||||
static async publish(id, reviewerId) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
status: 'published',
|
|
||||||
published_at: new Date(),
|
|
||||||
'moderation.human_reviewer': reviewerId,
|
|
||||||
'moderation.approved_at': new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Increment view count
|
|
||||||
*/
|
|
||||||
static async incrementViews(id) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
|
|
||||||
await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $inc: { view_count: 1 } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete post
|
|
||||||
*/
|
|
||||||
static async delete(id) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
||||||
return result.deletedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count posts by status
|
|
||||||
*/
|
|
||||||
static async countByStatus(status) {
|
|
||||||
const collection = await getCollection('blog_posts');
|
|
||||||
return await collection.countDocuments({ status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = BlogPost;
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
/**
|
|
||||||
* CaseSubmission Model
|
|
||||||
* Community case study submissions
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
|
|
||||||
class CaseSubmission {
|
|
||||||
/**
|
|
||||||
* Create a new case submission
|
|
||||||
*/
|
|
||||||
static async create(data) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
|
|
||||||
const submission = {
|
|
||||||
submitter: {
|
|
||||||
name: data.submitter.name,
|
|
||||||
email: data.submitter.email,
|
|
||||||
organization: data.submitter.organization,
|
|
||||||
public: data.submitter.public !== undefined ? data.submitter.public : false
|
|
||||||
},
|
|
||||||
case_study: {
|
|
||||||
title: data.case_study.title,
|
|
||||||
description: data.case_study.description,
|
|
||||||
failure_mode: data.case_study.failure_mode,
|
|
||||||
tractatus_applicability: data.case_study.tractatus_applicability,
|
|
||||||
evidence: data.case_study.evidence || [],
|
|
||||||
attachments: data.case_study.attachments || []
|
|
||||||
},
|
|
||||||
ai_review: {
|
|
||||||
relevance_score: data.ai_review?.relevance_score, // 0-1
|
|
||||||
completeness_score: data.ai_review?.completeness_score, // 0-1
|
|
||||||
recommended_category: data.ai_review?.recommended_category,
|
|
||||||
suggested_improvements: data.ai_review?.suggested_improvements || [],
|
|
||||||
claude_analysis: data.ai_review?.claude_analysis
|
|
||||||
},
|
|
||||||
moderation: {
|
|
||||||
status: data.moderation?.status || 'pending', // pending/approved/rejected/needs_info
|
|
||||||
reviewer: data.moderation?.reviewer,
|
|
||||||
review_notes: data.moderation?.review_notes,
|
|
||||||
reviewed_at: data.moderation?.reviewed_at
|
|
||||||
},
|
|
||||||
published_case_id: data.published_case_id,
|
|
||||||
submitted_at: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.insertOne(submission);
|
|
||||||
return { ...submission, _id: result.insertedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find submission by ID
|
|
||||||
*/
|
|
||||||
static async findById(id) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
return await collection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find by moderation status
|
|
||||||
*/
|
|
||||||
static async findByStatus(status, options = {}) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
const { limit = 20, skip = 0 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ 'moderation.status': status })
|
|
||||||
.sort({ submitted_at: -1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find high-relevance submissions pending review
|
|
||||||
*/
|
|
||||||
static async findHighRelevance(options = {}) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
const { limit = 10 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({
|
|
||||||
'moderation.status': 'pending',
|
|
||||||
'ai_review.relevance_score': { $gte: 0.7 }
|
|
||||||
})
|
|
||||||
.sort({ 'ai_review.relevance_score': -1 })
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update submission
|
|
||||||
*/
|
|
||||||
static async update(id, updates) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: updates }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Approve submission
|
|
||||||
*/
|
|
||||||
static async approve(id, reviewerId, notes = '') {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
'moderation.status': 'approved',
|
|
||||||
'moderation.reviewer': reviewerId,
|
|
||||||
'moderation.review_notes': notes,
|
|
||||||
'moderation.reviewed_at': new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject submission
|
|
||||||
*/
|
|
||||||
static async reject(id, reviewerId, reason) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
'moderation.status': 'rejected',
|
|
||||||
'moderation.reviewer': reviewerId,
|
|
||||||
'moderation.review_notes': reason,
|
|
||||||
'moderation.reviewed_at': new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request more information
|
|
||||||
*/
|
|
||||||
static async requestInfo(id, reviewerId, requestedInfo) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
'moderation.status': 'needs_info',
|
|
||||||
'moderation.reviewer': reviewerId,
|
|
||||||
'moderation.review_notes': requestedInfo,
|
|
||||||
'moderation.reviewed_at': new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Link to published case study
|
|
||||||
*/
|
|
||||||
static async linkPublished(id, publishedCaseId) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
published_case_id: new ObjectId(publishedCaseId),
|
|
||||||
'moderation.status': 'approved'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count by status
|
|
||||||
*/
|
|
||||||
static async countByStatus(status) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
return await collection.countDocuments({ 'moderation.status': status });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete submission
|
|
||||||
*/
|
|
||||||
static async delete(id) {
|
|
||||||
const collection = await getCollection('case_submissions');
|
|
||||||
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
||||||
return result.deletedCount > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = CaseSubmission;
|
|
||||||
|
|
@ -1,350 +0,0 @@
|
||||||
/**
|
|
||||||
* Document Model
|
|
||||||
* Technical papers, framework documentation, specifications
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
|
|
||||||
class Document {
|
|
||||||
/**
|
|
||||||
* Create a new document
|
|
||||||
*
|
|
||||||
* SECURITY: All new documents default to 'internal' visibility to prevent accidental exposure.
|
|
||||||
* Use publish() method to make documents public after review.
|
|
||||||
*/
|
|
||||||
static async create(data) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
|
|
||||||
// SECURITY: Require explicit visibility or default to 'internal' for safety
|
|
||||||
const visibility = data.visibility || 'internal';
|
|
||||||
|
|
||||||
// SECURITY: Validate visibility is a known value
|
|
||||||
const validVisibility = ['public', 'internal', 'confidential', 'archived'];
|
|
||||||
if (!validVisibility.includes(visibility)) {
|
|
||||||
throw new Error(`Invalid visibility: ${visibility}. Must be one of: ${validVisibility.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY: Prevent accidental public uploads - require category for public docs
|
|
||||||
if (visibility === 'public' && (!data.category || data.category === 'none')) {
|
|
||||||
throw new Error('Public documents must have a valid category (not "none")');
|
|
||||||
}
|
|
||||||
|
|
||||||
const document = {
|
|
||||||
title: data.title,
|
|
||||||
slug: data.slug,
|
|
||||||
quadrant: data.quadrant, // STR/OPS/TAC/SYS/STO
|
|
||||||
persistence: data.persistence, // HIGH/MEDIUM/LOW/VARIABLE
|
|
||||||
audience: data.audience || 'general', // technical, general, researcher, implementer, advocate, business, developer
|
|
||||||
visibility, // SECURITY: Defaults to 'internal', explicit required for 'public'
|
|
||||||
category: data.category || 'none', // conceptual, practical, reference, archived, project-tracking, research-proposal, research-topic
|
|
||||||
order: data.order || 999, // Display order (1-999, lower = higher priority)
|
|
||||||
archiveNote: data.archiveNote || null, // Explanation for why document was archived
|
|
||||||
workflow_status: 'draft', // SECURITY: Track publish workflow (draft, review, published)
|
|
||||||
content_html: data.content_html,
|
|
||||||
content_markdown: data.content_markdown,
|
|
||||||
toc: data.toc || [],
|
|
||||||
security_classification: data.security_classification || {
|
|
||||||
contains_credentials: false,
|
|
||||||
contains_financial_info: false,
|
|
||||||
contains_vulnerability_info: false,
|
|
||||||
contains_infrastructure_details: false,
|
|
||||||
requires_authentication: false
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
author: data.metadata?.author || 'John Stroh',
|
|
||||||
date_created: new Date(),
|
|
||||||
date_updated: new Date(),
|
|
||||||
version: data.metadata?.version || '1.0',
|
|
||||||
document_code: data.metadata?.document_code,
|
|
||||||
related_documents: data.metadata?.related_documents || [],
|
|
||||||
tags: data.metadata?.tags || []
|
|
||||||
},
|
|
||||||
translations: data.translations || {},
|
|
||||||
search_index: data.search_index || '',
|
|
||||||
download_formats: data.download_formats || {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.insertOne(document);
|
|
||||||
return { ...document, _id: result.insertedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find document by ID
|
|
||||||
*/
|
|
||||||
static async findById(id) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
return await collection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find document by slug
|
|
||||||
*/
|
|
||||||
static async findBySlug(slug) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
return await collection.findOne({ slug });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find documents by quadrant
|
|
||||||
*/
|
|
||||||
static async findByQuadrant(quadrant, options = {}) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 }, publicOnly = false } = options;
|
|
||||||
|
|
||||||
const filter = { quadrant };
|
|
||||||
if (publicOnly) {
|
|
||||||
filter.visibility = 'public';
|
|
||||||
}
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find(filter)
|
|
||||||
.sort(sort)
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find documents by audience
|
|
||||||
*/
|
|
||||||
static async findByAudience(audience, options = {}) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 }, publicOnly = false } = options;
|
|
||||||
|
|
||||||
const filter = { audience };
|
|
||||||
if (publicOnly) {
|
|
||||||
filter.visibility = 'public';
|
|
||||||
}
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find(filter)
|
|
||||||
.sort(sort)
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search documents
|
|
||||||
*/
|
|
||||||
static async search(query, options = {}) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
const { limit = 20, skip = 0, publicOnly = false } = options;
|
|
||||||
|
|
||||||
const filter = { $text: { $search: query } };
|
|
||||||
if (publicOnly) {
|
|
||||||
filter.visibility = 'public';
|
|
||||||
}
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find(
|
|
||||||
filter,
|
|
||||||
{ score: { $meta: 'textScore' } }
|
|
||||||
)
|
|
||||||
.sort({ score: { $meta: 'textScore' } })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update document
|
|
||||||
*/
|
|
||||||
static async update(id, updates) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
|
|
||||||
// If updates contains metadata, merge date_updated into it
|
|
||||||
// Otherwise set it as a separate field
|
|
||||||
const updatePayload = { ...updates };
|
|
||||||
if (updatePayload.metadata) {
|
|
||||||
updatePayload.metadata = {
|
|
||||||
...updatePayload.metadata,
|
|
||||||
date_updated: new Date()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
...updatePayload,
|
|
||||||
...(updatePayload.metadata ? {} : { 'metadata.date_updated': new Date() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete document
|
|
||||||
*/
|
|
||||||
static async delete(id) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
||||||
return result.deletedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a document (make it public after review)
|
|
||||||
*
|
|
||||||
* SECURITY: Requires explicit category and validates document is ready
|
|
||||||
* World-class UX: Clear workflow states and validation feedback
|
|
||||||
*
|
|
||||||
* @param {string} id - Document ID
|
|
||||||
* @param {object} options - Publish options
|
|
||||||
* @param {string} options.category - Required category for public docs
|
|
||||||
* @param {number} options.order - Display order
|
|
||||||
* @returns {Promise<{success: boolean, message: string, document?: object}>}
|
|
||||||
*/
|
|
||||||
static async publish(id, options = {}) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
|
|
||||||
// Get document
|
|
||||||
const doc = await this.findById(id);
|
|
||||||
if (!doc) {
|
|
||||||
return { success: false, message: 'Document not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate document is ready for publishing
|
|
||||||
if (!doc.content_markdown && !doc.content_html) {
|
|
||||||
return { success: false, message: 'Document must have content before publishing' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY: Require valid category for public docs
|
|
||||||
const category = options.category || doc.category;
|
|
||||||
if (!category || category === 'none') {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Document must have a valid category before publishing. Available categories: getting-started, technical-reference, research-theory, advanced-topics, case-studies, business-leadership, archives'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate category is in allowed list
|
|
||||||
const validCategories = [
|
|
||||||
'getting-started', 'technical-reference', 'research-theory',
|
|
||||||
'advanced-topics', 'case-studies', 'business-leadership', 'archives'
|
|
||||||
];
|
|
||||||
if (!validCategories.includes(category)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `Invalid category: ${category}. Must be one of: ${validCategories.join(', ')}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update to public
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
visibility: 'public',
|
|
||||||
category,
|
|
||||||
order: options.order !== undefined ? options.order : doc.order,
|
|
||||||
workflow_status: 'published',
|
|
||||||
'metadata.date_updated': new Date(),
|
|
||||||
'metadata.published_date': new Date(),
|
|
||||||
'metadata.published_by': options.publishedBy || 'admin'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.modifiedCount > 0) {
|
|
||||||
const updatedDoc = await this.findById(id);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Document published successfully in category: ${category}`,
|
|
||||||
document: updatedDoc
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, message: 'Failed to publish document' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unpublish a document (revert to internal)
|
|
||||||
*
|
|
||||||
* @param {string} id - Document ID
|
|
||||||
* @param {string} reason - Reason for unpublishing
|
|
||||||
* @returns {Promise<{success: boolean, message: string}>}
|
|
||||||
*/
|
|
||||||
static async unpublish(id, reason = '') {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
visibility: 'internal',
|
|
||||||
workflow_status: 'draft',
|
|
||||||
'metadata.date_updated': new Date(),
|
|
||||||
'metadata.unpublished_date': new Date(),
|
|
||||||
'metadata.unpublish_reason': reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0
|
|
||||||
? { success: true, message: 'Document unpublished successfully' }
|
|
||||||
: { success: false, message: 'Failed to unpublish document' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List documents by workflow status
|
|
||||||
*
|
|
||||||
* @param {string} status - Workflow status (draft, review, published)
|
|
||||||
* @param {object} options - List options
|
|
||||||
* @returns {Promise<Array>}
|
|
||||||
*/
|
|
||||||
static async listByWorkflowStatus(status, options = {}) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 } } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ workflow_status: status })
|
|
||||||
.sort(sort)
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all documents
|
|
||||||
*/
|
|
||||||
static async list(options = {}) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
const { limit = 50, skip = 0, sort = { order: 1, 'metadata.date_created': -1 }, filter = {} } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find(filter)
|
|
||||||
.sort(sort)
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List archived documents
|
|
||||||
*/
|
|
||||||
static async listArchived(options = {}) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 } } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ visibility: 'archived' })
|
|
||||||
.sort(sort)
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count documents
|
|
||||||
*/
|
|
||||||
static async count(filter = {}) {
|
|
||||||
const collection = await getCollection('documents');
|
|
||||||
return await collection.countDocuments(filter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = Document;
|
|
||||||
|
|
@ -1,333 +0,0 @@
|
||||||
/**
|
|
||||||
* Donation Model
|
|
||||||
* Koha (donation) system for Tractatus Framework support
|
|
||||||
*
|
|
||||||
* Privacy-first design:
|
|
||||||
* - Anonymous donations by default
|
|
||||||
* - Opt-in public acknowledgement
|
|
||||||
* - Email stored securely for receipts only
|
|
||||||
* - Public transparency metrics
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
|
|
||||||
class Donation {
|
|
||||||
/**
|
|
||||||
* Create a new donation record
|
|
||||||
*/
|
|
||||||
static async create(data) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
|
|
||||||
const donation = {
|
|
||||||
// Donation details
|
|
||||||
amount: data.amount, // In cents (in the specified currency)
|
|
||||||
currency: data.currency || 'nzd',
|
|
||||||
amount_nzd: data.amount_nzd || data.amount, // Amount in NZD for transparency calculations
|
|
||||||
exchange_rate_to_nzd: data.exchange_rate_to_nzd || 1.0, // Exchange rate at donation time
|
|
||||||
frequency: data.frequency, // 'monthly' or 'one_time'
|
|
||||||
tier: data.tier, // '5', '15', '50', or 'custom'
|
|
||||||
|
|
||||||
// Donor information (private)
|
|
||||||
donor: {
|
|
||||||
name: data.donor?.name || 'Anonymous',
|
|
||||||
email: data.donor?.email, // Required for receipt, kept private
|
|
||||||
country: data.donor?.country,
|
|
||||||
// Do NOT store full address unless required for tax purposes
|
|
||||||
},
|
|
||||||
|
|
||||||
// Public acknowledgement (opt-in)
|
|
||||||
public_acknowledgement: data.public_acknowledgement || false,
|
|
||||||
public_name: data.public_name || null, // Name to show publicly if opted in
|
|
||||||
|
|
||||||
// Stripe integration
|
|
||||||
stripe: {
|
|
||||||
customer_id: data.stripe?.customer_id,
|
|
||||||
subscription_id: data.stripe?.subscription_id, // For monthly donations
|
|
||||||
payment_intent_id: data.stripe?.payment_intent_id,
|
|
||||||
charge_id: data.stripe?.charge_id,
|
|
||||||
invoice_id: data.stripe?.invoice_id
|
|
||||||
},
|
|
||||||
|
|
||||||
// Status tracking
|
|
||||||
status: data.status || 'pending', // pending, completed, failed, cancelled, refunded
|
|
||||||
payment_date: data.payment_date || new Date(),
|
|
||||||
|
|
||||||
// Receipt tracking
|
|
||||||
receipt: {
|
|
||||||
sent: false,
|
|
||||||
sent_date: null,
|
|
||||||
receipt_number: null
|
|
||||||
},
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
metadata: {
|
|
||||||
source: data.metadata?.source || 'website', // website, api, manual
|
|
||||||
campaign: data.metadata?.campaign,
|
|
||||||
referrer: data.metadata?.referrer,
|
|
||||||
user_agent: data.metadata?.user_agent,
|
|
||||||
ip_country: data.metadata?.ip_country
|
|
||||||
},
|
|
||||||
|
|
||||||
// Timestamps
|
|
||||||
created_at: new Date(),
|
|
||||||
updated_at: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.insertOne(donation);
|
|
||||||
return { ...donation, _id: result.insertedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find donation by ID
|
|
||||||
*/
|
|
||||||
static async findById(id) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
return await collection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find donation by Stripe subscription ID
|
|
||||||
*/
|
|
||||||
static async findBySubscriptionId(subscriptionId) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
return await collection.findOne({ 'stripe.subscription_id': subscriptionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find donation by Stripe payment intent ID
|
|
||||||
*/
|
|
||||||
static async findByPaymentIntentId(paymentIntentId) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
return await collection.findOne({ 'stripe.payment_intent_id': paymentIntentId });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all donations by donor email (for admin/receipt purposes)
|
|
||||||
*/
|
|
||||||
static async findByDonorEmail(email) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
return await collection.find({ 'donor.email': email }).sort({ created_at: -1 }).toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update donation status
|
|
||||||
*/
|
|
||||||
static async updateStatus(id, status, additionalData = {}) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
status,
|
|
||||||
updated_at: new Date(),
|
|
||||||
...additionalData
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: updateData }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark receipt as sent
|
|
||||||
*/
|
|
||||||
static async markReceiptSent(id, receiptNumber) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
'receipt.sent': true,
|
|
||||||
'receipt.sent_date': new Date(),
|
|
||||||
'receipt.receipt_number': receiptNumber,
|
|
||||||
updated_at: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel recurring donation (subscription)
|
|
||||||
*/
|
|
||||||
static async cancelSubscription(subscriptionId) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ 'stripe.subscription_id': subscriptionId },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
status: 'cancelled',
|
|
||||||
updated_at: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get transparency metrics (PUBLIC DATA)
|
|
||||||
* Returns aggregated data for public transparency dashboard
|
|
||||||
*/
|
|
||||||
static async getTransparencyMetrics() {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
|
|
||||||
// Get all completed donations
|
|
||||||
const completedDonations = await collection.find({
|
|
||||||
status: 'completed'
|
|
||||||
}).toArray();
|
|
||||||
|
|
||||||
// Calculate totals (convert all to NZD for consistent totals)
|
|
||||||
const totalReceived = completedDonations.reduce((sum, d) => {
|
|
||||||
// Use amount_nzd if available, otherwise use amount (backwards compatibility)
|
|
||||||
const nzdAmount = d.amount_nzd || d.amount;
|
|
||||||
return sum + nzdAmount;
|
|
||||||
}, 0) / 100; // Convert cents to dollars
|
|
||||||
|
|
||||||
// Count monthly supporters (active subscriptions)
|
|
||||||
const monthlyDonations = completedDonations.filter(d => d.frequency === 'monthly');
|
|
||||||
const activeSubscriptions = monthlyDonations.filter(d => d.status === 'completed');
|
|
||||||
const monthlySupporters = new Set(activeSubscriptions.map(d => d.stripe.customer_id)).size;
|
|
||||||
|
|
||||||
// Count one-time donations
|
|
||||||
const oneTimeDonations = completedDonations.filter(d => d.frequency === 'one_time').length;
|
|
||||||
|
|
||||||
// Get public acknowledgements (donors who opted in)
|
|
||||||
const publicDonors = completedDonations
|
|
||||||
.filter(d => d.public_acknowledgement && d.public_name)
|
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
.slice(0, 20) // Latest 20 donors
|
|
||||||
.map(d => ({
|
|
||||||
name: d.public_name,
|
|
||||||
amount: d.amount / 100,
|
|
||||||
currency: d.currency || 'nzd',
|
|
||||||
amount_nzd: (d.amount_nzd || d.amount) / 100,
|
|
||||||
date: d.created_at,
|
|
||||||
frequency: d.frequency
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Calculate monthly recurring revenue (in NZD)
|
|
||||||
const monthlyRevenue = activeSubscriptions.reduce((sum, d) => {
|
|
||||||
const nzdAmount = d.amount_nzd || d.amount;
|
|
||||||
return sum + nzdAmount;
|
|
||||||
}, 0) / 100;
|
|
||||||
|
|
||||||
// Allocation breakdown (as per specification)
|
|
||||||
const allocation = {
|
|
||||||
hosting: 0.30,
|
|
||||||
development: 0.40,
|
|
||||||
research: 0.20,
|
|
||||||
community: 0.10
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
total_received: totalReceived,
|
|
||||||
monthly_supporters: monthlySupporters,
|
|
||||||
one_time_donations: oneTimeDonations,
|
|
||||||
monthly_recurring_revenue: monthlyRevenue,
|
|
||||||
allocation: allocation,
|
|
||||||
recent_donors: publicDonors,
|
|
||||||
last_updated: new Date()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get donation statistics (ADMIN ONLY)
|
|
||||||
*/
|
|
||||||
static async getStatistics(startDate = null, endDate = null) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
|
|
||||||
const query = { status: 'completed' };
|
|
||||||
if (startDate || endDate) {
|
|
||||||
query.created_at = {};
|
|
||||||
if (startDate) query.created_at.$gte = new Date(startDate);
|
|
||||||
if (endDate) query.created_at.$lte = new Date(endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const donations = await collection.find(query).toArray();
|
|
||||||
|
|
||||||
return {
|
|
||||||
total_count: donations.length,
|
|
||||||
total_amount: donations.reduce((sum, d) => sum + d.amount, 0) / 100,
|
|
||||||
by_frequency: {
|
|
||||||
monthly: donations.filter(d => d.frequency === 'monthly').length,
|
|
||||||
one_time: donations.filter(d => d.frequency === 'one_time').length
|
|
||||||
},
|
|
||||||
by_tier: {
|
|
||||||
tier_5: donations.filter(d => d.tier === '5').length,
|
|
||||||
tier_15: donations.filter(d => d.tier === '15').length,
|
|
||||||
tier_50: donations.filter(d => d.tier === '50').length,
|
|
||||||
custom: donations.filter(d => d.tier === 'custom').length
|
|
||||||
},
|
|
||||||
average_donation: donations.length > 0
|
|
||||||
? (donations.reduce((sum, d) => sum + d.amount, 0) / donations.length) / 100
|
|
||||||
: 0,
|
|
||||||
public_acknowledgements: donations.filter(d => d.public_acknowledgement).length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all donations (ADMIN ONLY - paginated)
|
|
||||||
*/
|
|
||||||
static async findAll(options = {}) {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
|
|
||||||
const {
|
|
||||||
page = 1,
|
|
||||||
limit = 20,
|
|
||||||
status = null,
|
|
||||||
frequency = null,
|
|
||||||
sortBy = 'created_at',
|
|
||||||
sortOrder = -1
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const query = {};
|
|
||||||
if (status) query.status = status;
|
|
||||||
if (frequency) query.frequency = frequency;
|
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
|
|
||||||
const donations = await collection
|
|
||||||
.find(query)
|
|
||||||
.sort({ [sortBy]: sortOrder })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
const total = await collection.countDocuments(query);
|
|
||||||
|
|
||||||
return {
|
|
||||||
donations,
|
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
pages: Math.ceil(total / limit)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create database indexes for performance
|
|
||||||
*/
|
|
||||||
static async createIndexes() {
|
|
||||||
const collection = await getCollection('koha_donations');
|
|
||||||
|
|
||||||
await collection.createIndex({ status: 1 });
|
|
||||||
await collection.createIndex({ frequency: 1 });
|
|
||||||
await collection.createIndex({ 'stripe.subscription_id': 1 });
|
|
||||||
await collection.createIndex({ 'stripe.payment_intent_id': 1 });
|
|
||||||
await collection.createIndex({ 'donor.email': 1 });
|
|
||||||
await collection.createIndex({ created_at: -1 });
|
|
||||||
await collection.createIndex({ public_acknowledgement: 1 });
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = Donation;
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
/**
|
|
||||||
* MediaInquiry Model
|
|
||||||
* Press/media inquiries with AI triage
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
|
|
||||||
class MediaInquiry {
|
|
||||||
/**
|
|
||||||
* Create a new media inquiry
|
|
||||||
*/
|
|
||||||
static async create(data) {
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
|
|
||||||
const inquiry = {
|
|
||||||
contact: {
|
|
||||||
name: data.contact.name,
|
|
||||||
email: data.contact.email,
|
|
||||||
outlet: data.contact.outlet,
|
|
||||||
phone: data.contact.phone
|
|
||||||
},
|
|
||||||
inquiry: {
|
|
||||||
subject: data.inquiry.subject,
|
|
||||||
message: data.inquiry.message,
|
|
||||||
deadline: data.inquiry.deadline ? new Date(data.inquiry.deadline) : null,
|
|
||||||
topic_areas: data.inquiry.topic_areas || []
|
|
||||||
},
|
|
||||||
ai_triage: {
|
|
||||||
urgency: data.ai_triage?.urgency, // high/medium/low
|
|
||||||
topic_sensitivity: data.ai_triage?.topic_sensitivity,
|
|
||||||
suggested_response_time: data.ai_triage?.suggested_response_time,
|
|
||||||
involves_values: data.ai_triage?.involves_values || false,
|
|
||||||
claude_summary: data.ai_triage?.claude_summary,
|
|
||||||
suggested_talking_points: data.ai_triage?.suggested_talking_points || []
|
|
||||||
},
|
|
||||||
status: data.status || 'new', // new/triaged/responded/closed
|
|
||||||
assigned_to: data.assigned_to,
|
|
||||||
response: {
|
|
||||||
sent_at: data.response?.sent_at,
|
|
||||||
content: data.response?.content,
|
|
||||||
responder: data.response?.responder
|
|
||||||
},
|
|
||||||
created_at: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.insertOne(inquiry);
|
|
||||||
return { ...inquiry, _id: result.insertedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find inquiry by ID
|
|
||||||
*/
|
|
||||||
static async findById(id) {
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
return await collection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find inquiries by status
|
|
||||||
*/
|
|
||||||
static async findByStatus(status, options = {}) {
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
const { limit = 20, skip = 0 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ status })
|
|
||||||
.sort({ created_at: -1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find high urgency inquiries
|
|
||||||
*/
|
|
||||||
static async findUrgent(options = {}) {
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
const { limit = 10 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({
|
|
||||||
'ai_triage.urgency': 'high',
|
|
||||||
status: { $in: ['new', 'triaged'] }
|
|
||||||
})
|
|
||||||
.sort({ created_at: -1 })
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update inquiry
|
|
||||||
*/
|
|
||||||
static async update(id, updates) {
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: updates }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign inquiry to user
|
|
||||||
*/
|
|
||||||
static async assign(id, userId) {
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
assigned_to: new ObjectId(userId),
|
|
||||||
status: 'triaged'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark as responded
|
|
||||||
*/
|
|
||||||
static async respond(id, responseData) {
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
status: 'responded',
|
|
||||||
'response.sent_at': new Date(),
|
|
||||||
'response.content': responseData.content,
|
|
||||||
'response.responder': responseData.responder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count by status
|
|
||||||
*/
|
|
||||||
static async countByStatus(status) {
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
return await collection.countDocuments({ status });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete inquiry
|
|
||||||
*/
|
|
||||||
static async delete(id) {
|
|
||||||
const collection = await getCollection('media_inquiries');
|
|
||||||
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
||||||
return result.deletedCount > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = MediaInquiry;
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
/**
|
|
||||||
* ModerationQueue Model
|
|
||||||
* Human oversight queue for AI actions
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
|
|
||||||
class ModerationQueue {
|
|
||||||
/**
|
|
||||||
* Add item to moderation queue
|
|
||||||
*/
|
|
||||||
static async create(data) {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
|
|
||||||
const item = {
|
|
||||||
// Type of moderation (NEW flexible field)
|
|
||||||
type: data.type, // BLOG_TOPIC_SUGGESTION/MEDIA_INQUIRY/CASE_STUDY/etc.
|
|
||||||
|
|
||||||
// Reference to specific item (optional - not needed for suggestions)
|
|
||||||
reference_collection: data.reference_collection || null, // blog_posts/media_inquiries/etc.
|
|
||||||
reference_id: data.reference_id ? new ObjectId(data.reference_id) : null,
|
|
||||||
|
|
||||||
// Tractatus quadrant
|
|
||||||
quadrant: data.quadrant || null, // STR/OPS/TAC/SYS/STO
|
|
||||||
|
|
||||||
// AI action data (flexible object)
|
|
||||||
data: data.data || {}, // Flexible data field for AI outputs
|
|
||||||
|
|
||||||
// AI metadata
|
|
||||||
ai_generated: data.ai_generated || false,
|
|
||||||
ai_version: data.ai_version || 'claude-sonnet-4-5',
|
|
||||||
|
|
||||||
// Human oversight
|
|
||||||
requires_human_approval: data.requires_human_approval || true,
|
|
||||||
human_required_reason: data.human_required_reason || 'AI-generated content requires human review',
|
|
||||||
|
|
||||||
// Priority and assignment
|
|
||||||
priority: data.priority || 'medium', // high/medium/low
|
|
||||||
assigned_to: data.assigned_to || null,
|
|
||||||
|
|
||||||
// Status tracking
|
|
||||||
status: data.status || 'PENDING_APPROVAL', // PENDING_APPROVAL/APPROVED/REJECTED
|
|
||||||
created_at: data.created_at || new Date(),
|
|
||||||
created_by: data.created_by ? new ObjectId(data.created_by) : null,
|
|
||||||
reviewed_at: null,
|
|
||||||
|
|
||||||
// Review decision
|
|
||||||
review_decision: {
|
|
||||||
action: null, // approve/reject/modify/escalate
|
|
||||||
notes: null,
|
|
||||||
reviewer: null
|
|
||||||
},
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
metadata: data.metadata || {},
|
|
||||||
|
|
||||||
// Legacy fields for backwards compatibility
|
|
||||||
item_type: data.item_type || null,
|
|
||||||
item_id: data.item_id ? new ObjectId(data.item_id) : null
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.insertOne(item);
|
|
||||||
return { ...item, _id: result.insertedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find item by ID
|
|
||||||
*/
|
|
||||||
static async findById(id) {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
return await collection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find pending items
|
|
||||||
*/
|
|
||||||
static async findPending(options = {}) {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
const { limit = 20, skip = 0, priority } = options;
|
|
||||||
|
|
||||||
const filter = { status: 'pending' };
|
|
||||||
if (priority) filter.priority = priority;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find(filter)
|
|
||||||
.sort({
|
|
||||||
priority: -1, // high first
|
|
||||||
created_at: 1 // oldest first
|
|
||||||
})
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find by item type
|
|
||||||
*/
|
|
||||||
static async findByType(itemType, options = {}) {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
const { limit = 20, skip = 0 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ item_type: itemType, status: 'pending' })
|
|
||||||
.sort({ created_at: -1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find by quadrant
|
|
||||||
*/
|
|
||||||
static async findByQuadrant(quadrant, options = {}) {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
const { limit = 20, skip = 0 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ quadrant, status: 'pending' })
|
|
||||||
.sort({ created_at: -1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Review item (approve/reject/modify/escalate)
|
|
||||||
*/
|
|
||||||
static async review(id, decision) {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
|
|
||||||
// Map action to status
|
|
||||||
const statusMap = {
|
|
||||||
'approve': 'approved',
|
|
||||||
'reject': 'rejected',
|
|
||||||
'escalate': 'escalated'
|
|
||||||
};
|
|
||||||
const status = statusMap[decision.action] || 'reviewed';
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
status: status,
|
|
||||||
reviewed_at: new Date(),
|
|
||||||
'review_decision.action': decision.action,
|
|
||||||
'review_decision.notes': decision.notes,
|
|
||||||
'review_decision.reviewer': decision.reviewer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Approve item
|
|
||||||
*/
|
|
||||||
static async approve(id, reviewerId, notes = '') {
|
|
||||||
return await this.review(id, {
|
|
||||||
action: 'approve',
|
|
||||||
notes,
|
|
||||||
reviewer: reviewerId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject item
|
|
||||||
*/
|
|
||||||
static async reject(id, reviewerId, notes) {
|
|
||||||
return await this.review(id, {
|
|
||||||
action: 'reject',
|
|
||||||
notes,
|
|
||||||
reviewer: reviewerId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escalate to strategic review
|
|
||||||
*/
|
|
||||||
static async escalate(id, reviewerId, reason) {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
quadrant: 'STRATEGIC',
|
|
||||||
priority: 'high',
|
|
||||||
human_required_reason: `ESCALATED: ${reason}`,
|
|
||||||
'review_decision.action': 'escalate',
|
|
||||||
'review_decision.notes': reason,
|
|
||||||
'review_decision.reviewer': reviewerId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count pending items
|
|
||||||
*/
|
|
||||||
static async countPending(filter = {}) {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
return await collection.countDocuments({
|
|
||||||
...filter,
|
|
||||||
status: 'pending'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stats by quadrant
|
|
||||||
*/
|
|
||||||
static async getStatsByQuadrant() {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
|
|
||||||
return await collection.aggregate([
|
|
||||||
{ $match: { status: 'pending' } },
|
|
||||||
{
|
|
||||||
$group: {
|
|
||||||
_id: '$quadrant',
|
|
||||||
count: { $sum: 1 },
|
|
||||||
high_priority: {
|
|
||||||
$sum: { $cond: [{ $eq: ['$priority', 'high'] }, 1, 0] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]).toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete item
|
|
||||||
*/
|
|
||||||
static async delete(id) {
|
|
||||||
const collection = await getCollection('moderation_queue');
|
|
||||||
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
||||||
return result.deletedCount > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = ModerationQueue;
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
/**
|
|
||||||
* Newsletter Subscription Model
|
|
||||||
* Manages email subscriptions for blog updates and framework news
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
|
|
||||||
class NewsletterSubscription {
|
|
||||||
/**
|
|
||||||
* Subscribe a new email address
|
|
||||||
*/
|
|
||||||
static async subscribe(data) {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
|
|
||||||
// Check if already subscribed
|
|
||||||
const existing = await collection.findOne({
|
|
||||||
email: data.email.toLowerCase()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// If previously unsubscribed, reactivate
|
|
||||||
if (!existing.active) {
|
|
||||||
await collection.updateOne(
|
|
||||||
{ _id: existing._id },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
active: true,
|
|
||||||
resubscribed_at: new Date(),
|
|
||||||
updated_at: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return { ...existing, active: true };
|
|
||||||
}
|
|
||||||
// Already subscribed and active
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new subscription
|
|
||||||
const subscription = {
|
|
||||||
email: data.email.toLowerCase(),
|
|
||||||
name: data.name || null,
|
|
||||||
source: data.source || 'blog', // blog/homepage/docs/etc
|
|
||||||
interests: data.interests || [], // e.g., ['framework-updates', 'case-studies', 'research']
|
|
||||||
verification_token: this._generateToken(),
|
|
||||||
verified: false, // Will be true after email verification
|
|
||||||
active: true,
|
|
||||||
subscribed_at: new Date(),
|
|
||||||
created_at: new Date(),
|
|
||||||
updated_at: new Date(),
|
|
||||||
metadata: {
|
|
||||||
ip_address: data.ip_address || null,
|
|
||||||
user_agent: data.user_agent || null,
|
|
||||||
referrer: data.referrer || null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.insertOne(subscription);
|
|
||||||
return { ...subscription, _id: result.insertedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify email subscription
|
|
||||||
*/
|
|
||||||
static async verify(token) {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ verification_token: token, active: true },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
verified: true,
|
|
||||||
verified_at: new Date(),
|
|
||||||
updated_at: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsubscribe an email address
|
|
||||||
*/
|
|
||||||
static async unsubscribe(email, token = null) {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
|
|
||||||
const filter = token
|
|
||||||
? { verification_token: token }
|
|
||||||
: { email: email.toLowerCase() };
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
filter,
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
active: false,
|
|
||||||
unsubscribed_at: new Date(),
|
|
||||||
updated_at: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find subscription by email
|
|
||||||
*/
|
|
||||||
static async findByEmail(email) {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
return await collection.findOne({ email: email.toLowerCase() });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find subscription by ID
|
|
||||||
*/
|
|
||||||
static async findById(id) {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
return await collection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all subscriptions
|
|
||||||
*/
|
|
||||||
static async list(options = {}) {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
const {
|
|
||||||
limit = 100,
|
|
||||||
skip = 0,
|
|
||||||
active = true,
|
|
||||||
verified = null,
|
|
||||||
source = null
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const filter = {};
|
|
||||||
if (active !== null) filter.active = active;
|
|
||||||
if (verified !== null) filter.verified = verified;
|
|
||||||
if (source) filter.source = source;
|
|
||||||
|
|
||||||
const subscriptions = await collection
|
|
||||||
.find(filter)
|
|
||||||
.sort({ created_at: -1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
return subscriptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count subscriptions
|
|
||||||
*/
|
|
||||||
static async count(filter = {}) {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
return await collection.countDocuments(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update subscription preferences
|
|
||||||
*/
|
|
||||||
static async updatePreferences(email, preferences) {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ email: email.toLowerCase(), active: true },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
interests: preferences.interests || [],
|
|
||||||
name: preferences.name || null,
|
|
||||||
updated_at: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get subscription statistics
|
|
||||||
*/
|
|
||||||
static async getStats() {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
|
|
||||||
const [
|
|
||||||
totalSubscribers,
|
|
||||||
activeSubscribers,
|
|
||||||
verifiedSubscribers,
|
|
||||||
recentSubscribers
|
|
||||||
] = await Promise.all([
|
|
||||||
collection.countDocuments({}),
|
|
||||||
collection.countDocuments({ active: true }),
|
|
||||||
collection.countDocuments({ active: true, verified: true }),
|
|
||||||
collection.countDocuments({
|
|
||||||
active: true,
|
|
||||||
created_at: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get source breakdown
|
|
||||||
const sourceBreakdown = await collection.aggregate([
|
|
||||||
{ $match: { active: true } },
|
|
||||||
{ $group: { _id: '$source', count: { $sum: 1 } } }
|
|
||||||
]).toArray();
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: totalSubscribers,
|
|
||||||
active: activeSubscribers,
|
|
||||||
verified: verifiedSubscribers,
|
|
||||||
recent_30_days: recentSubscribers,
|
|
||||||
by_source: sourceBreakdown.reduce((acc, item) => {
|
|
||||||
acc[item._id || 'unknown'] = item.count;
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete subscription (admin only)
|
|
||||||
*/
|
|
||||||
static async delete(id) {
|
|
||||||
const collection = await getCollection('newsletter_subscriptions');
|
|
||||||
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
||||||
return result.deletedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate verification token
|
|
||||||
*/
|
|
||||||
static _generateToken() {
|
|
||||||
return require('crypto').randomBytes(32).toString('hex');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = NewsletterSubscription;
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
/**
|
|
||||||
* Resource Model
|
|
||||||
* Curated directory of aligned resources
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
|
|
||||||
class Resource {
|
|
||||||
/**
|
|
||||||
* Create a new resource
|
|
||||||
*/
|
|
||||||
static async create(data) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
|
|
||||||
const resource = {
|
|
||||||
url: data.url,
|
|
||||||
title: data.title,
|
|
||||||
description: data.description,
|
|
||||||
category: data.category, // framework/tool/research/organization/educational
|
|
||||||
subcategory: data.subcategory,
|
|
||||||
alignment_score: data.alignment_score, // 0-1 alignment with Tractatus values
|
|
||||||
ai_analysis: {
|
|
||||||
summary: data.ai_analysis?.summary,
|
|
||||||
relevance: data.ai_analysis?.relevance,
|
|
||||||
quality_indicators: data.ai_analysis?.quality_indicators || [],
|
|
||||||
concerns: data.ai_analysis?.concerns || [],
|
|
||||||
claude_reasoning: data.ai_analysis?.claude_reasoning
|
|
||||||
},
|
|
||||||
status: data.status || 'pending', // pending/approved/rejected
|
|
||||||
reviewed_by: data.reviewed_by,
|
|
||||||
reviewed_at: data.reviewed_at,
|
|
||||||
tags: data.tags || [],
|
|
||||||
featured: data.featured || false,
|
|
||||||
added_at: new Date(),
|
|
||||||
last_checked: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.insertOne(resource);
|
|
||||||
return { ...resource, _id: result.insertedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find resource by ID
|
|
||||||
*/
|
|
||||||
static async findById(id) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
return await collection.findOne({ _id: new ObjectId(id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find resource by URL
|
|
||||||
*/
|
|
||||||
static async findByUrl(url) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
return await collection.findOne({ url });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find approved resources
|
|
||||||
*/
|
|
||||||
static async findApproved(options = {}) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
const { limit = 50, skip = 0, category } = options;
|
|
||||||
|
|
||||||
const filter = { status: 'approved' };
|
|
||||||
if (category) filter.category = category;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find(filter)
|
|
||||||
.sort({ alignment_score: -1, added_at: -1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find featured resources
|
|
||||||
*/
|
|
||||||
static async findFeatured(options = {}) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
const { limit = 10 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ status: 'approved', featured: true })
|
|
||||||
.sort({ alignment_score: -1 })
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find by category
|
|
||||||
*/
|
|
||||||
static async findByCategory(category, options = {}) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
const { limit = 30, skip = 0 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({ category, status: 'approved' })
|
|
||||||
.sort({ alignment_score: -1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find high-alignment pending resources
|
|
||||||
*/
|
|
||||||
static async findHighAlignment(options = {}) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
const { limit = 10 } = options;
|
|
||||||
|
|
||||||
return await collection
|
|
||||||
.find({
|
|
||||||
status: 'pending',
|
|
||||||
alignment_score: { $gte: 0.8 }
|
|
||||||
})
|
|
||||||
.sort({ alignment_score: -1 })
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update resource
|
|
||||||
*/
|
|
||||||
static async update(id, updates) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: updates }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Approve resource
|
|
||||||
*/
|
|
||||||
static async approve(id, reviewerId) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
status: 'approved',
|
|
||||||
reviewed_by: reviewerId,
|
|
||||||
reviewed_at: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject resource
|
|
||||||
*/
|
|
||||||
static async reject(id, reviewerId) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
status: 'rejected',
|
|
||||||
reviewed_by: reviewerId,
|
|
||||||
reviewed_at: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark as featured
|
|
||||||
*/
|
|
||||||
static async setFeatured(id, featured = true) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: { featured } }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update last checked timestamp
|
|
||||||
*/
|
|
||||||
static async updateLastChecked(id) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
|
|
||||||
await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: { last_checked: new Date() } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count by status
|
|
||||||
*/
|
|
||||||
static async countByStatus(status) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
return await collection.countDocuments({ status });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete resource
|
|
||||||
*/
|
|
||||||
static async delete(id) {
|
|
||||||
const collection = await getCollection('resources');
|
|
||||||
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
||||||
return result.deletedCount > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = Resource;
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
/**
|
|
||||||
* User Model
|
|
||||||
* Admin user accounts
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { ObjectId } = require('mongodb');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const { getCollection } = require('../utils/db.util');
|
|
||||||
|
|
||||||
class User {
|
|
||||||
/**
|
|
||||||
* Create a new user
|
|
||||||
*/
|
|
||||||
static async create(data) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
|
||||||
|
|
||||||
const user = {
|
|
||||||
email: data.email,
|
|
||||||
password: hashedPassword,
|
|
||||||
name: data.name,
|
|
||||||
role: data.role || 'admin', // admin/moderator/viewer
|
|
||||||
created_at: new Date(),
|
|
||||||
last_login: null,
|
|
||||||
active: data.active !== undefined ? data.active : true
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await collection.insertOne(user);
|
|
||||||
|
|
||||||
// Return user without password
|
|
||||||
const { password, ...userWithoutPassword } = { ...user, _id: result.insertedId };
|
|
||||||
return userWithoutPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by ID
|
|
||||||
*/
|
|
||||||
static async findById(id) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
const user = await collection.findOne({ _id: new ObjectId(id) });
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
const { password, ...userWithoutPassword } = user;
|
|
||||||
return userWithoutPassword;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by email
|
|
||||||
*/
|
|
||||||
static async findByEmail(email) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
return await collection.findOne({ email: email.toLowerCase() });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate user
|
|
||||||
*/
|
|
||||||
static async authenticate(email, password) {
|
|
||||||
const user = await this.findByEmail(email);
|
|
||||||
|
|
||||||
if (!user || !user.active) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await bcrypt.compare(password, user.password);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last login
|
|
||||||
await this.updateLastLogin(user._id);
|
|
||||||
|
|
||||||
// Return user without password
|
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
|
||||||
return userWithoutPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update last login timestamp
|
|
||||||
*/
|
|
||||||
static async updateLastLogin(id) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
|
|
||||||
await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: { last_login: new Date() } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user
|
|
||||||
*/
|
|
||||||
static async update(id, updates) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
|
|
||||||
// Remove password from updates (use changePassword for that)
|
|
||||||
const { password, ...safeUpdates } = updates;
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: safeUpdates }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change password
|
|
||||||
*/
|
|
||||||
static async changePassword(id, newPassword) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: { password: hashedPassword } }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deactivate user
|
|
||||||
*/
|
|
||||||
static async deactivate(id) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
|
|
||||||
const result = await collection.updateOne(
|
|
||||||
{ _id: new ObjectId(id) },
|
|
||||||
{ $set: { active: false } }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.modifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all users
|
|
||||||
*/
|
|
||||||
static async list(options = {}) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
const { limit = 50, skip = 0 } = options;
|
|
||||||
|
|
||||||
const users = await collection
|
|
||||||
.find({}, { projection: { password: 0 } })
|
|
||||||
.sort({ created_at: -1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count users
|
|
||||||
*/
|
|
||||||
static async count(filter = {}) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
return await collection.countDocuments(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete user
|
|
||||||
*/
|
|
||||||
static async delete(id) {
|
|
||||||
const collection = await getCollection('users');
|
|
||||||
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
||||||
return result.deletedCount > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = User;
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
/**
|
|
||||||
* Admin Routes
|
|
||||||
* Moderation queue and system management
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const adminController = require('../controllers/admin.controller');
|
|
||||||
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
|
|
||||||
const { validateRequired, validateObjectId } = require('../middleware/validation.middleware');
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All admin routes require authentication
|
|
||||||
*/
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moderation Queue
|
|
||||||
*/
|
|
||||||
|
|
||||||
// GET /api/admin/moderation - List moderation queue items
|
|
||||||
router.get('/moderation',
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(adminController.getModerationQueue)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/admin/moderation/:id - Get single moderation item
|
|
||||||
router.get('/moderation/:id',
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(adminController.getModerationItem)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/admin/moderation/:id/review - Review item (approve/reject/escalate)
|
|
||||||
router.post('/moderation/:id/review',
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
validateRequired(['action']),
|
|
||||||
asyncHandler(adminController.reviewModerationItem)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System Statistics
|
|
||||||
*/
|
|
||||||
|
|
||||||
// GET /api/admin/stats - Get system statistics
|
|
||||||
router.get('/stats',
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(adminController.getSystemStats)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity Log
|
|
||||||
*/
|
|
||||||
|
|
||||||
// GET /api/admin/activity - Get recent activity log
|
|
||||||
router.get('/activity',
|
|
||||||
requireRole('admin'),
|
|
||||||
asyncHandler(adminController.getActivityLog)
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/**
|
|
||||||
* Authentication Routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const authController = require('../controllers/auth.controller');
|
|
||||||
const { authenticateToken } = require('../middleware/auth.middleware');
|
|
||||||
const { validateEmail, validateRequired } = require('../middleware/validation.middleware');
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
|
|
||||||
// Rate limiter for login attempts (brute-force protection)
|
|
||||||
const loginLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 5, // 5 attempts per 15 minutes per IP
|
|
||||||
message: 'Too many login attempts from this IP. Please try again in 15 minutes.',
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
skipSuccessfulRequests: false // Count successful logins too (prevents credential stuffing)
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/auth/login
|
|
||||||
* Login with email and password
|
|
||||||
* Rate limited: 5 attempts per 15 minutes per IP
|
|
||||||
*/
|
|
||||||
router.post('/login',
|
|
||||||
loginLimiter,
|
|
||||||
validateRequired(['email', 'password']),
|
|
||||||
validateEmail('email'),
|
|
||||||
asyncHandler(authController.login)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/auth/me
|
|
||||||
* Get current authenticated user
|
|
||||||
*/
|
|
||||||
router.get('/me',
|
|
||||||
authenticateToken,
|
|
||||||
asyncHandler(authController.getCurrentUser)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/auth/logout
|
|
||||||
* Logout (logs the event, client removes token)
|
|
||||||
*/
|
|
||||||
router.post('/logout',
|
|
||||||
authenticateToken,
|
|
||||||
asyncHandler(authController.logout)
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
/**
|
|
||||||
* Blog Routes
|
|
||||||
* AI-curated blog endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const blogController = require('../controllers/blog.controller');
|
|
||||||
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
|
|
||||||
const { validateRequired, validateObjectId, validateSlug } = require('../middleware/validation.middleware');
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
// GET /api/blog/rss - RSS feed (must be before /:slug to avoid conflict)
|
|
||||||
router.get('/rss',
|
|
||||||
asyncHandler(blogController.generateRSSFeed)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/blog - List published posts
|
|
||||||
router.get('/',
|
|
||||||
asyncHandler(blogController.listPublishedPosts)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/blog/:slug - Get published post by slug
|
|
||||||
router.get('/:slug',
|
|
||||||
asyncHandler(blogController.getPublishedPost)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
// POST /api/blog/suggest-topics - AI-powered topic suggestions (TRA-OPS-0002)
|
|
||||||
router.post('/suggest-topics',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateRequired(['audience']),
|
|
||||||
asyncHandler(blogController.suggestTopics)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/blog/draft-post - AI-powered blog post drafting (TRA-OPS-0002)
|
|
||||||
// Enforces inst_016, inst_017, inst_018
|
|
||||||
router.post('/draft-post',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateRequired(['topic', 'audience']),
|
|
||||||
asyncHandler(blogController.draftBlogPost)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/blog/analyze-content - Analyze content for Tractatus compliance
|
|
||||||
router.post('/analyze-content',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateRequired(['title', 'body']),
|
|
||||||
asyncHandler(blogController.analyzeContent)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/blog/editorial-guidelines - Get editorial guidelines
|
|
||||||
router.get('/editorial-guidelines',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(blogController.getEditorialGuidelines)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/blog/admin/posts?status=draft
|
|
||||||
router.get('/admin/posts',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(blogController.listPostsByStatus)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/blog/admin/:id - Get any post by ID
|
|
||||||
router.get('/admin/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(blogController.getPostById)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/blog - Create new post
|
|
||||||
router.post('/',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateRequired(['title', 'slug', 'content']),
|
|
||||||
validateSlug,
|
|
||||||
asyncHandler(blogController.createPost)
|
|
||||||
);
|
|
||||||
|
|
||||||
// PUT /api/blog/:id - Update post
|
|
||||||
router.put('/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(blogController.updatePost)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/blog/:id/publish - Publish post
|
|
||||||
router.post('/:id/publish',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(blogController.publishPost)
|
|
||||||
);
|
|
||||||
|
|
||||||
// DELETE /api/blog/:id - Delete post
|
|
||||||
router.delete('/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(blogController.deletePost)
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
/**
|
|
||||||
* Case Study Routes
|
|
||||||
* Community case study submission endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const casesController = require('../controllers/cases.controller');
|
|
||||||
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
|
|
||||||
const { validateRequired, validateEmail, validateObjectId } = require('../middleware/validation.middleware');
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
const { createInputValidationMiddleware } = require('../middleware/input-validation.middleware');
|
|
||||||
const { formRateLimiter } = require('../middleware/rate-limit.middleware');
|
|
||||||
const { csrfProtection } = require('../middleware/csrf-protection.middleware');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Validation schema for case study submission
|
|
||||||
const caseSubmissionSchema = {
|
|
||||||
'submitter.name': { required: true, type: 'name', maxLength: 100 },
|
|
||||||
'submitter.email': { required: true, type: 'email', maxLength: 254 },
|
|
||||||
'submitter.organization': { required: false, type: 'default', maxLength: 200 },
|
|
||||||
'case_study.title': { required: true, type: 'title', maxLength: 200 },
|
|
||||||
'case_study.description': { required: true, type: 'description', maxLength: 50000 },
|
|
||||||
'case_study.failure_mode': { required: true, type: 'default', maxLength: 500 },
|
|
||||||
'case_study.context': { required: false, type: 'default', maxLength: 5000 },
|
|
||||||
'case_study.impact': { required: false, type: 'default', maxLength: 5000 },
|
|
||||||
'case_study.lessons_learned': { required: false, type: 'default', maxLength: 5000 }
|
|
||||||
};
|
|
||||||
|
|
||||||
// POST /api/cases/submit - Submit case study (public)
|
|
||||||
router.post('/submit',
|
|
||||||
formRateLimiter, // 5 requests per minute
|
|
||||||
csrfProtection, // CSRF validation
|
|
||||||
createInputValidationMiddleware(caseSubmissionSchema),
|
|
||||||
validateRequired([
|
|
||||||
'submitter.name',
|
|
||||||
'submitter.email',
|
|
||||||
'case_study.title',
|
|
||||||
'case_study.description',
|
|
||||||
'case_study.failure_mode'
|
|
||||||
]),
|
|
||||||
validateEmail('submitter.email'),
|
|
||||||
asyncHandler(casesController.submitCase)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
// GET /api/cases/submissions/stats - Get submission statistics (admin)
|
|
||||||
router.get('/submissions/stats',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(casesController.getStats)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/cases/submissions - List all submissions (admin)
|
|
||||||
router.get('/submissions',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(casesController.listSubmissions)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/cases/submissions/high-relevance - List high-relevance pending (admin)
|
|
||||||
router.get('/submissions/high-relevance',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(casesController.listHighRelevance)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/cases/submissions/:id - Get submission by ID (admin)
|
|
||||||
router.get('/submissions/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(casesController.getSubmission)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/cases/submissions/:id/approve - Approve submission (admin)
|
|
||||||
router.post('/submissions/:id/approve',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(casesController.approveSubmission)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/cases/submissions/:id/reject - Reject submission (admin)
|
|
||||||
router.post('/submissions/:id/reject',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
validateRequired(['reason']),
|
|
||||||
asyncHandler(casesController.rejectSubmission)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/cases/submissions/:id/request-info - Request more information (admin)
|
|
||||||
router.post('/submissions/:id/request-info',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
validateRequired(['requested_info']),
|
|
||||||
asyncHandler(casesController.requestMoreInfo)
|
|
||||||
);
|
|
||||||
|
|
||||||
// DELETE /api/cases/submissions/:id - Delete submission (admin)
|
|
||||||
router.delete('/submissions/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(casesController.deleteSubmission)
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
/**
|
|
||||||
* Demo Routes
|
|
||||||
* Public API endpoints for interactive demos
|
|
||||||
* Rate-limited to prevent abuse
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
|
|
||||||
// Import services
|
|
||||||
const {
|
|
||||||
classifier,
|
|
||||||
validator,
|
|
||||||
enforcer,
|
|
||||||
monitor
|
|
||||||
} = require('../services');
|
|
||||||
|
|
||||||
// Simple in-memory rate limiting for demos
|
|
||||||
const rateLimiter = new Map();
|
|
||||||
const RATE_LIMIT = 20; // requests per minute
|
|
||||||
const RATE_WINDOW = 60000; // 1 minute
|
|
||||||
|
|
||||||
function checkRateLimit(ip) {
|
|
||||||
const now = Date.now();
|
|
||||||
const userRequests = rateLimiter.get(ip) || [];
|
|
||||||
|
|
||||||
// Remove expired requests
|
|
||||||
const validRequests = userRequests.filter(time => now - time < RATE_WINDOW);
|
|
||||||
|
|
||||||
if (validRequests.length >= RATE_LIMIT) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
validRequests.push(now);
|
|
||||||
rateLimiter.set(ip, validRequests);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting middleware
|
|
||||||
const demoRateLimit = (req, res, next) => {
|
|
||||||
const ip = req.ip || req.connection.remoteAddress;
|
|
||||||
|
|
||||||
if (!checkRateLimit(ip)) {
|
|
||||||
return res.status(429).json({
|
|
||||||
error: 'Too Many Requests',
|
|
||||||
message: 'Rate limit exceeded. Please try again in a minute.',
|
|
||||||
retryAfter: 60
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/demo/classify
|
|
||||||
* Public instruction classification for demo
|
|
||||||
*/
|
|
||||||
router.post('/classify',
|
|
||||||
demoRateLimit,
|
|
||||||
asyncHandler(async (req, res) => {
|
|
||||||
const { instruction } = req.body;
|
|
||||||
|
|
||||||
if (!instruction || typeof instruction !== 'string') {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'instruction field is required and must be a string'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instruction.length > 500) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'instruction must be 500 characters or less'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const classification = classifier.classify({
|
|
||||||
text: instruction,
|
|
||||||
context: {},
|
|
||||||
timestamp: new Date(),
|
|
||||||
source: 'demo'
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
classification: {
|
|
||||||
quadrant: classification.quadrant,
|
|
||||||
persistence: classification.persistence,
|
|
||||||
temporal_scope: classification.temporal_scope || 'session',
|
|
||||||
verification_required: classification.verification_required || 'MANDATORY',
|
|
||||||
explicitness: classification.explicitness || 0.7,
|
|
||||||
human_oversight: classification.human_oversight || 'RECOMMENDED',
|
|
||||||
reasoning: classification.reasoning || generateReasoning(classification)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/demo/boundary-check
|
|
||||||
* Public boundary enforcement check for demo
|
|
||||||
*/
|
|
||||||
router.post('/boundary-check',
|
|
||||||
demoRateLimit,
|
|
||||||
asyncHandler(async (req, res) => {
|
|
||||||
const { decision, description } = req.body;
|
|
||||||
|
|
||||||
if (!decision || typeof decision !== 'string') {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'decision field is required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = {
|
|
||||||
type: 'decision',
|
|
||||||
decision: decision,
|
|
||||||
description: description || '',
|
|
||||||
timestamp: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
const enforcement = enforcer.enforce(action, { source: 'demo' });
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
enforcement: {
|
|
||||||
allowed: enforcement.allowed,
|
|
||||||
boundary_violated: enforcement.boundary_violated || null,
|
|
||||||
reasoning: enforcement.reasoning || generateBoundaryReasoning(enforcement, decision),
|
|
||||||
alternatives: enforcement.alternatives || [],
|
|
||||||
human_approval_required: !enforcement.allowed
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/demo/pressure-check
|
|
||||||
* Public pressure analysis for demo
|
|
||||||
*/
|
|
||||||
router.post('/pressure-check',
|
|
||||||
demoRateLimit,
|
|
||||||
asyncHandler(async (req, res) => {
|
|
||||||
const { tokens, messages, errors } = req.body;
|
|
||||||
|
|
||||||
if (typeof tokens !== 'number' || typeof messages !== 'number') {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'tokens and messages must be numbers'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = {
|
|
||||||
tokenUsage: tokens,
|
|
||||||
tokenBudget: 200000,
|
|
||||||
messageCount: messages,
|
|
||||||
errorCount: errors || 0,
|
|
||||||
source: 'demo'
|
|
||||||
};
|
|
||||||
|
|
||||||
const pressure = monitor.analyzePressure(context);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
pressure: {
|
|
||||||
level: pressure.level,
|
|
||||||
score: pressure.score,
|
|
||||||
percentage: Math.round(pressure.score * 100),
|
|
||||||
recommendations: pressure.recommendations || generatePressureRecommendations(pressure),
|
|
||||||
factors: pressure.factors || {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Generate reasoning for classification
|
|
||||||
*/
|
|
||||||
function generateReasoning(classification) {
|
|
||||||
const { quadrant, persistence } = classification;
|
|
||||||
|
|
||||||
const quadrantReasons = {
|
|
||||||
'STRATEGIC': 'This appears to involve long-term values, mission, or organizational direction.',
|
|
||||||
'OPERATIONAL': 'This relates to processes, policies, or project-level decisions.',
|
|
||||||
'TACTICAL': 'This is an immediate implementation or action-level instruction.',
|
|
||||||
'SYSTEM': 'This involves technical infrastructure or architectural decisions.',
|
|
||||||
'STOCHASTIC': 'This relates to exploration, innovation, or experimentation.'
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistenceReasons = {
|
|
||||||
'HIGH': 'Should remain active for the duration of the project or longer.',
|
|
||||||
'MEDIUM': 'Should remain active for this phase or session.',
|
|
||||||
'LOW': 'Single-use or temporary instruction.',
|
|
||||||
'VARIABLE': 'Applies conditionally based on context.'
|
|
||||||
};
|
|
||||||
|
|
||||||
return `${quadrantReasons[quadrant] || 'Classification based on instruction content.'} ${persistenceReasons[persistence] || ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Generate boundary enforcement reasoning
|
|
||||||
*/
|
|
||||||
function generateBoundaryReasoning(enforcement, decision) {
|
|
||||||
if (enforcement.allowed) {
|
|
||||||
return 'This is a technical decision that can be automated with appropriate verification.';
|
|
||||||
}
|
|
||||||
|
|
||||||
const boundaryReasons = {
|
|
||||||
'VALUES': 'Values decisions cannot be automated - they require human judgment.',
|
|
||||||
'USER_AGENCY': 'Decisions affecting user agency require explicit consent.',
|
|
||||||
'IRREVERSIBLE': 'Irreversible actions require human approval before execution.',
|
|
||||||
'STRATEGIC': 'Strategic direction decisions must be made by humans.',
|
|
||||||
'ETHICAL': 'Ethical considerations require human moral judgment.'
|
|
||||||
};
|
|
||||||
|
|
||||||
return boundaryReasons[enforcement.boundary_violated] ||
|
|
||||||
'This decision crosses into territory requiring human judgment.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Generate pressure recommendations
|
|
||||||
*/
|
|
||||||
function generatePressureRecommendations(pressure) {
|
|
||||||
const { level, score } = pressure;
|
|
||||||
|
|
||||||
if (level === 'NORMAL') {
|
|
||||||
return 'Operating normally. All systems green.';
|
|
||||||
} else if (level === 'ELEVATED') {
|
|
||||||
return 'Elevated pressure detected. Increased verification recommended.';
|
|
||||||
} else if (level === 'HIGH') {
|
|
||||||
return 'High pressure. Mandatory verification required for all actions.';
|
|
||||||
} else if (level === 'CRITICAL') {
|
|
||||||
return 'Critical pressure! Recommend context refresh or session restart.';
|
|
||||||
} else if (level === 'DANGEROUS') {
|
|
||||||
return 'DANGEROUS CONDITIONS. Human intervention required. Action execution blocked.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Pressure analysis complete.';
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
/**
|
|
||||||
* Documents Routes
|
|
||||||
* Framework documentation endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const documentsController = require('../controllers/documents.controller');
|
|
||||||
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
|
|
||||||
const { validateRequired, validateObjectId, validateSlug } = require('../middleware/validation.middleware');
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public routes (read-only)
|
|
||||||
*/
|
|
||||||
|
|
||||||
// GET /api/documents/search?q=query
|
|
||||||
router.get('/search',
|
|
||||||
asyncHandler(documentsController.searchDocuments)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/documents/archived
|
|
||||||
router.get('/archived',
|
|
||||||
asyncHandler(documentsController.listArchivedDocuments)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/documents/drafts (admin only)
|
|
||||||
router.get('/drafts',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
asyncHandler(documentsController.listDraftDocuments)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/documents
|
|
||||||
router.get('/', (req, res, next) => {
|
|
||||||
// Redirect browser requests to API documentation
|
|
||||||
const acceptsHtml = req.accepts('html');
|
|
||||||
const acceptsJson = req.accepts('json');
|
|
||||||
|
|
||||||
if (acceptsHtml && !acceptsJson) {
|
|
||||||
return res.redirect(302, '/api-reference.html#documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
}, asyncHandler(documentsController.listDocuments));
|
|
||||||
|
|
||||||
// GET /api/documents/:identifier (ID or slug)
|
|
||||||
router.get('/:identifier',
|
|
||||||
asyncHandler(documentsController.getDocument)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin routes (protected)
|
|
||||||
*/
|
|
||||||
|
|
||||||
// POST /api/documents
|
|
||||||
router.post('/',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateRequired(['title', 'slug', 'quadrant', 'content_markdown']),
|
|
||||||
validateSlug,
|
|
||||||
asyncHandler(documentsController.createDocument)
|
|
||||||
);
|
|
||||||
|
|
||||||
// PUT /api/documents/:id
|
|
||||||
router.put('/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(documentsController.updateDocument)
|
|
||||||
);
|
|
||||||
|
|
||||||
// DELETE /api/documents/:id
|
|
||||||
router.delete('/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(documentsController.deleteDocument)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/documents/:id/publish (admin only)
|
|
||||||
// SECURITY: Explicit publish workflow with validation
|
|
||||||
router.post('/:id/publish',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
validateRequired(['category']),
|
|
||||||
asyncHandler(documentsController.publishDocument)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/documents/:id/unpublish (admin only)
|
|
||||||
router.post('/:id/unpublish',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(documentsController.unpublishDocument)
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
/**
|
|
||||||
* Koha Routes
|
|
||||||
* Donation system API endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const rateLimit = require('express-rate-limit');
|
|
||||||
const kohaController = require('../controllers/koha.controller');
|
|
||||||
const { authenticateToken, requireAdmin } = require('../middleware/auth.middleware');
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting for donation endpoints
|
|
||||||
* More restrictive than general API limit to prevent abuse
|
|
||||||
*/
|
|
||||||
const donationLimiter = rateLimit({
|
|
||||||
windowMs: 60 * 60 * 1000, // 1 hour
|
|
||||||
max: 10, // 10 requests per hour per IP
|
|
||||||
message: 'Too many donation attempts from this IP. Please try again in an hour.',
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
// Skip rate limiting for webhook endpoint (Stripe needs reliable access)
|
|
||||||
skip: (req) => req.path === '/webhook'
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Create checkout session for donation
|
|
||||||
// POST /api/koha/checkout
|
|
||||||
// Body: { amount, frequency, tier, donor: { name, email, country }, public_acknowledgement, public_name }
|
|
||||||
router.post('/checkout', donationLimiter, kohaController.createCheckout);
|
|
||||||
|
|
||||||
// Stripe webhook endpoint
|
|
||||||
// POST /api/koha/webhook
|
|
||||||
// Note: Requires raw body, configured in app.js
|
|
||||||
router.post('/webhook', kohaController.handleWebhook);
|
|
||||||
|
|
||||||
// Get public transparency metrics
|
|
||||||
// GET /api/koha/transparency
|
|
||||||
router.get('/transparency', kohaController.getTransparency);
|
|
||||||
|
|
||||||
// Cancel recurring donation
|
|
||||||
// POST /api/koha/cancel
|
|
||||||
// Body: { subscriptionId, email }
|
|
||||||
// Rate limited to prevent abuse/guessing of subscription IDs
|
|
||||||
router.post('/cancel', donationLimiter, kohaController.cancelDonation);
|
|
||||||
|
|
||||||
// Create Stripe Customer Portal session
|
|
||||||
// POST /api/koha/portal
|
|
||||||
// Body: { email }
|
|
||||||
// Rate limited to prevent abuse
|
|
||||||
router.post('/portal', donationLimiter, kohaController.createPortalSession);
|
|
||||||
|
|
||||||
// Verify donation session (after Stripe redirect)
|
|
||||||
// GET /api/koha/verify/:sessionId
|
|
||||||
router.get('/verify/:sessionId', kohaController.verifySession);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin-only routes
|
|
||||||
* Requires JWT authentication with admin role
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Get donation statistics
|
|
||||||
// GET /api/koha/statistics?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
|
|
||||||
router.get('/statistics',
|
|
||||||
authenticateToken,
|
|
||||||
requireAdmin,
|
|
||||||
asyncHandler(kohaController.getStatistics)
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
/**
|
|
||||||
* Media Inquiry Routes
|
|
||||||
* Press/media inquiry submission and triage endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const mediaController = require('../controllers/media.controller');
|
|
||||||
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
|
|
||||||
const { validateRequired, validateEmail, validateObjectId } = require('../middleware/validation.middleware');
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
const { createInputValidationMiddleware } = require('../middleware/input-validation.middleware');
|
|
||||||
const { formRateLimiter } = require('../middleware/rate-limit.middleware');
|
|
||||||
const { csrfProtection } = require('../middleware/csrf-protection.middleware');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Validation schema for media inquiry submission
|
|
||||||
const mediaInquirySchema = {
|
|
||||||
'contact.name': { required: true, type: 'name', maxLength: 100 },
|
|
||||||
'contact.email': { required: true, type: 'email', maxLength: 254 },
|
|
||||||
'contact.outlet': { required: true, type: 'default', maxLength: 200 },
|
|
||||||
'contact.phone': { required: false, type: 'phone', maxLength: 20 },
|
|
||||||
'contact.role': { required: false, type: 'default', maxLength: 100 },
|
|
||||||
'inquiry.subject': { required: true, type: 'title', maxLength: 200 },
|
|
||||||
'inquiry.message': { required: true, type: 'description', maxLength: 5000 },
|
|
||||||
'inquiry.deadline': { required: false, type: 'default', maxLength: 100 }
|
|
||||||
};
|
|
||||||
|
|
||||||
// POST /api/media/inquiries - Submit media inquiry (public)
|
|
||||||
router.post('/inquiries',
|
|
||||||
formRateLimiter, // 5 requests per minute
|
|
||||||
csrfProtection, // CSRF validation
|
|
||||||
createInputValidationMiddleware(mediaInquirySchema),
|
|
||||||
validateRequired(['contact.name', 'contact.email', 'contact.outlet', 'inquiry.subject', 'inquiry.message']),
|
|
||||||
validateEmail('contact.email'),
|
|
||||||
asyncHandler(mediaController.submitInquiry)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/media/triage-stats - Get triage statistics (public, transparency)
|
|
||||||
router.get('/triage-stats',
|
|
||||||
asyncHandler(mediaController.getTriageStats)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
// GET /api/media/inquiries - List all inquiries (admin)
|
|
||||||
router.get('/inquiries',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(mediaController.listInquiries)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/media/inquiries/urgent - List high urgency inquiries (admin)
|
|
||||||
router.get('/inquiries/urgent',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(mediaController.listUrgentInquiries)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/media/inquiries/:id - Get inquiry by ID (admin)
|
|
||||||
router.get('/inquiries/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(mediaController.getInquiry)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/inquiries/:id/assign - Assign inquiry to user (admin)
|
|
||||||
router.post('/inquiries/:id/assign',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(mediaController.assignInquiry)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/inquiries/:id/triage - Run AI triage (admin)
|
|
||||||
router.post('/inquiries/:id/triage',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(mediaController.triageInquiry)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/inquiries/:id/respond - Mark as responded (admin)
|
|
||||||
router.post('/inquiries/:id/respond',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
validateRequired(['content']),
|
|
||||||
asyncHandler(mediaController.respondToInquiry)
|
|
||||||
);
|
|
||||||
|
|
||||||
// DELETE /api/media/inquiries/:id - Delete inquiry (admin)
|
|
||||||
router.delete('/inquiries/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
validateObjectId('id'),
|
|
||||||
asyncHandler(mediaController.deleteInquiry)
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
/**
|
|
||||||
* Newsletter Routes
|
|
||||||
* Public subscription management and admin endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const newsletterController = require('../controllers/newsletter.controller');
|
|
||||||
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
|
|
||||||
const { validateRequired } = require('../middleware/validation.middleware');
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
const { createInputValidationMiddleware } = require('../middleware/input-validation.middleware');
|
|
||||||
const { formRateLimiter } = require('../middleware/rate-limit.middleware');
|
|
||||||
const { csrfProtection } = require('../middleware/csrf-protection.middleware');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public Routes
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Validation schema for newsletter subscription
|
|
||||||
const newsletterSubscribeSchema = {
|
|
||||||
'email': { required: true, type: 'email', maxLength: 254 },
|
|
||||||
'name': { required: false, type: 'name', maxLength: 100 }
|
|
||||||
};
|
|
||||||
|
|
||||||
// POST /api/newsletter/subscribe - Subscribe to newsletter
|
|
||||||
router.post('/subscribe',
|
|
||||||
formRateLimiter, // 5 requests per minute
|
|
||||||
csrfProtection, // CSRF validation
|
|
||||||
createInputValidationMiddleware(newsletterSubscribeSchema),
|
|
||||||
validateRequired(['email']),
|
|
||||||
asyncHandler(newsletterController.subscribe)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/newsletter/verify/:token - Verify email subscription
|
|
||||||
router.get('/verify/:token',
|
|
||||||
asyncHandler(newsletterController.verify)
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/newsletter/unsubscribe - Unsubscribe from newsletter
|
|
||||||
router.post('/unsubscribe',
|
|
||||||
asyncHandler(newsletterController.unsubscribe)
|
|
||||||
);
|
|
||||||
|
|
||||||
// PUT /api/newsletter/preferences - Update subscription preferences
|
|
||||||
router.put('/preferences',
|
|
||||||
validateRequired(['email']),
|
|
||||||
asyncHandler(newsletterController.updatePreferences)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin Routes (require authentication)
|
|
||||||
*/
|
|
||||||
|
|
||||||
// GET /api/newsletter/admin/stats - Get newsletter statistics
|
|
||||||
router.get('/admin/stats',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(newsletterController.getStats)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/newsletter/admin/subscriptions - List all subscriptions
|
|
||||||
router.get('/admin/subscriptions',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin', 'moderator'),
|
|
||||||
asyncHandler(newsletterController.listSubscriptions)
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/newsletter/admin/export - Export subscriptions as CSV
|
|
||||||
router.get('/admin/export',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
asyncHandler(newsletterController.exportSubscriptions)
|
|
||||||
);
|
|
||||||
|
|
||||||
// DELETE /api/newsletter/admin/subscriptions/:id - Delete subscription
|
|
||||||
router.delete('/admin/subscriptions/:id',
|
|
||||||
authenticateToken,
|
|
||||||
requireRole('admin'),
|
|
||||||
asyncHandler(newsletterController.deleteSubscription)
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
/**
|
|
||||||
* Test Routes
|
|
||||||
* Development and testing endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const { createSecureUpload, ALLOWED_MIME_TYPES } = require('../middleware/file-security.middleware');
|
|
||||||
const { asyncHandler } = require('../middleware/error.middleware');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test file upload endpoint
|
|
||||||
* POST /api/test/upload
|
|
||||||
*
|
|
||||||
* Tests the complete file security pipeline:
|
|
||||||
* - Multer upload
|
|
||||||
* - MIME type validation
|
|
||||||
* - Magic number validation
|
|
||||||
* - ClamAV malware scanning
|
|
||||||
* - Quarantine system
|
|
||||||
*/
|
|
||||||
router.post('/upload',
|
|
||||||
...createSecureUpload({
|
|
||||||
fileType: 'document',
|
|
||||||
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
||||||
allowedMimeTypes: ALLOWED_MIME_TYPES.document,
|
|
||||||
fieldName: 'file'
|
|
||||||
}),
|
|
||||||
asyncHandler(async (req, res) => {
|
|
||||||
if (!req.file) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'No file uploaded'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Test file upload successful: ${req.file.originalname}`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'File uploaded and validated successfully',
|
|
||||||
file: {
|
|
||||||
originalName: req.file.originalname,
|
|
||||||
filename: req.file.filename,
|
|
||||||
mimetype: req.file.mimetype,
|
|
||||||
size: req.file.size,
|
|
||||||
path: req.file.path
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
mimeValidated: true,
|
|
||||||
malwareScan: 'passed',
|
|
||||||
quarantined: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get upload statistics
|
|
||||||
* GET /api/test/upload-stats
|
|
||||||
*/
|
|
||||||
router.get('/upload-stats',
|
|
||||||
asyncHandler(async (req, res) => {
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const uploadDir = process.env.UPLOAD_DIR || '/tmp/tractatus-uploads';
|
|
||||||
const quarantineDir = process.env.QUARANTINE_DIR || '/var/quarantine/tractatus';
|
|
||||||
|
|
||||||
const uploadFiles = await fs.readdir(uploadDir).catch(() => []);
|
|
||||||
const quarantineFiles = await fs.readdir(quarantineDir).catch(() => []);
|
|
||||||
|
|
||||||
// Get quarantine details
|
|
||||||
const quarantineDetails = [];
|
|
||||||
for (const file of quarantineFiles) {
|
|
||||||
if (file.endsWith('.json')) {
|
|
||||||
const metadataPath = path.join(quarantineDir, file);
|
|
||||||
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
|
||||||
quarantineDetails.push(metadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
stats: {
|
|
||||||
uploads: {
|
|
||||||
directory: uploadDir,
|
|
||||||
count: uploadFiles.length,
|
|
||||||
files: uploadFiles
|
|
||||||
},
|
|
||||||
quarantine: {
|
|
||||||
directory: quarantineDir,
|
|
||||||
count: Math.floor(quarantineFiles.length / 2), // Each quarantined file has .json metadata
|
|
||||||
items: quarantineDetails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Upload stats error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
message: 'Failed to retrieve upload statistics'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -1,407 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2025 John G Stroh
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adaptive Communication Orchestrator Service
|
|
||||||
* Prevents linguistic hierarchy in pluralistic deliberation
|
|
||||||
*
|
|
||||||
* Support Service for PluralisticDeliberationOrchestrator
|
|
||||||
*
|
|
||||||
* Implements:
|
|
||||||
* - inst_029: Adaptive Communication Tone (detect and mirror stakeholder style)
|
|
||||||
* - inst_030: Anti-Patronizing Language Filter (blocks condescending terms)
|
|
||||||
* - inst_031: Regional Communication Norms (Australian/NZ, Japanese, Māori protocols)
|
|
||||||
* - inst_032: Multilingual Engagement Protocol (language barrier accommodation)
|
|
||||||
*
|
|
||||||
* Core Principle:
|
|
||||||
* If Tractatus facilitates "non-hierarchical deliberation" but only communicates
|
|
||||||
* in formal academic English, it imposes Western liberal norms and excludes
|
|
||||||
* non-academics, non-English speakers, and working-class communities.
|
|
||||||
*
|
|
||||||
* Solution: Same deliberation outcome, culturally appropriate communication.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Communication style profiles
|
|
||||||
*/
|
|
||||||
const COMMUNICATION_STYLES = {
|
|
||||||
FORMAL_ACADEMIC: {
|
|
||||||
name: 'Formal Academic',
|
|
||||||
characteristics: ['citations', 'technical terms', 'formal register', 'hedging'],
|
|
||||||
tone: 'formal',
|
|
||||||
example: 'Thank you for your principled contribution grounded in privacy rights theory.'
|
|
||||||
},
|
|
||||||
CASUAL_DIRECT: {
|
|
||||||
name: 'Casual Direct (Australian/NZ)',
|
|
||||||
characteristics: ['directness', 'anti-tall-poppy', 'informal', 'pragmatic'],
|
|
||||||
tone: 'casual',
|
|
||||||
pub_test: true, // Would this sound awkward in casual pub conversation?
|
|
||||||
example: 'Right, here\'s where we landed: Save lives first, but only when it\'s genuinely urgent.'
|
|
||||||
},
|
|
||||||
MAORI_PROTOCOL: {
|
|
||||||
name: 'Te Reo Māori Protocol',
|
|
||||||
characteristics: ['mihi', 'whanaungatanga', 'collective framing', 'taonga respect'],
|
|
||||||
tone: 'respectful',
|
|
||||||
cultural_elements: ['kia ora', 'ngā mihi', 'whānau', 'kōrero', 'whakaaro', 'kei te pai'],
|
|
||||||
example: 'Kia ora [Name]. Ngā mihi for bringing the voice of your whānau to this kōrero.'
|
|
||||||
},
|
|
||||||
JAPANESE_FORMAL: {
|
|
||||||
name: 'Japanese Formal (Honne/Tatemae aware)',
|
|
||||||
characteristics: ['indirect', 'high context', 'relationship-focused', 'face-saving'],
|
|
||||||
tone: 'formal',
|
|
||||||
cultural_concepts: ['honne', 'tatemae', 'wa', 'uchi/soto'],
|
|
||||||
example: 'We have carefully considered your valued perspective in reaching this decision.'
|
|
||||||
},
|
|
||||||
PLAIN_LANGUAGE: {
|
|
||||||
name: 'Plain Language',
|
|
||||||
characteristics: ['simple', 'clear', 'accessible', 'non-jargon'],
|
|
||||||
tone: 'neutral',
|
|
||||||
example: 'We decided to prioritize safety in this case. Here\'s why...'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patronizing terms to filter (inst_030)
|
|
||||||
*/
|
|
||||||
const PATRONIZING_PATTERNS = [
|
|
||||||
{ pattern: /\bsimply\b/gi, reason: 'Implies task is trivial' },
|
|
||||||
{ pattern: /\bobviously\b/gi, reason: 'Dismisses difficulty' },
|
|
||||||
{ pattern: /\bclearly\b/gi, reason: 'Assumes shared understanding' },
|
|
||||||
{ pattern: /\bas you may know\b/gi, reason: 'Condescending hedge' },
|
|
||||||
{ pattern: /\bof course\b/gi, reason: 'Assumes obviousness' },
|
|
||||||
{ pattern: /\bjust\b(?= (do|make|use|try))/gi, reason: 'Minimizes complexity' },
|
|
||||||
{ pattern: /\bbasically\b/gi, reason: 'Can be condescending' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Language detection patterns (basic - production would use proper i18n library)
|
|
||||||
*/
|
|
||||||
const LANGUAGE_PATTERNS = {
|
|
||||||
'te-reo-maori': /\b(kia ora|ngā mihi|whānau|kōrero|aroha|mana|taonga)\b/i,
|
|
||||||
'japanese': /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/,
|
|
||||||
'spanish': /\b(hola|gracias|por favor|señor|señora)\b/i,
|
|
||||||
'french': /\b(bonjour|merci|monsieur|madame)\b/i,
|
|
||||||
'german': /\b(guten tag|danke|herr|frau)\b/i
|
|
||||||
};
|
|
||||||
|
|
||||||
class AdaptiveCommunicationOrchestrator {
|
|
||||||
constructor() {
|
|
||||||
this.styles = COMMUNICATION_STYLES;
|
|
||||||
this.patronizingPatterns = PATRONIZING_PATTERNS;
|
|
||||||
this.languagePatterns = LANGUAGE_PATTERNS;
|
|
||||||
|
|
||||||
// Statistics tracking
|
|
||||||
this.stats = {
|
|
||||||
total_adaptations: 0,
|
|
||||||
by_style: {
|
|
||||||
FORMAL_ACADEMIC: 0,
|
|
||||||
CASUAL_DIRECT: 0,
|
|
||||||
MAORI_PROTOCOL: 0,
|
|
||||||
JAPANESE_FORMAL: 0,
|
|
||||||
PLAIN_LANGUAGE: 0
|
|
||||||
},
|
|
||||||
patronizing_terms_removed: 0,
|
|
||||||
languages_detected: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info('AdaptiveCommunicationOrchestrator initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapt communication to target audience style
|
|
||||||
* @param {String} message - The message to adapt
|
|
||||||
* @param {Object} context - Audience context
|
|
||||||
* @returns {String} Adapted message
|
|
||||||
*/
|
|
||||||
adaptCommunication(message, context = {}) {
|
|
||||||
try {
|
|
||||||
let adaptedMessage = message;
|
|
||||||
|
|
||||||
// 1. Detect input language (inst_032)
|
|
||||||
const detectedLanguage = this._detectLanguage(message);
|
|
||||||
if (detectedLanguage && detectedLanguage !== 'english') {
|
|
||||||
logger.info('Non-English language detected', { language: detectedLanguage });
|
|
||||||
// In production, would trigger translation workflow
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Apply anti-patronizing filter (inst_030)
|
|
||||||
adaptedMessage = this._removePatronizingLanguage(adaptedMessage);
|
|
||||||
|
|
||||||
// 3. Adapt to target communication style (inst_029)
|
|
||||||
const targetStyle = context.audience || 'PLAIN_LANGUAGE';
|
|
||||||
adaptedMessage = this._adaptToStyle(adaptedMessage, targetStyle, context);
|
|
||||||
|
|
||||||
// 4. Apply regional/cultural adaptations (inst_031)
|
|
||||||
if (context.cultural_context) {
|
|
||||||
adaptedMessage = this._applyCulturalContext(adaptedMessage, context.cultural_context);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stats.total_adaptations++;
|
|
||||||
if (this.stats.by_style[targetStyle] !== undefined) {
|
|
||||||
this.stats.by_style[targetStyle]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return adaptedMessage;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Communication adaptation error:', error);
|
|
||||||
// Fallback: return original message
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if message passes pub test (inst_029)
|
|
||||||
* Would this sound awkward in casual Australian/NZ pub conversation?
|
|
||||||
* @param {String} message - The message to check
|
|
||||||
* @returns {Object} Pub test result
|
|
||||||
*/
|
|
||||||
pubTest(message) {
|
|
||||||
const awkwardIndicators = [
|
|
||||||
{ pattern: /\bhereby\b/gi, reason: 'Too formal/legal' },
|
|
||||||
{ pattern: /\bforthwith\b/gi, reason: 'Archaic formal' },
|
|
||||||
{ pattern: /\bnotwithstanding\b/gi, reason: 'Unnecessarily complex' },
|
|
||||||
{ pattern: /\bpursuant to\b/gi, reason: 'Overly legal' },
|
|
||||||
{ pattern: /\bin accordance with\b/gi, reason: 'Bureaucratic' },
|
|
||||||
{ pattern: /\bas per\b/gi, reason: 'Business jargon' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const violations = [];
|
|
||||||
|
|
||||||
for (const indicator of awkwardIndicators) {
|
|
||||||
const matches = message.match(indicator.pattern);
|
|
||||||
if (matches) {
|
|
||||||
violations.push({
|
|
||||||
term: matches[0],
|
|
||||||
reason: indicator.reason,
|
|
||||||
suggestion: 'Use simpler, conversational language'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
passes: violations.length === 0,
|
|
||||||
violations,
|
|
||||||
message: violations.length === 0
|
|
||||||
? 'Message passes pub test - sounds natural in casual conversation'
|
|
||||||
: 'Message would sound awkward in casual pub conversation'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect communication style from incoming message
|
|
||||||
* @param {String} message - The message to analyze
|
|
||||||
* @returns {String} Detected style key
|
|
||||||
*/
|
|
||||||
detectStyle(message) {
|
|
||||||
const lowerMessage = message.toLowerCase();
|
|
||||||
|
|
||||||
// Check for Māori protocol indicators
|
|
||||||
if (this.languagePatterns['te-reo-maori'].test(message)) {
|
|
||||||
return 'MAORI_PROTOCOL';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for formal academic indicators
|
|
||||||
const formalIndicators = ['furthermore', 'notwithstanding', 'pursuant to', 'hereby', 'therefore'];
|
|
||||||
const formalCount = formalIndicators.filter(term => lowerMessage.includes(term)).length;
|
|
||||||
if (formalCount >= 2) {
|
|
||||||
return 'FORMAL_ACADEMIC';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for casual/direct indicators
|
|
||||||
const casualIndicators = ['right,', 'ok,', 'yeah,', 'fair?', 'reckon', 'mate'];
|
|
||||||
const casualCount = casualIndicators.filter(term => lowerMessage.includes(term)).length;
|
|
||||||
if (casualCount >= 1) {
|
|
||||||
return 'CASUAL_DIRECT';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to plain language
|
|
||||||
return 'PLAIN_LANGUAGE';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate culturally-adapted greeting
|
|
||||||
* @param {String} recipientName - Name of recipient
|
|
||||||
* @param {Object} context - Cultural context
|
|
||||||
* @returns {String} Appropriate greeting
|
|
||||||
*/
|
|
||||||
generateGreeting(recipientName, context = {}) {
|
|
||||||
const style = context.communication_style || 'PLAIN_LANGUAGE';
|
|
||||||
|
|
||||||
switch (style) {
|
|
||||||
case 'MAORI_PROTOCOL':
|
|
||||||
return `Kia ora ${recipientName}`;
|
|
||||||
case 'JAPANESE_FORMAL':
|
|
||||||
return `${recipientName}様、いつもお世話になっております。`; // Formal Japanese greeting
|
|
||||||
case 'CASUAL_DIRECT':
|
|
||||||
return `Hi ${recipientName}`;
|
|
||||||
case 'FORMAL_ACADEMIC':
|
|
||||||
return `Dear ${recipientName},`;
|
|
||||||
default:
|
|
||||||
return `Hello ${recipientName}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private helper methods
|
|
||||||
*/
|
|
||||||
|
|
||||||
_detectLanguage(message) {
|
|
||||||
for (const [language, pattern] of Object.entries(this.languagePatterns)) {
|
|
||||||
if (pattern.test(message)) {
|
|
||||||
// Track language detection
|
|
||||||
if (!this.stats.languages_detected[language]) {
|
|
||||||
this.stats.languages_detected[language] = 0;
|
|
||||||
}
|
|
||||||
this.stats.languages_detected[language]++;
|
|
||||||
|
|
||||||
return language;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'english'; // Default assumption
|
|
||||||
}
|
|
||||||
|
|
||||||
_removePatronizingLanguage(message) {
|
|
||||||
let cleaned = message;
|
|
||||||
let removedCount = 0;
|
|
||||||
|
|
||||||
for (const { pattern, reason } of this.patronizingPatterns) {
|
|
||||||
const beforeLength = cleaned.length;
|
|
||||||
cleaned = cleaned.replace(pattern, '');
|
|
||||||
const afterLength = cleaned.length;
|
|
||||||
|
|
||||||
if (beforeLength !== afterLength) {
|
|
||||||
removedCount++;
|
|
||||||
logger.debug('Removed patronizing term', { reason });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removedCount > 0) {
|
|
||||||
this.stats.patronizing_terms_removed += removedCount;
|
|
||||||
// Clean up extra spaces left by removals
|
|
||||||
cleaned = cleaned.replace(/\s{2,}/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
_adaptToStyle(message, styleKey, context) {
|
|
||||||
const style = this.styles[styleKey];
|
|
||||||
|
|
||||||
if (!style) {
|
|
||||||
logger.warn('Unknown communication style', { styleKey });
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Style-specific adaptations
|
|
||||||
switch (styleKey) {
|
|
||||||
case 'FORMAL_ACADEMIC':
|
|
||||||
return this._adaptToFormalAcademic(message, context);
|
|
||||||
case 'CASUAL_DIRECT':
|
|
||||||
return this._adaptToCasualDirect(message, context);
|
|
||||||
case 'MAORI_PROTOCOL':
|
|
||||||
return this._adaptToMaoriProtocol(message, context);
|
|
||||||
case 'JAPANESE_FORMAL':
|
|
||||||
return this._adaptToJapaneseFormal(message, context);
|
|
||||||
case 'PLAIN_LANGUAGE':
|
|
||||||
return this._adaptToPlainLanguage(message, context);
|
|
||||||
default:
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_adaptToFormalAcademic(message, context) {
|
|
||||||
// Add formal register, hedge appropriately
|
|
||||||
// (In production, would use NLP transformation)
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
_adaptToCasualDirect(message, context) {
|
|
||||||
// Remove unnecessary formality, make direct
|
|
||||||
let adapted = message;
|
|
||||||
|
|
||||||
// Replace formal phrases with casual equivalents
|
|
||||||
const replacements = [
|
|
||||||
{ formal: /I would like to inform you that/gi, casual: 'Just so you know,' },
|
|
||||||
{ formal: /It is important to note that/gi, casual: 'Key thing:' },
|
|
||||||
{ formal: /We have determined that/gi, casual: 'We figured' },
|
|
||||||
{ formal: /In accordance with/gi, casual: 'Following' }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { formal, casual } of replacements) {
|
|
||||||
adapted = adapted.replace(formal, casual);
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapted;
|
|
||||||
}
|
|
||||||
|
|
||||||
_adaptToMaoriProtocol(message, context) {
|
|
||||||
// Add appropriate te reo Māori protocol elements
|
|
||||||
// (In production, would consult with Māori language experts)
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
_adaptToJapaneseFormal(message, context) {
|
|
||||||
// Add appropriate Japanese formal register elements
|
|
||||||
// (In production, would use Japanese language processing)
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
_adaptToPlainLanguage(message, context) {
|
|
||||||
// Simplify jargon, use clear language
|
|
||||||
let adapted = message;
|
|
||||||
|
|
||||||
// Replace jargon with plain equivalents
|
|
||||||
const jargonReplacements = [
|
|
||||||
{ jargon: /utilize/gi, plain: 'use' },
|
|
||||||
{ jargon: /facilitate/gi, plain: 'help' },
|
|
||||||
{ jargon: /implement/gi, plain: 'do' },
|
|
||||||
{ jargon: /endeavor/gi, plain: 'try' }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { jargon, plain } of jargonReplacements) {
|
|
||||||
adapted = adapted.replace(jargon, plain);
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapted;
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyCulturalContext(message, culturalContext) {
|
|
||||||
// Apply cultural adaptations based on context
|
|
||||||
// (In production, would be much more sophisticated)
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get communication adaptation statistics
|
|
||||||
* @returns {Object} Statistics object
|
|
||||||
*/
|
|
||||||
getStats() {
|
|
||||||
return {
|
|
||||||
...this.stats,
|
|
||||||
timestamp: new Date()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
const orchestrator = new AdaptiveCommunicationOrchestrator();
|
|
||||||
|
|
||||||
// Export both singleton (default) and class (for testing)
|
|
||||||
module.exports = orchestrator;
|
|
||||||
module.exports.AdaptiveCommunicationOrchestrator = AdaptiveCommunicationOrchestrator;
|
|
||||||
|
|
@ -1,609 +0,0 @@
|
||||||
/**
|
|
||||||
* Blog Curation Service
|
|
||||||
*
|
|
||||||
* AI-assisted blog content curation with mandatory human oversight.
|
|
||||||
* Implements Tractatus framework boundary enforcement for content generation.
|
|
||||||
*
|
|
||||||
* Governance: TRA-OPS-0002 (AI suggests, human decides)
|
|
||||||
* Boundary Rules:
|
|
||||||
* - inst_016: NEVER fabricate statistics or make unverifiable claims
|
|
||||||
* - inst_017: NEVER use absolute assurance terms (guarantee, ensures 100%, etc.)
|
|
||||||
* - inst_018: NEVER claim production-ready status without evidence
|
|
||||||
*
|
|
||||||
* All AI-generated content MUST be reviewed and approved by a human before publication.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const claudeAPI = require('./ClaudeAPI.service');
|
|
||||||
const BoundaryEnforcer = require('./BoundaryEnforcer.service');
|
|
||||||
const { getMemoryProxy } = require('./MemoryProxy.service');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
class BlogCurationService {
|
|
||||||
constructor() {
|
|
||||||
// Initialize MemoryProxy for governance rule persistence and audit logging
|
|
||||||
this.memoryProxy = getMemoryProxy();
|
|
||||||
this.enforcementRules = {}; // Will load inst_016, inst_017, inst_018
|
|
||||||
this.memoryProxyInitialized = false;
|
|
||||||
|
|
||||||
// Editorial guidelines - core principles for blog content
|
|
||||||
this.editorialGuidelines = {
|
|
||||||
tone: 'Professional, informative, evidence-based',
|
|
||||||
voice: 'Third-person objective (AI safety framework documentation)',
|
|
||||||
style: 'Clear, accessible technical writing',
|
|
||||||
principles: [
|
|
||||||
'Transparency: Cite sources for all claims',
|
|
||||||
'Honesty: Acknowledge limitations and unknowns',
|
|
||||||
'Evidence: No fabricated statistics or unverifiable claims',
|
|
||||||
'Humility: No absolute guarantees or 100% assurances',
|
|
||||||
'Accuracy: Production status claims must have evidence'
|
|
||||||
],
|
|
||||||
forbiddenPatterns: [
|
|
||||||
'Fabricated statistics without sources',
|
|
||||||
'Absolute terms: guarantee, ensures 100%, never fails, always works',
|
|
||||||
'Unverified production claims: battle-tested (without evidence), industry-standard (without adoption metrics)',
|
|
||||||
'Emotional manipulation or fear-mongering',
|
|
||||||
'Misleading comparisons or false dichotomies'
|
|
||||||
],
|
|
||||||
targetWordCounts: {
|
|
||||||
short: '600-900 words',
|
|
||||||
medium: '1000-1500 words',
|
|
||||||
long: '1800-2500 words'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize MemoryProxy and load enforcement rules
|
|
||||||
* @returns {Promise<Object>} Initialization result
|
|
||||||
*/
|
|
||||||
async initialize() {
|
|
||||||
try {
|
|
||||||
await this.memoryProxy.initialize();
|
|
||||||
|
|
||||||
// Load critical enforcement rules from memory
|
|
||||||
const criticalRuleIds = ['inst_016', 'inst_017', 'inst_018'];
|
|
||||||
let rulesLoaded = 0;
|
|
||||||
|
|
||||||
for (const ruleId of criticalRuleIds) {
|
|
||||||
const rule = await this.memoryProxy.getRule(ruleId);
|
|
||||||
if (rule) {
|
|
||||||
this.enforcementRules[ruleId] = rule;
|
|
||||||
rulesLoaded++;
|
|
||||||
} else {
|
|
||||||
logger.warn(`[BlogCuration] Enforcement rule ${ruleId} not found in memory`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.memoryProxyInitialized = true;
|
|
||||||
|
|
||||||
logger.info('[BlogCuration] MemoryProxy initialized', {
|
|
||||||
rulesLoaded,
|
|
||||||
totalCriticalRules: criticalRuleIds.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
rulesLoaded,
|
|
||||||
enforcementRules: Object.keys(this.enforcementRules)
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[BlogCuration] Failed to initialize MemoryProxy', {
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
// Continue with existing validation logic even if memory fails
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
rulesLoaded: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draft a full blog post using AI
|
|
||||||
*
|
|
||||||
* @param {Object} params - Blog post parameters
|
|
||||||
* @param {string} params.topic - Blog post topic/title
|
|
||||||
* @param {string} params.audience - Target audience (researcher/implementer/advocate)
|
|
||||||
* @param {string} params.length - Desired length (short/medium/long)
|
|
||||||
* @param {string} params.focus - Optional focus area or angle
|
|
||||||
* @returns {Promise<Object>} Draft blog post with metadata
|
|
||||||
*/
|
|
||||||
async draftBlogPost(params) {
|
|
||||||
const { topic, audience, length = 'medium', focus } = params;
|
|
||||||
|
|
||||||
logger.info(`[BlogCuration] Drafting blog post: "${topic}" for ${audience}`);
|
|
||||||
|
|
||||||
// 1. Boundary check - content generation requires human oversight
|
|
||||||
const boundaryCheck = BoundaryEnforcer.enforce({
|
|
||||||
description: 'Generate AI-drafted blog content for human review',
|
|
||||||
text: 'Blog post will be queued for mandatory human approval before publication',
|
|
||||||
classification: { quadrant: 'OPERATIONAL' },
|
|
||||||
type: 'content_generation'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!boundaryCheck.allowed) {
|
|
||||||
logger.warn(`[BlogCuration] Boundary check failed: ${boundaryCheck.reasoning}`);
|
|
||||||
throw new Error(`Boundary violation: ${boundaryCheck.reasoning}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Build system prompt with editorial guidelines and Tractatus constraints
|
|
||||||
const systemPrompt = this._buildSystemPrompt(audience);
|
|
||||||
|
|
||||||
// 3. Build user prompt for blog post generation
|
|
||||||
const userPrompt = this._buildDraftPrompt(topic, audience, length, focus);
|
|
||||||
|
|
||||||
// 4. Call Claude API
|
|
||||||
const messages = [{ role: 'user', content: userPrompt }];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await claudeAPI.sendMessage(messages, {
|
|
||||||
system: systemPrompt,
|
|
||||||
max_tokens: this._getMaxTokens(length),
|
|
||||||
temperature: 0.7 // Balanced creativity and consistency
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = claudeAPI.extractJSON(response);
|
|
||||||
|
|
||||||
// 5. Validate generated content against Tractatus principles
|
|
||||||
const validation = await this._validateContent(content);
|
|
||||||
|
|
||||||
// 6. Return draft with validation results
|
|
||||||
return {
|
|
||||||
draft: content,
|
|
||||||
validation,
|
|
||||||
boundary_check: boundaryCheck,
|
|
||||||
metadata: {
|
|
||||||
generated_at: new Date(),
|
|
||||||
model: response.model,
|
|
||||||
usage: response.usage,
|
|
||||||
audience,
|
|
||||||
length,
|
|
||||||
requires_human_approval: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[BlogCuration] Draft generation failed:', error);
|
|
||||||
throw new Error(`Blog draft generation failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suggest blog topics based on audience and existing documents
|
|
||||||
* (Fetches documents from site as context for topic generation)
|
|
||||||
*
|
|
||||||
* @param {string} audience - Target audience
|
|
||||||
* @param {string} theme - Optional theme/focus
|
|
||||||
* @returns {Promise<Array>} Topic suggestions with metadata
|
|
||||||
*/
|
|
||||||
async suggestTopics(audience, theme = null) {
|
|
||||||
logger.info(`[BlogCuration] Suggesting topics: audience=${audience}, theme=${theme || 'general'}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch existing documents as context
|
|
||||||
const Document = require('../models/Document.model');
|
|
||||||
const documents = await Document.list({ limit: 20, skip: 0 });
|
|
||||||
|
|
||||||
// Build context from document titles and summaries
|
|
||||||
const documentContext = documents.map(doc => ({
|
|
||||||
title: doc.title,
|
|
||||||
slug: doc.slug,
|
|
||||||
summary: doc.summary || doc.description || ''
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Generate topics with document context
|
|
||||||
const systemPrompt = `You are a content strategist for the Tractatus AI Safety Framework.
|
|
||||||
Your role is to suggest blog post topics that educate audiences about AI safety through sovereignty,
|
|
||||||
transparency, harmlessness, and community principles.
|
|
||||||
|
|
||||||
The framework prevents AI from making irreducible human decisions and requires human oversight
|
|
||||||
for all values-sensitive choices.
|
|
||||||
|
|
||||||
EXISTING DOCUMENTS ON SITE:
|
|
||||||
${documentContext.map(d => `- ${d.title}: ${d.summary}`).join('\n')}
|
|
||||||
|
|
||||||
Suggest topics that:
|
|
||||||
1. Complement existing content (don't duplicate)
|
|
||||||
2. Address gaps in current documentation
|
|
||||||
3. Provide practical insights for ${audience} audience
|
|
||||||
4. Maintain Tractatus principles (no fabricated stats, no absolute guarantees)`;
|
|
||||||
|
|
||||||
const userPrompt = theme
|
|
||||||
? `Based on the existing documents above, suggest 5-7 NEW blog post topics for ${audience} audience focused on: ${theme}
|
|
||||||
|
|
||||||
For each topic, provide:
|
|
||||||
{
|
|
||||||
"title": "compelling, specific title",
|
|
||||||
"rationale": "why this topic fills a gap or complements existing content",
|
|
||||||
"target_word_count": 800-1500,
|
|
||||||
"key_points": ["3-5 bullet points"],
|
|
||||||
"tractatus_angle": "how it relates to framework principles"
|
|
||||||
}
|
|
||||||
|
|
||||||
Respond with JSON array.`
|
|
||||||
: `Based on the existing documents above, suggest 5-7 NEW blog post topics for ${audience} audience about the Tractatus AI Safety Framework.
|
|
||||||
|
|
||||||
For each topic, provide:
|
|
||||||
{
|
|
||||||
"title": "compelling, specific title",
|
|
||||||
"rationale": "why this topic fills a gap or complements existing content",
|
|
||||||
"target_word_count": 800-1500,
|
|
||||||
"key_points": ["3-5 bullet points"],
|
|
||||||
"tractatus_angle": "how it relates to framework principles"
|
|
||||||
}
|
|
||||||
|
|
||||||
Respond with JSON array.`;
|
|
||||||
|
|
||||||
const messages = [{ role: 'user', content: userPrompt }];
|
|
||||||
|
|
||||||
const response = await claudeAPI.sendMessage(messages, {
|
|
||||||
system: systemPrompt,
|
|
||||||
max_tokens: 2048
|
|
||||||
});
|
|
||||||
|
|
||||||
const topics = claudeAPI.extractJSON(response);
|
|
||||||
|
|
||||||
// Validate topics don't contain forbidden patterns
|
|
||||||
const validatedTopics = topics.map(topic => ({
|
|
||||||
...topic,
|
|
||||||
validation: this._validateTopicTitle(topic.title)
|
|
||||||
}));
|
|
||||||
|
|
||||||
return validatedTopics;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[BlogCuration] Topic suggestion failed:', error);
|
|
||||||
throw new Error(`Topic suggestion failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze existing blog content for Tractatus compliance
|
|
||||||
*
|
|
||||||
* @param {Object} content - Blog post content {title, body}
|
|
||||||
* @returns {Promise<Object>} Compliance analysis
|
|
||||||
*/
|
|
||||||
async analyzeContentCompliance(content) {
|
|
||||||
const { title, body } = content;
|
|
||||||
|
|
||||||
logger.info(`[BlogCuration] Analyzing content compliance: "${title}"`);
|
|
||||||
|
|
||||||
const systemPrompt = `You are a Tractatus Framework compliance auditor.
|
|
||||||
Analyze content for violations of these principles:
|
|
||||||
|
|
||||||
1. NEVER fabricate statistics or make unverifiable claims
|
|
||||||
2. NEVER use absolute assurance terms (guarantee, ensures 100%, never fails, always works)
|
|
||||||
3. NEVER claim production-ready status without concrete evidence
|
|
||||||
4. ALWAYS cite sources for statistics and claims
|
|
||||||
5. ALWAYS acknowledge limitations and unknowns
|
|
||||||
|
|
||||||
Return JSON with compliance analysis.`;
|
|
||||||
|
|
||||||
const userPrompt = `Analyze this blog post for Tractatus compliance:
|
|
||||||
|
|
||||||
Title: ${title}
|
|
||||||
|
|
||||||
Content:
|
|
||||||
${body}
|
|
||||||
|
|
||||||
Respond with JSON:
|
|
||||||
{
|
|
||||||
"compliant": true/false,
|
|
||||||
"violations": [
|
|
||||||
{
|
|
||||||
"type": "FABRICATED_STAT|ABSOLUTE_CLAIM|UNVERIFIED_PRODUCTION|OTHER",
|
|
||||||
"severity": "HIGH|MEDIUM|LOW",
|
|
||||||
"excerpt": "problematic text snippet",
|
|
||||||
"reasoning": "why this violates principles",
|
|
||||||
"suggested_fix": "how to correct it"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"warnings": ["..."],
|
|
||||||
"strengths": ["..."],
|
|
||||||
"overall_score": 0-100,
|
|
||||||
"recommendation": "PUBLISH|EDIT_REQUIRED|REJECT"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const messages = [{ role: 'user', content: userPrompt }];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await claudeAPI.sendMessage(messages, {
|
|
||||||
system: systemPrompt,
|
|
||||||
max_tokens: 2048
|
|
||||||
});
|
|
||||||
|
|
||||||
return claudeAPI.extractJSON(response);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[BlogCuration] Compliance analysis failed:', error);
|
|
||||||
throw new Error(`Compliance analysis failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate SEO-friendly slug from title
|
|
||||||
*
|
|
||||||
* @param {string} title - Blog post title
|
|
||||||
* @returns {string} URL-safe slug
|
|
||||||
*/
|
|
||||||
generateSlug(title) {
|
|
||||||
return title
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
.substring(0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract excerpt from blog content
|
|
||||||
*
|
|
||||||
* @param {string} content - Full blog content (HTML or markdown)
|
|
||||||
* @param {number} maxLength - Maximum excerpt length (default 200)
|
|
||||||
* @returns {string} Excerpt
|
|
||||||
*/
|
|
||||||
extractExcerpt(content, maxLength = 200) {
|
|
||||||
// Strip HTML/markdown tags
|
|
||||||
const plainText = content
|
|
||||||
.replace(/<[^>]*>/g, '')
|
|
||||||
.replace(/[#*_`]/g, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (plainText.length <= maxLength) {
|
|
||||||
return plainText;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find last complete sentence within maxLength
|
|
||||||
const excerpt = plainText.substring(0, maxLength);
|
|
||||||
const lastPeriod = excerpt.lastIndexOf('.');
|
|
||||||
|
|
||||||
if (lastPeriod > maxLength * 0.5) {
|
|
||||||
return excerpt.substring(0, lastPeriod + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return excerpt.substring(0, maxLength).trim() + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build system prompt with editorial guidelines
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_buildSystemPrompt(audience) {
|
|
||||||
const audienceContext = {
|
|
||||||
researcher: 'Academic researchers, AI safety specialists, technical analysts',
|
|
||||||
implementer: 'Software engineers, system architects, technical decision-makers',
|
|
||||||
advocate: 'Policy makers, ethicists, public stakeholders, non-technical audiences',
|
|
||||||
general: 'Mixed audience with varying technical backgrounds'
|
|
||||||
};
|
|
||||||
|
|
||||||
return `You are a professional technical writer creating content for the Tractatus AI Safety Framework blog.
|
|
||||||
|
|
||||||
AUDIENCE: ${audienceContext[audience] || audienceContext.general}
|
|
||||||
|
|
||||||
TRACTATUS FRAMEWORK CORE PRINCIPLES:
|
|
||||||
1. What cannot be systematized must not be automated
|
|
||||||
2. AI must never make irreducible human decisions
|
|
||||||
3. Sovereignty: User agency over values and goals
|
|
||||||
4. Transparency: Explicit instructions, audit trails, governance logs
|
|
||||||
5. Harmlessness: Boundary enforcement prevents values automation
|
|
||||||
6. Community: Open frameworks, shared governance patterns
|
|
||||||
|
|
||||||
EDITORIAL GUIDELINES:
|
|
||||||
- Tone: ${this.editorialGuidelines.tone}
|
|
||||||
- Voice: ${this.editorialGuidelines.voice}
|
|
||||||
- Style: ${this.editorialGuidelines.style}
|
|
||||||
|
|
||||||
MANDATORY CONSTRAINTS (inst_016, inst_017, inst_018):
|
|
||||||
${this.editorialGuidelines.principles.map(p => `- ${p}`).join('\n')}
|
|
||||||
|
|
||||||
FORBIDDEN PATTERNS:
|
|
||||||
${this.editorialGuidelines.forbiddenPatterns.map(p => `- ${p}`).join('\n')}
|
|
||||||
|
|
||||||
OUTPUT FORMAT: JSON with structure:
|
|
||||||
{
|
|
||||||
"title": "SEO-friendly title (60 chars max)",
|
|
||||||
"subtitle": "Compelling subtitle (120 chars max)",
|
|
||||||
"content": "Full blog post content in Markdown format",
|
|
||||||
"excerpt": "Brief excerpt (150-200 chars)",
|
|
||||||
"tags": ["tag1", "tag2", "tag3"],
|
|
||||||
"tractatus_angle": "How this relates to framework principles",
|
|
||||||
"sources": ["URL or reference for claims made"],
|
|
||||||
"word_count": actual_word_count
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build user prompt for blog post draft
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_buildDraftPrompt(topic, audience, length, focus) {
|
|
||||||
const wordCount = {
|
|
||||||
short: '600-900',
|
|
||||||
medium: '1000-1500',
|
|
||||||
long: '1800-2500'
|
|
||||||
}[length] || '1000-1500';
|
|
||||||
|
|
||||||
let prompt = `Write a blog post about: ${topic}
|
|
||||||
|
|
||||||
Target word count: ${wordCount} words
|
|
||||||
Audience: ${audience}`;
|
|
||||||
|
|
||||||
if (focus) {
|
|
||||||
prompt += `\nSpecific focus: ${focus}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt += `
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
1. Evidence-based: Cite sources for all statistics and claims
|
|
||||||
2. Honest: Acknowledge limitations, unknowns, trade-offs
|
|
||||||
3. No fabricated data or unverifiable claims
|
|
||||||
4. No absolute guarantees or 100% assurances
|
|
||||||
5. Clear connection to Tractatus framework principles
|
|
||||||
6. Actionable insights or takeaways for the ${audience} audience
|
|
||||||
7. SEO-friendly structure with headers, lists, and clear sections
|
|
||||||
|
|
||||||
Respond with JSON as specified in the system prompt.`;
|
|
||||||
|
|
||||||
return prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get max tokens based on target length
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_getMaxTokens(length) {
|
|
||||||
const tokenMap = {
|
|
||||||
short: 2048,
|
|
||||||
medium: 3072,
|
|
||||||
long: 4096
|
|
||||||
};
|
|
||||||
return tokenMap[length] || 3072;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate content against Tractatus principles
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async _validateContent(content) {
|
|
||||||
const violations = [];
|
|
||||||
const warnings = [];
|
|
||||||
|
|
||||||
const textToCheck = `${content.title} ${content.subtitle} ${content.content}`.toLowerCase();
|
|
||||||
|
|
||||||
// Check for forbidden patterns (inst_016, inst_017, inst_018)
|
|
||||||
const forbiddenTerms = {
|
|
||||||
absolute_guarantees: ['guarantee', 'guarantees', 'guaranteed', 'ensures 100%', 'never fails', 'always works', '100% safe', '100% secure'],
|
|
||||||
fabricated_stats: [], // Can't detect without external validation
|
|
||||||
unverified_production: ['battle-tested', 'production-proven', 'industry-standard']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check absolute guarantees (inst_017)
|
|
||||||
forbiddenTerms.absolute_guarantees.forEach(term => {
|
|
||||||
if (textToCheck.includes(term)) {
|
|
||||||
violations.push({
|
|
||||||
type: 'ABSOLUTE_GUARANTEE',
|
|
||||||
severity: 'HIGH',
|
|
||||||
term,
|
|
||||||
instruction: 'inst_017',
|
|
||||||
message: `Forbidden absolute assurance term: "${term}"`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check unverified production claims (inst_018)
|
|
||||||
forbiddenTerms.unverified_production.forEach(term => {
|
|
||||||
if (textToCheck.includes(term) && (!content.sources || content.sources.length === 0)) {
|
|
||||||
warnings.push({
|
|
||||||
type: 'UNVERIFIED_CLAIM',
|
|
||||||
severity: 'MEDIUM',
|
|
||||||
term,
|
|
||||||
instruction: 'inst_018',
|
|
||||||
message: `Production claim "${term}" requires citation`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for uncited statistics (inst_016)
|
|
||||||
const statPattern = /\d+(\.\d+)?%/g;
|
|
||||||
const statsFound = (content.content.match(statPattern) || []).length;
|
|
||||||
|
|
||||||
if (statsFound > 0 && (!content.sources || content.sources.length === 0)) {
|
|
||||||
warnings.push({
|
|
||||||
type: 'UNCITED_STATISTICS',
|
|
||||||
severity: 'HIGH',
|
|
||||||
instruction: 'inst_016',
|
|
||||||
message: `Found ${statsFound} statistics without sources - verify these are not fabricated`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = violations.length === 0;
|
|
||||||
|
|
||||||
const validationResult = {
|
|
||||||
valid: isValid,
|
|
||||||
violations,
|
|
||||||
warnings,
|
|
||||||
stats_found: statsFound,
|
|
||||||
sources_provided: content.sources?.length || 0,
|
|
||||||
recommendation: violations.length > 0 ? 'REJECT' :
|
|
||||||
warnings.length > 0 ? 'REVIEW_REQUIRED' :
|
|
||||||
'APPROVED'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Audit validation decision
|
|
||||||
this._auditValidationDecision(content, validationResult);
|
|
||||||
|
|
||||||
return validationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audit content validation decision to memory (async, non-blocking)
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_auditValidationDecision(content, validationResult) {
|
|
||||||
// Only audit if MemoryProxy is initialized
|
|
||||||
if (!this.memoryProxyInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract violation instruction IDs
|
|
||||||
const violatedRules = [
|
|
||||||
...validationResult.violations.map(v => v.instruction),
|
|
||||||
...validationResult.warnings.map(w => w.instruction)
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
// Audit asynchronously (don't block validation)
|
|
||||||
this.memoryProxy.auditDecision({
|
|
||||||
sessionId: 'blog-curation-service',
|
|
||||||
action: 'content_validation',
|
|
||||||
rulesChecked: Object.keys(this.enforcementRules),
|
|
||||||
violations: violatedRules,
|
|
||||||
allowed: validationResult.valid,
|
|
||||||
metadata: {
|
|
||||||
content_title: content.title,
|
|
||||||
violation_count: validationResult.violations.length,
|
|
||||||
warning_count: validationResult.warnings.length,
|
|
||||||
stats_found: validationResult.stats_found,
|
|
||||||
sources_provided: validationResult.sources_provided,
|
|
||||||
recommendation: validationResult.recommendation
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
logger.error('[BlogCuration] Failed to audit validation decision', {
|
|
||||||
error: error.message,
|
|
||||||
title: content.title
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate topic title for forbidden patterns
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_validateTopicTitle(title) {
|
|
||||||
const textToCheck = title.toLowerCase();
|
|
||||||
const issues = [];
|
|
||||||
|
|
||||||
// Check for absolute guarantees
|
|
||||||
if (textToCheck.match(/guarantee|100%|never fail|always work/)) {
|
|
||||||
issues.push('Contains absolute assurance language');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: issues.length === 0,
|
|
||||||
issues
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get editorial guidelines (for display in admin UI)
|
|
||||||
*
|
|
||||||
* @returns {Object} Editorial guidelines
|
|
||||||
*/
|
|
||||||
getEditorialGuidelines() {
|
|
||||||
return this.editorialGuidelines;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
module.exports = new BlogCurationService();
|
|
||||||
|
|
@ -1,425 +0,0 @@
|
||||||
/**
|
|
||||||
* Claude API Service
|
|
||||||
*
|
|
||||||
* Provides interface to Anthropic's Claude API for AI-powered features.
|
|
||||||
* All AI operations go through this service to ensure consistent error handling,
|
|
||||||
* rate limiting, and governance compliance.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const claudeAPI = require('./ClaudeAPI.service');
|
|
||||||
* const response = await claudeAPI.sendMessage(messages, options);
|
|
||||||
*/
|
|
||||||
|
|
||||||
const https = require('https');
|
|
||||||
|
|
||||||
class ClaudeAPIService {
|
|
||||||
constructor() {
|
|
||||||
this.apiKey = process.env.CLAUDE_API_KEY;
|
|
||||||
this.model = process.env.CLAUDE_MODEL || 'claude-sonnet-4-5-20250929';
|
|
||||||
this.maxTokens = parseInt(process.env.CLAUDE_MAX_TOKENS) || 4096;
|
|
||||||
this.apiVersion = '2023-06-01';
|
|
||||||
this.hostname = 'api.anthropic.com';
|
|
||||||
|
|
||||||
if (!this.apiKey) {
|
|
||||||
console.error('WARNING: CLAUDE_API_KEY not set in environment variables');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a message to Claude API
|
|
||||||
*
|
|
||||||
* @param {Array} messages - Array of message objects [{role: 'user', content: '...'}]
|
|
||||||
* @param {Object} options - Optional overrides (model, max_tokens, temperature)
|
|
||||||
* @returns {Promise<Object>} API response
|
|
||||||
*/
|
|
||||||
async sendMessage(messages, options = {}) {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
throw new Error('Claude API key not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
model: options.model || this.model,
|
|
||||||
max_tokens: options.max_tokens || this.maxTokens,
|
|
||||||
messages: messages,
|
|
||||||
...(options.system && { system: options.system }),
|
|
||||||
...(options.temperature && { temperature: options.temperature })
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this._makeRequest(payload);
|
|
||||||
|
|
||||||
// Log usage for monitoring
|
|
||||||
if (response.usage) {
|
|
||||||
console.log(`[ClaudeAPI] Usage: ${response.usage.input_tokens} in, ${response.usage.output_tokens} out`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ClaudeAPI] Error:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make HTTP request to Claude API
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @param {Object} payload - Request payload
|
|
||||||
* @returns {Promise<Object>} Parsed response
|
|
||||||
*/
|
|
||||||
_makeRequest(payload) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const postData = JSON.stringify(payload);
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
hostname: this.hostname,
|
|
||||||
port: 443,
|
|
||||||
path: '/v1/messages',
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-api-key': this.apiKey,
|
|
||||||
'anthropic-version': this.apiVersion,
|
|
||||||
'Content-Length': Buffer.byteLength(postData)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
|
||||||
let data = '';
|
|
||||||
|
|
||||||
res.on('data', (chunk) => {
|
|
||||||
data += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on('end', () => {
|
|
||||||
if (res.statusCode === 200) {
|
|
||||||
try {
|
|
||||||
const response = JSON.parse(data);
|
|
||||||
resolve(response);
|
|
||||||
} catch (error) {
|
|
||||||
reject(new Error(`Failed to parse API response: ${error.message}`));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reject(new Error(`API request failed with status ${res.statusCode}: ${data}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (error) => {
|
|
||||||
reject(new Error(`Request error: ${error.message}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
req.write(postData);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract text content from Claude API response
|
|
||||||
*
|
|
||||||
* @param {Object} response - Claude API response
|
|
||||||
* @returns {string} Extracted text content
|
|
||||||
*/
|
|
||||||
extractTextContent(response) {
|
|
||||||
if (!response || !response.content || !Array.isArray(response.content)) {
|
|
||||||
throw new Error('Invalid Claude API response format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const textBlock = response.content.find(block => block.type === 'text');
|
|
||||||
if (!textBlock) {
|
|
||||||
throw new Error('No text content in Claude API response');
|
|
||||||
}
|
|
||||||
|
|
||||||
return textBlock.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse JSON from Claude response (handles markdown code blocks)
|
|
||||||
*
|
|
||||||
* @param {Object} response - Claude API response
|
|
||||||
* @returns {Object} Parsed JSON
|
|
||||||
*/
|
|
||||||
extractJSON(response) {
|
|
||||||
const text = this.extractTextContent(response);
|
|
||||||
|
|
||||||
// Remove markdown code blocks if present
|
|
||||||
let jsonText = text.trim();
|
|
||||||
if (jsonText.startsWith('```json')) {
|
|
||||||
jsonText = jsonText.replace(/^```json\n/, '').replace(/\n```$/, '');
|
|
||||||
} else if (jsonText.startsWith('```')) {
|
|
||||||
jsonText = jsonText.replace(/^```\n/, '').replace(/\n```$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(jsonText);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to parse JSON from Claude response: ${error.message}\nText: ${jsonText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify an instruction into Tractatus quadrants
|
|
||||||
*
|
|
||||||
* @param {string} instructionText - The instruction to classify
|
|
||||||
* @returns {Promise<Object>} Classification result
|
|
||||||
*/
|
|
||||||
async classifyInstruction(instructionText) {
|
|
||||||
const messages = [{
|
|
||||||
role: 'user',
|
|
||||||
content: `Classify the following instruction into one of these quadrants: STRATEGIC, OPERATIONAL, TACTICAL, SYSTEM, or STOCHASTIC.
|
|
||||||
|
|
||||||
Instruction: "${instructionText}"
|
|
||||||
|
|
||||||
Respond with JSON only:
|
|
||||||
{
|
|
||||||
"quadrant": "...",
|
|
||||||
"persistence": "HIGH/MEDIUM/LOW",
|
|
||||||
"temporal_scope": "PROJECT/SESSION/TASK",
|
|
||||||
"verification_required": "MANDATORY/RECOMMENDED/NONE",
|
|
||||||
"explicitness": 0.0-1.0,
|
|
||||||
"reasoning": "brief explanation"
|
|
||||||
}`
|
|
||||||
}];
|
|
||||||
|
|
||||||
const response = await this.sendMessage(messages, { max_tokens: 1024 });
|
|
||||||
return this.extractJSON(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate blog topic suggestions
|
|
||||||
*
|
|
||||||
* @param {string} audience - Target audience (researcher/implementer/advocate)
|
|
||||||
* @param {string} theme - Optional theme or focus area
|
|
||||||
* @returns {Promise<Array>} Array of topic suggestions
|
|
||||||
*/
|
|
||||||
async generateBlogTopics(audience, theme = null) {
|
|
||||||
const systemPrompt = `You are a content strategist for the Tractatus AI Safety Framework.
|
|
||||||
Your role is to suggest blog post topics that educate audiences about AI safety through sovereignty,
|
|
||||||
transparency, harmlessness, and community principles.
|
|
||||||
|
|
||||||
The framework prevents AI from making irreducible human decisions and requires human oversight
|
|
||||||
for all values-sensitive choices.`;
|
|
||||||
|
|
||||||
const userPrompt = theme
|
|
||||||
? `Suggest 5-7 blog post topics for ${audience} audience focused on: ${theme}
|
|
||||||
|
|
||||||
For each topic, provide:
|
|
||||||
- Title (compelling, specific)
|
|
||||||
- Subtitle (1 sentence)
|
|
||||||
- Target word count (800-1500)
|
|
||||||
- Key points to cover (3-5 bullets)
|
|
||||||
- Tractatus angle (how it relates to framework)
|
|
||||||
|
|
||||||
Respond with JSON array.`
|
|
||||||
: `Suggest 5-7 blog post topics for ${audience} audience about the Tractatus AI Safety Framework.
|
|
||||||
|
|
||||||
For each topic, provide:
|
|
||||||
- Title (compelling, specific)
|
|
||||||
- Subtitle (1 sentence)
|
|
||||||
- Target word count (800-1500)
|
|
||||||
- Key points to cover (3-5 bullets)
|
|
||||||
- Tractatus angle (how it relates to framework)
|
|
||||||
|
|
||||||
Respond with JSON array.`;
|
|
||||||
|
|
||||||
const messages = [{ role: 'user', content: userPrompt }];
|
|
||||||
|
|
||||||
const response = await this.sendMessage(messages, {
|
|
||||||
system: systemPrompt,
|
|
||||||
max_tokens: 2048
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.extractJSON(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify media inquiry by priority
|
|
||||||
*
|
|
||||||
* @param {Object} inquiry - Media inquiry object {outlet, request, deadline}
|
|
||||||
* @returns {Promise<Object>} Classification with priority and reasoning
|
|
||||||
*/
|
|
||||||
async classifyMediaInquiry(inquiry) {
|
|
||||||
const { outlet, request, deadline } = inquiry;
|
|
||||||
|
|
||||||
const systemPrompt = `You are a media relations assistant for the Tractatus AI Safety Framework.
|
|
||||||
Classify media inquiries by priority (HIGH/MEDIUM/LOW) based on:
|
|
||||||
- Outlet credibility and reach
|
|
||||||
- Request type (interview, comment, feature)
|
|
||||||
- Deadline urgency
|
|
||||||
- Topic relevance to framework`;
|
|
||||||
|
|
||||||
const userPrompt = `Classify this media inquiry:
|
|
||||||
|
|
||||||
Outlet: ${outlet}
|
|
||||||
Request: ${request}
|
|
||||||
Deadline: ${deadline || 'Not specified'}
|
|
||||||
|
|
||||||
Respond with JSON:
|
|
||||||
{
|
|
||||||
"priority": "HIGH/MEDIUM/LOW",
|
|
||||||
"reasoning": "brief explanation",
|
|
||||||
"recommended_response_time": "hours or days",
|
|
||||||
"suggested_spokesperson": "technical expert, policy lead, or framework creator"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const messages = [{ role: 'user', content: userPrompt }];
|
|
||||||
|
|
||||||
const response = await this.sendMessage(messages, {
|
|
||||||
system: systemPrompt,
|
|
||||||
max_tokens: 1024
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.extractJSON(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draft suggested response to media inquiry
|
|
||||||
* (ALWAYS requires human approval before sending - TRA-OPS-0003)
|
|
||||||
*
|
|
||||||
* @param {Object} inquiry - Media inquiry object
|
|
||||||
* @param {string} priority - Priority classification
|
|
||||||
* @returns {Promise<string>} Draft response text
|
|
||||||
*/
|
|
||||||
async draftMediaResponse(inquiry, priority) {
|
|
||||||
const { outlet, request } = inquiry;
|
|
||||||
|
|
||||||
const systemPrompt = `You are drafting a suggested response to a media inquiry about the Tractatus AI Safety Framework.
|
|
||||||
|
|
||||||
IMPORTANT: This is a DRAFT only. A human will review and approve before sending.
|
|
||||||
|
|
||||||
Framework Core Principles:
|
|
||||||
1. What cannot be systematized must not be automated
|
|
||||||
2. AI must never make irreducible human decisions
|
|
||||||
3. Sovereignty: User agency over values and goals
|
|
||||||
4. Transparency: Explicit instructions, audit trails
|
|
||||||
5. Harmlessness: Boundary enforcement prevents values automation
|
|
||||||
6. Community: Open frameworks, shared governance`;
|
|
||||||
|
|
||||||
const userPrompt = `Draft a ${priority} priority response to:
|
|
||||||
|
|
||||||
Outlet: ${outlet}
|
|
||||||
Request: ${request}
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- Professional, informative tone
|
|
||||||
- 150-250 words
|
|
||||||
- Offer specific value (interview, technical details, case studies)
|
|
||||||
- Mention framework website: agenticgovernance.digital
|
|
||||||
- Include contact for follow-up
|
|
||||||
|
|
||||||
Respond with plain text (not JSON).`;
|
|
||||||
|
|
||||||
const messages = [{ role: 'user', content: userPrompt }];
|
|
||||||
|
|
||||||
const response = await this.sendMessage(messages, {
|
|
||||||
system: systemPrompt,
|
|
||||||
max_tokens: 1024
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.extractTextContent(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze case study relevance
|
|
||||||
*
|
|
||||||
* @param {Object} caseStudy - Case study object {title, description, evidence}
|
|
||||||
* @returns {Promise<Object>} Analysis with relevance score
|
|
||||||
*/
|
|
||||||
async analyzeCaseRelevance(caseStudy) {
|
|
||||||
const { title, description, evidence } = caseStudy;
|
|
||||||
|
|
||||||
const systemPrompt = `You are evaluating case study submissions for the Tractatus AI Safety Framework.
|
|
||||||
|
|
||||||
Assess relevance based on:
|
|
||||||
1. Demonstrates framework principles (sovereignty, transparency, harmlessness)
|
|
||||||
2. Shows AI safety concerns addressed by Tractatus
|
|
||||||
3. Provides concrete evidence or examples
|
|
||||||
4. Offers insights valuable to community
|
|
||||||
5. Ethical considerations (privacy, consent, impact)
|
|
||||||
|
|
||||||
Score 0-100 where:
|
|
||||||
- 80-100: Highly relevant, publish with minor edits
|
|
||||||
- 60-79: Relevant, needs some editing
|
|
||||||
- 40-59: Somewhat relevant, major editing needed
|
|
||||||
- 0-39: Not relevant or low quality`;
|
|
||||||
|
|
||||||
const userPrompt = `Analyze this case study submission:
|
|
||||||
|
|
||||||
Title: ${title}
|
|
||||||
Description: ${description}
|
|
||||||
Evidence: ${evidence || 'Not provided'}
|
|
||||||
|
|
||||||
Respond with JSON:
|
|
||||||
{
|
|
||||||
"relevance_score": 0-100,
|
|
||||||
"strengths": ["..."],
|
|
||||||
"weaknesses": ["..."],
|
|
||||||
"recommended_action": "PUBLISH/EDIT/REJECT",
|
|
||||||
"ethical_concerns": ["..."] or null,
|
|
||||||
"suggested_improvements": ["..."]
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const messages = [{ role: 'user', content: userPrompt }];
|
|
||||||
|
|
||||||
const response = await this.sendMessage(messages, {
|
|
||||||
system: systemPrompt,
|
|
||||||
max_tokens: 1536
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.extractJSON(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Curate external resources (websites, papers, tools)
|
|
||||||
*
|
|
||||||
* @param {Object} resource - Resource object {url, title, description}
|
|
||||||
* @returns {Promise<Object>} Curation analysis
|
|
||||||
*/
|
|
||||||
async curateResource(resource) {
|
|
||||||
const { url, title, description } = resource;
|
|
||||||
|
|
||||||
const systemPrompt = `You are curating external resources for the Tractatus AI Safety Framework resource directory.
|
|
||||||
|
|
||||||
Evaluate based on:
|
|
||||||
1. Alignment with framework values (sovereignty, transparency, harmlessness)
|
|
||||||
2. Quality and credibility of source
|
|
||||||
3. Relevance to AI safety, governance, or ethics
|
|
||||||
4. Usefulness to target audiences (researchers, implementers, advocates)
|
|
||||||
|
|
||||||
Categorize into:
|
|
||||||
- PAPERS: Academic research, technical documentation
|
|
||||||
- TOOLS: Software, frameworks, libraries
|
|
||||||
- ORGANIZATIONS: Aligned groups, communities
|
|
||||||
- STANDARDS: Regulatory frameworks, best practices
|
|
||||||
- ARTICLES: Blog posts, essays, commentaries`;
|
|
||||||
|
|
||||||
const userPrompt = `Evaluate this resource for inclusion:
|
|
||||||
|
|
||||||
URL: ${url}
|
|
||||||
Title: ${title}
|
|
||||||
Description: ${description}
|
|
||||||
|
|
||||||
Respond with JSON:
|
|
||||||
{
|
|
||||||
"recommended": true/false,
|
|
||||||
"category": "PAPERS/TOOLS/ORGANIZATIONS/STANDARDS/ARTICLES",
|
|
||||||
"alignment_score": 0-100,
|
|
||||||
"target_audience": ["researcher", "implementer", "advocate"],
|
|
||||||
"tags": ["..."],
|
|
||||||
"reasoning": "brief explanation",
|
|
||||||
"concerns": ["..."] or null
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const messages = [{ role: 'user', content: userPrompt }];
|
|
||||||
|
|
||||||
const response = await this.sendMessage(messages, {
|
|
||||||
system: systemPrompt,
|
|
||||||
max_tokens: 1024
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.extractJSON(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
module.exports = new ClaudeAPIService();
|
|
||||||
|
|
@ -1,442 +0,0 @@
|
||||||
/**
|
|
||||||
* CLAUDE.md Analyzer Service
|
|
||||||
*
|
|
||||||
* Parses CLAUDE.md files and extracts candidate governance rules.
|
|
||||||
* Classifies statements by quality and provides migration recommendations.
|
|
||||||
*
|
|
||||||
* Part of Phase 2: AI Rule Optimizer & CLAUDE.md Analyzer
|
|
||||||
*/
|
|
||||||
|
|
||||||
const RuleOptimizer = require('./RuleOptimizer.service');
|
|
||||||
|
|
||||||
class ClaudeMdAnalyzer {
|
|
||||||
constructor() {
|
|
||||||
// Keywords for quadrant classification
|
|
||||||
this.quadrantKeywords = {
|
|
||||||
STRATEGIC: ['architecture', 'design', 'philosophy', 'approach', 'values', 'mission', 'vision', 'goal'],
|
|
||||||
OPERATIONAL: ['workflow', 'process', 'procedure', 'convention', 'standard', 'practice', 'guideline'],
|
|
||||||
TACTICAL: ['implementation', 'code', 'function', 'class', 'variable', 'syntax', 'pattern'],
|
|
||||||
SYSTEM: ['port', 'database', 'server', 'infrastructure', 'deployment', 'environment', 'service'],
|
|
||||||
STORAGE: ['state', 'session', 'cache', 'persistence', 'data', 'storage', 'memory']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Imperative indicators (for HIGH persistence)
|
|
||||||
this.imperatives = ['MUST', 'SHALL', 'REQUIRED', 'PROHIBITED', 'NEVER', 'ALWAYS', 'MANDATORY'];
|
|
||||||
|
|
||||||
// Preference indicators (for MEDIUM persistence)
|
|
||||||
this.preferences = ['SHOULD', 'RECOMMENDED', 'PREFERRED', 'ENCOURAGED'];
|
|
||||||
|
|
||||||
// Suggestion indicators (for LOW persistence)
|
|
||||||
this.suggestions = ['MAY', 'CAN', 'CONSIDER', 'TRY', 'MIGHT'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse CLAUDE.md content into structured sections
|
|
||||||
*
|
|
||||||
* @param {string} content - Raw CLAUDE.md content
|
|
||||||
* @returns {Object} Parsed structure with sections
|
|
||||||
*/
|
|
||||||
parse(content) {
|
|
||||||
const lines = content.split('\n');
|
|
||||||
const sections = [];
|
|
||||||
let currentSection = null;
|
|
||||||
|
|
||||||
lines.forEach((line, index) => {
|
|
||||||
// Detect headings (# or ##)
|
|
||||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
|
|
||||||
if (headingMatch) {
|
|
||||||
if (currentSection) {
|
|
||||||
sections.push(currentSection);
|
|
||||||
}
|
|
||||||
currentSection = {
|
|
||||||
level: headingMatch[1].length,
|
|
||||||
title: headingMatch[2].trim(),
|
|
||||||
content: [],
|
|
||||||
lineStart: index
|
|
||||||
};
|
|
||||||
} else if (currentSection && line.trim()) {
|
|
||||||
currentSection.content.push(line.trim());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentSection) {
|
|
||||||
sections.push(currentSection);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalLines: lines.length,
|
|
||||||
sections,
|
|
||||||
content: content
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract candidate rules from parsed content
|
|
||||||
*
|
|
||||||
* @param {Object} parsedContent - Output from parse()
|
|
||||||
* @returns {Array<Object>} Array of candidate rules
|
|
||||||
*/
|
|
||||||
extractCandidateRules(parsedContent) {
|
|
||||||
const candidates = [];
|
|
||||||
|
|
||||||
parsedContent.sections.forEach(section => {
|
|
||||||
section.content.forEach(statement => {
|
|
||||||
// Skip very short statements
|
|
||||||
if (statement.length < 15) return;
|
|
||||||
|
|
||||||
// Detect if statement has imperative language
|
|
||||||
const hasImperative = this.imperatives.some(word =>
|
|
||||||
new RegExp(`\\b${word}\\b`).test(statement)
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasPreference = this.preferences.some(word =>
|
|
||||||
new RegExp(`\\b${word}\\b`, 'i').test(statement)
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSuggestion = this.suggestions.some(word =>
|
|
||||||
new RegExp(`\\b${word}\\b`, 'i').test(statement)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only process statements with governance language
|
|
||||||
if (!hasImperative && !hasPreference && !hasSuggestion) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classify quadrant based on keywords
|
|
||||||
const quadrant = this._classifyQuadrant(statement);
|
|
||||||
|
|
||||||
// Classify persistence based on language strength
|
|
||||||
let persistence = 'LOW';
|
|
||||||
if (hasImperative) persistence = 'HIGH';
|
|
||||||
else if (hasPreference) persistence = 'MEDIUM';
|
|
||||||
|
|
||||||
// Detect parameters (ports, paths, etc.)
|
|
||||||
const parameters = this._extractParameters(statement);
|
|
||||||
|
|
||||||
// Analyze quality using RuleOptimizer
|
|
||||||
const analysis = RuleOptimizer.analyzeRule(statement);
|
|
||||||
|
|
||||||
// Determine quality tier
|
|
||||||
let quality = 'TOO_NEBULOUS';
|
|
||||||
let autoConvert = false;
|
|
||||||
|
|
||||||
if (analysis.overallScore >= 80) {
|
|
||||||
quality = 'HIGH';
|
|
||||||
autoConvert = true;
|
|
||||||
} else if (analysis.overallScore >= 60) {
|
|
||||||
quality = 'NEEDS_CLARIFICATION';
|
|
||||||
autoConvert = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate optimized version
|
|
||||||
const optimized = RuleOptimizer.optimize(statement, { mode: 'aggressive' });
|
|
||||||
|
|
||||||
candidates.push({
|
|
||||||
originalText: statement,
|
|
||||||
sectionTitle: section.title,
|
|
||||||
quadrant,
|
|
||||||
persistence,
|
|
||||||
parameters,
|
|
||||||
quality,
|
|
||||||
autoConvert,
|
|
||||||
analysis: {
|
|
||||||
clarityScore: analysis.clarity.score,
|
|
||||||
specificityScore: analysis.specificity.score,
|
|
||||||
actionabilityScore: analysis.actionability.score,
|
|
||||||
overallScore: analysis.overallScore,
|
|
||||||
issues: [
|
|
||||||
...analysis.clarity.issues,
|
|
||||||
...analysis.specificity.issues,
|
|
||||||
...analysis.actionability.issues
|
|
||||||
]
|
|
||||||
},
|
|
||||||
suggestedRule: {
|
|
||||||
text: optimized.optimized,
|
|
||||||
scope: this._determineScope(optimized.optimized),
|
|
||||||
quadrant,
|
|
||||||
persistence,
|
|
||||||
variables: this._detectVariables(optimized.optimized),
|
|
||||||
clarityScore: RuleOptimizer.analyzeRule(optimized.optimized).overallScore
|
|
||||||
},
|
|
||||||
improvements: analysis.suggestions.map(s => s.reason)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect redundant rules
|
|
||||||
*
|
|
||||||
* @param {Array<Object>} candidates - Candidate rules
|
|
||||||
* @returns {Array<Object>} Redundancy groups with merge suggestions
|
|
||||||
*/
|
|
||||||
detectRedundancies(candidates) {
|
|
||||||
const redundancies = [];
|
|
||||||
const processed = new Set();
|
|
||||||
|
|
||||||
for (let i = 0; i < candidates.length; i++) {
|
|
||||||
if (processed.has(i)) continue;
|
|
||||||
|
|
||||||
const similar = [];
|
|
||||||
for (let j = i + 1; j < candidates.length; j++) {
|
|
||||||
if (processed.has(j)) continue;
|
|
||||||
|
|
||||||
const similarity = this._calculateSimilarity(
|
|
||||||
candidates[i].originalText,
|
|
||||||
candidates[j].originalText
|
|
||||||
);
|
|
||||||
|
|
||||||
if (similarity > 0.7) {
|
|
||||||
similar.push(j);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (similar.length > 0) {
|
|
||||||
const group = [candidates[i], ...similar.map(idx => candidates[idx])];
|
|
||||||
similar.forEach(idx => processed.add(idx));
|
|
||||||
processed.add(i);
|
|
||||||
|
|
||||||
redundancies.push({
|
|
||||||
rules: group.map(c => c.originalText),
|
|
||||||
mergeSuggestion: this._suggestMerge(group)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return redundancies;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate migration plan from analysis
|
|
||||||
*
|
|
||||||
* @param {Object} analysis - Complete analysis with candidates and redundancies
|
|
||||||
* @returns {Object} Migration plan
|
|
||||||
*/
|
|
||||||
generateMigrationPlan(analysis) {
|
|
||||||
const { candidates, redundancies } = analysis;
|
|
||||||
|
|
||||||
const highQuality = candidates.filter(c => c.quality === 'HIGH');
|
|
||||||
const needsClarification = candidates.filter(c => c.quality === 'NEEDS_CLARIFICATION');
|
|
||||||
const tooNebulous = candidates.filter(c => c.quality === 'TOO_NEBULOUS');
|
|
||||||
|
|
||||||
return {
|
|
||||||
summary: {
|
|
||||||
totalStatements: candidates.length,
|
|
||||||
highQuality: highQuality.length,
|
|
||||||
needsClarification: needsClarification.length,
|
|
||||||
tooNebulous: tooNebulous.length,
|
|
||||||
redundancies: redundancies.length,
|
|
||||||
autoConvertable: candidates.filter(c => c.autoConvert).length
|
|
||||||
},
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
phase: 'Auto-Convert',
|
|
||||||
count: highQuality.length,
|
|
||||||
description: 'High-quality rules that can be auto-converted',
|
|
||||||
rules: highQuality.map(c => c.suggestedRule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phase: 'Review & Clarify',
|
|
||||||
count: needsClarification.length,
|
|
||||||
description: 'Rules needing clarification before conversion',
|
|
||||||
rules: needsClarification.map(c => ({
|
|
||||||
original: c.originalText,
|
|
||||||
suggested: c.suggestedRule,
|
|
||||||
issues: c.analysis.issues,
|
|
||||||
improvements: c.improvements
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phase: 'Manual Rewrite',
|
|
||||||
count: tooNebulous.length,
|
|
||||||
description: 'Statements too vague - require manual rewrite',
|
|
||||||
rules: tooNebulous.map(c => ({
|
|
||||||
original: c.originalText,
|
|
||||||
suggestions: c.improvements
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phase: 'Merge Redundancies',
|
|
||||||
count: redundancies.length,
|
|
||||||
description: 'Similar rules that should be merged',
|
|
||||||
groups: redundancies
|
|
||||||
}
|
|
||||||
],
|
|
||||||
estimatedTime: this._estimateMigrationTime(candidates, redundancies)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze complete CLAUDE.md file
|
|
||||||
*
|
|
||||||
* @param {string} content - CLAUDE.md content
|
|
||||||
* @returns {Object} Complete analysis
|
|
||||||
*/
|
|
||||||
analyze(content) {
|
|
||||||
const parsed = this.parse(content);
|
|
||||||
const candidates = this.extractCandidateRules(parsed);
|
|
||||||
const redundancies = this.detectRedundancies(candidates);
|
|
||||||
const migrationPlan = this.generateMigrationPlan({ candidates, redundancies });
|
|
||||||
|
|
||||||
return {
|
|
||||||
parsed,
|
|
||||||
candidates,
|
|
||||||
redundancies,
|
|
||||||
migrationPlan,
|
|
||||||
quality: {
|
|
||||||
highQuality: candidates.filter(c => c.quality === 'HIGH').length,
|
|
||||||
needsClarification: candidates.filter(c => c.quality === 'NEEDS_CLARIFICATION').length,
|
|
||||||
tooNebulous: candidates.filter(c => c.quality === 'TOO_NEBULOUS').length,
|
|
||||||
averageScore: Math.round(
|
|
||||||
candidates.reduce((sum, c) => sum + c.analysis.overallScore, 0) / candidates.length
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== PRIVATE HELPER METHODS ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify statement into Tractatus quadrant
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_classifyQuadrant(statement) {
|
|
||||||
const lower = statement.toLowerCase();
|
|
||||||
let bestMatch = 'TACTICAL';
|
|
||||||
let maxMatches = 0;
|
|
||||||
|
|
||||||
for (const [quadrant, keywords] of Object.entries(this.quadrantKeywords)) {
|
|
||||||
const matches = keywords.filter(keyword => lower.includes(keyword)).length;
|
|
||||||
if (matches > maxMatches) {
|
|
||||||
maxMatches = matches;
|
|
||||||
bestMatch = quadrant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract parameters from statement (ports, paths, etc.)
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_extractParameters(statement) {
|
|
||||||
const parameters = {};
|
|
||||||
|
|
||||||
// Port numbers
|
|
||||||
const portMatch = statement.match(/port\s+(\d+)/i);
|
|
||||||
if (portMatch) {
|
|
||||||
parameters.port = portMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database types
|
|
||||||
const dbMatch = statement.match(/\b(mongodb|postgresql|mysql|redis)\b/i);
|
|
||||||
if (dbMatch) {
|
|
||||||
parameters.database = dbMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paths
|
|
||||||
const pathMatch = statement.match(/[\/\\][\w\/\\.-]+/);
|
|
||||||
if (pathMatch) {
|
|
||||||
parameters.path = pathMatch[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment
|
|
||||||
const envMatch = statement.match(/\b(production|development|staging|test)\b/i);
|
|
||||||
if (envMatch) {
|
|
||||||
parameters.environment = envMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return parameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect variables in optimized text
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_detectVariables(text) {
|
|
||||||
const matches = text.matchAll(/\$\{([A-Z_]+)\}/g);
|
|
||||||
return Array.from(matches, m => m[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if rule should be universal or project-specific
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_determineScope(text) {
|
|
||||||
// If has variables, likely universal
|
|
||||||
if (this._detectVariables(text).length > 0) {
|
|
||||||
return 'UNIVERSAL';
|
|
||||||
}
|
|
||||||
|
|
||||||
// If references specific project name, project-specific
|
|
||||||
if (/\b(tractatus|family-history|sydigital)\b/i.test(text)) {
|
|
||||||
return 'PROJECT_SPECIFIC';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to universal for reusability
|
|
||||||
return 'UNIVERSAL';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate text similarity (Jaccard coefficient)
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_calculateSimilarity(text1, text2) {
|
|
||||||
const words1 = new Set(text1.toLowerCase().split(/\s+/));
|
|
||||||
const words2 = new Set(text2.toLowerCase().split(/\s+/));
|
|
||||||
|
|
||||||
const intersection = new Set([...words1].filter(w => words2.has(w)));
|
|
||||||
const union = new Set([...words1, ...words2]);
|
|
||||||
|
|
||||||
return intersection.size / union.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suggest merged rule from similar rules
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_suggestMerge(group) {
|
|
||||||
// Take the most specific rule as base
|
|
||||||
const sorted = group.sort((a, b) =>
|
|
||||||
b.analysis.specificityScore - a.analysis.specificityScore
|
|
||||||
);
|
|
||||||
|
|
||||||
return sorted[0].suggestedRule.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate time needed for migration
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_estimateMigrationTime(candidates, redundancies) {
|
|
||||||
const autoConvert = candidates.filter(c => c.autoConvert).length;
|
|
||||||
const needsReview = candidates.filter(c => !c.autoConvert && c.quality !== 'TOO_NEBULOUS').length;
|
|
||||||
const needsRewrite = candidates.filter(c => c.quality === 'TOO_NEBULOUS').length;
|
|
||||||
|
|
||||||
// Auto-convert: 1 min each (review)
|
|
||||||
// Needs review: 5 min each (review + edit)
|
|
||||||
// Needs rewrite: 10 min each (rewrite from scratch)
|
|
||||||
// Redundancies: 3 min each (merge)
|
|
||||||
|
|
||||||
const minutes = (autoConvert * 1) +
|
|
||||||
(needsReview * 5) +
|
|
||||||
(needsRewrite * 10) +
|
|
||||||
(redundancies.length * 3);
|
|
||||||
|
|
||||||
return {
|
|
||||||
minutes,
|
|
||||||
hours: Math.round(minutes / 60 * 10) / 10,
|
|
||||||
breakdown: {
|
|
||||||
autoConvert: `${autoConvert} rules × 1 min = ${autoConvert} min`,
|
|
||||||
needsReview: `${needsReview} rules × 5 min = ${needsReview * 5} min`,
|
|
||||||
needsRewrite: `${needsRewrite} rules × 10 min = ${needsRewrite * 10} min`,
|
|
||||||
redundancies: `${redundancies.length} groups × 3 min = ${redundancies.length * 3} min`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = new ClaudeMdAnalyzer();
|
|
||||||
|
|
@ -1,489 +0,0 @@
|
||||||
/**
|
|
||||||
* Media Triage Service
|
|
||||||
* AI-powered media inquiry triage with Tractatus governance
|
|
||||||
*
|
|
||||||
* GOVERNANCE PRINCIPLES:
|
|
||||||
* - AI analyzes and suggests, humans decide
|
|
||||||
* - All reasoning must be transparent
|
|
||||||
* - Values decisions require human approval
|
|
||||||
* - No auto-responses without human review
|
|
||||||
* - Boundary enforcement for sensitive topics
|
|
||||||
*/
|
|
||||||
|
|
||||||
const Anthropic = require('@anthropic-ai/sdk');
|
|
||||||
const logger = require('../utils/logger.util');
|
|
||||||
|
|
||||||
class MediaTriageService {
|
|
||||||
constructor() {
|
|
||||||
// Initialize Anthropic client
|
|
||||||
this.client = new Anthropic({
|
|
||||||
apiKey: process.env.CLAUDE_API_KEY
|
|
||||||
});
|
|
||||||
|
|
||||||
// Topic sensitivity keywords (triggers boundary enforcement)
|
|
||||||
this.SENSITIVE_TOPICS = [
|
|
||||||
'values', 'ethics', 'strategic direction', 'partnerships',
|
|
||||||
'te tiriti', 'māori', 'indigenous', 'governance philosophy',
|
|
||||||
'framework limitations', 'criticism', 'controversy'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Urgency indicators
|
|
||||||
this.URGENCY_INDICATORS = {
|
|
||||||
high: ['urgent', 'asap', 'immediate', 'breaking', 'deadline today', 'deadline tomorrow'],
|
|
||||||
medium: ['deadline this week', 'timely', 'soon'],
|
|
||||||
low: ['no deadline', 'general inquiry', 'background']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform AI triage on media inquiry
|
|
||||||
* Returns structured analysis for human review
|
|
||||||
*/
|
|
||||||
async triageInquiry(inquiry) {
|
|
||||||
try {
|
|
||||||
logger.info(`AI triaging inquiry: ${inquiry._id}`);
|
|
||||||
|
|
||||||
// Step 1: Analyze urgency
|
|
||||||
const urgencyAnalysis = await this.analyzeUrgency(inquiry);
|
|
||||||
|
|
||||||
// Step 2: Detect topic sensitivity
|
|
||||||
const sensitivityAnalysis = await this.analyzeTopicSensitivity(inquiry);
|
|
||||||
|
|
||||||
// Step 3: Check if involves values (BoundaryEnforcer)
|
|
||||||
const valuesCheck = this.checkInvolvesValues(inquiry, sensitivityAnalysis);
|
|
||||||
|
|
||||||
// Step 4: Generate suggested talking points
|
|
||||||
const talkingPoints = await this.generateTalkingPoints(inquiry, sensitivityAnalysis);
|
|
||||||
|
|
||||||
// Step 5: Draft response (ALWAYS requires human approval)
|
|
||||||
const draftResponse = await this.generateDraftResponse(inquiry, talkingPoints, valuesCheck);
|
|
||||||
|
|
||||||
// Step 6: Calculate suggested response time
|
|
||||||
const suggestedResponseTime = this.calculateResponseTime(urgencyAnalysis, inquiry);
|
|
||||||
|
|
||||||
// Compile triage result with full transparency
|
|
||||||
const triageResult = {
|
|
||||||
urgency: urgencyAnalysis.level,
|
|
||||||
urgency_score: urgencyAnalysis.score,
|
|
||||||
urgency_reasoning: urgencyAnalysis.reasoning,
|
|
||||||
|
|
||||||
topic_sensitivity: sensitivityAnalysis.level,
|
|
||||||
sensitivity_reasoning: sensitivityAnalysis.reasoning,
|
|
||||||
|
|
||||||
involves_values: valuesCheck.involves_values,
|
|
||||||
values_reasoning: valuesCheck.reasoning,
|
|
||||||
boundary_enforcement: valuesCheck.boundary_enforcement,
|
|
||||||
|
|
||||||
suggested_response_time: suggestedResponseTime,
|
|
||||||
suggested_talking_points: talkingPoints,
|
|
||||||
|
|
||||||
draft_response: draftResponse.content,
|
|
||||||
draft_response_reasoning: draftResponse.reasoning,
|
|
||||||
draft_requires_human_approval: true, // ALWAYS
|
|
||||||
|
|
||||||
triaged_at: new Date(),
|
|
||||||
ai_model: 'claude-3-5-sonnet-20241022',
|
|
||||||
framework_compliance: {
|
|
||||||
boundary_enforcer_checked: true,
|
|
||||||
human_approval_required: true,
|
|
||||||
reasoning_transparent: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info(`Triage complete for inquiry ${inquiry._id}: urgency=${urgencyAnalysis.level}, values=${valuesCheck.involves_values}`);
|
|
||||||
|
|
||||||
return triageResult;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Media triage error:', error);
|
|
||||||
throw new Error(`Triage failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze urgency level of inquiry
|
|
||||||
*/
|
|
||||||
async analyzeUrgency(inquiry) {
|
|
||||||
const prompt = `Analyze the urgency of this media inquiry and provide a structured assessment.
|
|
||||||
|
|
||||||
INQUIRY DETAILS:
|
|
||||||
Subject: ${inquiry.inquiry.subject}
|
|
||||||
Message: ${inquiry.inquiry.message}
|
|
||||||
Deadline: ${inquiry.inquiry.deadline || 'Not specified'}
|
|
||||||
Outlet: ${inquiry.contact.outlet}
|
|
||||||
|
|
||||||
TASK:
|
|
||||||
1. Determine urgency level: HIGH, MEDIUM, or LOW
|
|
||||||
2. Provide urgency score (0-100)
|
|
||||||
3. Explain your reasoning
|
|
||||||
|
|
||||||
URGENCY GUIDELINES:
|
|
||||||
- HIGH (80-100): Breaking news, same-day deadline, crisis response
|
|
||||||
- MEDIUM (40-79): This week deadline, feature story, ongoing coverage
|
|
||||||
- LOW (0-39): No deadline, background research, general inquiry
|
|
||||||
|
|
||||||
Respond in JSON format:
|
|
||||||
{
|
|
||||||
"level": "HIGH|MEDIUM|LOW",
|
|
||||||
"score": 0-100,
|
|
||||||
"reasoning": "2-3 sentence explanation"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = await this.client.messages.create({
|
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
|
||||||
max_tokens: 500,
|
|
||||||
messages: [{
|
|
||||||
role: 'user',
|
|
||||||
content: prompt
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseText = message.content[0].text;
|
|
||||||
const analysis = JSON.parse(responseText);
|
|
||||||
|
|
||||||
return {
|
|
||||||
level: analysis.level.toLowerCase(),
|
|
||||||
score: analysis.score,
|
|
||||||
reasoning: analysis.reasoning
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Urgency analysis error:', error);
|
|
||||||
// Fallback to basic analysis
|
|
||||||
return this.basicUrgencyAnalysis(inquiry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze topic sensitivity
|
|
||||||
*/
|
|
||||||
async analyzeTopicSensitivity(inquiry) {
|
|
||||||
const prompt = `Analyze the topic sensitivity of this media inquiry for an AI safety framework organization.
|
|
||||||
|
|
||||||
INQUIRY DETAILS:
|
|
||||||
Subject: ${inquiry.inquiry.subject}
|
|
||||||
Message: ${inquiry.inquiry.message}
|
|
||||||
Topics: ${inquiry.inquiry.topic_areas?.join(', ') || 'Not specified'}
|
|
||||||
|
|
||||||
TASK:
|
|
||||||
Determine if this inquiry touches on sensitive topics such as:
|
|
||||||
- Framework values or ethics
|
|
||||||
- Strategic partnerships
|
|
||||||
- Indigenous data sovereignty (Te Tiriti o Waitangi)
|
|
||||||
- Framework limitations or criticisms
|
|
||||||
- Controversial AI safety debates
|
|
||||||
|
|
||||||
Provide sensitivity level: HIGH, MEDIUM, or LOW
|
|
||||||
|
|
||||||
Respond in JSON format:
|
|
||||||
{
|
|
||||||
"level": "HIGH|MEDIUM|LOW",
|
|
||||||
"reasoning": "2-3 sentence explanation of why this topic is sensitive or not"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = await this.client.messages.create({
|
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
|
||||||
max_tokens: 500,
|
|
||||||
messages: [{
|
|
||||||
role: 'user',
|
|
||||||
content: prompt
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseText = message.content[0].text;
|
|
||||||
const analysis = JSON.parse(responseText);
|
|
||||||
|
|
||||||
return {
|
|
||||||
level: analysis.level.toLowerCase(),
|
|
||||||
reasoning: analysis.reasoning
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Sensitivity analysis error:', error);
|
|
||||||
// Fallback to keyword-based analysis
|
|
||||||
return this.basicSensitivityAnalysis(inquiry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if inquiry involves framework values (BoundaryEnforcer)
|
|
||||||
*/
|
|
||||||
checkInvolvesValues(inquiry, sensitivityAnalysis) {
|
|
||||||
// Keywords that indicate values territory
|
|
||||||
const valuesKeywords = [
|
|
||||||
'values', 'ethics', 'mission', 'principles', 'philosophy',
|
|
||||||
'te tiriti', 'indigenous', 'sovereignty', 'partnership',
|
|
||||||
'governance', 'strategy', 'direction', 'why tractatus'
|
|
||||||
];
|
|
||||||
|
|
||||||
const combinedText = `${inquiry.inquiry.subject} ${inquiry.inquiry.message}`.toLowerCase();
|
|
||||||
const hasValuesKeyword = valuesKeywords.some(keyword => combinedText.includes(keyword));
|
|
||||||
const isHighSensitivity = sensitivityAnalysis.level === 'high';
|
|
||||||
|
|
||||||
const involves_values = hasValuesKeyword || isHighSensitivity;
|
|
||||||
|
|
||||||
return {
|
|
||||||
involves_values,
|
|
||||||
reasoning: involves_values
|
|
||||||
? 'This inquiry touches on framework values, strategic direction, or sensitive topics. Human approval required for any response (BoundaryEnforcer).'
|
|
||||||
: 'This inquiry is operational/technical in nature. Standard response workflow applies.',
|
|
||||||
boundary_enforcement: involves_values
|
|
||||||
? 'ENFORCED: Response must be reviewed and approved by John Stroh before sending.'
|
|
||||||
: 'NOT_REQUIRED: Standard review process applies.',
|
|
||||||
escalation_required: involves_values,
|
|
||||||
escalation_reason: involves_values
|
|
||||||
? 'Values-sensitive topic detected by BoundaryEnforcer'
|
|
||||||
: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate suggested talking points
|
|
||||||
*/
|
|
||||||
async generateTalkingPoints(inquiry, sensitivityAnalysis) {
|
|
||||||
const prompt = `Generate 3-5 concise talking points for responding to this media inquiry about an AI safety framework.
|
|
||||||
|
|
||||||
INQUIRY DETAILS:
|
|
||||||
Subject: ${inquiry.inquiry.subject}
|
|
||||||
Message: ${inquiry.inquiry.message}
|
|
||||||
Sensitivity: ${sensitivityAnalysis.level}
|
|
||||||
|
|
||||||
GUIDELINES:
|
|
||||||
- Focus on factual, verifiable information
|
|
||||||
- Avoid speculation or aspirational claims
|
|
||||||
- Stay within established framework documentation
|
|
||||||
- Be honest about limitations
|
|
||||||
- NO fabricated statistics
|
|
||||||
- NO absolute guarantees
|
|
||||||
|
|
||||||
Respond with JSON array of talking points:
|
|
||||||
["Point 1", "Point 2", "Point 3", ...]`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = await this.client.messages.create({
|
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
|
||||||
max_tokens: 800,
|
|
||||||
messages: [{
|
|
||||||
role: 'user',
|
|
||||||
content: prompt
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseText = message.content[0].text;
|
|
||||||
const points = JSON.parse(responseText);
|
|
||||||
|
|
||||||
return Array.isArray(points) ? points : [];
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Talking points generation error:', error);
|
|
||||||
return [
|
|
||||||
'Tractatus is a development-stage AI safety framework',
|
|
||||||
'Focus on architectural safety guarantees and human oversight',
|
|
||||||
'Open source and transparent governance'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate draft response (ALWAYS requires human approval)
|
|
||||||
*/
|
|
||||||
async generateDraftResponse(inquiry, talkingPoints, valuesCheck) {
|
|
||||||
const prompt = `Draft a professional response to this media inquiry. This draft will be reviewed and edited by humans before sending.
|
|
||||||
|
|
||||||
INQUIRY DETAILS:
|
|
||||||
From: ${inquiry.contact.name} (${inquiry.contact.outlet})
|
|
||||||
Subject: ${inquiry.inquiry.subject}
|
|
||||||
Message: ${inquiry.inquiry.message}
|
|
||||||
|
|
||||||
TALKING POINTS TO INCLUDE:
|
|
||||||
${talkingPoints.map((p, i) => `${i + 1}. ${p}`).join('\n')}
|
|
||||||
|
|
||||||
VALUES CHECK:
|
|
||||||
${valuesCheck.involves_values ? '⚠️ This touches on framework values - response requires strategic approval' : 'Standard operational inquiry'}
|
|
||||||
|
|
||||||
GUIDELINES:
|
|
||||||
- Professional and helpful tone
|
|
||||||
- 2-3 paragraphs maximum
|
|
||||||
- Include contact info for follow-up
|
|
||||||
- Offer to provide additional resources
|
|
||||||
- Be honest about framework status (development stage)
|
|
||||||
- NO fabricated statistics or guarantees
|
|
||||||
|
|
||||||
Draft the response:`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = await this.client.messages.create({
|
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
|
||||||
max_tokens: 1000,
|
|
||||||
messages: [{
|
|
||||||
role: 'user',
|
|
||||||
content: prompt
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
const draftContent = message.content[0].text;
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: draftContent,
|
|
||||||
reasoning: 'AI-generated draft based on talking points. MUST be reviewed and approved by human before sending.',
|
|
||||||
requires_approval: true,
|
|
||||||
approval_level: valuesCheck.involves_values ? 'STRATEGIC' : 'OPERATIONAL'
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Draft response generation error:', error);
|
|
||||||
return {
|
|
||||||
content: `[DRAFT GENERATION FAILED - Manual response required]\n\nHi ${inquiry.contact.name},\n\nThank you for your inquiry about Tractatus. We'll get back to you shortly with a detailed response.\n\nBest regards,\nTractatus Team`,
|
|
||||||
reasoning: 'Fallback template due to AI generation error',
|
|
||||||
requires_approval: true,
|
|
||||||
approval_level: 'OPERATIONAL'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate suggested response time in hours
|
|
||||||
*/
|
|
||||||
calculateResponseTime(urgencyAnalysis, inquiry) {
|
|
||||||
if (inquiry.inquiry.deadline) {
|
|
||||||
const deadline = new Date(inquiry.inquiry.deadline);
|
|
||||||
const now = new Date();
|
|
||||||
const hoursUntilDeadline = (deadline - now) / (1000 * 60 * 60);
|
|
||||||
return Math.max(1, Math.floor(hoursUntilDeadline * 0.5)); // Aim for 50% of time to deadline
|
|
||||||
}
|
|
||||||
|
|
||||||
// Based on urgency score
|
|
||||||
if (urgencyAnalysis.level === 'high') {
|
|
||||||
return 4; // 4 hours
|
|
||||||
} else if (urgencyAnalysis.level === 'medium') {
|
|
||||||
return 24; // 1 day
|
|
||||||
} else {
|
|
||||||
return 72; // 3 days
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Basic urgency analysis (fallback)
|
|
||||||
*/
|
|
||||||
basicUrgencyAnalysis(inquiry) {
|
|
||||||
const text = `${inquiry.inquiry.subject} ${inquiry.inquiry.message}`.toLowerCase();
|
|
||||||
let score = 30; // Default low
|
|
||||||
let level = 'low';
|
|
||||||
|
|
||||||
// Check for urgency keywords
|
|
||||||
for (const [urgencyLevel, keywords] of Object.entries(this.URGENCY_INDICATORS)) {
|
|
||||||
for (const keyword of keywords) {
|
|
||||||
if (text.includes(keyword)) {
|
|
||||||
if (urgencyLevel === 'high') {
|
|
||||||
score = 85;
|
|
||||||
level = 'high';
|
|
||||||
} else if (urgencyLevel === 'medium' && score < 60) {
|
|
||||||
score = 60;
|
|
||||||
level = 'medium';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check deadline
|
|
||||||
if (inquiry.inquiry.deadline) {
|
|
||||||
const deadline = new Date(inquiry.inquiry.deadline);
|
|
||||||
const now = new Date();
|
|
||||||
const hoursUntilDeadline = (deadline - now) / (1000 * 60 * 60);
|
|
||||||
|
|
||||||
if (hoursUntilDeadline < 24) {
|
|
||||||
score = 90;
|
|
||||||
level = 'high';
|
|
||||||
} else if (hoursUntilDeadline < 72) {
|
|
||||||
score = 65;
|
|
||||||
level = 'medium';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
level,
|
|
||||||
score,
|
|
||||||
reasoning: `Basic analysis based on keywords and deadline. Urgency level: ${level}.`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Basic sensitivity analysis (fallback)
|
|
||||||
*/
|
|
||||||
basicSensitivityAnalysis(inquiry) {
|
|
||||||
const text = `${inquiry.inquiry.subject} ${inquiry.inquiry.message}`.toLowerCase();
|
|
||||||
let level = 'low';
|
|
||||||
|
|
||||||
for (const keyword of this.SENSITIVE_TOPICS) {
|
|
||||||
if (text.includes(keyword)) {
|
|
||||||
level = 'high';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
level,
|
|
||||||
reasoning: level === 'high'
|
|
||||||
? 'Topic involves potentially sensitive framework areas'
|
|
||||||
: 'Standard operational inquiry'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get triage statistics for transparency
|
|
||||||
*/
|
|
||||||
async getTriageStats(inquiries) {
|
|
||||||
const stats = {
|
|
||||||
total_triaged: inquiries.length,
|
|
||||||
by_urgency: {
|
|
||||||
high: 0,
|
|
||||||
medium: 0,
|
|
||||||
low: 0
|
|
||||||
},
|
|
||||||
by_sensitivity: {
|
|
||||||
high: 0,
|
|
||||||
medium: 0,
|
|
||||||
low: 0
|
|
||||||
},
|
|
||||||
involves_values_count: 0,
|
|
||||||
boundary_enforcements: 0,
|
|
||||||
avg_response_time_hours: 0,
|
|
||||||
human_overrides: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const inquiry of inquiries) {
|
|
||||||
if (inquiry.ai_triage) {
|
|
||||||
// Count by urgency
|
|
||||||
if (inquiry.ai_triage.urgency) {
|
|
||||||
stats.by_urgency[inquiry.ai_triage.urgency]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count by sensitivity
|
|
||||||
if (inquiry.ai_triage.topic_sensitivity) {
|
|
||||||
stats.by_sensitivity[inquiry.ai_triage.topic_sensitivity]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count values involvements
|
|
||||||
if (inquiry.ai_triage.involves_values) {
|
|
||||||
stats.involves_values_count++;
|
|
||||||
stats.boundary_enforcements++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average response time
|
|
||||||
if (inquiry.ai_triage.suggested_response_time) {
|
|
||||||
stats.avg_response_time_hours += inquiry.ai_triage.suggested_response_time;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.total_triaged > 0) {
|
|
||||||
stats.avg_response_time_hours = Math.round(stats.avg_response_time_hours / stats.total_triaged);
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = new MediaTriageService();
|
|
||||||
|
|
@ -1,515 +0,0 @@
|
||||||
/**
|
|
||||||
* Koha Service
|
|
||||||
* Donation processing service for Tractatus Framework
|
|
||||||
*
|
|
||||||
* Based on passport-consolidated's StripeService pattern
|
|
||||||
* Handles multi-currency donations via Stripe (reusing existing account)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
|
||||||
const Donation = require('../models/Donation.model');
|
|
||||||
const {
|
|
||||||
isSupportedCurrency,
|
|
||||||
convertToNZD,
|
|
||||||
getExchangeRate
|
|
||||||
} = require('../config/currencies.config');
|
|
||||||
|
|
||||||
// Simple logger (uses console)
|
|
||||||
const logger = {
|
|
||||||
info: (...args) => console.log(...args),
|
|
||||||
error: (...args) => console.error(...args),
|
|
||||||
warn: (...args) => console.warn(...args)
|
|
||||||
};
|
|
||||||
|
|
||||||
class KohaService {
|
|
||||||
constructor() {
|
|
||||||
this.stripe = stripe;
|
|
||||||
this.priceIds = {
|
|
||||||
// NZD monthly tiers
|
|
||||||
monthly_5: process.env.STRIPE_KOHA_5_PRICE_ID,
|
|
||||||
monthly_15: process.env.STRIPE_KOHA_15_PRICE_ID,
|
|
||||||
monthly_50: process.env.STRIPE_KOHA_50_PRICE_ID,
|
|
||||||
// One-time donation (custom amount)
|
|
||||||
one_time: process.env.STRIPE_KOHA_ONETIME_PRICE_ID
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe Checkout session for donation
|
|
||||||
* @param {Object} donationData - Donation details
|
|
||||||
* @returns {Object} Checkout session data
|
|
||||||
*/
|
|
||||||
async createCheckoutSession(donationData) {
|
|
||||||
try {
|
|
||||||
const { amount, currency, frequency, tier, donor, public_acknowledgement, public_name } = donationData;
|
|
||||||
|
|
||||||
// Validate currency
|
|
||||||
const donationCurrency = (currency || 'nzd').toUpperCase();
|
|
||||||
if (!isSupportedCurrency(donationCurrency)) {
|
|
||||||
throw new Error(`Unsupported currency: ${donationCurrency}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate inputs
|
|
||||||
if (!amount || amount < 100) {
|
|
||||||
throw new Error('Minimum donation amount is $1.00');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!frequency || !['monthly', 'one_time'].includes(frequency)) {
|
|
||||||
throw new Error('Invalid frequency. Must be "monthly" or "one_time"');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!donor?.email) {
|
|
||||||
throw new Error('Donor email is required for receipt');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate NZD equivalent for transparency metrics
|
|
||||||
const amountNZD = donationCurrency === 'NZD' ? amount : convertToNZD(amount, donationCurrency);
|
|
||||||
const exchangeRate = getExchangeRate(donationCurrency);
|
|
||||||
|
|
||||||
logger.info(`[KOHA] Creating checkout session: ${frequency} donation of ${donationCurrency} $${amount / 100} (NZD $${amountNZD / 100})`);
|
|
||||||
|
|
||||||
// Create or retrieve Stripe customer
|
|
||||||
let stripeCustomer;
|
|
||||||
try {
|
|
||||||
// Search for existing customer by email
|
|
||||||
const customers = await this.stripe.customers.list({
|
|
||||||
email: donor.email,
|
|
||||||
limit: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (customers.data.length > 0) {
|
|
||||||
stripeCustomer = customers.data[0];
|
|
||||||
logger.info(`[KOHA] Using existing customer ${stripeCustomer.id}`);
|
|
||||||
} else {
|
|
||||||
stripeCustomer = await this.stripe.customers.create({
|
|
||||||
email: donor.email,
|
|
||||||
name: donor.name || 'Anonymous Donor',
|
|
||||||
metadata: {
|
|
||||||
source: 'tractatus_koha',
|
|
||||||
public_acknowledgement: public_acknowledgement ? 'yes' : 'no'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.info(`[KOHA] Created new customer ${stripeCustomer.id}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Failed to create/retrieve customer:', error);
|
|
||||||
throw new Error('Failed to process donor information');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare checkout session parameters
|
|
||||||
const sessionParams = {
|
|
||||||
payment_method_types: ['card'],
|
|
||||||
customer: stripeCustomer.id,
|
|
||||||
mode: frequency === 'monthly' ? 'subscription' : 'payment',
|
|
||||||
success_url: `${process.env.FRONTEND_URL || 'https://agenticgovernance.digital'}/koha/success.html?session_id={CHECKOUT_SESSION_ID}`,
|
|
||||||
cancel_url: `${process.env.FRONTEND_URL || 'https://agenticgovernance.digital'}/koha.html`,
|
|
||||||
metadata: {
|
|
||||||
frequency: frequency,
|
|
||||||
tier: tier,
|
|
||||||
currency: donationCurrency,
|
|
||||||
amount_nzd: String(amountNZD),
|
|
||||||
exchange_rate: String(exchangeRate),
|
|
||||||
donor_name: donor.name || 'Anonymous',
|
|
||||||
public_acknowledgement: public_acknowledgement ? 'yes' : 'no',
|
|
||||||
public_name: public_name || '',
|
|
||||||
source: 'tractatus_website'
|
|
||||||
},
|
|
||||||
allow_promotion_codes: true, // Allow coupon codes
|
|
||||||
billing_address_collection: 'auto'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add line items based on frequency
|
|
||||||
if (frequency === 'monthly') {
|
|
||||||
// Subscription mode - use price ID for recurring donations
|
|
||||||
const priceId = this.priceIds[`monthly_${tier}`];
|
|
||||||
if (!priceId) {
|
|
||||||
throw new Error(`Invalid monthly tier: ${tier}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionParams.line_items = [{
|
|
||||||
price: priceId,
|
|
||||||
quantity: 1
|
|
||||||
}];
|
|
||||||
|
|
||||||
sessionParams.subscription_data = {
|
|
||||||
metadata: {
|
|
||||||
tier: tier,
|
|
||||||
public_acknowledgement: public_acknowledgement ? 'yes' : 'no'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// One-time payment mode - use custom amount
|
|
||||||
sessionParams.line_items = [{
|
|
||||||
price_data: {
|
|
||||||
currency: donationCurrency.toLowerCase(),
|
|
||||||
product_data: {
|
|
||||||
name: 'Tractatus Framework Support',
|
|
||||||
description: 'One-time donation to support the Tractatus Framework for AI safety',
|
|
||||||
images: ['https://agenticgovernance.digital/images/tractatus-icon.svg']
|
|
||||||
},
|
|
||||||
unit_amount: amount // Amount in cents
|
|
||||||
},
|
|
||||||
quantity: 1
|
|
||||||
}];
|
|
||||||
|
|
||||||
sessionParams.payment_intent_data = {
|
|
||||||
metadata: {
|
|
||||||
tier: tier || 'custom',
|
|
||||||
public_acknowledgement: public_acknowledgement ? 'yes' : 'no'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create checkout session
|
|
||||||
const session = await this.stripe.checkout.sessions.create(sessionParams);
|
|
||||||
|
|
||||||
logger.info(`[KOHA] Checkout session created: ${session.id}`);
|
|
||||||
|
|
||||||
// Create pending donation record in database
|
|
||||||
await Donation.create({
|
|
||||||
amount: amount,
|
|
||||||
currency: donationCurrency.toLowerCase(),
|
|
||||||
amount_nzd: amountNZD,
|
|
||||||
exchange_rate_to_nzd: exchangeRate,
|
|
||||||
frequency: frequency,
|
|
||||||
tier: tier,
|
|
||||||
donor: {
|
|
||||||
name: donor.name || 'Anonymous',
|
|
||||||
email: donor.email,
|
|
||||||
country: donor.country
|
|
||||||
},
|
|
||||||
public_acknowledgement: public_acknowledgement || false,
|
|
||||||
public_name: public_name || null,
|
|
||||||
stripe: {
|
|
||||||
customer_id: stripeCustomer.id
|
|
||||||
},
|
|
||||||
status: 'pending',
|
|
||||||
metadata: {
|
|
||||||
source: 'website',
|
|
||||||
session_id: session.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
checkoutUrl: session.url,
|
|
||||||
frequency: frequency,
|
|
||||||
amount: amount / 100
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Checkout session creation failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle webhook events from Stripe
|
|
||||||
* @param {Object} event - Stripe webhook event
|
|
||||||
*/
|
|
||||||
async handleWebhook(event) {
|
|
||||||
try {
|
|
||||||
logger.info(`[KOHA] Processing webhook event: ${event.type}`);
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case 'checkout.session.completed':
|
|
||||||
await this.handleCheckoutComplete(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'payment_intent.succeeded':
|
|
||||||
await this.handlePaymentSuccess(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'payment_intent.payment_failed':
|
|
||||||
await this.handlePaymentFailure(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'invoice.paid':
|
|
||||||
// Recurring subscription payment succeeded
|
|
||||||
await this.handleInvoicePaid(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'invoice.payment_failed':
|
|
||||||
// Recurring subscription payment failed
|
|
||||||
await this.handleInvoicePaymentFailed(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.subscription.created':
|
|
||||||
case 'customer.subscription.updated':
|
|
||||||
await this.handleSubscriptionUpdate(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.subscription.deleted':
|
|
||||||
await this.handleSubscriptionCancellation(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.info(`[KOHA] Unhandled webhook event type: ${event.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Webhook processing error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle successful checkout completion
|
|
||||||
*/
|
|
||||||
async handleCheckoutComplete(session) {
|
|
||||||
try {
|
|
||||||
const frequency = session.metadata.frequency;
|
|
||||||
const tier = session.metadata.tier;
|
|
||||||
const currency = session.metadata.currency || session.currency?.toUpperCase() || 'NZD';
|
|
||||||
const amountNZD = session.metadata.amount_nzd ? parseInt(session.metadata.amount_nzd) : session.amount_total;
|
|
||||||
const exchangeRate = session.metadata.exchange_rate ? parseFloat(session.metadata.exchange_rate) : 1.0;
|
|
||||||
|
|
||||||
logger.info(`[KOHA] Checkout completed: ${frequency} donation, tier: ${tier}, currency: ${currency}`);
|
|
||||||
|
|
||||||
// Find pending donation or create new one
|
|
||||||
let donation = await Donation.findByPaymentIntentId(session.payment_intent);
|
|
||||||
|
|
||||||
if (!donation) {
|
|
||||||
// Create donation record from session data
|
|
||||||
donation = await Donation.create({
|
|
||||||
amount: session.amount_total,
|
|
||||||
currency: currency.toLowerCase(),
|
|
||||||
amount_nzd: amountNZD,
|
|
||||||
exchange_rate_to_nzd: exchangeRate,
|
|
||||||
frequency: frequency,
|
|
||||||
tier: tier,
|
|
||||||
donor: {
|
|
||||||
name: session.metadata.donor_name || 'Anonymous',
|
|
||||||
email: session.customer_email
|
|
||||||
},
|
|
||||||
public_acknowledgement: session.metadata.public_acknowledgement === 'yes',
|
|
||||||
public_name: session.metadata.public_name || null,
|
|
||||||
stripe: {
|
|
||||||
customer_id: session.customer,
|
|
||||||
subscription_id: session.subscription || null,
|
|
||||||
payment_intent_id: session.payment_intent
|
|
||||||
},
|
|
||||||
status: 'completed',
|
|
||||||
payment_date: new Date()
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Update existing donation
|
|
||||||
await Donation.updateStatus(donation._id, 'completed', {
|
|
||||||
'stripe.subscription_id': session.subscription || null,
|
|
||||||
'stripe.payment_intent_id': session.payment_intent,
|
|
||||||
payment_date: new Date()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send receipt email (async, don't wait)
|
|
||||||
this.sendReceiptEmail(donation).catch(err =>
|
|
||||||
logger.error('[KOHA] Failed to send receipt email:', err)
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`[KOHA] Donation recorded: ${currency} $${session.amount_total / 100} (NZD $${amountNZD / 100})`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Error handling checkout completion:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle successful payment
|
|
||||||
*/
|
|
||||||
async handlePaymentSuccess(paymentIntent) {
|
|
||||||
try {
|
|
||||||
logger.info(`[KOHA] Payment succeeded: ${paymentIntent.id}`);
|
|
||||||
|
|
||||||
const donation = await Donation.findByPaymentIntentId(paymentIntent.id);
|
|
||||||
if (donation && donation.status === 'pending') {
|
|
||||||
await Donation.updateStatus(donation._id, 'completed', {
|
|
||||||
payment_date: new Date()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Error handling payment success:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle failed payment
|
|
||||||
*/
|
|
||||||
async handlePaymentFailure(paymentIntent) {
|
|
||||||
try {
|
|
||||||
logger.warn(`[KOHA] Payment failed: ${paymentIntent.id}`);
|
|
||||||
|
|
||||||
const donation = await Donation.findByPaymentIntentId(paymentIntent.id);
|
|
||||||
if (donation) {
|
|
||||||
await Donation.updateStatus(donation._id, 'failed', {
|
|
||||||
'metadata.failure_reason': paymentIntent.last_payment_error?.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Error handling payment failure:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle paid invoice (recurring subscription payment)
|
|
||||||
*/
|
|
||||||
async handleInvoicePaid(invoice) {
|
|
||||||
try {
|
|
||||||
logger.info(`[KOHA] Invoice paid: ${invoice.id} for subscription ${invoice.subscription}`);
|
|
||||||
|
|
||||||
// Create new donation record for this recurring payment
|
|
||||||
const subscription = await this.stripe.subscriptions.retrieve(invoice.subscription);
|
|
||||||
|
|
||||||
// Get currency from invoice or metadata
|
|
||||||
const currency = (invoice.currency || subscription.metadata.currency || 'NZD').toUpperCase();
|
|
||||||
const amount = invoice.amount_paid;
|
|
||||||
|
|
||||||
// Calculate NZD equivalent
|
|
||||||
const amountNZD = currency === 'NZD' ? amount : convertToNZD(amount, currency);
|
|
||||||
const exchangeRate = getExchangeRate(currency);
|
|
||||||
|
|
||||||
await Donation.create({
|
|
||||||
amount: amount,
|
|
||||||
currency: currency.toLowerCase(),
|
|
||||||
amount_nzd: amountNZD,
|
|
||||||
exchange_rate_to_nzd: exchangeRate,
|
|
||||||
frequency: 'monthly',
|
|
||||||
tier: subscription.metadata.tier,
|
|
||||||
donor: {
|
|
||||||
email: invoice.customer_email
|
|
||||||
},
|
|
||||||
public_acknowledgement: subscription.metadata.public_acknowledgement === 'yes',
|
|
||||||
stripe: {
|
|
||||||
customer_id: invoice.customer,
|
|
||||||
subscription_id: invoice.subscription,
|
|
||||||
invoice_id: invoice.id,
|
|
||||||
charge_id: invoice.charge
|
|
||||||
},
|
|
||||||
status: 'completed',
|
|
||||||
payment_date: new Date(invoice.created * 1000)
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`[KOHA] Recurring donation recorded: ${currency} $${amount / 100} (NZD $${amountNZD / 100})`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Error handling invoice paid:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle failed invoice payment
|
|
||||||
*/
|
|
||||||
async handleInvoicePaymentFailed(invoice) {
|
|
||||||
try {
|
|
||||||
logger.warn(`[KOHA] Invoice payment failed: ${invoice.id}`);
|
|
||||||
// Could send notification email to donor here
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Error handling invoice payment failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle subscription updates
|
|
||||||
*/
|
|
||||||
async handleSubscriptionUpdate(subscription) {
|
|
||||||
logger.info(`[KOHA] Subscription updated: ${subscription.id}, status: ${subscription.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle subscription cancellation
|
|
||||||
*/
|
|
||||||
async handleSubscriptionCancellation(subscription) {
|
|
||||||
try {
|
|
||||||
logger.info(`[KOHA] Subscription cancelled: ${subscription.id}`);
|
|
||||||
|
|
||||||
await Donation.cancelSubscription(subscription.id);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Error handling subscription cancellation:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify webhook signature
|
|
||||||
*/
|
|
||||||
verifyWebhookSignature(payload, signature) {
|
|
||||||
try {
|
|
||||||
return this.stripe.webhooks.constructEvent(
|
|
||||||
payload,
|
|
||||||
signature,
|
|
||||||
process.env.STRIPE_KOHA_WEBHOOK_SECRET
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Webhook signature verification failed:', error);
|
|
||||||
throw new Error('Invalid webhook signature');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get transparency metrics for public dashboard
|
|
||||||
*/
|
|
||||||
async getTransparencyMetrics() {
|
|
||||||
try {
|
|
||||||
return await Donation.getTransparencyMetrics();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Error getting transparency metrics:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send receipt email (placeholder)
|
|
||||||
*/
|
|
||||||
async sendReceiptEmail(donation) {
|
|
||||||
// TODO: Implement email service integration
|
|
||||||
logger.info(`[KOHA] Receipt email would be sent to ${donation.donor.email}`);
|
|
||||||
|
|
||||||
// Generate receipt number
|
|
||||||
const receiptNumber = `KOHA-${new Date().getFullYear()}-${String(donation._id).slice(-8).toUpperCase()}`;
|
|
||||||
|
|
||||||
await Donation.markReceiptSent(donation._id, receiptNumber);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel a recurring donation (admin or donor-initiated)
|
|
||||||
*/
|
|
||||||
async cancelRecurringDonation(subscriptionId) {
|
|
||||||
try {
|
|
||||||
logger.info(`[KOHA] Cancelling subscription: ${subscriptionId}`);
|
|
||||||
|
|
||||||
// Cancel in Stripe
|
|
||||||
await this.stripe.subscriptions.cancel(subscriptionId);
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
await Donation.cancelSubscription(subscriptionId);
|
|
||||||
|
|
||||||
return { success: true, message: 'Subscription cancelled successfully' };
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Error cancelling subscription:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get donation statistics (admin only)
|
|
||||||
*/
|
|
||||||
async getStatistics(startDate = null, endDate = null) {
|
|
||||||
try {
|
|
||||||
return await Donation.getStatistics(startDate, endDate);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[KOHA] Error getting statistics:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create singleton instance
|
|
||||||
const kohaService = new KohaService();
|
|
||||||
|
|
||||||
module.exports = kohaService;
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
/**
|
|
||||||
* Document Section Parser
|
|
||||||
* Analyzes markdown documents and creates card-based sections
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse document into sections based on H2 headings
|
|
||||||
*/
|
|
||||||
function parseDocumentSections(markdown, contentHtml) {
|
|
||||||
if (!markdown) return [];
|
|
||||||
|
|
||||||
const sections = [];
|
|
||||||
const lines = markdown.split('\n');
|
|
||||||
let currentSection = null;
|
|
||||||
let sectionContent = [];
|
|
||||||
|
|
||||||
// Find H1 (document title) first
|
|
||||||
let documentTitle = '';
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
const h1Match = line.match(/^#\s+(.+)$/);
|
|
||||||
if (h1Match) {
|
|
||||||
documentTitle = h1Match[1].trim();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse sections by H2 headings
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Check for H2 heading (## Heading)
|
|
||||||
const h2Match = line.match(/^##\s+(.+)$/);
|
|
||||||
|
|
||||||
if (h2Match) {
|
|
||||||
// Save previous section if exists
|
|
||||||
if (currentSection) {
|
|
||||||
currentSection.content = sectionContent.join('\n').trim();
|
|
||||||
currentSection.excerpt = extractExcerpt(currentSection.content);
|
|
||||||
currentSection.readingTime = estimateReadingTime(currentSection.content);
|
|
||||||
currentSection.technicalLevel = detectTechnicalLevel(currentSection.content);
|
|
||||||
currentSection.category = categorizeSection(currentSection.title, currentSection.content);
|
|
||||||
sections.push(currentSection);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new section
|
|
||||||
const title = h2Match[1].trim();
|
|
||||||
const slug = generateSlug(title);
|
|
||||||
|
|
||||||
currentSection = {
|
|
||||||
title,
|
|
||||||
slug,
|
|
||||||
level: 2,
|
|
||||||
content: '',
|
|
||||||
excerpt: '',
|
|
||||||
readingTime: 0,
|
|
||||||
technicalLevel: 'basic',
|
|
||||||
category: 'conceptual'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Include the H2 heading itself in the section content
|
|
||||||
sectionContent = [line];
|
|
||||||
} else if (currentSection) {
|
|
||||||
// Only add content until we hit another H2 or H1
|
|
||||||
const isH1 = line.match(/^#\s+[^#]/);
|
|
||||||
|
|
||||||
if (isH1) {
|
|
||||||
// Skip H1 (document title) - don't add to section
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all other content (including H3, H4, paragraphs, etc.)
|
|
||||||
sectionContent.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save last section
|
|
||||||
if (currentSection && sectionContent.length > 0) {
|
|
||||||
currentSection.content = sectionContent.join('\n').trim();
|
|
||||||
currentSection.excerpt = extractExcerpt(currentSection.content);
|
|
||||||
currentSection.readingTime = estimateReadingTime(currentSection.content);
|
|
||||||
currentSection.technicalLevel = detectTechnicalLevel(currentSection.content);
|
|
||||||
currentSection.category = categorizeSection(currentSection.title, currentSection.content);
|
|
||||||
sections.push(currentSection);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract excerpt from content (first 2-3 sentences, max 150 chars)
|
|
||||||
*/
|
|
||||||
function extractExcerpt(content) {
|
|
||||||
if (!content) return '';
|
|
||||||
|
|
||||||
// Remove markdown formatting
|
|
||||||
let text = content
|
|
||||||
.replace(/^#+\s+/gm, '') // Remove headings
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '$1') // Remove bold
|
|
||||||
.replace(/\*(.+?)\*/g, '$1') // Remove italic
|
|
||||||
.replace(/`(.+?)`/g, '$1') // Remove code
|
|
||||||
.replace(/\[(.+?)\]\(.+?\)/g, '$1') // Remove links
|
|
||||||
.replace(/^[-*]\s+/gm, '') // Remove list markers
|
|
||||||
.replace(/^\d+\.\s+/gm, '') // Remove numbered lists
|
|
||||||
.replace(/^>\s+/gm, '') // Remove blockquotes
|
|
||||||
.replace(/\n+/g, ' ') // Collapse newlines
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Get first 2-3 sentences
|
|
||||||
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
|
|
||||||
let excerpt = sentences.slice(0, 2).join(' ');
|
|
||||||
|
|
||||||
// Truncate to 150 chars if needed
|
|
||||||
if (excerpt.length > 150) {
|
|
||||||
excerpt = excerpt.substring(0, 147) + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
return excerpt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate reading time in minutes (avg 200 words/min)
|
|
||||||
*/
|
|
||||||
function estimateReadingTime(content) {
|
|
||||||
if (!content) return 1;
|
|
||||||
|
|
||||||
const words = content.split(/\s+/).length;
|
|
||||||
const minutes = Math.ceil(words / 200);
|
|
||||||
|
|
||||||
return Math.max(1, minutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect technical level based on content
|
|
||||||
*/
|
|
||||||
function detectTechnicalLevel(content) {
|
|
||||||
if (!content) return 'basic';
|
|
||||||
|
|
||||||
const lowerContent = content.toLowerCase();
|
|
||||||
|
|
||||||
// Technical indicators
|
|
||||||
const technicalTerms = [
|
|
||||||
'api', 'database', 'mongodb', 'algorithm', 'architecture',
|
|
||||||
'implementation', 'node.js', 'javascript', 'typescript',
|
|
||||||
'async', 'await', 'promise', 'class', 'function',
|
|
||||||
'middleware', 'authentication', 'authorization', 'encryption',
|
|
||||||
'hash', 'token', 'jwt', 'rest', 'graphql'
|
|
||||||
];
|
|
||||||
|
|
||||||
const advancedTerms = [
|
|
||||||
'metacognitive', 'stochastic', 'quadrant classification',
|
|
||||||
'intersection observer', 'csp', 'security policy',
|
|
||||||
'cross-reference validation', 'boundary enforcement',
|
|
||||||
'architectural constraints', 'formal verification'
|
|
||||||
];
|
|
||||||
|
|
||||||
let technicalScore = 0;
|
|
||||||
let advancedScore = 0;
|
|
||||||
|
|
||||||
// Count technical terms
|
|
||||||
technicalTerms.forEach(term => {
|
|
||||||
const regex = new RegExp(`\\b${term}\\b`, 'gi');
|
|
||||||
const matches = lowerContent.match(regex);
|
|
||||||
if (matches) technicalScore += matches.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Count advanced terms
|
|
||||||
advancedTerms.forEach(term => {
|
|
||||||
const regex = new RegExp(`\\b${term}\\b`, 'gi');
|
|
||||||
const matches = lowerContent.match(regex);
|
|
||||||
if (matches) advancedScore += matches.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for code blocks
|
|
||||||
const codeBlocks = (content.match(/```/g) || []).length / 2;
|
|
||||||
technicalScore += codeBlocks * 3;
|
|
||||||
|
|
||||||
// Determine level
|
|
||||||
if (advancedScore >= 3 || technicalScore >= 15) {
|
|
||||||
return 'advanced';
|
|
||||||
} else if (technicalScore >= 5) {
|
|
||||||
return 'intermediate';
|
|
||||||
} else {
|
|
||||||
return 'basic';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Categorize section based on title and content
|
|
||||||
*/
|
|
||||||
function categorizeSection(title, content) {
|
|
||||||
const lowerTitle = title.toLowerCase();
|
|
||||||
const lowerContent = content.toLowerCase();
|
|
||||||
|
|
||||||
// Category keywords
|
|
||||||
const categories = {
|
|
||||||
conceptual: [
|
|
||||||
'what is', 'introduction', 'overview', 'why', 'philosophy',
|
|
||||||
'concept', 'theory', 'principle', 'background', 'motivation'
|
|
||||||
],
|
|
||||||
technical: [
|
|
||||||
'architecture', 'implementation', 'technical', 'code', 'api',
|
|
||||||
'configuration', 'setup', 'installation', 'integration',
|
|
||||||
'class', 'function', 'service', 'component'
|
|
||||||
],
|
|
||||||
practical: [
|
|
||||||
'quick start', 'tutorial', 'guide', 'how to', 'example',
|
|
||||||
'walkthrough', 'getting started', 'usage', 'practice'
|
|
||||||
],
|
|
||||||
reference: [
|
|
||||||
'reference', 'api', 'specification', 'documentation',
|
|
||||||
'glossary', 'terms', 'definitions', 'index'
|
|
||||||
],
|
|
||||||
critical: [
|
|
||||||
'security', 'warning', 'important', 'critical', 'boundary',
|
|
||||||
'safety', 'risk', 'violation', 'error', 'failure'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check title first (higher weight)
|
|
||||||
for (const [category, keywords] of Object.entries(categories)) {
|
|
||||||
for (const keyword of keywords) {
|
|
||||||
if (lowerTitle.includes(keyword)) {
|
|
||||||
return category;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check content (lower weight)
|
|
||||||
const contentScores = {};
|
|
||||||
for (const [category, keywords] of Object.entries(categories)) {
|
|
||||||
contentScores[category] = 0;
|
|
||||||
for (const keyword of keywords) {
|
|
||||||
const regex = new RegExp(`\\b${keyword}\\b`, 'gi');
|
|
||||||
const matches = lowerContent.match(regex);
|
|
||||||
if (matches) contentScores[category] += matches.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return category with highest score
|
|
||||||
const maxCategory = Object.keys(contentScores).reduce((a, b) =>
|
|
||||||
contentScores[a] > contentScores[b] ? a : b
|
|
||||||
);
|
|
||||||
|
|
||||||
return contentScores[maxCategory] > 0 ? maxCategory : 'conceptual';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate URL-safe slug from title
|
|
||||||
*/
|
|
||||||
function generateSlug(title) {
|
|
||||||
return title
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
parseDocumentSections,
|
|
||||||
extractExcerpt,
|
|
||||||
estimateReadingTime,
|
|
||||||
detectTechnicalLevel,
|
|
||||||
categorizeSection,
|
|
||||||
generateSlug
|
|
||||||
};
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
/**
|
|
||||||
* JWT Utility
|
|
||||||
* Token generation and verification for admin authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'CHANGE_THIS_IN_PRODUCTION';
|
|
||||||
const JWT_EXPIRY = process.env.JWT_EXPIRY || '7d';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate JWT token
|
|
||||||
*/
|
|
||||||
function generateToken(payload) {
|
|
||||||
return jwt.sign(payload, JWT_SECRET, {
|
|
||||||
expiresIn: JWT_EXPIRY,
|
|
||||||
issuer: 'tractatus',
|
|
||||||
audience: 'tractatus-admin'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify JWT token
|
|
||||||
*/
|
|
||||||
function verifyToken(token) {
|
|
||||||
try {
|
|
||||||
return jwt.verify(token, JWT_SECRET, {
|
|
||||||
issuer: 'tractatus',
|
|
||||||
audience: 'tractatus-admin'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Invalid token: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode token without verification (for debugging)
|
|
||||||
*/
|
|
||||||
function decodeToken(token) {
|
|
||||||
return jwt.decode(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract token from Authorization header
|
|
||||||
*/
|
|
||||||
function extractTokenFromHeader(authHeader) {
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return authHeader.substring(7);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
generateToken,
|
|
||||||
verifyToken,
|
|
||||||
decodeToken,
|
|
||||||
extractTokenFromHeader
|
|
||||||
};
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
/**
|
|
||||||
* Markdown Utility
|
|
||||||
* Convert markdown to HTML with syntax highlighting
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { marked } = require('marked');
|
|
||||||
const hljs = require('highlight.js');
|
|
||||||
const sanitizeHtml = require('sanitize-html');
|
|
||||||
|
|
||||||
// Custom renderer to add IDs to headings
|
|
||||||
const renderer = new marked.Renderer();
|
|
||||||
const originalHeadingRenderer = renderer.heading.bind(renderer);
|
|
||||||
|
|
||||||
renderer.heading = function(text, level, raw) {
|
|
||||||
// Generate slug from heading text
|
|
||||||
const slug = raw
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return `<h${level} id="${slug}">${text}</h${level}>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configure marked
|
|
||||||
marked.setOptions({
|
|
||||||
renderer: renderer,
|
|
||||||
highlight: function(code, lang) {
|
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
|
||||||
try {
|
|
||||||
return hljs.highlight(code, { language: lang }).value;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Highlight error:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hljs.highlightAuto(code).value;
|
|
||||||
},
|
|
||||||
gfm: true,
|
|
||||||
breaks: false,
|
|
||||||
pedantic: false,
|
|
||||||
smartLists: true,
|
|
||||||
smartypants: true
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert markdown to HTML
|
|
||||||
*/
|
|
||||||
function markdownToHtml(markdown) {
|
|
||||||
if (!markdown) return '';
|
|
||||||
|
|
||||||
const html = marked(markdown);
|
|
||||||
|
|
||||||
// Sanitize HTML to prevent XSS
|
|
||||||
return sanitizeHtml(html, {
|
|
||||||
allowedTags: [
|
|
||||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
||||||
'p', 'br', 'hr',
|
|
||||||
'strong', 'em', 'u', 'code', 'pre',
|
|
||||||
'a', 'img',
|
|
||||||
'ul', 'ol', 'li',
|
|
||||||
'blockquote',
|
|
||||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
||||||
'div', 'span',
|
|
||||||
'sup', 'sub',
|
|
||||||
'del', 'ins'
|
|
||||||
],
|
|
||||||
allowedAttributes: {
|
|
||||||
'a': ['href', 'title', 'target', 'rel'],
|
|
||||||
'img': ['src', 'alt', 'title', 'width', 'height'],
|
|
||||||
'h1': ['id'],
|
|
||||||
'h2': ['id'],
|
|
||||||
'h3': ['id'],
|
|
||||||
'h4': ['id'],
|
|
||||||
'h5': ['id'],
|
|
||||||
'h6': ['id'],
|
|
||||||
'code': ['class'],
|
|
||||||
'pre': ['class'],
|
|
||||||
'div': ['class'],
|
|
||||||
'span': ['class'],
|
|
||||||
'table': ['class'],
|
|
||||||
'th': ['scope', 'class'],
|
|
||||||
'td': ['class']
|
|
||||||
},
|
|
||||||
allowedClasses: {
|
|
||||||
'code': ['language-*', 'hljs', 'hljs-*'],
|
|
||||||
'pre': ['hljs'],
|
|
||||||
'div': ['highlight'],
|
|
||||||
'span': ['hljs-*']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract table of contents from markdown
|
|
||||||
*/
|
|
||||||
function extractTOC(markdown) {
|
|
||||||
if (!markdown) return [];
|
|
||||||
|
|
||||||
const headings = [];
|
|
||||||
const lines = markdown.split('\n');
|
|
||||||
|
|
||||||
lines.forEach(line => {
|
|
||||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
const level = match[1].length;
|
|
||||||
const title = match[2].replace(/[#*_`]/g, '').trim();
|
|
||||||
const slug = title.toLowerCase()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
headings.push({
|
|
||||||
level,
|
|
||||||
title,
|
|
||||||
slug
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return headings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract front matter from markdown
|
|
||||||
*/
|
|
||||||
function extractFrontMatter(markdown) {
|
|
||||||
if (!markdown) return { metadata: {}, content: markdown };
|
|
||||||
|
|
||||||
const frontMatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
|
||||||
const match = markdown.match(frontMatterRegex);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return { metadata: {}, content: markdown };
|
|
||||||
}
|
|
||||||
|
|
||||||
const frontMatter = match[1];
|
|
||||||
const content = match[2];
|
|
||||||
const metadata = {};
|
|
||||||
|
|
||||||
frontMatter.split('\n').forEach(line => {
|
|
||||||
const [key, ...valueParts] = line.split(':');
|
|
||||||
if (key && valueParts.length) {
|
|
||||||
metadata[key.trim()] = valueParts.join(':').trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { metadata, content };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate slug from title
|
|
||||||
*/
|
|
||||||
function generateSlug(title) {
|
|
||||||
return title
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
markdownToHtml,
|
|
||||||
extractTOC,
|
|
||||||
extractFrontMatter,
|
|
||||||
generateSlug
|
|
||||||
};
|
|
||||||
Loading…
Add table
Reference in a new issue