tractatus/src/controllers/documents.controller.js
TheFlow d98eff8c32 fix: Optimize docs page API (7MB→19KB), fix categories, add MI translations
- Add summary projection to Document.list() excluding heavy content fields
- Fix 23 documents with invalid categories (framework/governance/reference)
- Archive 9 duplicate documents (kept canonical short-slug versions)
- Add Te Reo Māori UI translations to docs-app.js and document-cards.js
- Refactor language checks to be extensible (no more hardcoded EN/DE/FR)
- Remove unused font preloads from docs.html (fixes browser warning)
- Add resources to valid publish categories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:14:02 +13:00

764 lines
19 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;
}
// Use summary projection for list endpoint (returns ~7KB instead of ~7MB)
const summary = req.query.fields !== 'full';
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,
summary
});
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 (embedded in document)
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,
download_formats: translation.download_formats || document.download_formats,
sections: undefined, // Force traditional view (not card view)
language: lang,
translation_metadata: translation.metadata
};
return res.json({
success: true,
document: translatedDoc
});
}
// FALLBACK: Check for separate language document (e.g., glossary-de, glossary-fr)
// Try appending language code to slug
const translatedSlug = `${identifier}-${lang}`;
const translatedDoc = await Document.findBySlug(translatedSlug);
if (translatedDoc) {
return res.json({
success: true,
document: {
...translatedDoc,
language: lang,
sections: undefined // Force traditional view
}
});
}
// 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
};