feat(blog): add Manage Submission modal for publication tracking

Implements comprehensive submission tracking workflow for blog posts
targeting external publications. This feature enables systematic
management of submission packages and progress monitoring.

Frontend:
- Add submission-modal.js with complete modal implementation
- Modal includes publication selector (22 ranked publications)
- 4-item submission checklist (cover letter, pitch, notes, bio)
- Auto-save on blur with success indicators
- Progress bar (0-100%) tracking completion
- Requirements display per publication
- Update blog-validation.js with event handlers
- Update cache versions (HTML, service worker, version.json)

Backend:
- Add GET /api/blog/:id/submissions endpoint
- Add PUT /api/blog/:id/submissions endpoint (upsert logic)
- Implement getSubmissions and updateSubmission controllers
- Fix publications controller to use config helper functions
- Integration with SubmissionTracking MongoDB model

Version: 1.8.4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TheFlow 2025-10-24 01:55:06 +13:00
parent f05473e23a
commit ff44a41930
8 changed files with 3470 additions and 9 deletions

View file

@ -0,0 +1,422 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>External Communications Manager | Tractatus Admin</title>
<link rel="stylesheet" href="/css/tailwind.css?v=1761223918">
<link rel="stylesheet" href="/css/tractatus-theme.min.css?v=1761223918">
<script defer src="/js/admin/auth-check.js?v=1761223918"></script>
<style>
.content-type-card input[type="radio"]:checked + div {
border-color: #3b82f6;
background-color: #eff6ff;
}
</style>
</head>
<body class="bg-gray-50">
<!-- Navigation -->
<div id="admin-navbar" data-page-title="External Communications" data-page-icon="blog"></div>
<script src="/js/components/navbar-admin.js?v=1761223918"></script>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Section Navigation Tabs -->
<div class="mb-6">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button data-section="validation" class="section-tab border-blue-500 text-blue-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
📋 Pre-Submission
</button>
<button data-section="draft" class="section-tab border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
✨ Generate
</button>
<button data-section="queue" class="section-tab border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
⏳ AI Draft Approval
</button>
<button data-section="guidelines" class="section-tab border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
📖 Guidelines
</button>
<button data-section="published" class="section-tab border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
📰 Published
</button>
</nav>
</div>
<div class="mt-2 text-xs text-gray-500 px-1">
<strong>Workflow:</strong> Generate → AI Approval → Pre-Submission (validate & prep) → Submit to publications
</div>
</div>
<!-- Tractatus Enforcement Notice -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg aria-hidden="true" class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Tractatus Framework Enforcement Active</h3>
<div class="mt-2 text-sm text-blue-700">
<p>All AI-generated content is validated against:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li><strong>inst_016:</strong> No fabricated statistics or unverifiable claims</li>
<li><strong>inst_017:</strong> No absolute assurance terms (guarantee, 100%, etc.)</li>
<li><strong>inst_018:</strong> No unverified production-ready claims</li>
</ul>
<p class="mt-2 text-xs">🤖 <strong>TRA-OPS-0002:</strong> AI suggests, human decides. All content requires human review and approval.</p>
</div>
</div>
</div>
</div>
<!-- Draft Content Section -->
<div id="draft-section" class="hidden">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Generate External Content</h2>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Draft Form -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow p-6">
<form id="draft-form">
<div class="space-y-6">
<!-- STEP 1: Content Type -->
<div class="pb-4 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Step 1: Content Type</h3>
<div class="grid grid-cols-2 gap-3">
<label class="content-type-card">
<input type="radio" name="contentType" value="blog" class="sr-only" checked>
<div class="p-4 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all">
<div class="font-medium text-gray-900">Website Blog</div>
<div class="text-xs text-gray-500 mt-1">Long-form content for agenticgovernance.digital</div>
</div>
</label>
<label class="content-type-card">
<input type="radio" name="contentType" value="letter" class="sr-only">
<div class="p-4 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all">
<div class="font-medium text-gray-900">Letter to Editor</div>
<div class="text-xs text-gray-500 mt-1">200-300 words, article reference required</div>
</div>
</label>
<label class="content-type-card">
<input type="radio" name="contentType" value="oped" class="sr-only">
<div class="p-4 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all">
<div class="font-medium text-gray-900">Op-Ed / Opinion</div>
<div class="text-xs text-gray-500 mt-1">750-1500 words, external publication</div>
</div>
</label>
<label class="content-type-card">
<input type="radio" name="contentType" value="social" class="sr-only">
<div class="p-4 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all">
<div class="font-medium text-gray-900">Social Media</div>
<div class="text-xs text-gray-500 mt-1">LinkedIn, Substack, Medium</div>
</div>
</label>
</div>
</div>
<!-- STEP 2: Publication Target (conditional) -->
<div id="publication-section" class="pb-4 border-b border-gray-200 hidden">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Step 2: Publication Target</h3>
<select id="publication-target" name="publicationTarget"
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<option value="">Select publication...</option>
</select>
<!-- Publication info display -->
<div id="publication-info" class="mt-4 hidden p-4 bg-gray-50 rounded-md">
<dl class="space-y-2 text-sm">
<div>
<dt class="font-medium text-gray-700">Word Count:</dt>
<dd class="text-gray-600" id="pub-word-count">-</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Submission:</dt>
<dd class="text-gray-600" id="pub-submission">-</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Response Time:</dt>
<dd class="text-gray-600" id="pub-response">-</dd>
</div>
</dl>
</div>
<!-- Publication-Specific Topic Suggestions -->
<button type="button" id="suggest-topics-btn"
class="mt-4 px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed hidden">
<span id="suggest-topics-text">✨ Get Topic Suggestions for This Publication</span>
<span id="suggest-topics-loader" class="hidden">⏳ Generating...</span>
</button>
<!-- Topic Suggestions Display -->
<div id="topic-suggestions" class="mt-4 hidden"></div>
</div>
<!-- STEP 3: Article Reference (for letters only) -->
<div id="article-reference-section" class="pb-4 border-b border-gray-200 hidden">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Step 3: Article Reference</h3>
<div class="space-y-3">
<div>
<label for="article-title" class="block text-sm font-medium text-gray-700">Article Title *</label>
<input type="text" id="article-title" name="articleTitle"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., AI Regulation in Brussels">
</div>
<div>
<label for="article-date" class="block text-sm font-medium text-gray-700">Publication Date *</label>
<input type="date" id="article-date" name="articleDate"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="main-point" class="block text-sm font-medium text-gray-700">Your Main Point *</label>
<textarea id="main-point" name="mainPoint" rows="3"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., EU AI Act needs stronger enforcement mechanisms to be effective"></textarea>
</div>
</div>
</div>
<!-- STEP 4: Topic/Theme (for blog/oped) -->
<div id="topic-section" class="pb-4 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-900 mb-3">
<span id="topic-step-label">Step 2</span>: Topic & Content
</h3>
<div class="space-y-3">
<div>
<label for="topic" class="block text-sm font-medium text-gray-700">Topic *</label>
<input type="text" id="topic" name="topic" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Understanding AI Boundary Enforcement in Production Systems">
</div>
<div>
<label for="focus" class="block text-sm font-medium text-gray-700">Specific Focus (Optional)</label>
<input type="text" id="focus" name="focus"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Real-world implementation challenges, case studies, best practices">
</div>
</div>
</div>
<!-- STEP 5: Audience & Context -->
<div id="context-section" class="pb-4 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-900 mb-3">
<span id="context-step-label">Step 3</span>: Audience & Context
</h3>
<div class="space-y-3">
<div>
<label for="audience" class="block text-sm font-medium text-gray-700">Target Audience *</label>
<select id="audience" name="audience" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<option value="">Select audience...</option>
<option value="research">Research (Academic, AI safety specialists)</option>
<option value="implementer">Implementer (Engineers, architects)</option>
<option value="leader">Leader (Policy makers, executives)</option>
<option value="general">General (Mixed backgrounds)</option>
</select>
</div>
<div>
<label for="tone" class="block text-sm font-medium text-gray-700">Tone & Approach</label>
<select id="tone" name="tone"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<option value="standard">Standard (Professional, balanced)</option>
<option value="academic">Academic (Rigorous, citations)</option>
<option value="practical">Practical (Action-oriented)</option>
<option value="strategic">Strategic (High-level, business)</option>
<option value="conversational">Conversational (Accessible, engaging)</option>
</select>
</div>
<div>
<label for="culture" class="block text-sm font-medium text-gray-700">Cultural Context</label>
<select id="culture" name="culture"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<option value="universal">Universal (Culture-neutral)</option>
<option value="indigenous">Indigenous (Māori, First Nations)</option>
<option value="global-south">Global South (Emerging economies)</option>
<option value="asia-pacific">Asia-Pacific</option>
<option value="european">European</option>
<option value="north-american">North American</option>
</select>
</div>
<div>
<label for="language" class="block text-sm font-medium text-gray-700">Language</label>
<select id="language" name="language"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<option value="en">English</option>
<option value="mi">Te Reo Māori</option>
<option value="es">Español (Spanish)</option>
<option value="fr">Français (French)</option>
<option value="de">Deutsch (German)</option>
<option value="zh">中文 (Chinese)</option>
<option value="ja">日本語 (Japanese)</option>
</select>
</div>
</div>
</div>
<!-- Submit -->
<div class="flex items-center justify-between pt-2">
<button type="submit" id="draft-btn"
class="bg-blue-600 text-white px-6 py-2 rounded-md font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
Generate Draft
</button>
<span id="draft-status" class="text-sm text-gray-500"></span>
</div>
</div>
</form>
</div>
</div>
<!-- Quick Actions -->
<div class="space-y-4">
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div class="space-y-3">
<button id="suggest-topics-btn"
class="w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 rounded-md border border-gray-200">
<div class="font-medium text-gray-900">Suggest Topics</div>
<div class="text-sm text-gray-500">Get AI topic ideas for editorial calendar</div>
</button>
<button id="analyze-content-btn"
class="w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 rounded-md border border-gray-200">
<div class="font-medium text-gray-900">Analyze Content</div>
<div class="text-sm text-gray-500">Check existing content for compliance</div>
</button>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-2">Statistics</h3>
<div class="space-y-3">
<div>
<div class="text-2xl font-bold text-gray-900" id="stat-pending-drafts">-</div>
<div class="text-sm text-gray-500">Pending Drafts</div>
</div>
<div>
<div class="text-2xl font-bold text-gray-900" id="stat-published-posts">-</div>
<div class="text-sm text-gray-500">Published Posts</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Validation Section -->
<div id="validation-section">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Pre-Submission Review & Validation</h2>
<p class="text-sm text-gray-600 mb-4">Articles awaiting final validation and submission package preparation before sending to publications.</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg aria-hidden="true" class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Two-Level Validation</h3>
<div class="mt-2 text-sm text-blue-700">
<p>Articles are validated for both <strong>content similarity</strong> (substantive differences in arguments) and <strong>title similarity</strong> (avoiding confusion in the same market). Both checks must pass before submission.</p>
</div>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900">Pending Review Articles</h3>
<button id="refresh-validation-btn" class="text-sm text-blue-600 hover:text-blue-800">
Refresh
</button>
</div>
</div>
<div id="validation-list" class="divide-y divide-gray-200">
<div class="px-6 py-8 text-center text-gray-500">Loading articles...</div>
</div>
</div>
</div>
<!-- Review Queue Section -->
<div id="queue-section" class="hidden">
<h2 class="text-2xl font-bold text-gray-900 mb-6">AI-Generated Draft Approval Queue</h2>
<p class="text-sm text-gray-600 mb-4">AI-generated drafts requiring human review and approval before becoming articles.</p>
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900">Pending Drafts</h3>
<button id="refresh-queue-btn" class="text-sm text-blue-600 hover:text-blue-800">
Refresh
</button>
</div>
</div>
<div id="draft-queue" class="divide-y divide-gray-200">
<div class="px-6 py-8 text-center text-gray-500">Loading queue...</div>
</div>
</div>
</div>
<!-- Guidelines Section -->
<div id="guidelines-section" class="hidden">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Editorial Guidelines</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Writing Standards</h3>
<dl id="editorial-standards" class="space-y-3">
<div class="text-center text-gray-500">Loading guidelines...</div>
</dl>
</div>
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Forbidden Patterns</h3>
<ul id="forbidden-patterns" class="space-y-2">
<li class="text-center text-gray-500">Loading patterns...</li>
</ul>
</div>
</div>
<div class="mt-6 bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Core Principles</h3>
<ul id="core-principles" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<li class="text-center text-gray-500">Loading principles...</li>
</ul>
</div>
</div>
<!-- Published Posts Section -->
<div id="published-section" class="hidden">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Published Website Content</h2>
<p class="text-sm text-gray-600 mb-4">Articles published on the Tractatus website. These are different from outreach articles submitted to external publications.</p>
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900">Published Articles</h3>
<button id="refresh-published-btn" class="text-sm text-blue-600 hover:text-blue-800">
Refresh
</button>
</div>
</div>
<div id="published-list" class="divide-y divide-gray-200">
<div class="px-6 py-8 text-center text-gray-500">Loading published posts...</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<div id="modal-container"></div>
<script src="/js/admin/blog-curation.js?v=1761223918"></script>
<script src="/js/admin/blog-curation-enhanced.js?v=1761223918"></script>
<script src="/js/admin/blog-validation.js?v=1761223918"></script>
<script src="/js/admin/submission-modal.js?v=1761223918"></script>
</body>
</html>

