/** * 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 };