/** * 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 SubmissionTracking = require('../models/SubmissionTracking.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' }); } } /** * Generate publication-specific topic suggestions * POST /api/blog/suggest-topics-for-publication * * Uses publication readership profile to generate tailored topic suggestions */ async function suggestTopicsForPublication(req, res) { try { const { publicationId } = req.body; if (!publicationId) { return res.status(400).json({ error: 'Bad Request', message: 'Publication ID is required' }); } // Load publication config const publicationConfig = require('../config/publication-targets.config'); const publication = publicationConfig.getPublicationById(publicationId); if (!publication) { return res.status(404).json({ error: 'Not Found', message: 'Publication not found' }); } // Check if publication has readership profile if (!publication.readership) { return res.status(400).json({ error: 'Bad Request', message: 'Publication does not have a readership profile configured' }); } // Build context for topic generation const context = { publicationName: publication.name, publicationType: publication.type, primaryAudience: publication.readership.primary, demographics: publication.readership.demographics, contentPreferences: publication.readership.contentPreferences, editorial: publication.editorial }; logger.info(`Generating publication-specific topics for ${publication.name}`); // Generate 3-5 tailored topic suggestions const topics = await claudeAPI.generatePublicationTopics(context); // Return topics with auto-fill suggestions const response = { success: true, publication: { id: publication.id, name: publication.name, type: publication.type }, autoFill: { audience: publication.readership.primary, tone: publication.readership.contentPreferences.tone, culture: publication.readership.contentPreferences.culture, language: publication.requirements?.language || 'en' }, topics: topics.map(topic => ({ title: topic.title, rationale: topic.rationale, hook: topic.hook, keyPoints: topic.keyPoints })) }; res.json(response); } catch (error) { logger.error('Publication topic generation error:', error); res.status(500).json({ error: 'Internal Server Error', message: 'Failed to generate publication-specific topics' }); } } /** * 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 { contentType, audience, theme, topic, focus, tone, culture, language, publicationTarget, articleReference } = req.body; // Validate content type const validContentTypes = ['blog', 'letter', 'oped', 'social']; const actualContentType = contentType || 'blog'; // Default to blog for backwards compatibility if (!validContentTypes.includes(actualContentType)) { return res.status(400).json({ error: 'Bad Request', message: `Content type must be one of: ${validContentTypes.join(', ')}` }); } // Validate audience const validAudiences = ['research', 'implementer', 'leader', 'general']; if (!audience || !validAudiences.includes(audience)) { return res.status(400).json({ error: 'Bad Request', message: `Audience must be one of: ${validAudiences.join(', ')}` }); } // Validate optional parameters const validTones = ['standard', 'academic', 'practical', 'strategic', 'conversational']; const validCultures = ['universal', 'indigenous', 'global-south', 'asia-pacific', 'european', 'north-american']; const validLanguages = ['en', 'mi', 'es', 'fr', 'de', 'zh', 'ja']; const targetContext = { contentType: actualContentType, audience, tone: tone && validTones.includes(tone) ? tone : 'standard', culture: culture && validCultures.includes(culture) ? culture : 'universal', language: language && validLanguages.includes(language) ? language : 'en', theme, topic, focus, publicationTarget, articleReference }; logger.info(`External content generation requested: ${actualContentType} for ${audience}`); // 1. Boundary check (TRA-OPS-0002: Editorial decisions require human oversight) const boundaryCheck = BoundaryEnforcer.enforce({ description: `Generate ${actualContentType} content for external communications`, text: 'AI provides draft, human reviews and approves before submission', classification: { quadrant: 'OPERATIONAL' }, type: 'external_content_generation' }); // Log boundary check await GovernanceLog.create({ action: actualContentType === 'blog' ? 'BLOG_TOPIC_SUGGESTION' : 'EXTERNAL_CONTENT_GENERATION', user_id: req.user._id, user_email: req.user.email, timestamp: new Date(), boundary_check: boundaryCheck, outcome: boundaryCheck.allowed ? 'QUEUED_FOR_APPROVAL' : 'BLOCKED', details: targetContext }); if (!boundaryCheck.allowed) { logger.warn(`Content generation 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. Route to appropriate generation method based on content type let generatedContent; let publication = null; if (actualContentType === 'letter') { // Load publication config const publicationConfig = require('../config/publication-targets.config'); publication = publicationConfig.getPublicationById(publicationTarget); if (!publication) { return res.status(400).json({ error: 'Bad Request', message: 'Invalid publication target for letter' }); } if (!articleReference || !articleReference.title || !articleReference.mainPoint) { return res.status(400).json({ error: 'Bad Request', message: 'Letter to editor requires article reference (title, date, mainPoint)' }); } logger.info(`Generating letter to editor for ${publication.name}`); generatedContent = await claudeAPI.generateLetterToEditor(targetContext, publication, articleReference); } else if (actualContentType === 'oped' || actualContentType === 'social') { // Load publication config const publicationConfig = require('../config/publication-targets.config'); // Debug logging logger.info(`Looking up publication: ${publicationTarget} for ${actualContentType}`); publication = publicationConfig.getPublicationById(publicationTarget); logger.info(`Publication found: ${publication ? publication.name : 'NULL'}`); if (!publication) { return res.status(400).json({ error: 'Bad Request', message: `Invalid publication target for op-ed/social (received: ${publicationTarget})` }); } if (!topic) { return res.status(400).json({ error: 'Bad Request', message: 'Op-ed/social content requires a topic' }); } logger.info(`Generating ${actualContentType} for ${publication.name}`); generatedContent = await claudeAPI.generateOpEd(targetContext, publication, topic, focus); } else { // Blog topic suggestions (original behavior) generatedContent = await claudeAPI.generateBlogTopics(targetContext); logger.info(`Claude API returned ${generatedContent.length} topic suggestions`); } // 3. Create moderation queue entry (human approval required) const queueType = { 'blog': 'BLOG_TOPIC_SUGGESTION', 'letter': 'EXTERNAL_CONTENT_LETTER', 'oped': 'EXTERNAL_CONTENT_OPED', 'social': 'EXTERNAL_CONTENT_SOCIAL' }[actualContentType]; const queueEntry = await ModerationQueue.create({ type: queueType, reference_collection: actualContentType === 'blog' ? 'blog_posts' : 'external_content', data: { ...targetContext, content: generatedContent, publication: publication ? { id: publication.id, name: publication.name, rank: publication.rank, submission: publication.submission } : null, 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', content_type: actualContentType } }); logger.info(`Created moderation queue entry: ${queueEntry._id} for ${actualContentType}`); // 4. Return response (content queued for human review) const responseMessage = { 'blog': 'Blog topic suggestions generated. Awaiting human review and approval.', 'letter': `Letter to editor for ${publication.name} generated. Awaiting human review before submission.`, 'oped': `Op-ed for ${publication.name} generated. Awaiting human review before submission.`, 'social': `Social media content for ${publication.name} generated. Awaiting human review.` }[actualContentType]; res.json({ success: true, message: responseMessage, queue_id: queueEntry._id, content: generatedContent, contentType: actualContentType, publication: publication ? { name: publication.name, rank: publication.rank, wordCount: `${publication.requirements.wordCount.min}-${publication.requirements.wordCount.max}`, submission: publication.submission.email || publication.submission.method } : null, 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 = ` Tractatus AI Safety Framework Blog ${baseUrl}/blog.html Insights, updates, and analysis on AI governance, safety frameworks, and the Tractatus boundary enforcement approach. en-us ${buildDate} ${baseUrl}/images/tractatus-icon.svg Tractatus AI Safety Framework ${baseUrl}/blog.html `; // 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 => ` ${escapeXml(tag)}` ).join('\n'); rss += ` ${escapeXml(post.title)} ${postUrl} ${postUrl} ${escapeXml(description)} ${escapeXml(author)} ${pubDate} ${categories ? `${categories}\n` : ''} `; } // Close RSS XML 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' }); } } /** * Validate content uniqueness before submission * POST /api/blog/validate-uniqueness */ async function validateUniqueness(req, res) { try { const { title, content, targetPublicationId, excludeId } = req.body; if (!content) { return res.status(400).json({ error: 'Bad Request', message: 'Content is required' }); } const { getInstance: getContentSimilarity } = require('../services/ContentSimilarity.service'); const contentSimilarity = getContentSimilarity(); // Get all published articles (exclude current if editing) const existingArticles = await BlogPost.findPublished({ limit: 100 }); // Filter out the article being edited const articlesToCheck = excludeId ? existingArticles.filter(a => a._id.toString() !== excludeId) : existingArticles; logger.info(`Checking uniqueness against ${articlesToCheck.length} existing articles`); // Check similarity against each existing article const similarities = []; let maxSimilarity = 0; for (const existing of articlesToCheck.slice(0, 20)) { // Limit to 20 most recent for performance try { const result = await contentSimilarity.analyzeSimilarity( { title, content, wordCount: content.split(/\s+/).length }, { title: existing.title, content: existing.content, wordCount: existing.content?.split(/\s+/).length || 0 }, { targetPublication1: targetPublicationId } ); if (result.finalSimilarity > 0.3) { // Only report significant similarities similarities.push({ articleId: existing._id, articleTitle: existing.title, similarity: result.finalSimilarity, baseSimilarity: result.baseSimilarity, verdict: result.verdict, recommendation: result.recommendation }); } maxSimilarity = Math.max(maxSimilarity, result.finalSimilarity); } catch (error) { logger.warn(`Similarity check failed for ${existing.title}:`, error.message); // Continue checking other articles } } // Sort by similarity similarities.sort((a, b) => b.similarity - a.similarity); // Determine overall result const unique = maxSimilarity < 0.70; res.json({ success: true, unique, maxSimilarity, similarArticles: similarities.slice(0, 5), // Top 5 most similar warning: !unique ? '⚠️ This content is very similar to existing articles' : null, recommendation: unique ? '✅ Content is sufficiently unique' : '🚫 Consider significant revisions to differentiate from existing content' }); } catch (error) { logger.error('Validate uniqueness error:', error); res.status(500).json({ error: 'Internal Server Error', message: 'Failed to validate uniqueness' }); } } /** * Check submission conflicts * POST /api/blog/check-submission-conflict */ async function checkSubmissionConflict(req, res) { try { const { contentId, targetPublicationId } = req.body; if (!contentId || !targetPublicationId) { return res.status(400).json({ error: 'Bad Request', message: 'contentId and targetPublicationId are required' }); } const { getInstance: getContentSimilarity } = require('../services/ContentSimilarity.service'); const SubmissionTracking = require('../models/SubmissionTracking.model'); const contentSimilarity = getContentSimilarity(); const result = await contentSimilarity.checkSubmissionConflict( contentId, targetPublicationId, SubmissionTracking ); res.json({ success: true, ...result }); } catch (error) { logger.error('Check submission conflict error:', error); res.status(500).json({ error: 'Internal Server Error', message: 'Failed to check submission conflict' }); } } /** * Validate article comprehensively (content + title similarity) * POST /api/blog/validate-article */ async function validateArticle(req, res) { try { const { articleId, targetPublicationId } = req.body; const { getInstance: getContentSimilarity } = require('../services/ContentSimilarity.service'); const contentSimilarity = getContentSimilarity(); // Get the article const article = await BlogPost.findById(articleId); if (!article) { return res.status(404).json({ error: 'Not Found', message: 'Article not found' }); } // Get all other pending/ready articles const otherArticles = await BlogPost.findByStatus('pending_review', { limit: 100 }); const articlesToCheck = otherArticles.filter(a => a._id.toString() !== articleId); const validationResults = { articleId, articleTitle: article.title, targetPublication: targetPublicationId, contentChecks: [], titleChecks: [], overallStatus: 'pass', warnings: [], errors: [] }; // Check against each other article for (const other of articlesToCheck) { // Content similarity check try { const contentResult = await contentSimilarity.analyzeSimilarity( { title: article.title, content: article.content, wordCount: article.content.split(/\s+/).length }, { title: other.title, content: other.content, wordCount: other.content?.split(/\s+/).length || 0 }, { targetPublication1: targetPublicationId } ); validationResults.contentChecks.push({ comparedWith: other.title, comparedWithId: other._id, similarity: contentResult.finalSimilarity, pass: contentResult.allowed, message: contentResult.recommendation }); if (!contentResult.allowed && contentResult.finalSimilarity > 0.7) { validationResults.errors.push(`Content too similar to "${other.title}" (${Math.round(contentResult.finalSimilarity * 100)}%)`); validationResults.overallStatus = 'fail'; } } catch (err) { logger.warn(`Content similarity check failed for ${other.title}:`, err.message); } // Title similarity check const titleResult = contentSimilarity.analyzeTitleSimilarity(article.title, other.title); validationResults.titleChecks.push({ comparedWith: other.title, comparedWithId: other._id, similarity: titleResult.similarity, pass: titleResult.pass, sharedWords: titleResult.sharedWords, message: titleResult.message }); if (!titleResult.pass) { validationResults.errors.push(`Title too similar to "${other.title}" (${Math.round(titleResult.similarity * 100)}%) - shared words: ${titleResult.sharedWords.join(', ')}`); validationResults.overallStatus = 'fail'; } else if (titleResult.similarity >= 0.4) { validationResults.warnings.push(`Title has some overlap with "${other.title}" (${Math.round(titleResult.similarity * 100)}%)`); } } // Sort checks by similarity (highest first) validationResults.contentChecks.sort((a, b) => b.similarity - a.similarity); validationResults.titleChecks.sort((a, b) => b.similarity - a.similarity); // Add summary validationResults.summary = { totalChecks: articlesToCheck.length, contentPasses: validationResults.contentChecks.filter(c => c.pass).length, contentFails: validationResults.contentChecks.filter(c => !c.pass).length, titlePasses: validationResults.titleChecks.filter(c => c.pass).length, titleFails: validationResults.titleChecks.filter(c => !c.pass).length, canSubmit: validationResults.overallStatus === 'pass' }; res.json({ success: true, validation: validationResults }); } catch (error) { logger.error('Validate article error:', error); res.status(500).json({ error: 'Internal Server Error', message: 'Failed to validate article' }); } } /** * Helper: Escape XML special characters */ function escapeXml(unsafe) { if (!unsafe) return ''; return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } module.exports = { listPublishedPosts, getPublishedPost, listPostsByStatus, getPostById, createPost, updatePost, publishPost, deletePost, suggestTopics, suggestTopicsForPublication, draftBlogPost, analyzeContent, getEditorialGuidelines, generateRSSFeed, validateUniqueness, checkSubmissionConflict, validateArticle, getSubmissions, updateSubmission }; /** * Get submission tracking data for a blog post * GET /api/blog/:id/submissions */ async function getSubmissions(req, res) { try { const { id } = req.params; // Get the blog post const post = await BlogPost.findById(id); if (!post) { return res.status(404).json({ success: false, message: 'Blog post not found' }); } // Get all submissions for this post const submissions = await SubmissionTracking.find({ blogPostId: id }) .populate('createdBy', 'email') .sort({ createdAt: -1 }); res.json({ success: true, submissions }); } catch (error) { logger.error('Error fetching submissions:', error); res.status(500).json({ success: false, message: 'Failed to fetch submissions', error: error.message }); } } /** * Create or update submission tracking * PUT /api/blog/:id/submissions */ async function updateSubmission(req, res) { try { const { id } = req.params; const { publicationId, submissionPackage } = req.body; const userId = req.user._id; // Get the blog post const post = await BlogPost.findById(id); if (!post) { return res.status(404).json({ success: false, message: 'Blog post not found' }); } // Get publication config (we'll need to load this from config) const publicationTargets = require('../config/publication-targets.config'); const publicationConfig = publicationTargets.getPublicationById(publicationId); if (!publicationConfig) { return res.status(400).json({ success: false, message: 'Invalid publication ID' }); } // Find or create submission tracking record let submission = await SubmissionTracking.findOne({ blogPostId: id, publicationId }); if (submission) { // Update existing submission if (submissionPackage) { // Merge submissionPackage fields Object.keys(submissionPackage).forEach(field => { if (!submission.submissionPackage) { submission.submissionPackage = {}; } submission.submissionPackage[field] = { ...submission.submissionPackage[field], ...submissionPackage[field] }; }); } submission.lastUpdatedBy = userId; await submission.save(); } else { // Create new submission submission = new SubmissionTracking({ blogPostId: id, publicationId, publicationName: publicationConfig.name, title: post.title, wordCount: post.wordCount, contentType: publicationConfig.type, submissionPackage: submissionPackage || {}, createdBy: userId, lastUpdatedBy: userId }); await submission.save(); } // Populate and return await submission.populate('createdBy', 'email'); res.json({ success: true, submission }); } catch (error) { logger.error('Error updating submission:', error); res.status(500).json({ success: false, message: 'Failed to update submission', error: error.message }); } }