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:
TheFlow 2025-10-21 21:22:40 +13:00
parent 47a553dd04
commit 60f239c0bf
77 changed files with 0 additions and 21354 deletions

View file

@ -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!)

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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);

View file

@ -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();
});

View file

@ -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';
}
});

View file

@ -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, '&lt;').replace(/>/g, '&gt;')}</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>
`;
});

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}
})();

View file

@ -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;
})();

View file

@ -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">&times;</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();
}
}
});
}
}

View file

@ -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;

View file

@ -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();
}
})();

View file

@ -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
};
}
})();

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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' });
});
}

View file

@ -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();
}
})();

View file

@ -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();

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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');
}
}

View file

@ -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();
}

View file

@ -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);
}
});

View file

@ -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');
}
}
});

View file

@ -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';
}
});

View file

@ -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();

View file

@ -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;
}

View file

@ -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');
}
}
});

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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();

View file

@ -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();
}

View file

@ -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
};

View file

@ -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
};

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
module.exports = {
listPublishedPosts,
getPublishedPost,
listPostsByStatus,
getPostById,
createPost,
updatePost,
publishPost,
deletePost,
suggestTopics,
draftBlogPost,
analyzeContent,
getEditorialGuidelines,
generateRSSFeed
};

View file

@ -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
};

View file

@ -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
};

View file

@ -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'
});
}
};

View file

@ -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
};

View file

@ -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'
});
}
};

View file

@ -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
};

View file

@ -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
};

View file

@ -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
};

View file

@ -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
};

View file

@ -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
};

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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;

View file

@ -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
};

View file

@ -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
};

View file

@ -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
};