From 0d75492c600fee8a7cdaa686cc4e32e3deb87523 Mon Sep 17 00:00:00 2001 From: TheFlow Date: Tue, 7 Oct 2025 00:36:40 +1300 Subject: [PATCH] feat: add API routes, controllers, and migration tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented complete backend API foundation with authentication, document management, blog operations, and admin functionality. Added migration tools for database seeding and document import. **Controllers (4 files):** - auth.controller.js: User authentication (login, getCurrentUser, logout) - documents.controller.js: Document CRUD operations - blog.controller.js: Blog post management with admin/public access - admin.controller.js: Admin dashboard (stats, moderation queue, activity) **Routes (5 files):** - auth.routes.js: Authentication endpoints - documents.routes.js: Document API endpoints - blog.routes.js: Blog API endpoints - admin.routes.js: Admin API endpoints - index.js: Central routing configuration with API documentation **Migration Tools (2 scripts):** - seed-admin.js: Create admin user for system access - migrate-documents.js: Import markdown documents with metadata extraction, slug generation, and dry-run support. Successfully migrated 8 documents from anthropic-submission directory. **Server Updates:** - Integrated all API routes under /api namespace - Updated homepage to reflect completed API implementation - Maintained security middleware (Helmet, CORS, rate limiting) **Testing:** ✅ Server starts successfully on port 9000 ✅ Authentication flow working (login, token validation) ✅ Document endpoints tested (list, get by slug) ✅ Admin stats endpoint verified (requires authentication) ✅ Migration completed: 8 documents imported **Database Status:** - Documents collection: 8 technical papers - Users collection: 1 admin user - All indexes operational This completes the core backend API infrastructure. Next steps: build Tractatus governance services (InstructionClassifier, CrossReferenceValidator, BoundaryEnforcer). 🤖 Generated with Claude Code Co-Authored-By: Claude --- scripts/migrate-documents.js | 342 ++++++++++++++++++++++++ scripts/seed-admin.js | 113 ++++++++ src/controllers/admin.controller.js | 289 ++++++++++++++++++++ src/controllers/auth.controller.js | 121 +++++++++ src/controllers/blog.controller.js | 332 +++++++++++++++++++++++ src/controllers/documents.controller.js | 265 ++++++++++++++++++ src/routes/admin.routes.js | 64 +++++ src/routes/auth.routes.js | 41 +++ src/routes/blog.routes.js | 80 ++++++ src/routes/documents.routes.js | 62 +++++ src/routes/index.js | 64 +++++ src/server.js | 28 +- 12 files changed, 1782 insertions(+), 19 deletions(-) create mode 100755 scripts/migrate-documents.js create mode 100755 scripts/seed-admin.js create mode 100644 src/controllers/admin.controller.js create mode 100644 src/controllers/auth.controller.js create mode 100644 src/controllers/blog.controller.js create mode 100644 src/controllers/documents.controller.js create mode 100644 src/routes/admin.routes.js create mode 100644 src/routes/auth.routes.js create mode 100644 src/routes/blog.routes.js create mode 100644 src/routes/documents.routes.js create mode 100644 src/routes/index.js diff --git a/scripts/migrate-documents.js b/scripts/migrate-documents.js new file mode 100755 index 00000000..167111ee --- /dev/null +++ b/scripts/migrate-documents.js @@ -0,0 +1,342 @@ +#!/usr/bin/env node +/** + * Document Migration Script + * Migrates markdown documents into the MongoDB database + * + * Usage: + * npm run migrate:docs # Interactive mode + * node scripts/migrate-documents.js --source /path/to/docs --dry-run + * node scripts/migrate-documents.js --source /path/to/docs --force + */ + +require('dotenv').config(); + +const fs = require('fs').promises; +const path = require('path'); +const { connect, close } = require('../src/utils/db.util'); +const Document = require('../src/models/Document.model'); +const { markdownToHtml, extractTOC, generateSlug } = require('../src/utils/markdown.util'); +const logger = require('../src/utils/logger.util'); + +// Parse command line arguments +const args = process.argv.slice(2); +const sourceArg = args.indexOf('--source'); +const dryRun = args.includes('--dry-run'); +const force = args.includes('--force'); + +// Default source paths +const DEFAULT_SOURCES = [ + '/home/theflow/projects/tractatus/docs/markdown', + '/home/theflow/projects/sydigital/stochastic/innovation-exploration/anthropic-submission' +]; + +/** + * Extract front matter from markdown + */ +function extractFrontMatter(content) { + const frontMatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontMatterRegex); + + if (!match) { + return { frontMatter: {}, content }; + } + + const frontMatterText = match[1]; + const remainingContent = match[2]; + + // Parse YAML-like front matter + const frontMatter = {}; + frontMatterText.split('\n').forEach(line => { + const [key, ...valueParts] = line.split(':'); + if (key && valueParts.length > 0) { + const value = valueParts.join(':').trim(); + frontMatter[key.trim()] = value.replace(/^["']|["']$/g, ''); // Remove quotes + } + }); + + return { frontMatter, content: remainingContent }; +} + +/** + * Extract metadata from filename and content + */ +function extractMetadata(filename, content, frontMatter) { + // Try to extract document identifier from filename + // Patterns: TRA-VAL-0001, STO-INN-0010, etc. + const identifierMatch = filename.match(/([A-Z]{3}-[A-Z]{3}-\d{4})/); + const identifier = identifierMatch ? identifierMatch[1] : null; + + // Extract quadrant from identifier + let quadrant = null; + if (identifier) { + const [quad] = identifier.split('-'); + const quadrantMap = { + 'STR': 'strategic', + 'OPS': 'operational', + 'TAC': 'tactical', + 'SYS': 'system', + 'STO': 'stochastic' + }; + quadrant = quadrantMap[quad] || null; + } + + // Extract title from first H1 or front matter + let title = frontMatter.title || null; + if (!title) { + const h1Match = content.match(/^#\s+(.+)$/m); + title = h1Match ? h1Match[1] : path.basename(filename, '.md'); + } + + // Extract version from identifier or front matter + let version = frontMatter.version || '1.0'; + if (identifier && identifier.match(/v(\d+-\d+)/)) { + version = identifier.match(/v(\d+-\d+)/)[1].replace('-', '.'); + } + + // Determine document type + let type = frontMatter.type || 'governance'; + if (filename.includes('technical-proposal')) type = 'technical'; + else if (filename.includes('appendix')) type = 'technical'; + else if (filename.includes('framework')) type = 'framework'; + else if (filename.includes('whitepaper')) type = 'research'; + else if (filename.includes('case-stud')) type = 'case-study'; + + // Extract author + const author = frontMatter.author || 'System'; + + // Extract tags + const tags = frontMatter.tags + ? frontMatter.tags.split(',').map(t => t.trim()) + : []; + + return { + identifier, + title, + type, + quadrant, + version, + author, + tags, + status: 'published' + }; +} + +/** + * Process a single markdown file + */ +async function processMarkdownFile(filePath, sourcePath) { + const filename = path.basename(filePath); + const rawContent = await fs.readFile(filePath, 'utf-8'); + + // Extract front matter + const { frontMatter, content } = extractFrontMatter(rawContent); + + // Extract metadata + const metadata = extractMetadata(filename, content, frontMatter); + + // Convert to HTML + const htmlContent = markdownToHtml(content); + + // Extract table of contents + const tableOfContents = extractTOC(content); + + // Generate slug from title + const slug = generateSlug(metadata.title); + + // Build document object matching Document model schema + const doc = { + title: metadata.title, + slug: slug, + quadrant: metadata.quadrant, + persistence: 'HIGH', // Default for technical documents + content_html: htmlContent, + content_markdown: content, + toc: tableOfContents, + metadata: { + author: metadata.author, + version: metadata.version, + document_code: metadata.identifier, + tags: metadata.tags, + original_filename: filename, + source_path: path.relative(sourcePath, filePath), + migrated_at: new Date() + }, + search_index: content.toLowerCase(), + translations: {}, + download_formats: {} + }; + + return doc; +} + +/** + * Find all markdown files in directory + */ +async function findMarkdownFiles(dirPath) { + const files = []; + + async function scan(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip node_modules and hidden directories + if (!entry.name.startsWith('.') && entry.name !== 'node_modules') { + await scan(fullPath); + } + } else if (entry.isFile() && entry.name.endsWith('.md')) { + // Skip README files + if (!entry.name.toLowerCase().includes('readme')) { + files.push(fullPath); + } + } + } + } + + await scan(dirPath); + return files; +} + +/** + * Main migration function + */ +async function migrate() { + try { + console.log('\n=== Tractatus Document Migration ===\n'); + + // Determine source path + let sourcePath; + if (sourceArg !== -1 && args[sourceArg + 1]) { + sourcePath = args[sourceArg + 1]; + } else { + // Check default sources + for (const defaultPath of DEFAULT_SOURCES) { + try { + const stat = await fs.stat(defaultPath); + if (stat.isDirectory()) { + const files = await fs.readdir(defaultPath); + if (files.length > 0) { + sourcePath = defaultPath; + break; + } + } + } catch (err) { + // Path doesn't exist, try next + } + } + } + + if (!sourcePath) { + console.error('❌ No source path specified and no documents found in default locations.'); + console.log('\nUsage: npm run migrate:docs -- --source /path/to/docs'); + console.log('\nDefault locations checked:'); + DEFAULT_SOURCES.forEach(p => console.log(` - ${p}`)); + process.exit(1); + } + + console.log(`📂 Source: ${sourcePath}`); + console.log(`🔍 Mode: ${dryRun ? 'DRY RUN (no changes)' : 'MIGRATION (will write to database)'}`); + console.log(''); + + // Find markdown files + const markdownFiles = await findMarkdownFiles(sourcePath); + + if (markdownFiles.length === 0) { + console.log('⚠️ No markdown files found.'); + process.exit(0); + } + + console.log(`Found ${markdownFiles.length} markdown file(s):\n`); + markdownFiles.forEach((file, i) => { + console.log(` ${i + 1}. ${path.relative(sourcePath, file)}`); + }); + console.log(''); + + if (!dryRun) { + // Connect to database + await connect(); + } + + // Process each file + let createdCount = 0; + let updatedCount = 0; + let skippedCount = 0; + let errorsCount = 0; + + for (const filePath of markdownFiles) { + try { + const doc = await processMarkdownFile(filePath, sourcePath); + const filename = path.basename(filePath); + + if (dryRun) { + console.log(`✓ [DRY RUN] ${filename}`); + console.log(` Title: ${doc.title}`); + console.log(` Slug: ${doc.slug}`); + console.log(` Quadrant: ${doc.quadrant || 'none'}`); + console.log(` Code: ${doc.metadata.document_code || 'none'}`); + console.log(''); + createdCount++; + } else { + // Check if document already exists by slug + const existing = await Document.findBySlug(doc.slug); + + if (existing && !force) { + console.log(`⊘ SKIPPED ${filename} (already exists: ${existing.slug})`); + skippedCount++; + } else if (existing && force) { + // Update existing document + const updatedDoc = await Document.update(existing._id, doc); + console.log(`↻ UPDATED ${filename} (${updatedDoc.slug})`); + updatedCount++; + } else { + // Create new document + const createdDoc = await Document.create(doc); + console.log(`✓ CREATED ${filename} (${createdDoc.slug})`); + createdCount++; + } + } + + } catch (error) { + console.error(`✗ ERROR processing ${path.basename(filePath)}: ${error.message}`); + logger.error(`Migration error for ${filePath}:`, error); + errorsCount++; + } + } + + // Summary + console.log('\n=== Migration Summary ===\n'); + console.log(` Total files: ${markdownFiles.length}`); + console.log(` Created: ${createdCount}`); + console.log(` Updated: ${updatedCount}`); + console.log(` Skipped: ${skippedCount}`); + console.log(` Errors: ${errorsCount}`); + console.log(''); + + if (dryRun) { + console.log('💡 This was a dry run. No changes were made.'); + console.log(' Run without --dry-run to perform actual migration.'); + } + + if (!dryRun) { + logger.info(`Document migration completed: ${createdCount} created, ${updatedCount} updated, ${skippedCount} skipped, ${errorsCount} errors`); + } + + } catch (error) { + console.error('\n❌ Migration failed:', error.message); + logger.error('Migration error:', error); + process.exit(1); + } finally { + if (!dryRun) { + await close(); + } + } +} + +// Run if called directly +if (require.main === module) { + migrate(); +} + +module.exports = migrate; diff --git a/scripts/seed-admin.js b/scripts/seed-admin.js new file mode 100755 index 00000000..26e1ef44 --- /dev/null +++ b/scripts/seed-admin.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Seed Admin User Script + * Creates the initial admin user for the Tractatus platform + * + * Usage: npm run seed:admin + */ + +require('dotenv').config(); + +const readline = require('readline'); +const { connect, close } = require('../src/utils/db.util'); +const User = require('../src/models/User.model'); +const logger = require('../src/utils/logger.util'); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +function question(prompt) { + return new Promise((resolve) => { + rl.question(prompt, resolve); + }); +} + +async function seedAdmin() { + try { + console.log('\n=== Tractatus Admin User Setup ===\n'); + + // Connect to database + await connect(); + + // Check if admin user already exists + const existingAdmin = await User.findByEmail(process.env.ADMIN_EMAIL || 'admin@tractatus.local'); + + if (existingAdmin) { + console.log('⚠️ Admin user already exists.'); + const overwrite = await question('Do you want to delete and recreate? (yes/no): '); + + if (overwrite.toLowerCase() !== 'yes') { + console.log('Cancelled. No changes made.'); + await cleanup(); + return; + } + + await User.deleteOne({ _id: existingAdmin._id }); + console.log('✅ Existing admin user deleted.'); + } + + // Get admin details + console.log('\nEnter admin user details:'); + const name = await question('Name (default: Admin User): ') || 'Admin User'; + const email = await question(`Email (default: ${process.env.ADMIN_EMAIL || 'admin@tractatus.local'}): `) + || process.env.ADMIN_EMAIL + || 'admin@tractatus.local'; + + // Password input (hidden) + console.log('\n⚠️ Password will be visible. Use a development password only.'); + const password = await question('Password (min 8 chars): '); + + if (!password || password.length < 8) { + console.error('❌ Password must be at least 8 characters.'); + await cleanup(); + return; + } + + // Create admin user + const admin = await User.create({ + name, + email, + password, // Will be hashed by the model + role: 'admin', + active: true + }); + + console.log('\n✅ Admin user created successfully!'); + console.log('\nCredentials:'); + console.log(` Email: ${admin.email}`); + console.log(` Role: ${admin.role}`); + console.log(` ID: ${admin._id}`); + console.log('\nYou can now login at: POST /api/auth/login'); + console.log(''); + + logger.info(`Admin user created: ${admin.email}`); + + } catch (error) { + console.error('\n❌ Error creating admin user:', error.message); + logger.error('Admin seed error:', error); + process.exit(1); + } finally { + await cleanup(); + } +} + +async function cleanup() { + rl.close(); + await close(); +} + +// Handle Ctrl+C +process.on('SIGINT', async () => { + console.log('\n\n👋 Cancelled by user'); + await cleanup(); + process.exit(0); +}); + +// Run if called directly +if (require.main === module) { + seedAdmin(); +} + +module.exports = seedAdmin; diff --git a/src/controllers/admin.controller.js b/src/controllers/admin.controller.js new file mode 100644 index 00000000..226e8c93 --- /dev/null +++ b/src/controllers/admin.controller.js @@ -0,0 +1,289 @@ +/** + * 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 } = req.query; + + let items; + let total; + + if (quadrant) { + items = await ModerationQueue.findByQuadrant(quadrant, { + limit: parseInt(limit), + skip: parseInt(skip) + }); + total = await ModerationQueue.countPending({ quadrant }); + } else if (item_type) { + items = await ModerationQueue.findByType(item_type, { + limit: parseInt(limit), + skip: parseInt(skip) + }); + total = await ModerationQueue.countPending({ item_type }); + } 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, + 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; + + switch (action) { + case 'approve': + success = await ModerationQueue.approve(id, req.userId, notes); + 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` + }); + + } catch (error) { + logger.error('Review moderation item error:', error); + res.status(500).json({ + error: 'Internal Server Error', + message: 'An error occurred' + }); + } +} + +/** + * 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 +}; diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 00000000..b82218bf --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,121 @@ +/** + * 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, + token, + user: { + id: user._id, + 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 +}; diff --git a/src/controllers/blog.controller.js b/src/controllers/blog.controller.js new file mode 100644 index 00000000..6e615d0f --- /dev/null +++ b/src/controllers/blog.controller.js @@ -0,0 +1,332 @@ +/** + * Blog Controller + * AI-curated blog with human oversight + */ + +const BlogPost = require('../models/BlogPost.model'); +const { markdownToHtml } = require('../utils/markdown.util'); +const logger = require('../utils/logger.util'); + +/** + * 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' + }); + } +} + +module.exports = { + listPublishedPosts, + getPublishedPost, + listPostsByStatus, + getPostById, + createPost, + updatePost, + publishPost, + deletePost +}; diff --git a/src/controllers/documents.controller.js b/src/controllers/documents.controller.js new file mode 100644 index 00000000..660e8912 --- /dev/null +++ b/src/controllers/documents.controller.js @@ -0,0 +1,265 @@ +/** + * 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 } = req.query; + + let documents; + let total; + + if (quadrant) { + documents = await Document.findByQuadrant(quadrant, { + limit: parseInt(limit), + skip: parseInt(skip) + }); + total = await Document.count({ quadrant }); + } else { + documents = await Document.list({ + limit: parseInt(limit), + skip: parseInt(skip) + }); + total = await Document.count(); + } + + 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 + * GET /api/documents/search + */ +async function searchDocuments(req, res) { + try { + const { q, limit = 20, skip = 0 } = req.query; + + if (!q) { + return res.status(400).json({ + error: 'Bad Request', + message: 'Search query (q) is required' + }); + } + + const documents = await Document.search(q, { + limit: parseInt(limit), + skip: parseInt(skip) + }); + + res.json({ + success: true, + query: q, + documents, + count: documents.length + }); + + } 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, 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, + 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' + }); + } +} + +module.exports = { + listDocuments, + getDocument, + searchDocuments, + createDocument, + updateDocument, + deleteDocument +}; diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js new file mode 100644 index 00000000..a22cc49f --- /dev/null +++ b/src/routes/admin.routes.js @@ -0,0 +1,64 @@ +/** + * 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; diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js new file mode 100644 index 00000000..dc1d07f5 --- /dev/null +++ b/src/routes/auth.routes.js @@ -0,0 +1,41 @@ +/** + * Authentication Routes + */ + +const express = require('express'); +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'); + +/** + * POST /api/auth/login + * Login with email and password + */ +router.post('/login', + validateRequired(['email', 'password']), + validateEmail, + 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; diff --git a/src/routes/blog.routes.js b/src/routes/blog.routes.js new file mode 100644 index 00000000..67d25afc --- /dev/null +++ b/src/routes/blog.routes.js @@ -0,0 +1,80 @@ +/** + * 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 - 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 + */ + +// 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; diff --git a/src/routes/documents.routes.js b/src/routes/documents.routes.js new file mode 100644 index 00000000..8710bb85 --- /dev/null +++ b/src/routes/documents.routes.js @@ -0,0 +1,62 @@ +/** + * 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 +router.get('/', + 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) +); + +module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 00000000..27a4fcfa --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,64 @@ +/** + * Routes Index + * Central routing configuration + */ + +const express = require('express'); +const router = express.Router(); + +// Import route modules +const authRoutes = require('./auth.routes'); +const documentsRoutes = require('./documents.routes'); +const blogRoutes = require('./blog.routes'); +const adminRoutes = require('./admin.routes'); + +// Mount routes +router.use('/auth', authRoutes); +router.use('/documents', documentsRoutes); +router.use('/blog', blogRoutes); +router.use('/admin', adminRoutes); + +// API root endpoint +router.get('/', (req, res) => { + res.json({ + name: 'Tractatus AI Safety Framework API', + version: '1.0.0', + status: 'operational', + endpoints: { + auth: { + login: 'POST /api/auth/login', + me: 'GET /api/auth/me', + logout: 'POST /api/auth/logout' + }, + documents: { + list: 'GET /api/documents', + get: 'GET /api/documents/:identifier', + search: 'GET /api/documents/search?q=query', + create: 'POST /api/documents (admin)', + update: 'PUT /api/documents/:id (admin)', + delete: 'DELETE /api/documents/:id (admin)' + }, + blog: { + list: 'GET /api/blog', + get: 'GET /api/blog/:slug', + create: 'POST /api/blog (admin)', + update: 'PUT /api/blog/:id (admin)', + publish: 'POST /api/blog/:id/publish (admin)', + delete: 'DELETE /api/blog/:id (admin)', + admin_list: 'GET /api/blog/admin/posts?status=draft (admin)', + admin_get: 'GET /api/blog/admin/:id (admin)' + }, + admin: { + moderation_queue: 'GET /api/admin/moderation', + moderation_item: 'GET /api/admin/moderation/:id', + review: 'POST /api/admin/moderation/:id/review', + stats: 'GET /api/admin/stats', + activity: 'GET /api/admin/activity' + } + }, + documentation: '/api/docs', + health: '/health' + }); +}); + +module.exports = router; diff --git a/src/server.js b/src/server.js index 6492dc8c..97c466b4 100644 --- a/src/server.js +++ b/src/server.js @@ -66,23 +66,9 @@ app.get('/health', (req, res) => { }); }); -// API routes (placeholder - will be implemented) -app.get('/api', (req, res) => { - res.json({ - name: config.appName, - version: '1.0.0', - message: 'Tractatus AI Safety Framework API', - documentation: '/api/docs', - endpoints: { - documents: '/api/documents', - blog: '/api/blog', - media: '/api/media', - cases: '/api/cases', - resources: '/api/resources', - admin: '/api/admin' - } - }); -}); +// API routes +const apiRoutes = require('./routes/index'); +app.use('/api', apiRoutes); // Homepage (temporary) app.get('/', (req, res) => { @@ -117,14 +103,18 @@ app.get('/', (req, res) => {
  • ✓ Express server running (port ${config.port})
  • ✓ Database initialized (10 collections)
  • ✓ Core models implemented
  • -
  • ⏳ API routes (in development)
  • +
  • ✓ API routes complete (auth, documents, blog, admin)
  • ⏳ Frontend (pending)
  • Available Endpoints

    Phase 1 Development - Not for public use