View file

@ -0,0 +1,815 @@
/**
* Blog Article Validation
* Two-level validation: Content Similarity + Title Similarity
*/
// Helper to safely convert ObjectId to string
function toStringId(id) {
if (!id) {
console.warn('[toStringId] Received empty id');
return '';
}
if (typeof id === 'string') return id;
// Handle Buffer object (MongoDB ObjectId as buffer)
if (typeof id === 'object' && id.buffer) {
console.log('[toStringId] Converting buffer to hex string');
const bytes = [];
for (let i = 0; i < 12; i++) {
if (id.buffer[i] !== undefined) {
bytes.push(id.buffer[i].toString(16).padStart(2, '0'));
}
}
const hexString = bytes.join('');
console.log('[toStringId] Hex string:', hexString);
return hexString;
}
// Check for other common MongoDB ObjectId representations
if (typeof id === 'object') {
if (id.$oid) return id.$oid;
if (id.id) return id.id;
if (id._id) return id._id;
}
const result = String(id);
console.log('[toStringId] Fallback String():', result);
return result;
}
// Get auth token
function getAuthToken() {
return localStorage.getItem('admin_token');
}
// API call helper
async function apiCall(endpoint, options = {}) {
const token = getAuthToken();
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
};
const response = await fetch(endpoint, { ...defaultOptions, ...options });
if (response.status === 401) {
localStorage.removeItem('admin_token');
window.location.href = '/admin/login.html';
throw new Error('Unauthorized');
}
return response;
}
// Load all pending review articles
async function loadValidationArticles() {
const listDiv = document.getElementById('validation-list');
if (!listDiv) return;
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">Loading articles...</div>';
try {
// Load articles
const articlesResponse = await apiCall('/api/blog/admin/posts?status=pending_review&limit=100');
if (!articlesResponse.ok) {
throw new Error('Failed to load articles');
}
const articlesData = await articlesResponse.json();
const articles = articlesData.posts || [];
console.log('[Validation] Loaded articles:', articles.length);
if (articles.length > 0) {
console.log('[Validation] First article._id type:', typeof articles[0]._id, articles[0]._id);
}
if (articles.length === 0) {
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">No articles pending review</div>';
return;
}
// Load submission tracking for each article
// Note: Submissions endpoint not yet implemented, gracefully handle 404
const submissionData = {};
// TODO: Uncomment when /api/blog/:id/submissions endpoint is implemented
/*
for (const article of articles) {
const articleId = toStringId(article._id);
try {
const subResponse = await apiCall(`/api/blog/${articleId}/submissions`);
if (subResponse.ok) {
const subData = await subResponse.json();
if (subData.submissions && subData.submissions.length > 0) {
submissionData[articleId] = subData.submissions[0]; // Get first active submission
}
}
} catch (err) {
// Silently ignore - endpoint not implemented yet
}
}
*/
listDiv.innerHTML = articles.map(article => {
const articleId = toStringId(article._id);
const wordCount = article.content ? article.content.split(/\s+/).length : 0;
const submission = submissionData[articleId];
// Calculate checklist completion
let checklistItems = 0;
let checklistCompleted = 0;
if (submission && submission.submissionPackage) {
const pkg = submission.submissionPackage;
if (pkg.coverLetter) {
checklistItems++;
if (pkg.coverLetter.completed) checklistCompleted++;
}
if (pkg.notesToEditor) {
checklistItems++;
if (pkg.notesToEditor.completed) checklistCompleted++;
}
if (pkg.authorBio) {
checklistItems++;
if (pkg.authorBio.completed) checklistCompleted++;
}
if (pkg.pitchEmail) {
checklistItems++;
if (pkg.pitchEmail.completed) checklistCompleted++;
}
}
const checklistColor = checklistCompleted === checklistItems && checklistItems > 0
? 'text-green-600'
: 'text-yellow-600';
return `
<div class="px-6 py-4 hover:bg-gray-50 border-l-4 ${submission ? 'border-l-blue-500' : 'border-l-gray-300'}" data-article-id="${articleId}">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h4 class="font-medium text-gray-900">${article.title}</h4>
${submission ? `<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded font-medium">${submission.publicationName}</span>` : ''}
</div>
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span>Slug: ${article.slug}</span>
<span>Words: ${wordCount}</span>
<span>Created: ${new Date(article.created_at).toLocaleDateString()}</span>
${submission ? `<span class="font-medium">${submission.contentType.toUpperCase()}</span>` : ''}
</div>
${submission ? `
<div class="mt-2 flex items-center gap-2">
<span class="text-xs ${checklistColor}">
📋 Checklist: ${checklistCompleted}/${checklistItems} complete
</span>
${submission.submissionMethod ? `<span class="text-xs text-gray-500">via ${submission.submissionMethod}</span>` : ''}
</div>
` : '<div class="mt-2 text-xs text-orange-600">⚠️ No submission tracking - click "Manage Submission"</div>'}
<div class="mt-3 flex items-center gap-2">
<div class="validation-status-${articleId} inline-flex items-center gap-2">
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
Not validated yet
</span>
</div>
</div>
</div>
<div class="ml-4 flex items-center gap-2 flex-wrap">
<button class="manage-submission px-4 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700"
data-article-id="${articleId}"
data-submission-id="${submission ? submission._id : ''}">
Manage Submission
</button>
<button class="edit-article px-4 py-2 bg-gray-600 text-white rounded-md text-sm hover:bg-gray-700"
data-article-id="${articleId}">
Edit
</button>
<button class="validate-article px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700"
data-article-id="${articleId}"
data-article-title="${article.title.replace(/"/g, '&quot;')}">
Validate
</button>
</div>
</div>
</div>
`;
}).join('');
// Add edit handlers
listDiv.querySelectorAll('.edit-article').forEach(btn => {
btn.addEventListener('click', () => {
const articleId = btn.dataset.articleId;
openEditModal(articleId);
});
});
// Add validate handlers
listDiv.querySelectorAll('.validate-article').forEach(btn => {
btn.addEventListener('click', () => {
const articleId = btn.dataset.articleId;
const articleTitle = btn.dataset.articleTitle;
runValidation(articleId, articleTitle);
});
});
} catch (error) {
console.error('Failed to load validation articles:', error);
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-red-500">Error loading articles</div>';
}
}
// Run validation on an article
async function runValidation(articleId, articleTitle) {
const statusDiv = document.querySelector(`.validation-status-${articleId}`);
const btn = document.querySelector(`.validate-article[data-article-id="${articleId}"]`);
if (!statusDiv || !btn) return;
// Update UI
btn.disabled = true;
btn.textContent = 'Validating...';
statusDiv.innerHTML = '<span class="px-2 py-1 bg-blue-100 text-blue-600 text-xs rounded">Validating...</span>';
try {
const response = await apiCall('/api/blog/validate-article', {
method: 'POST',
body: JSON.stringify({ articleId })
});
const result = await response.json();
if (response.ok && result.validation) {
const v = result.validation;
// Count passes and fails
const contentFails = v.contentChecks.filter(c => !c.pass).length;
const titleFails = v.titleChecks.filter(c => !c.pass).length;
// Build status badges
let badges = [];
// Content badge
if (contentFails === 0) {
badges.push('<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">✅ Content: Pass</span>');
} else {
badges.push(`<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">❌ Content: ${contentFails} conflict(s)</span>`);
}
// Title badge
if (titleFails === 0) {
badges.push('<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">✅ Title: Pass</span>');
} else {
badges.push(`<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">🚫 Title: ${titleFails} conflict(s)</span>`);
}
statusDiv.innerHTML = badges.join(' ');
// Show detailed modal
showValidationModal(articleTitle, result.validation);
} else {
statusDiv.innerHTML = '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">Error</span>';
alert(`Validation error: ${result.message || 'Unknown error'}`);
}
} catch (error) {
console.error('Validation error:', error);
statusDiv.innerHTML = '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">Error</span>';
alert(`Validation error: ${error.message}`);
} finally {
btn.disabled = false;
btn.textContent = 'Validate';
}
}
// Show validation results modal
function showValidationModal(articleTitle, validation) {
const { contentChecks, titleChecks, overallStatus, errors, warnings, summary } = validation;
const contentChecksHtml = contentChecks.length > 0 ? `
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Content Similarity Checks (${summary.contentPasses} pass, ${summary.contentFails} fail)</h4>
<div class="space-y-2">
${contentChecks.slice(0, 5).map(check => {
const bgClass = check.pass ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200';
const iconClass = check.pass ? 'text-green-600' : 'text-red-600';
const icon = check.pass ? '✅' : '❌';
return `
<div class="border ${bgClass} rounded-md p-3">
<div class="flex items-start gap-2">
<span class="${iconClass} text-lg">${icon}</span>
<div class="flex-1">
<div class="text-sm font-medium text-gray-900">vs. "${check.comparedWith}"</div>
<div class="text-xs text-gray-600 mt-1">
Similarity: ${Math.round(check.similarity * 100)}%
</div>
<div class="text-xs text-gray-700 mt-1">${check.message}</div>
</div>
</div>
</div>
`;
}).join('')}
</div>
</div>
` : '<div class="text-sm text-gray-500 mb-6">No other articles to compare</div>';
const titleChecksHtml = titleChecks.length > 0 ? `
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Title Similarity Checks (${summary.titlePasses} pass, ${summary.titleFails} fail)</h4>
<div class="space-y-2">
${titleChecks.slice(0, 5).map(check => {
const bgClass = check.pass ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200';
const iconClass = check.pass ? 'text-green-600' : 'text-red-600';
const icon = check.pass ? '✅' : '🚫';
return `
<div class="border ${bgClass} rounded-md p-3">
<div class="flex items-start gap-2">
<span class="${iconClass} text-lg">${icon}</span>
<div class="flex-1">
<div class="text-sm font-medium text-gray-900">vs. "${check.comparedWith}"</div>
<div class="text-xs text-gray-600 mt-1">
Similarity: ${Math.round(check.similarity * 100)}%
${check.sharedWords && check.sharedWords.length > 0 ? ` | Shared: ${check.sharedWords.join(', ')}` : ''}
</div>
<div class="text-xs text-gray-700 mt-1">${check.message}</div>
</div>
</div>
</div>
`;
}).join('')}
</div>
</div>
` : '<div class="text-sm text-gray-500 mb-6">No other articles to compare</div>';
const errorsHtml = errors.length > 0 ? `
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
<h5 class="font-medium text-red-900 mb-2"> Errors - Cannot Submit</h5>
<ul class="text-sm text-red-800 list-disc list-inside space-y-1">
${errors.map(e => `<li>${e}</li>`).join('')}
</ul>
</div>
` : '';
const warningsHtml = warnings.length > 0 ? `
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-4">
<h5 class="font-medium text-yellow-900 mb-2"> Warnings</h5>
<ul class="text-sm text-yellow-800 list-disc list-inside space-y-1">
${warnings.map(w => `<li>${w}</li>`).join('')}
</ul>
</div>
` : '';
const summaryClass = overallStatus === 'pass' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800';
const summaryText = overallStatus === 'pass' ? '✅ PASS - Ready for submission' : '❌ FAIL - Revisions required';
const modal = `
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900">Validation: "${articleTitle}"</h3>
<button class="close-validation-modal text-gray-400 hover:text-gray-600">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4">
<div class="mb-6">
<div class="flex items-center justify-between p-4 border-2 rounded-lg ${summaryClass.replace('100', '200')}">
<span class="font-semibold">${summaryText}</span>
<span class="text-sm">Checked against ${summary.totalChecks} article(s)</span>
</div>
</div>
${errorsHtml}
${warningsHtml}
${contentChecksHtml}
${titleChecksHtml}
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end">
<button class="close-validation-modal px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
Close
</button>
</div>
</div>
</div>
`;
const container = document.getElementById('modal-container');
if (!container) return;
container.innerHTML = modal;
// Close modal handlers
container.querySelectorAll('.close-validation-modal').forEach(btn => {
btn.addEventListener('click', () => {
container.innerHTML = '';
});
});
}
// Open edit modal
async function openEditModal(articleId) {
const container = document.getElementById('modal-container');
container.innerHTML = '<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 p-4"><div class="text-white">Loading article...</div></div>';
try {
const response = await apiCall(`/api/blog/admin/${articleId}`);
if (!response.ok) {
throw new Error('Failed to load article');
}
const data = await response.json();
const article = data.post;
const modal = `
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900">Edit Article</h3>
<button class="close-edit-modal text-gray-400 hover:text-gray-600">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4">
<form id="edit-article-form">
<div class="space-y-4">
<div>
<label for="edit-title" class="block text-sm font-medium text-gray-700">Title *</label>
<input type="text" id="edit-title" name="title" required
value="${article.title.replace(/"/g, '&quot;')}"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">Change this if there's a title conflict</p>
</div>
<div>
<label for="edit-slug" class="block text-sm font-medium text-gray-700">Slug *</label>
<input type="text" id="edit-slug" name="slug" required
value="${article.slug}"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">URL-friendly version (lowercase, hyphens)</p>
</div>
<div>
<label for="edit-excerpt" class="block text-sm font-medium text-gray-700">Excerpt</label>
<textarea id="edit-excerpt" name="excerpt" rows="3"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">${article.excerpt || ''}</textarea>
</div>
<div>
<label for="edit-content" class="block text-sm font-medium text-gray-700">Content *</label>
<textarea id="edit-content" name="content" rows="15" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">${article.content}</textarea>
<p class="mt-1 text-xs text-gray-500">Markdown format</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="edit-status" class="block text-sm font-medium text-gray-700">Status</label>
<select id="edit-status" name="status"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<option value="draft" ${article.status === 'draft' ? 'selected' : ''}>Draft</option>
<option value="pending_review" ${article.status === 'pending_review' ? 'selected' : ''}>Pending Review</option>
<option value="published" ${article.status === 'published' ? 'selected' : ''}>Published</option>
</select>
</div>
<div>
<label for="edit-tags" class="block text-sm font-medium text-gray-700">Tags</label>
<input type="text" id="edit-tags" name="tags"
value="${(article.tags || []).join(', ')}"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">Comma-separated</p>
</div>
</div>
</div>
<div id="edit-status-message" class="mt-4 text-sm"></div>
</form>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-between">
<button class="close-edit-modal px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button id="save-article-btn" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Save Changes
</button>
</div>
</div>
</div>
`;
container.innerHTML = modal;
// Close handlers
container.querySelectorAll('.close-edit-modal').forEach(btn => {
btn.addEventListener('click', () => {
container.innerHTML = '';
});
});
// Save handler
container.querySelector('#save-article-btn').addEventListener('click', () => {
saveArticle(articleId);
});
} catch (error) {
console.error('Failed to load article:', error);
container.innerHTML = '';
alert('Failed to load article: ' + error.message);
}
}
// Save article changes
async function saveArticle(articleId) {
const statusDiv = document.getElementById('edit-status-message');
const saveBtn = document.getElementById('save-article-btn');
const form = document.getElementById('edit-article-form');
const formData = new FormData(form);
const updates = {
title: formData.get('title'),
slug: formData.get('slug'),
excerpt: formData.get('excerpt'),
content: formData.get('content'),
status: formData.get('status'),
tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t)
};
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
statusDiv.textContent = 'Saving changes...';
statusDiv.className = 'mt-4 text-sm text-blue-600';
try {
const response = await apiCall(`/api/blog/${articleId}`, {
method: 'PUT',
body: JSON.stringify(updates)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to save changes');
}
statusDiv.textContent = '✓ Changes saved successfully!';
statusDiv.className = 'mt-4 text-sm text-green-600';
// Close modal and refresh list after short delay
setTimeout(() => {
document.getElementById('modal-container').innerHTML = '';
loadValidationArticles();
}, 1000);
} catch (error) {
console.error('Save error:', error);
statusDiv.textContent = `✗ Error: ${error.message}`;
statusDiv.className = 'mt-4 text-sm text-red-600';
saveBtn.disabled = false;
saveBtn.textContent = 'Save Changes';
}
}
// Load published posts
async function loadPublishedPosts() {
const listDiv = document.getElementById('published-list');
if (!listDiv) return;
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">Loading published posts...</div>';
try {
const response = await apiCall('/api/blog/admin/posts?status=published&limit=100');
if (!response.ok) {
throw new Error('Failed to load published posts');
}
const data = await response.json();
const posts = data.posts || [];
if (posts.length === 0) {
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">No published posts found.</div>';
return;
}
listDiv.innerHTML = posts.map(post => {
const articleId = toStringId(post._id);
const wordCount = post.content ? post.content.split(/\s+/).length : 0;
const excerpt = post.excerpt || (post.content ? post.content.substring(0, 150) + '...' : 'No excerpt');
const tags = (post.tags || []).slice(0, 3).map(tag =>
`<span class="inline-block bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded">${tag}</span>`
).join(' ');
return `
<div class="px-6 py-4 hover:bg-gray-50">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900">${post.title}</h4>
<p class="mt-1 text-sm text-gray-600">${excerpt}</p>
<div class="mt-2 flex items-center gap-4 text-xs text-gray-500">
<span>${wordCount} words</span>
<span>Slug: ${post.slug}</span>
${post.publishedAt ? `<span>Published: ${new Date(post.publishedAt).toLocaleDateString()}</span>` : ''}
</div>
${tags ? `<div class="mt-2 flex flex-wrap gap-1">${tags}</div>` : ''}
</div>
<div class="ml-4 flex flex-col gap-2">
<button class="read-article text-sm text-blue-600 hover:text-blue-800" data-article-id="${articleId}">
Read
</button>
<a href="https://agenticgovernance.digital/blog/${post.slug}" target="_blank"
class="text-sm text-gray-600 hover:text-gray-800">
View Live
</a>
</div>
</div>
</div>
`;
}).join('');
// Attach read button handlers
document.querySelectorAll('.read-article').forEach(btn => {
btn.addEventListener('click', async (e) => {
const articleId = e.target.dataset.articleId;
const post = posts.find(p => toStringId(p._id) === articleId);
if (post) {
showReadModal(post);
}
});
});
} catch (error) {
console.error('Failed to load published posts:', error);
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-red-500">Failed to load published posts. Please try again.</div>';
}
}
// Show read modal for a post
function showReadModal(post) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50';
modal.innerHTML = `
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white">
<div class="flex justify-between items-start mb-4">
<h3 class="text-2xl font-bold text-gray-900">${post.title}</h3>
<button class="close-modal text-gray-400 hover:text-gray-600 text-3xl font-bold">&times;</button>
</div>
<div class="mb-4 text-sm text-gray-600">
<div class="flex items-center gap-4">
<span>Slug: ${post.slug}</span>
${post.publishedAt ? `<span>Published: ${new Date(post.publishedAt).toLocaleDateString()}</span>` : ''}
</div>
${post.tags && post.tags.length > 0 ? `
<div class="mt-2 flex flex-wrap gap-1">
${post.tags.map(tag => `<span class="bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded">${tag}</span>`).join(' ')}
</div>
` : ''}
</div>
${post.excerpt ? `
<div class="mb-4 p-4 bg-gray-50 rounded">
<p class="text-sm italic text-gray-700">${post.excerpt}</p>
</div>
` : ''}
<div class="prose max-w-none">
${post.content || '<p class="text-gray-500">No content available.</p>'}
</div>
<div class="mt-6 flex justify-end gap-3">
<a href="https://agenticgovernance.digital/blog/${post.slug}" target="_blank"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
View Live
</a>
<button class="close-modal px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
Close
</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Close modal handlers
modal.querySelectorAll('.close-modal').forEach(btn => {
btn.addEventListener('click', () => modal.remove());
});
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
// Initialize section navigation
function initSectionNavigation() {
const tabs = document.querySelectorAll('.section-tab');
const sections = {
validation: document.getElementById('validation-section'),
draft: document.getElementById('draft-section'),
queue: document.getElementById('queue-section'),
guidelines: document.getElementById('guidelines-section'),
published: document.getElementById('published-section')
};
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetSection = tab.dataset.section;
// Update tab styles
tabs.forEach(t => {
t.classList.remove('border-blue-500', 'text-blue-600');
t.classList.add('border-transparent', 'text-gray-500');
});
tab.classList.remove('border-transparent', 'text-gray-500');
tab.classList.add('border-blue-500', 'text-blue-600');
// Show/hide sections
Object.keys(sections).forEach(key => {
if (key === targetSection) {
sections[key].classList.remove('hidden');
// Load data for specific sections
if (key === 'validation') {
loadValidationArticles();
} else if (key === 'published') {
loadPublishedPosts();
}
} else {
sections[key].classList.add('hidden');
}
});
});
});
}
// Initialize validation section
function initValidation() {
console.log('[Validation] Initializing validation section');
const refreshBtn = document.getElementById('refresh-validation-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', loadValidationArticles);
}
const refreshPublishedBtn = document.getElementById('refresh-published-btn');
if (refreshPublishedBtn) {
refreshPublishedBtn.addEventListener('click', loadPublishedPosts);
}
// Auto-load validation articles
const validationList = document.getElementById('validation-list');
if (validationList) {
console.log('[Validation] Loading validation articles...');
loadValidationArticles();
} else {
console.warn('[Validation] validation-list element not found');
}
// Initialize section navigation
initSectionNavigation();
}
// Run on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initValidation);
} else {
initValidation();
}
// ===========================================
// MANAGE SUBMISSION MODAL
// ===========================================
// Global state for submission modal
let currentSubmissionArticleId = null;
let currentSubmissionId = null;
let currentArticleData = null;
/**
* Initialize Manage Submission button handlers
*/
function initManageSubmissionHandlers() {
// Event delegation for manage submission buttons
document.addEventListener('click', (e) => {
if (e.target.classList.contains('manage-submission')) {
const articleId = e.target.dataset.articleId;
const submissionId = e.target.dataset.submissionId;
openManageSubmissionModal(articleId, submissionId || null);
}
});
}
// Call this when page loads
initManageSubmissionHandlers();

View file

@ -0,0 +1,379 @@
/**
* Manage Submission Modal
* Handles submission tracking for blog posts to external publications
*/
// Create the modal HTML structure dynamically
function createManageSubmissionModal() {
const modal = document.createElement('div');
modal.id = 'manage-submission-modal';
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden';
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto mx-4">
<div class="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
<h2 class="text-2xl font-bold text-gray-900">Manage Submission</h2>
<button id="close-submission-modal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-6 space-y-6">
<!-- Article Info -->
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-r">
<h3 id="article-title" class="font-semibold text-gray-900 mb-1"></h3>
<p id="article-excerpt" class="text-sm text-gray-600"></p>
<div class="mt-2 text-xs text-gray-500">
<span id="article-word-count"></span> words
</div>
</div>
<!-- Publication Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Target Publication
</label>
<select id="publication-select"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">Select a publication...</option>
</select>
<div id="publication-requirements" class="mt-3 hidden">
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 text-sm">
<h4 class="font-semibold text-gray-900 mb-2">Requirements</h4>
<div id="requirements-content"></div>
</div>
</div>
</div>
<!-- Progress Indicator -->
<div>
<div class="flex justify-between text-sm text-gray-600 mb-2">
<span>Submission Package Progress</span>
<span id="progress-percentage">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div id="progress-bar" class="bg-green-600 h-2.5 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Checklist Items -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900">Submission Package</h3>
<!-- Cover Letter -->
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center">
<input type="checkbox" id="check-coverLetter"
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
onchange="saveChecklistItem('coverLetter')">
<label for="check-coverLetter" class="ml-3 font-medium text-gray-900">
Cover Letter
</label>
</div>
<span id="saved-coverLetter" class="text-xs text-green-600 hidden"> Saved</span>
</div>
<textarea id="content-coverLetter"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows="4"
placeholder="Write your cover letter here..."
onblur="saveChecklistItem('coverLetter')"></textarea>
</div>
<!-- Pitch Email -->
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center">
<input type="checkbox" id="check-pitchEmail"
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
onchange="saveChecklistItem('pitchEmail')">
<label for="check-pitchEmail" class="ml-3 font-medium text-gray-900">
Pitch Email
</label>
</div>
<span id="saved-pitchEmail" class="text-xs text-green-600 hidden"> Saved</span>
</div>
<textarea id="content-pitchEmail"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows="4"
placeholder="Write your pitch email here..."
onblur="saveChecklistItem('pitchEmail')"></textarea>
</div>
<!-- Notes to Editor -->
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center">
<input type="checkbox" id="check-notesToEditor"
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
onchange="saveChecklistItem('notesToEditor')">
<label for="check-notesToEditor" class="ml-3 font-medium text-gray-900">
Notes to Editor
</label>
</div>
<span id="saved-notesToEditor" class="text-xs text-green-600 hidden"> Saved</span>
</div>
<textarea id="content-notesToEditor"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows="4"
placeholder="Any additional notes for the editor..."
onblur="saveChecklistItem('notesToEditor')"></textarea>
</div>
<!-- Author Bio -->
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center">
<input type="checkbox" id="check-authorBio"
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
onchange="saveChecklistItem('authorBio')">
<label for="check-authorBio" class="ml-3 font-medium text-gray-900">
Author Bio
</label>
</div>
<span id="saved-authorBio" class="text-xs text-green-600 hidden"> Saved</span>
</div>
<textarea id="content-authorBio"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows="3"
placeholder="Your author biography..."
onblur="saveChecklistItem('authorBio')"></textarea>
</div>
</div>
</div>
</div>
`;
return modal;
}
// Open modal and load data
async function openManageSubmissionModal(articleId, submissionId) {
currentSubmissionArticleId = articleId;
currentSubmissionId = submissionId;
// Create modal if it doesn't exist
let modal = document.getElementById('manage-submission-modal');
if (!modal) {
modal = createManageSubmissionModal();
document.getElementById('modal-container').appendChild(modal);
// Add close handler
document.getElementById('close-submission-modal').addEventListener('click', closeManageSubmissionModal);
// Load publication targets
await loadPublicationTargets();
// Add publication change handler
document.getElementById('publication-select').addEventListener('change', onPublicationChange);
}
// Load article and submission data
await loadSubmissionData(articleId);
// Show modal
modal.classList.remove('hidden');
}
// Close modal
function closeManageSubmissionModal() {
const modal = document.getElementById('manage-submission-modal');
if (modal) {
modal.classList.add('hidden');
clearSubmissionForm();
}
currentSubmissionArticleId = null;
currentSubmissionId = null;
currentArticleData = null;
}
// Load publication targets into dropdown
async function loadPublicationTargets() {
try {
const response = await fetch('/api/publications');
const data = await response.json();
const select = document.getElementById('publication-select');
if (!select) return; // Modal not created yet
const publications = data.data || [];
publications.forEach(pub => {
const option = document.createElement('option');
option.value = pub.id;
option.textContent = `${pub.name} (${pub.type})`;
option.dataset.requirements = JSON.stringify(pub.requirements);
option.dataset.submission = JSON.stringify(pub.submission);
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load publication targets:', error);
}
}
// Handle publication selection change
function onPublicationChange(e) {
const select = e.target;
const selectedOption = select.options[select.selectedIndex];
const requirementsDiv = document.getElementById('publication-requirements');
const requirementsContent = document.getElementById('requirements-content');
if (!selectedOption.value) {
requirementsDiv.classList.add('hidden');
return;
}
const requirements = JSON.parse(selectedOption.dataset.requirements || '{}');
const submission = JSON.parse(selectedOption.dataset.submission || '{}');
let html = '<ul class="space-y-1">';
if (requirements.wordCount) {
html += `<li><strong>Word Count:</strong> ${requirements.wordCount.min}-${requirements.wordCount.max} words</li>`;
}
if (requirements.exclusivity) {
html += '<li><strong>Exclusivity:</strong> Must not be published elsewhere</li>';
}
if (submission.method) {
html += `<li><strong>Method:</strong> ${submission.method}`;
if (submission.email) {
html += ` (${submission.email})`;
}
html += '</li>';
}
if (submission.responseTime) {
html += `<li><strong>Response Time:</strong> ${submission.responseTime.min}-${submission.responseTime.max} ${submission.responseTime.unit}</li>`;
}
html += '</ul>';
requirementsContent.innerHTML = html;
requirementsDiv.classList.remove('hidden');
}
// Load submission data
async function loadSubmissionData(articleId) {
try {
// Load article data
const articleResponse = await fetch(`/api/blog/${articleId}`);
const articleData = await articleResponse.json();
currentArticleData = articleData;
// Populate article info
document.getElementById('article-title').textContent = articleData.title;
document.getElementById('article-excerpt').textContent = articleData.excerpt || '';
document.getElementById('article-word-count').textContent = articleData.wordCount || 0;
// Load existing submission if any
const submissionResponse = await fetch(`/api/blog/${articleId}/submissions`);
if (submissionResponse.ok) {
const submissionData = await submissionResponse.json();
if (submissionData.submissions && submissionData.submissions.length > 0) {
const submission = submissionData.submissions[0];
currentSubmissionId = submission._id;
// Populate publication select
if (submission.publicationId) {
document.getElementById('publication-select').value = submission.publicationId;
onPublicationChange({ target: document.getElementById('publication-select') });
}
// Populate checklist items
const fields = ['coverLetter', 'pitchEmail', 'notesToEditor', 'authorBio'];
fields.forEach(field => {
const packageData = submission.submissionPackage?.[field];
if (packageData) {
document.getElementById(`check-${field}`).checked = packageData.completed || false;
document.getElementById(`content-${field}`).value = packageData.content || '';
}
});
updateProgress();
}
}
} catch (error) {
console.error('Failed to load submission data:', error);
}
}
// Clear submission form
function clearSubmissionForm() {
document.getElementById('publication-select').value = '';
document.getElementById('publication-requirements').classList.add('hidden');
const fields = ['coverLetter', 'pitchEmail', 'notesToEditor', 'authorBio'];
fields.forEach(field => {
document.getElementById(`check-${field}`).checked = false;
document.getElementById(`content-${field}`).value = '';
});
updateProgress();
}
// Save checklist item
async function saveChecklistItem(field) {
if (!currentSubmissionArticleId) return;
const publicationId = document.getElementById('publication-select').value;
if (!publicationId) {
alert('Please select a publication first');
return;
}
const completed = document.getElementById(`check-${field}`).checked;
const content = document.getElementById(`content-${field}`).value;
try {
const response = await fetch(`/api/blog/${currentSubmissionArticleId}/submissions`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
publicationId,
submissionPackage: {
[field]: {
completed,
content,
lastUpdated: new Date().toISOString()
}
}
})
});
if (response.ok) {
const data = await response.json();
currentSubmissionId = data.submission._id;
// Show saved indicator
const savedIndicator = document.getElementById(`saved-${field}`);
savedIndicator.classList.remove('hidden');
setTimeout(() => {
savedIndicator.classList.add('hidden');
}, 2000);
updateProgress();
} else {
console.error('Failed to save checklist item');
}
} catch (error) {
console.error('Error saving checklist item:', error);
}
}
// Update progress indicator
function updateProgress() {
const fields = ['coverLetter', 'pitchEmail', 'notesToEditor', 'authorBio'];
let completed = 0;
fields.forEach(field => {
if (document.getElementById(`check-${field}`).checked) {
completed++;
}
});
const percentage = Math.round((completed / fields.length) * 100);
document.getElementById('progress-percentage').textContent = `${percentage}%`;
document.getElementById('progress-bar').style.width = `${percentage}%`;
}

