Problem:
- Card view uses sections array which contains English text
- Translated documents showed English content in cards
- Only document title was translated
Solution:
- Set sections = undefined for translated documents
- Forces frontend to use traditional full-document view
- Traditional view displays content_html which IS translated
Result:
- Translated documents now show fully translated content
- Card view disabled for translations (traditional view instead)
- All content (title + body) now displays in German/French
Testing:
- German: "Einführung in den Tractatus-Rahmen", "Was ist Tractatus?"
- content_html confirmed 17KB of translated German text
🌐 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
743 lines
18 KiB
JavaScript
743 lines
18 KiB
JavaScript
/**
|
|
* Documents Controller
|
|
* Handles framework documentation CRUD operations
|
|
*/
|
|
|
|
const Document = require('../models/Document.model');
|
|
const { markdownToHtml, extractTOC } = require('../utils/markdown.util');
|
|
const { extractAndProcessSections } = require('../utils/sections.util');
|
|
const logger = require('../utils/logger.util');
|
|
|
|
/**
|
|
* List all documents
|
|
* GET /api/documents
|
|
*/
|
|
async function listDocuments(req, res) {
|
|
try {
|
|
const { limit = 50, skip = 0, quadrant, audience } = req.query;
|
|
|
|
let documents;
|
|
let total;
|
|
|
|
// Build filter - only show public documents (not internal/confidential)
|
|
const filter = {
|
|
visibility: 'public'
|
|
};
|
|
if (quadrant) {
|
|
filter.quadrant = quadrant;
|
|
}
|
|
if (audience) {
|
|
filter.audience = audience;
|
|
}
|
|
|
|
if (quadrant && !audience) {
|
|
documents = await Document.findByQuadrant(quadrant, {
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip),
|
|
publicOnly: true
|
|
});
|
|
total = await Document.count(filter);
|
|
} else if (audience && !quadrant) {
|
|
documents = await Document.findByAudience(audience, {
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip),
|
|
publicOnly: true
|
|
});
|
|
total = await Document.count(filter);
|
|
} else {
|
|
documents = await Document.list({
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip),
|
|
filter
|
|
});
|
|
total = await Document.count(filter);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
documents,
|
|
pagination: {
|
|
total,
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip),
|
|
hasMore: parseInt(skip) + documents.length < total
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('List documents error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get document by ID or slug
|
|
* GET /api/documents/:identifier?lang=de
|
|
*
|
|
* Supports i18n: Returns translated version if lang parameter provided
|
|
*/
|
|
async function getDocument(req, res) {
|
|
try {
|
|
const { identifier } = req.params;
|
|
const { lang } = req.query; // en, de, fr
|
|
|
|
// Try to find by ID first, then by slug
|
|
let document;
|
|
if (identifier.match(/^[0-9a-fA-F]{24}$/)) {
|
|
document = await Document.findById(identifier);
|
|
} else {
|
|
document = await Document.findBySlug(identifier);
|
|
}
|
|
|
|
if (!document) {
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: 'Document not found'
|
|
});
|
|
}
|
|
|
|
// If language parameter provided and not English, return translated version
|
|
if (lang && lang !== 'en') {
|
|
const supportedLangs = ['de', 'fr'];
|
|
|
|
if (!supportedLangs.includes(lang)) {
|
|
return res.status(400).json({
|
|
error: 'Bad Request',
|
|
message: `Unsupported language: ${lang}. Supported: ${supportedLangs.join(', ')}`
|
|
});
|
|
}
|
|
|
|
// Check if translation exists
|
|
if (document.translations && document.translations[lang]) {
|
|
const translation = document.translations[lang];
|
|
|
|
// WORKAROUND: Don't include sections for translations
|
|
// This forces frontend to use traditional full-document view
|
|
// which displays the fully translated content_html
|
|
// Card view sections are English-only until we fix markdown translation
|
|
const translatedDoc = {
|
|
...document,
|
|
title: translation.title || document.title,
|
|
content_html: translation.content_html || document.content_html,
|
|
content_markdown: translation.content_markdown || document.content_markdown,
|
|
toc: translation.toc || document.toc,
|
|
sections: undefined, // Force traditional view (not card view)
|
|
language: lang,
|
|
translation_metadata: translation.metadata
|
|
};
|
|
|
|
return res.json({
|
|
success: true,
|
|
document: translatedDoc
|
|
});
|
|
} else {
|
|
// Translation not available
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: `Translation not available for language: ${lang}`,
|
|
available_languages: Object.keys(document.translations || {})
|
|
});
|
|
}
|
|
}
|
|
|
|
// Default: Return English version
|
|
res.json({
|
|
success: true,
|
|
document: {
|
|
...document,
|
|
language: 'en'
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Get document error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search documents with faceted filtering
|
|
* GET /api/documents/search?q=...&quadrant=...&persistence=...&audience=...
|
|
*/
|
|
async function searchDocuments(req, res) {
|
|
try {
|
|
const { q, quadrant, persistence, audience, limit = 20, skip = 0 } = req.query;
|
|
|
|
// Build filter for faceted search
|
|
const filter = {
|
|
visibility: 'public'
|
|
};
|
|
|
|
// Add facet filters
|
|
if (quadrant) {
|
|
filter.quadrant = quadrant.toUpperCase();
|
|
}
|
|
if (persistence) {
|
|
filter.persistence = persistence.toUpperCase();
|
|
}
|
|
if (audience) {
|
|
filter.audience = audience.toLowerCase();
|
|
}
|
|
|
|
let documents;
|
|
|
|
// If text query provided, use full-text search with filters
|
|
if (q && q.trim()) {
|
|
const { getCollection } = require('../utils/db.util');
|
|
const collection = await getCollection('documents');
|
|
|
|
// Add text search to filter
|
|
filter.$text = { $search: q };
|
|
|
|
documents = await collection
|
|
.find(filter, { score: { $meta: 'textScore' } })
|
|
.sort({ score: { $meta: 'textScore' } })
|
|
.skip(parseInt(skip))
|
|
.limit(parseInt(limit))
|
|
.toArray();
|
|
} else {
|
|
// No text query - just filter by facets
|
|
documents = await Document.list({
|
|
filter,
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip),
|
|
sort: { order: 1, 'metadata.date_created': -1 }
|
|
});
|
|
}
|
|
|
|
// Count total matching documents
|
|
const { getCollection } = require('../utils/db.util');
|
|
const collection = await getCollection('documents');
|
|
const total = await collection.countDocuments(filter);
|
|
|
|
res.json({
|
|
success: true,
|
|
query: q || null,
|
|
filters: {
|
|
quadrant: quadrant || null,
|
|
persistence: persistence || null,
|
|
audience: audience || null
|
|
},
|
|
documents,
|
|
count: documents.length,
|
|
total,
|
|
pagination: {
|
|
total,
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip),
|
|
hasMore: parseInt(skip) + documents.length < total
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Search documents error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create document (admin only)
|
|
* POST /api/documents
|
|
*/
|
|
async function createDocument(req, res) {
|
|
try {
|
|
const { title, slug, quadrant, persistence, audience, content_markdown, metadata } = req.body;
|
|
|
|
// Convert markdown to HTML
|
|
const content_html = markdownToHtml(content_markdown);
|
|
|
|
// Extract table of contents
|
|
const toc = extractTOC(content_markdown);
|
|
|
|
// Create search index from content
|
|
const search_index = `${title} ${content_markdown}`.toLowerCase();
|
|
|
|
const document = await Document.create({
|
|
title,
|
|
slug,
|
|
quadrant,
|
|
persistence,
|
|
audience: audience || 'general',
|
|
content_html,
|
|
content_markdown,
|
|
toc,
|
|
metadata,
|
|
search_index
|
|
});
|
|
|
|
logger.info(`Document created: ${slug} by ${req.user.email}`);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
document
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Create document error:', error);
|
|
|
|
// Handle duplicate slug
|
|
if (error.code === 11000) {
|
|
return res.status(409).json({
|
|
error: 'Conflict',
|
|
message: 'A document with this slug already exists'
|
|
});
|
|
}
|
|
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update document (admin only)
|
|
* PUT /api/documents/:id
|
|
*/
|
|
async function updateDocument(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
const updates = { ...req.body };
|
|
|
|
// If content_markdown is updated, regenerate HTML and TOC
|
|
if (updates.content_markdown) {
|
|
updates.content_html = markdownToHtml(updates.content_markdown);
|
|
updates.toc = extractTOC(updates.content_markdown);
|
|
updates.search_index = `${updates.title || ''} ${updates.content_markdown}`.toLowerCase();
|
|
}
|
|
|
|
const success = await Document.update(id, updates);
|
|
|
|
if (!success) {
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: 'Document not found'
|
|
});
|
|
}
|
|
|
|
const document = await Document.findById(id);
|
|
|
|
logger.info(`Document updated: ${id} by ${req.user.email}`);
|
|
|
|
res.json({
|
|
success: true,
|
|
document
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Update document error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete document (admin only)
|
|
* DELETE /api/documents/:id
|
|
*/
|
|
async function deleteDocument(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const success = await Document.delete(id);
|
|
|
|
if (!success) {
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: 'Document not found'
|
|
});
|
|
}
|
|
|
|
logger.info(`Document deleted: ${id} by ${req.user.email}`);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Document deleted successfully'
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Delete document error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List archived documents
|
|
* GET /api/documents/archived
|
|
*/
|
|
async function listArchivedDocuments(req, res) {
|
|
try {
|
|
const { limit = 50, skip = 0 } = req.query;
|
|
|
|
const documents = await Document.listArchived({
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip)
|
|
});
|
|
|
|
const total = await Document.count({ visibility: 'archived' });
|
|
|
|
res.json({
|
|
success: true,
|
|
documents,
|
|
pagination: {
|
|
total,
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip),
|
|
hasMore: parseInt(skip) + documents.length < total
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('List archived documents error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publish a document (admin only)
|
|
* POST /api/documents/:id/publish
|
|
*
|
|
* SECURITY: Explicit publish workflow prevents accidental exposure
|
|
* World-class UX: Clear validation messages guide admins
|
|
*/
|
|
async function publishDocument(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
const { category, order } = req.body;
|
|
|
|
const result = await Document.publish(id, {
|
|
category,
|
|
order,
|
|
publishedBy: req.user?.email || 'admin'
|
|
});
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({
|
|
error: 'Bad Request',
|
|
message: result.message
|
|
});
|
|
}
|
|
|
|
logger.info(`Document published: ${id} by ${req.user?.email || 'admin'} (category: ${category})`);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: result.message,
|
|
document: result.document
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Publish document error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: error.message || 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unpublish a document (admin only)
|
|
* POST /api/documents/:id/unpublish
|
|
*/
|
|
async function unpublishDocument(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
const { reason } = req.body;
|
|
|
|
const result = await Document.unpublish(id, reason);
|
|
|
|
if (!result.success) {
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: result.message
|
|
});
|
|
}
|
|
|
|
logger.info(`Document unpublished: ${id} by ${req.user?.email || 'admin'} (reason: ${reason || 'none'})`);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: result.message
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Unpublish document error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List draft documents (admin only)
|
|
* GET /api/documents/drafts
|
|
*/
|
|
async function listDraftDocuments(req, res) {
|
|
try {
|
|
const { limit = 50, skip = 0 } = req.query;
|
|
|
|
const documents = await Document.listByWorkflowStatus('draft', {
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip)
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
documents,
|
|
pagination: {
|
|
total: documents.length,
|
|
limit: parseInt(limit),
|
|
skip: parseInt(skip)
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('List draft documents error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Translate a document using DeepL (admin only)
|
|
* POST /api/documents/:id/translate
|
|
*
|
|
* Body: { targetLang: 'de' | 'fr', force: false }
|
|
*/
|
|
async function translateDocument(req, res) {
|
|
try {
|
|
const { id } = req.params;
|
|
const { targetLang, force = false } = req.body;
|
|
|
|
// Validate target language
|
|
const supportedLangs = ['de', 'fr'];
|
|
if (!supportedLangs.includes(targetLang)) {
|
|
return res.status(400).json({
|
|
error: 'Bad Request',
|
|
message: `Unsupported target language: ${targetLang}. Supported: ${supportedLangs.join(', ')}`
|
|
});
|
|
}
|
|
|
|
// Get document
|
|
const document = await Document.findById(id);
|
|
if (!document) {
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: 'Document not found'
|
|
});
|
|
}
|
|
|
|
// Check if translation already exists
|
|
if (!force && document.translations && document.translations[targetLang]) {
|
|
return res.status(409).json({
|
|
error: 'Conflict',
|
|
message: `Translation already exists for ${targetLang}. Use force: true to overwrite.`,
|
|
existing_translation: document.translations[targetLang].metadata
|
|
});
|
|
}
|
|
|
|
// Translate using DeepL service
|
|
const deeplService = require('../services/DeepL.service');
|
|
|
|
if (!deeplService.isAvailable()) {
|
|
return res.status(503).json({
|
|
error: 'Service Unavailable',
|
|
message: 'DeepL API is not configured. Set DEEPL_API_KEY environment variable.'
|
|
});
|
|
}
|
|
|
|
// Perform translation
|
|
logger.info(`Starting translation of document ${id} to ${targetLang} by ${req.user?.email || 'admin'}`);
|
|
|
|
const translation = await deeplService.translateDocument(document, targetLang);
|
|
|
|
// Update document with translation
|
|
const updates = {
|
|
[`translations.${targetLang}`]: translation
|
|
};
|
|
|
|
await Document.update(id, updates);
|
|
|
|
logger.info(`Translation complete: ${id} to ${targetLang}`);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Document translated to ${targetLang} successfully`,
|
|
translation: {
|
|
language: targetLang,
|
|
title: translation.title,
|
|
metadata: translation.metadata
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Translate document error:', error);
|
|
|
|
if (error.message.includes('DeepL')) {
|
|
return res.status(503).json({
|
|
error: 'Service Unavailable',
|
|
message: error.message
|
|
});
|
|
}
|
|
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'Translation failed',
|
|
details: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available translations for a document
|
|
* GET /api/documents/:identifier/translations
|
|
*/
|
|
async function getTranslations(req, res) {
|
|
try {
|
|
const { identifier } = req.params;
|
|
|
|
// Try to find by ID first, then by slug
|
|
let document;
|
|
if (identifier.match(/^[0-9a-fA-F]{24}$/)) {
|
|
document = await Document.findById(identifier);
|
|
} else {
|
|
document = await Document.findBySlug(identifier);
|
|
}
|
|
|
|
if (!document) {
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: 'Document not found'
|
|
});
|
|
}
|
|
|
|
// Build list of available translations
|
|
const translations = {
|
|
en: {
|
|
available: true,
|
|
title: document.title,
|
|
metadata: {
|
|
original: true,
|
|
version: document.metadata?.version || '1.0'
|
|
}
|
|
}
|
|
};
|
|
|
|
// Add translations if they exist
|
|
if (document.translations) {
|
|
Object.keys(document.translations).forEach(lang => {
|
|
translations[lang] = {
|
|
available: true,
|
|
title: document.translations[lang].title,
|
|
metadata: document.translations[lang].metadata
|
|
};
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
document_slug: document.slug,
|
|
document_title: document.title,
|
|
translations
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Get translations error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a translation (admin only)
|
|
* DELETE /api/documents/:id/translations/:lang
|
|
*/
|
|
async function deleteTranslation(req, res) {
|
|
try {
|
|
const { id, lang } = req.params;
|
|
|
|
// Validate language
|
|
if (lang === 'en') {
|
|
return res.status(400).json({
|
|
error: 'Bad Request',
|
|
message: 'Cannot delete original English version'
|
|
});
|
|
}
|
|
|
|
// Get document
|
|
const document = await Document.findById(id);
|
|
if (!document) {
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: 'Document not found'
|
|
});
|
|
}
|
|
|
|
// Check if translation exists
|
|
if (!document.translations || !document.translations[lang]) {
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: `Translation not found for language: ${lang}`
|
|
});
|
|
}
|
|
|
|
// Remove translation
|
|
const updates = {
|
|
[`translations.${lang}`]: null
|
|
};
|
|
|
|
await Document.update(id, updates);
|
|
|
|
logger.info(`Translation deleted: ${id} (${lang}) by ${req.user?.email || 'admin'}`);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Translation for ${lang} deleted successfully`
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Delete translation error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal Server Error',
|
|
message: 'An error occurred'
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
listDocuments,
|
|
getDocument,
|
|
searchDocuments,
|
|
createDocument,
|
|
updateDocument,
|
|
deleteDocument,
|
|
listArchivedDocuments,
|
|
publishDocument,
|
|
unpublishDocument,
|
|
listDraftDocuments,
|
|
translateDocument,
|
|
getTranslations,
|
|
deleteTranslation
|
|
};
|