tractatus/public/js/admin/case-moderation.js
TheFlow ac2db33732 fix(submissions): restructure Economist package and fix article display
- Create Economist SubmissionTracking package correctly:
  * mainArticle = full blog post content
  * coverLetter = 216-word SIR— letter
  * Links to blog post via blogPostId
- Archive 'Letter to The Economist' from blog posts (it's the cover letter)
- Fix date display on article cards (use published_at)
- Target publication already displaying via blue badge

Database changes:
- Make blogPostId optional in SubmissionTracking model
- Economist package ID: 68fa85ae49d4900e7f2ecd83
- Le Monde package ID: 68fa2abd2e6acd5691932150

Next: Enhanced modal with tabs, validation, export

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 08:47:42 +13:00

671 lines
23 KiB
JavaScript

/**
* Case Study Moderation Admin Interface
* Handles review and moderation of community-submitted case studies
*/
(function() {
'use strict';
// State
let submissions = [];
let currentFilters = {
status: 'pending',
failureMode: '',
score: '',
sortBy: 'submitted_at'
};
let currentSubmission = null;
let pendingAction = null;
// DOM Elements
const elements = {
// Stats
statTotal: document.getElementById('stat-total'),
statPending: document.getElementById('stat-pending'),
statApproved: document.getElementById('stat-approved'),
statRejected: document.getElementById('stat-rejected'),
// Filters
filterStatus: document.getElementById('filter-status'),
filterFailureMode: document.getElementById('filter-failure-mode'),
filterScore: document.getElementById('filter-score'),
sortBy: document.getElementById('sort-by'),
clearFiltersBtn: document.getElementById('clear-filters-btn'),
filterResults: document.getElementById('filter-results'),
// Submissions
submissionsContainer: document.getElementById('submissions-container'),
// Details Modal
detailsModal: document.getElementById('details-modal'),
closeDetailsModal: document.getElementById('close-details-modal'),
detailsModalCloseBtn: document.getElementById('details-modal-close-btn'),
detailsModalContent: document.getElementById('details-modal-content'),
requestInfoBtn: document.getElementById('request-info-btn'),
rejectBtn: document.getElementById('reject-btn'),
approveBtn: document.getElementById('approve-btn'),
// Action Modal
actionModal: document.getElementById('action-modal'),
closeActionModal: document.getElementById('close-action-modal'),
actionModalTitle: document.getElementById('action-modal-title'),
actionModalContent: document.getElementById('action-modal-content'),
actionNotes: document.getElementById('action-notes'),
actionNotesLabel: document.getElementById('action-notes-label'),
actionModalCancelBtn: document.getElementById('action-modal-cancel-btn'),
actionModalSubmitBtn: document.getElementById('action-modal-submit-btn'),
// Auth
adminName: document.getElementById('admin-name'),
logoutBtn: document.getElementById('logout-btn')
};
/**
* Initialize the application
*/
async function init() {
// Check authentication
const token = localStorage.getItem('admin_token');
if (!token) {
window.location.href = '/admin/login.html';
return;
}
// Set admin name
const adminEmail = localStorage.getItem('admin_email');
if (elements.adminName && adminEmail) {
elements.adminName.textContent = adminEmail;
}
// Attach event listeners
attachEventListeners();
// Load data
await loadSubmissions();
await loadStats();
}
/**
* Attach event listeners
*/
function attachEventListeners() {
// Filters
if (elements.filterStatus) {
elements.filterStatus.addEventListener('change', handleFilterChange);
}
if (elements.filterFailureMode) {
elements.filterFailureMode.addEventListener('change', handleFilterChange);
}
if (elements.filterScore) {
elements.filterScore.addEventListener('change', handleFilterChange);
}
if (elements.sortBy) {
elements.sortBy.addEventListener('change', handleFilterChange);
}
if (elements.clearFiltersBtn) {
elements.clearFiltersBtn.addEventListener('click', clearFilters);
}
// Details Modal
if (elements.closeDetailsModal) {
elements.closeDetailsModal.addEventListener('click', closeDetailsModal);
}
if (elements.detailsModalCloseBtn) {
elements.detailsModalCloseBtn.addEventListener('click', closeDetailsModal);
}
if (elements.requestInfoBtn) {
elements.requestInfoBtn.addEventListener('click', () => openActionModal('request_info'));
}
if (elements.rejectBtn) {
elements.rejectBtn.addEventListener('click', () => openActionModal('reject'));
}
if (elements.approveBtn) {
elements.approveBtn.addEventListener('click', () => openActionModal('approve'));
}
// Action Modal
if (elements.closeActionModal) {
elements.closeActionModal.addEventListener('click', closeActionModal);
}
if (elements.actionModalCancelBtn) {
elements.actionModalCancelBtn.addEventListener('click', closeActionModal);
}
if (elements.actionModalSubmitBtn) {
elements.actionModalSubmitBtn.addEventListener('click', handleActionSubmit);
}
// Logout
if (elements.logoutBtn) {
elements.logoutBtn.addEventListener('click', () => {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_email');
window.location.href = '/admin/login.html';
});
}
}
/**
* Load submissions from API
*/
async function loadSubmissions() {
try {
const token = localStorage.getItem('admin_token');
const params = new URLSearchParams();
if (currentFilters.status) params.append('status', currentFilters.status);
if (currentFilters.failureMode) params.append('failure_mode', currentFilters.failureMode);
if (currentFilters.score) params.append('score', currentFilters.score);
if (currentFilters.sortBy) params.append('sort', currentFilters.sortBy);
const response = await fetch(`/api/cases/submissions?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401) {
localStorage.removeItem('admin_token');
window.location.href = '/admin/login.html';
return;
}
const data = await response.json();
if (data.success) {
submissions = data.submissions || [];
renderSubmissions();
} else {
showToast('Failed to load submissions', 'error');
}
} catch (error) {
console.error('Error loading submissions:', error);
showToast('Error loading submissions', 'error');
}
}
/**
* Load statistics
*/
async function loadStats() {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch('/api/cases/submissions/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
if (data.success) {
const stats = data.stats || {};
if (elements.statTotal) elements.statTotal.textContent = stats.total || 0;
if (elements.statPending) elements.statPending.textContent = stats.pending || 0;
if (elements.statApproved) elements.statApproved.textContent = stats.approved || 0;
if (elements.statRejected) elements.statRejected.textContent = stats.rejected || 0;
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
/**
* Render submissions list
*/
function renderSubmissions() {
if (!elements.submissionsContainer) return;
// Update filter results count
if (elements.filterResults) {
const count = submissions.length;
elements.filterResults.textContent = `Showing ${count} submission${count !== 1 ? 's' : ''}`;
}
// No submissions
if (submissions.length === 0) {
elements.submissionsContainer.innerHTML = `
<div class="bg-white rounded-lg shadow p-12 text-center text-gray-500">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-lg font-medium">No submissions found</p>
<p class="text-sm mt-2">Try adjusting your filters</p>
</div>
`;
return;
}
// Render submission cards
const submissionsHTML = submissions.map(submission => {
const statusColors = {
'pending': 'bg-yellow-100 text-yellow-800',
'approved': 'bg-green-100 text-green-800',
'rejected': 'bg-red-100 text-red-800',
'needs_info': 'bg-blue-100 text-blue-800'
};
const statusColor = statusColors[submission.moderation?.status] || 'bg-gray-100 text-gray-800';
const relevanceScore = submission.ai_review?.relevance_score;
const scoreColor = relevanceScore >= 0.7 ? 'text-green-600' : relevanceScore >= 0.4 ? 'text-yellow-600' : 'text-red-600';
const submittedDate = new Date(submission.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
return `
<div class="bg-white rounded-lg shadow hover:shadow-lg transition cursor-pointer mb-4 border border-gray-200 hover:border-blue-500 submission-card"
data-submission-id="${submission._id}">
<div class="p-6">
<!-- Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-2">${escapeHtml(submission.case_study?.title || 'Untitled')}</h3>
<div class="flex flex-wrap gap-2 mb-2">
<span class="px-2 py-1 ${statusColor} rounded text-xs font-medium">
${submission.moderation?.status?.toUpperCase() || 'PENDING'}
</span>
<span class="px-2 py-1 bg-purple-100 text-purple-800 rounded text-xs font-medium">
${submission.case_study?.failure_mode?.replace(/_/g, ' ').toUpperCase() || 'OTHER'}
</span>
${relevanceScore !== undefined ? `
<span class="px-2 py-1 bg-gray-100 ${scoreColor} rounded text-xs font-medium">
AI Score: ${(relevanceScore * 100).toFixed(0)}%
</span>
` : ''}
</div>
</div>
</div>
<!-- Submitter -->
<div class="text-sm text-gray-600 mb-3">
<span class="font-medium">Submitted by:</span>
${escapeHtml(submission.submitter?.name || 'Anonymous')}
${submission.submitter?.organization ? ` (${escapeHtml(submission.submitter.organization)})` : ''}
</div>
<!-- Description Preview -->
<p class="text-sm text-gray-700 line-clamp-2 mb-3">
${escapeHtml(submission.case_study?.description || '').substring(0, 200)}...
</p>
<!-- Footer -->
<div class="flex items-center justify-between text-xs text-gray-500">
<span>Submitted: ${submittedDate}</span>
${submission.moderation?.reviewed_at ? `
<span>Reviewed: ${new Date(submission.moderation.reviewed_at).toLocaleDateString()}</span>
` : ''}
</div>
</div>
</div>
`;
}).join('');
elements.submissionsContainer.innerHTML = submissionsHTML;
// Attach click handlers to submission cards (CSP-compliant)
document.querySelectorAll('.submission-card').forEach(card => {
card.addEventListener('click', () => {
const submissionId = card.getAttribute('data-submission-id');
if (submissionId) {
viewSubmission(submissionId);
}
});
});
}
/**
* View submission details
*/
async function viewSubmission(submissionId) {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`/api/cases/submissions/${submissionId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
if (data.success) {
currentSubmission = data.submission;
renderSubmissionDetails(data.submission);
openDetailsModal();
} else {
showToast('Failed to load submission details', 'error');
}
} catch (error) {
console.error('Error loading submission:', error);
showToast('Error loading submission details', 'error');
}
}
/**
* Render submission details in modal
*/
function renderSubmissionDetails(submission) {
if (!elements.detailsModalContent) return;
const html = `
<!-- Status Banner -->
<div class="mb-6 p-4 rounded-lg ${getStatusBannerColor(submission.moderation?.status)}">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">Status: ${submission.moderation?.status?.toUpperCase() || 'PENDING'}</p>
${submission.moderation?.reviewed_at ? `
<p class="text-sm mt-1">Reviewed: ${new Date(submission.moderation.reviewed_at).toLocaleString()}</p>
` : ''}
</div>
${submission.ai_review?.relevance_score !== undefined ? `
<div class="text-right">
<p class="font-medium">AI Relevance Score</p>
<p class="text-2xl font-bold">${(submission.ai_review.relevance_score * 100).toFixed(0)}%</p>
</div>
` : ''}
</div>
</div>
<!-- Submitter Information -->
<div class="mb-6">
<h4 class="text-lg font-semibold text-gray-900 mb-3">Submitter Information</h4>
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<p><span class="font-medium">Name:</span> ${escapeHtml(submission.submitter?.name || 'N/A')}</p>
<p><span class="font-medium">Email:</span> ${escapeHtml(submission.submitter?.email || 'N/A')}</p>
${submission.submitter?.organization ? `
<p><span class="font-medium">Organization:</span> ${escapeHtml(submission.submitter.organization)}</p>
` : ''}
<p><span class="font-medium">Public Attribution:</span> ${submission.submitter?.public ? 'Yes' : 'No'}</p>
</div>
</div>
<!-- Case Study Details -->
<div class="mb-6">
<h4 class="text-lg font-semibold text-gray-900 mb-3">Case Study Details</h4>
<div class="space-y-4">
<div>
<p class="text-sm font-medium text-gray-700 mb-1">Title</p>
<p class="text-gray-900">${escapeHtml(submission.case_study?.title || 'N/A')}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-700 mb-1">Failure Mode</p>
<p class="text-gray-900">${submission.case_study?.failure_mode?.replace(/_/g, ' ').toUpperCase() || 'N/A'}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-700 mb-1">Description</p>
<div class="text-gray-900 whitespace-pre-wrap bg-gray-50 rounded p-4">${escapeHtml(submission.case_study?.description || 'N/A')}</div>
</div>
<div>
<p class="text-sm font-medium text-gray-700 mb-1">Tractatus Applicability</p>
<div class="text-gray-900 whitespace-pre-wrap bg-gray-50 rounded p-4">${escapeHtml(submission.case_study?.tractatus_applicability || 'N/A')}</div>
</div>
${submission.case_study?.evidence?.length > 0 ? `
<div>
<p class="text-sm font-medium text-gray-700 mb-2">Evidence Links</p>
<ul class="list-disc list-inside space-y-1">
${submission.case_study.evidence.map(url => `
<li><a href="${escapeHtml(url)}" target="_blank" class="text-blue-600 hover:underline">${escapeHtml(url)}</a></li>
`).join('')}
</ul>
</div>
` : ''}
</div>
</div>
<!-- AI Review (if available) -->
${submission.ai_review?.claude_analysis ? `
<div class="mb-6">
<h4 class="text-lg font-semibold text-gray-900 mb-3">AI Analysis</h4>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p class="text-sm text-blue-900 whitespace-pre-wrap">${escapeHtml(submission.ai_review.claude_analysis)}</p>
</div>
</div>
` : ''}
<!-- Review Notes (if any) -->
${submission.moderation?.review_notes ? `
<div class="mb-6">
<h4 class="text-lg font-semibold text-gray-900 mb-3">Review Notes</h4>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-gray-900 whitespace-pre-wrap">${escapeHtml(submission.moderation.review_notes)}</p>
</div>
</div>
` : ''}
`;
elements.detailsModalContent.innerHTML = html;
// Show/hide action buttons based on status
const isPending = submission.moderation?.status === 'pending' || submission.moderation?.status === 'needs_info';
if (elements.requestInfoBtn) elements.requestInfoBtn.style.display = isPending ? 'inline-block' : 'none';
if (elements.rejectBtn) elements.rejectBtn.style.display = isPending ? 'inline-block' : 'none';
if (elements.approveBtn) elements.approveBtn.style.display = isPending ? 'inline-block' : 'none';
}
/**
* Get status banner color
*/
function getStatusBannerColor(status) {
const colors = {
'pending': 'bg-yellow-50 border border-yellow-200',
'approved': 'bg-green-50 border border-green-200',
'rejected': 'bg-red-50 border border-red-200',
'needs_info': 'bg-blue-50 border border-blue-200'
};
return colors[status] || 'bg-gray-50 border border-gray-200';
}
/**
* Open action modal
*/
function openActionModal(action) {
if (!currentSubmission) return;
pendingAction = action;
const titles = {
'approve': 'Approve Case Study',
'reject': 'Reject Case Study',
'request_info': 'Request More Information'
};
const labels = {
'approve': 'Approval Notes',
'reject': 'Rejection Reason',
'request_info': 'Information Needed'
};
const content = {
'approve': 'This case study will be approved and may be published. Add any notes for the submitter:',
'reject': 'Please provide a reason for rejecting this submission:',
'request_info': 'What additional information do you need from the submitter?'
};
if (elements.actionModalTitle) {
elements.actionModalTitle.textContent = titles[action] || 'Action';
}
if (elements.actionModalContent) {
elements.actionModalContent.innerHTML = `
<div class="bg-gray-50 rounded-lg p-4 mb-4">
<p class="text-sm font-medium text-gray-900 mb-2">${escapeHtml(currentSubmission.case_study?.title || 'Untitled')}</p>
<p class="text-xs text-gray-600">${content[action]}</p>
</div>
`;
}
if (elements.actionNotesLabel) {
elements.actionNotesLabel.textContent = labels[action] || 'Notes';
}
if (elements.actionNotes) {
elements.actionNotes.value = '';
}
if (elements.actionModal) {
elements.actionModal.classList.remove('hidden');
}
}
/**
* Close action modal
*/
function closeActionModal() {
if (elements.actionModal) {
elements.actionModal.classList.add('hidden');
}
pendingAction = null;
}
/**
* Handle action submission
*/
async function handleActionSubmit() {
if (!currentSubmission || !pendingAction) return;
const notes = elements.actionNotes?.value.trim();
if (!notes) {
showToast('Please enter notes', 'error');
return;
}
const endpoints = {
'approve': '/api/cases/submissions/' + currentSubmission._id + '/approve',
'reject': '/api/cases/submissions/' + currentSubmission._id + '/reject',
'request_info': '/api/cases/submissions/' + currentSubmission._id + '/request-info'
};
// Build request body based on action (different endpoints expect different parameter names)
const requestBody = {};
if (pendingAction === 'approve') {
requestBody.notes = notes;
} else if (pendingAction === 'reject') {
requestBody.reason = notes; // Controller expects 'reason' for reject
} else if (pendingAction === 'request_info') {
requestBody.requested_info = notes; // Controller expects 'requested_info'
}
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(endpoints[pendingAction], {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (data.success) {
showToast(`Successfully ${pendingAction.replace('_', ' ')}d submission`, 'success');
closeActionModal();
closeDetailsModal();
await loadSubmissions();
await loadStats();
} else {
showToast(data.message || 'Action failed', 'error');
}
} catch (error) {
console.error('Error:', error);
showToast('Error processing action', 'error');
}
}
/**
* Open details modal
*/
function openDetailsModal() {
if (elements.detailsModal) {
elements.detailsModal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
}
/**
* Close details modal
*/
function closeDetailsModal() {
if (elements.detailsModal) {
elements.detailsModal.classList.add('hidden');
document.body.style.overflow = '';
}
currentSubmission = null;
}
/**
* Handle filter changes
*/
function handleFilterChange() {
currentFilters.status = elements.filterStatus?.value || '';
currentFilters.failureMode = elements.filterFailureMode?.value || '';
currentFilters.score = elements.filterScore?.value || '';
currentFilters.sortBy = elements.sortBy?.value || 'submitted_at';
loadSubmissions();
}
/**
* Clear all filters
*/
function clearFilters() {
if (elements.filterStatus) elements.filterStatus.value = '';
if (elements.filterFailureMode) elements.filterFailureMode.value = '';
if (elements.filterScore) elements.filterScore.value = '';
if (elements.sortBy) elements.sortBy.value = 'submitted_at';
currentFilters = {
status: '',
failureMode: '',
score: '',
sortBy: 'submitted_at'
};
loadSubmissions();
}
/**
* Show toast notification
*/
function showToast(message, type = 'info') {
const toast = document.createElement('div');
const colors = {
'success': 'bg-green-500',
'error': 'bg-red-500',
'info': 'bg-blue-500'
};
toast.className = `${colors[type] || colors.info} text-white px-6 py-3 rounded-lg shadow-lg`;
toast.textContent = message;
const container = document.getElementById('toast-container');
if (container) {
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();