197
public/service-worker.js Normal file
View file

@ -0,0 +1,197 @@
/**
* Tractatus Service Worker
* - Version management and update notifications
* - Cache management for offline support
* - PWA functionality
*/
const CACHE_VERSION = '1.8.4';
const CACHE_NAME = `tractatus-v${CACHE_VERSION}`;
const VERSION_CHECK_INTERVAL = 3600000; // 1 hour in milliseconds
// Assets to cache immediately on install
const CRITICAL_ASSETS = [
'/',
'/index.html',
'/css/tailwind.css',
'/js/components/navbar.js',
'/images/tractatus-icon.svg',
'/favicon.svg'
];
// Install event - cache critical assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[Service Worker] Caching critical assets');
return cache.addAll(CRITICAL_ASSETS);
}).then(() => {
// Force activation of new service worker
return self.skipWaiting();
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('[Service Worker] Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => {
// Take control of all clients immediately
return self.clients.claim();
})
);
});
// Fetch event - network-first strategy with cache fallback
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip chrome-extension and other non-http requests
if (!url.protocol.startsWith('http')) {
return;
}
// HTML files: Network-ONLY (never cache, always fetch fresh)
// This ensures users always get the latest content without cache refresh
if (request.destination === 'document' || url.pathname.endsWith('.html')) {
event.respondWith(
fetch(request)
.catch(() => {
// Only for offline fallback: serve cached index.html
if (url.pathname === '/' || url.pathname === '/index.html') {
return caches.match('/index.html');
}
// All other HTML: network only, fail if offline
throw new Error('Network required for HTML pages');
})
);
return;
}
// Static assets (CSS, JS, images): Network-first for versioned URLs, cache-first for others
if (
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'image' ||
request.destination === 'font'
) {
// If URL has version parameter, always fetch fresh (network-first)
const hasVersionParam = url.searchParams.has('v');
if (hasVersionParam) {
// Network-first for versioned assets (ensures cache-busting works)
event.respondWith(
fetch(request).then((response) => {
// Cache the response for offline use
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
return response;
}).catch(() => {
// Fallback to cache if offline
return caches.match(request);
})
);
} else {
// Cache-first for non-versioned assets
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(request).then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
return response;
});
})
);
}
return;
}
// API calls and other requests: Network-first
event.respondWith(
fetch(request)
.then((response) => {
return response;
})
.catch(() => {
return caches.match(request);
})
);
});
// Message event - handle version checks from clients
self.addEventListener('message', (event) => {
if (event.data.type === 'CHECK_VERSION') {
checkVersion().then((versionInfo) => {
event.ports[0].postMessage({
type: 'VERSION_INFO',
...versionInfo
});
});
}
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Check for version updates
async function checkVersion() {
try {
const response = await fetch('/version.json', { cache: 'no-store' });
const serverVersion = await response.json();
return {
currentVersion: CACHE_VERSION,
serverVersion: serverVersion.version,
updateAvailable: CACHE_VERSION !== serverVersion.version,
forceUpdate: serverVersion.forceUpdate,
changelog: serverVersion.changelog
};
} catch (error) {
console.error('[Service Worker] Version check failed:', error);
return {
currentVersion: CACHE_VERSION,
serverVersion: null,
updateAvailable: false,
error: true
};
}
}
// Periodic background sync for version checks (if supported)
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'version-check') {
event.waitUntil(
checkVersion().then((versionInfo) => {
if (versionInfo.updateAvailable) {
// Notify all clients about update
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({
type: 'UPDATE_AVAILABLE',
...versionInfo
});
});
});
}
})
);
}
});

