Phase 1: Cultural Sensitivity Detection Layer - Detects Western-centric framing (democracy, individual rights, freedom) - Detects Indigenous exclusion (missing Te Tiriti, CARE principles) - FLAGS for human review, never auto-blocks (preserves human agency) Implementation: - PluralisticDeliberationOrchestrator.assessCulturalSensitivity() - Pattern-based detection (Western-centric governance, Indigenous exclusion) - Risk levels: LOW, MEDIUM, HIGH - Recommended actions: APPROVE, SUGGEST_ADAPTATION, HUMAN_REVIEW - High-risk audiences: Non-Western countries (CN, RU, SA, IR, VN, TH, ID, MY, PH), Indigenous communities - Audit logging to MongoDB - media.controller.js respondToInquiry() - Cultural check after ContentGovernanceChecker passes - Stores cultural_sensitivity in response metadata - Returns flag if HIGH risk (doesn't block, flags for review) - blog.controller.js publishPost() - Cultural check after framework governance check - Stores cultural_sensitivity in moderation.cultural_sensitivity - Returns flag if HIGH risk (doesn't block, flags for review) - MediaInquiry.model.js - Added country, cultural_context fields to contact - respond() method supports cultural_sensitivity in response metadata Framework Integration: - Dual-layer governance: Universal rules (ContentGovernanceChecker) + Cultural sensitivity (PluralisticDeliberationOrchestrator) - inst_081 pluralism: Different value frameworks equally legitimate - Human-in-the-loop: AI detects/suggests, human decides Next: Phase 2 (UI/workflow), Phase 3 (learning/refinement) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1536 lines
45 KiB
JavaScript
1536 lines
45 KiB
JavaScript
/**
|
|
* 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');
|
|
const PluralisticDeliberationOrchestrator = require('../services/PluralisticDeliberationOrchestrator.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
|
|
});
|
|
}
|
|
|
|
// Framework governance check (inst_016/017/018/079/080/081)
|
|
const { execSync } = require('child_process');
|
|
try {
|
|
const frameworkCheck = execSync(
|
|
`node scripts/framework-check-blog-content.js --post-id ${id}`,
|
|
{ encoding: 'utf8', timeout: 10000 }
|
|
);
|
|
|
|
const checkResult = JSON.parse(frameworkCheck);
|
|
|
|
if (!checkResult.success) {
|
|
// Store framework violations in moderation record
|
|
await BlogPost.update(id, {
|
|
'moderation.framework_violations': checkResult.violations,
|
|
'moderation.framework_check_timestamp': new Date()
|
|
});
|
|
|
|
return res.status(400).json({
|
|
error: 'Framework Violations',
|
|
message: 'Content violates governance rules - publication blocked',
|
|
violations: checkResult.violations,
|
|
summary: checkResult.summary
|
|
});
|
|
}
|
|
|
|
// Store successful framework check
|
|
await BlogPost.update(id, {
|
|
'moderation.framework_check_passed': true,
|
|
'moderation.framework_check_timestamp': new Date()
|
|
});
|
|
|
|
} catch (frameworkError) {
|
|
logger.error('Framework check failed:', frameworkError);
|
|
// Non-blocking: Allow publication but log the failure
|
|
await BlogPost.update(id, {
|
|
'moderation.framework_check_error': frameworkError.message,
|
|
'moderation.framework_check_timestamp': new Date()
|
|
});
|
|
}
|
|
|
|
// Cultural sensitivity check (inst_081 pluralism)
|
|
// Detects Western-centric framing, Indigenous exclusion
|
|
// FLAGS for review, never blocks (human decides)
|
|
let culturalCheck = null;
|
|
let culturalReviewRequired = false;
|
|
|
|
try {
|
|
// Get full text for cultural check (title + excerpt + content)
|
|
const fullText = [post.title, post.excerpt, post.content]
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
.replace(/<[^>]*>/g, ''); // Strip HTML tags
|
|
|
|
culturalCheck = await PluralisticDeliberationOrchestrator.assessCulturalSensitivity(fullText, {
|
|
audience: {
|
|
// Phase 2: will add target_publications, language fields to BlogPost
|
|
tags: post.tags
|
|
},
|
|
content_type: 'blog_post',
|
|
post_id: id
|
|
});
|
|
|
|
if (culturalCheck.risk_level === 'HIGH') {
|
|
culturalReviewRequired = true;
|
|
logger.warn(`Blog post flagged for cultural sensitivity review`, {
|
|
post_id: id,
|
|
slug: post.slug,
|
|
risk_level: culturalCheck.risk_level,
|
|
concerns_count: culturalCheck.concerns.length
|
|
});
|
|
}
|
|
|
|
// Store cultural check results
|
|
await BlogPost.update(id, {
|
|
'moderation.cultural_sensitivity': {
|
|
risk_level: culturalCheck.risk_level,
|
|
concerns: culturalCheck.concerns,
|
|
suggestions: culturalCheck.suggestions,
|
|
recommended_action: culturalCheck.recommended_action,
|
|
checked_at: culturalCheck.timestamp
|
|
}
|
|
});
|
|
|
|
} catch (culturalError) {
|
|
logger.error('Cultural sensitivity check failed:', culturalError);
|
|
// Non-blocking: Allow publication but log the failure
|
|
await BlogPost.update(id, {
|
|
'moderation.cultural_sensitivity_error': culturalError.message
|
|
});
|
|
}
|
|
|
|
// 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}`, {
|
|
cultural_review_required: culturalReviewRequired
|
|
});
|
|
|
|
const response = {
|
|
success: true,
|
|
post: updatedPost,
|
|
message: culturalReviewRequired
|
|
? 'Post published - FLAGGED for cultural sensitivity review'
|
|
: 'Post published successfully'
|
|
};
|
|
|
|
if (culturalCheck) {
|
|
response.cultural_sensitivity = {
|
|
risk_level: culturalCheck.risk_level,
|
|
review_required: culturalReviewRequired,
|
|
concerns_count: culturalCheck.concerns.length,
|
|
suggestions_count: culturalCheck.suggestions.length
|
|
};
|
|
|
|
// Include details if review required
|
|
if (culturalReviewRequired) {
|
|
response.cultural_sensitivity.concerns = culturalCheck.concerns;
|
|
response.cultural_sensitivity.suggestions = culturalCheck.suggestions;
|
|
response.cultural_sensitivity.note = 'Human review recommended (inst_081 pluralism)';
|
|
}
|
|
}
|
|
|
|
res.json(response);
|
|
|
|
} 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 = `<?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'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check content for framework violations
|
|
* POST /api/blog/check-framework
|
|
* Body: { content: string, title: string }
|
|
*/
|
|
async function checkFramework(req, res) {
|
|
try {
|
|
const { content, title, postId } = req.body;
|
|
|
|
if (!content && !title && !postId) {
|
|
return res.status(400).json({
|
|
error: 'Bad Request',
|
|
message: 'Content, title, or postId required'
|
|
});
|
|
}
|
|
|
|
const { execSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Run framework check script
|
|
try {
|
|
let checkCommand;
|
|
|
|
if (postId) {
|
|
// Use post-id approach (reads from database)
|
|
checkCommand = `node scripts/framework-check-blog-content.js --post-id ${postId}`;
|
|
} else {
|
|
// Write content to temp file to avoid escaping issues
|
|
const tmpFile = path.join('/tmp', `blog-check-${Date.now()}.txt`);
|
|
fs.writeFileSync(tmpFile, content || '');
|
|
|
|
checkCommand = `node scripts/framework-check-blog-content.js --content "$(cat ${tmpFile})" --title "${(title || '').replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
const checkResult = execSync(checkCommand, {
|
|
encoding: 'utf8',
|
|
timeout: 10000,
|
|
// Redirect stderr to stdout to capture all output
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
// Parse JSON output (skip log lines)
|
|
const lines = checkResult.split('\n');
|
|
const jsonLine = lines.find(line => line.trim().startsWith('{'));
|
|
|
|
if (!jsonLine) {
|
|
throw new Error('No JSON output from framework check');
|
|
}
|
|
|
|
const result = JSON.parse(jsonLine);
|
|
|
|
return res.json({
|
|
success: result.success,
|
|
violations: result.violations || [],
|
|
framework_checks: result.framework_checks || {},
|
|
summary: result.summary || {}
|
|
});
|
|
|
|
} catch (scriptError) {
|
|
// Script returned non-zero exit code (violations found)
|
|
if (scriptError.stdout) {
|
|
try {
|
|
const lines = scriptError.stdout.split('\n');
|
|
const jsonLine = lines.find(line => line.trim().startsWith('{'));
|
|
|
|
if (jsonLine) {
|
|
const result = JSON.parse(jsonLine);
|
|
return res.json({
|
|
success: false,
|
|
violations: result.violations || [],
|
|
framework_checks: result.framework_checks || {},
|
|
summary: result.summary || {}
|
|
});
|
|
}
|
|
} catch (parseError) {
|
|
logger.error('Framework check parse error:', parseError);
|
|
}
|
|
}
|
|
|
|
logger.error('Framework check script error:', scriptError.message);
|
|
return res.status(500).json({
|
|
error: 'Framework Check Failed',
|
|
message: 'Script execution failed'
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error('Check framework error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'Framework check failed'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper: Escape XML special characters
|
|
*/
|
|
function escapeXml(unsafe) {
|
|
if (!unsafe) return '';
|
|
return unsafe
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
module.exports = {
|
|
listPublishedPosts,
|
|
getPublishedPost,
|
|
listPostsByStatus,
|
|
getPostById,
|
|
createPost,
|
|
updatePost,
|
|
publishPost,
|
|
deletePost,
|
|
suggestTopics,
|
|
suggestTopicsForPublication,
|
|
draftBlogPost,
|
|
analyzeContent,
|
|
getEditorialGuidelines,
|
|
generateRSSFeed,
|
|
validateUniqueness,
|
|
checkSubmissionConflict,
|
|
validateArticle,
|
|
getSubmissions,
|
|
updateSubmission,
|
|
checkFramework
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
});
|
|
}
|
|
}
|