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
47a553dd04
commit
60f239c0bf
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