View file

@ -1,14 +1,15 @@
{
"version": "1.5.0",
"buildDate": "2025-10-23T00:17:00Z",
"version": "1.8.4",
"buildDate": "2025-10-24T09:45:00Z",
"changelog": [
"Leader page: Full WCAG accessibility, 9 accordions with proper ARIA, keyboard navigation",
"Converted accordion divs to semantic <button> elements with aria-expanded, aria-controls",
"Added keyboard support: Enter and Space keys toggle accordions",
"Mobile optimization: 44px/48px touch targets, touch-action: manipulation",
"Accordion content panels: role='region' and aria-labelledby for screen readers",
"Previous: Implementer diagrams, Researcher Berlin/Weil, Footer i18n deep merge"
"Blog Validation: ✨ NEW! Manage Submission modal for tracking publication submissions",
"Blog Validation: Track submission packages (cover letters, pitch emails, author bios)",
"Blog Validation: Progress indicators and automated checklist saving",
"Blog Validation: Integration with 22 ranked publication targets",
"API: New submission tracking endpoints (GET/PUT /api/blog/:id/submissions)",
"Backend: SubmissionTracking model for managing publication workflows",
"Cache: Updated cache-busting and service worker version"
],
"forceUpdate": true,
"minVersion": "1.1.5"
"minVersion": "1.8.0"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,157 @@
/**
* Publications Controller
* API endpoints for publication targets metadata
*/
const publicationTargets = require('../config/publication-targets.config');
/**
* GET /api/publications
* Get all publication targets with optional filtering
*/
async function getPublications(req, res) {
try {
const {
type, // letter, oped, social
tier, // premier, specialist, regional, digital
culture, // global-north, asia, developing-world
minRank,
maxRank,
language // en, de, fr, pt, zh, etc.
} = req.query;
// Get all publications
let publications = publicationTargets.getAllPublications();
// Apply filters
if (type) {
publications = publications.filter(p => p.type === type);
}
if (tier) {
publications = publications.filter(p => p.tier === tier);
}
if (culture) {
publications = publications.filter(p =>
p.culture && p.culture.includes(culture)
);
}
if (minRank) {
publications = publications.filter(p => p.rank >= parseInt(minRank, 10));
}
if (maxRank) {
publications = publications.filter(p => p.rank <= parseInt(maxRank, 10));
}
if (language) {
publications = publications.filter(p =>
p.requirements && p.requirements.language === language
);
}
// Sort by rank
publications.sort((a, b) => a.rank - b.rank);
res.json({
success: true,
count: publications.length,
data: publications
});
} catch (error) {
console.error('[Publications] Get publications error:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch publications'
});
}
}
/**
* GET /api/publications/:id
* Get specific publication by ID
*/
async function getPublicationById(req, res) {
try {
const { id } = req.params;
const publication = publicationTargets.getPublicationById(id);
if (!publication) {
return res.status(404).json({
success: false,
error: 'Publication not found'
});
}
res.json({
success: true,
data: publication
});
} catch (error) {
console.error('[Publications] Get publication by ID error:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch publication'
});
}
}
/**
* GET /api/publications/summary
* Get summary statistics about publications
*/
async function getPublicationsSummary(req, res) {
try {
const publications = publicationTargets.getAllPublications();
// Calculate summary statistics
const summary = {
total: publications.length,
byType: {
letter: publications.filter(p => p.type === 'letter').length,
oped: publications.filter(p => p.type === 'oped').length,
social: publications.filter(p => p.type === 'social').length
},
byTier: {
premier: publications.filter(p => p.tier === 'premier').length,
specialist: publications.filter(p => p.tier === 'specialist').length,
regional: publications.filter(p => p.tier === 'regional').length,
digital: publications.filter(p => p.tier === 'digital').length
},
byCulture: {
'global-north': publications.filter(p => p.culture && p.culture.includes('global-north')).length,
'asia': publications.filter(p => p.culture && p.culture.includes('asia')).length,
'developing-world': publications.filter(p => p.culture && p.culture.includes('developing-world')).length
},
byLanguage: {
en: publications.filter(p => !p.requirements?.language || p.requirements.language === 'en').length,
de: publications.filter(p => p.requirements?.language === 'de').length,
fr: publications.filter(p => p.requirements?.language === 'fr').length,
pt: publications.filter(p => p.requirements?.language === 'pt').length,
zh: publications.filter(p => p.requirements?.language === 'zh').length
},
verified: publications.filter(p => p.verified).length,
unverified: publications.filter(p => !p.verified).length
};
res.json({
success: true,
data: summary
});
} catch (error) {
console.error('[Publications] Get summary error:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch publication summary'
});
}
}
module.exports = {
getPublications,
getPublicationById,
getPublicationsSummary
};

165
src/routes/blog.routes.js Normal file
View file

@ -0,0 +1,165 @@
/**
* Blog Routes
* AI-curated blog endpoints
*/
const express = require('express');
const router = express.Router();
const blogController = require('../controllers/blog.controller');
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
const { validateRequired, validateObjectId, validateSlug } = require('../middleware/validation.middleware');
const { asyncHandler } = require('../middleware/error.middleware');
/**
* Public routes
*/
// GET /api/blog/rss - RSS feed (must be before /:slug to avoid conflict)
router.get('/rss',
asyncHandler(blogController.generateRSSFeed)
);
// GET /api/blog - List published posts
router.get('/',
asyncHandler(blogController.listPublishedPosts)
);
// GET /api/blog/:slug - Get published post by slug
router.get('/:slug',
asyncHandler(blogController.getPublishedPost)
);
/**
* Admin routes
*/
// POST /api/blog/suggest-topics - AI-powered topic suggestions (TRA-OPS-0002)
router.post('/suggest-topics',
authenticateToken,
requireRole('admin'),
validateRequired(['audience']),
asyncHandler(blogController.suggestTopics)
);
// POST /api/blog/suggest-topics-for-publication - Publication-specific topic suggestions
router.post('/suggest-topics-for-publication',
authenticateToken,
requireRole('admin'),
validateRequired(['publicationId']),
asyncHandler(blogController.suggestTopicsForPublication)
);
// POST /api/blog/draft-post - AI-powered blog post drafting (TRA-OPS-0002)
// Enforces inst_016, inst_017, inst_018
router.post('/draft-post',
authenticateToken,
requireRole('admin'),
validateRequired(['topic', 'audience']),
asyncHandler(blogController.draftBlogPost)
);
// POST /api/blog/analyze-content - Analyze content for Tractatus compliance
router.post('/analyze-content',
authenticateToken,
requireRole('admin'),
validateRequired(['title', 'body']),
asyncHandler(blogController.analyzeContent)
);
// POST /api/blog/validate-uniqueness - Check content uniqueness against existing articles
router.post('/validate-uniqueness',
authenticateToken,
requireRole('admin'),
validateRequired(['content']),
asyncHandler(blogController.validateUniqueness)
);
// POST /api/blog/check-submission-conflict - Check for submission conflicts
router.post('/check-submission-conflict',
authenticateToken,
requireRole('admin'),
validateRequired(['contentId', 'targetPublicationId']),
asyncHandler(blogController.checkSubmissionConflict)
);
// POST /api/blog/validate-article - Comprehensive validation (content + title)
router.post('/validate-article',
authenticateToken,
requireRole('admin'),
validateRequired(['articleId']),
asyncHandler(blogController.validateArticle)
);
// GET /api/blog/editorial-guidelines - Get editorial guidelines
router.get('/editorial-guidelines',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(blogController.getEditorialGuidelines)
);
// GET /api/blog/admin/posts?status=draft
router.get('/admin/posts',
authenticateToken,
requireRole('admin', 'moderator'),
asyncHandler(blogController.listPostsByStatus)
);
// GET /api/blog/admin/:id - Get any post by ID
router.get('/admin/:id',
authenticateToken,
requireRole('admin', 'moderator'),
validateObjectId('id'),
asyncHandler(blogController.getPostById)
);
// POST /api/blog - Create new post
router.post('/',
authenticateToken,
requireRole('admin'),
validateRequired(['title', 'slug', 'content']),
validateSlug,
asyncHandler(blogController.createPost)
);
// PUT /api/blog/:id - Update post
router.put('/:id',
authenticateToken,
requireRole('admin'),
validateObjectId('id'),
asyncHandler(blogController.updatePost)
);
// POST /api/blog/:id/publish - Publish post
router.post('/:id/publish',
authenticateToken,
requireRole('admin'),
validateObjectId('id'),
asyncHandler(blogController.publishPost)
);
// DELETE /api/blog/:id - Delete post
router.delete('/:id',
authenticateToken,
requireRole('admin'),
validateObjectId('id'),
asyncHandler(blogController.deletePost)
);
// GET /api/blog/:id/submissions - Get submission tracking data
router.get('/:id/submissions',
authenticateToken,
requireRole('admin'),
validateObjectId('id'),
asyncHandler(blogController.getSubmissions)
);
// PUT /api/blog/:id/submissions - Create/update submission tracking
router.put('/:id/submissions',
authenticateToken,
requireRole('admin'),
validateObjectId('id'),
asyncHandler(blogController.updateSubmission)
);
module.exports = router;