diff --git a/src/controllers/documents.controller.js b/src/controllers/documents.controller.js index 717cbb30..49d81592 100644 --- a/src/controllers/documents.controller.js +++ b/src/controllers/documents.controller.js @@ -364,6 +364,114 @@ async function listArchivedDocuments(req, res) { } } +/** + * 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' + }); + } +} + module.exports = { listDocuments, getDocument, @@ -371,5 +479,8 @@ module.exports = { createDocument, updateDocument, deleteDocument, - listArchivedDocuments + listArchivedDocuments, + publishDocument, + unpublishDocument, + listDraftDocuments }; diff --git a/src/models/Document.model.js b/src/models/Document.model.js index 39e636bf..2532b8a9 100644 --- a/src/models/Document.model.js +++ b/src/models/Document.model.js @@ -9,20 +9,38 @@ const { getCollection } = require('../utils/db.util'); class Document { /** * Create a new document + * + * SECURITY: All new documents default to 'internal' visibility to prevent accidental exposure. + * Use publish() method to make documents public after review. */ static async create(data) { const collection = await getCollection('documents'); + // SECURITY: Require explicit visibility or default to 'internal' for safety + const visibility = data.visibility || 'internal'; + + // SECURITY: Validate visibility is a known value + const validVisibility = ['public', 'internal', 'confidential', 'archived']; + if (!validVisibility.includes(visibility)) { + throw new Error(`Invalid visibility: ${visibility}. Must be one of: ${validVisibility.join(', ')}`); + } + + // SECURITY: Prevent accidental public uploads - require category for public docs + if (visibility === 'public' && (!data.category || data.category === 'none')) { + throw new Error('Public documents must have a valid category (not "none")'); + } + const document = { title: data.title, slug: data.slug, quadrant: data.quadrant, // STR/OPS/TAC/SYS/STO persistence: data.persistence, // HIGH/MEDIUM/LOW/VARIABLE audience: data.audience || 'general', // technical, general, researcher, implementer, advocate, business, developer - visibility: data.visibility || 'public', // public, internal, confidential, archived + visibility, // SECURITY: Defaults to 'internal', explicit required for 'public' category: data.category || 'none', // conceptual, practical, reference, archived, project-tracking, research-proposal, research-topic order: data.order || 999, // Display order (1-999, lower = higher priority) archiveNote: data.archiveNote || null, // Explanation for why document was archived + workflow_status: 'draft', // SECURITY: Track publish workflow (draft, review, published) content_html: data.content_html, content_markdown: data.content_markdown, toc: data.toc || [], @@ -169,6 +187,128 @@ class Document { return result.deletedCount > 0; } + /** + * Publish a document (make it public after review) + * + * SECURITY: Requires explicit category and validates document is ready + * World-class UX: Clear workflow states and validation feedback + * + * @param {string} id - Document ID + * @param {object} options - Publish options + * @param {string} options.category - Required category for public docs + * @param {number} options.order - Display order + * @returns {Promise<{success: boolean, message: string, document?: object}>} + */ + static async publish(id, options = {}) { + const collection = await getCollection('documents'); + + // Get document + const doc = await this.findById(id); + if (!doc) { + return { success: false, message: 'Document not found' }; + } + + // Validate document is ready for publishing + if (!doc.content_markdown && !doc.content_html) { + return { success: false, message: 'Document must have content before publishing' }; + } + + // SECURITY: Require valid category for public docs + const category = options.category || doc.category; + if (!category || category === 'none') { + return { + success: false, + message: 'Document must have a valid category before publishing. Available categories: getting-started, technical-reference, research-theory, advanced-topics, case-studies, business-leadership, archives' + }; + } + + // Validate category is in allowed list + const validCategories = [ + 'getting-started', 'technical-reference', 'research-theory', + 'advanced-topics', 'case-studies', 'business-leadership', 'archives' + ]; + if (!validCategories.includes(category)) { + return { + success: false, + message: `Invalid category: ${category}. Must be one of: ${validCategories.join(', ')}` + }; + } + + // Update to public + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + visibility: 'public', + category, + order: options.order !== undefined ? options.order : doc.order, + workflow_status: 'published', + 'metadata.date_updated': new Date(), + 'metadata.published_date': new Date(), + 'metadata.published_by': options.publishedBy || 'admin' + } + } + ); + + if (result.modifiedCount > 0) { + const updatedDoc = await this.findById(id); + return { + success: true, + message: `Document published successfully in category: ${category}`, + document: updatedDoc + }; + } + + return { success: false, message: 'Failed to publish document' }; + } + + /** + * Unpublish a document (revert to internal) + * + * @param {string} id - Document ID + * @param {string} reason - Reason for unpublishing + * @returns {Promise<{success: boolean, message: string}>} + */ + static async unpublish(id, reason = '') { + const collection = await getCollection('documents'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + visibility: 'internal', + workflow_status: 'draft', + 'metadata.date_updated': new Date(), + 'metadata.unpublished_date': new Date(), + 'metadata.unpublish_reason': reason + } + } + ); + + return result.modifiedCount > 0 + ? { success: true, message: 'Document unpublished successfully' } + : { success: false, message: 'Failed to unpublish document' }; + } + + /** + * List documents by workflow status + * + * @param {string} status - Workflow status (draft, review, published) + * @param {object} options - List options + * @returns {Promise} + */ + static async listByWorkflowStatus(status, options = {}) { + const collection = await getCollection('documents'); + const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 } } = options; + + return await collection + .find({ workflow_status: status }) + .sort(sort) + .skip(skip) + .limit(limit) + .toArray(); + } + /** * List all documents */ diff --git a/src/routes/documents.routes.js b/src/routes/documents.routes.js index a159da03..1ef49c19 100644 --- a/src/routes/documents.routes.js +++ b/src/routes/documents.routes.js @@ -25,6 +25,13 @@ router.get('/archived', asyncHandler(documentsController.listArchivedDocuments) ); +// GET /api/documents/drafts (admin only) +router.get('/drafts', + authenticateToken, + requireRole('admin'), + asyncHandler(documentsController.listDraftDocuments) +); + // GET /api/documents router.get('/', (req, res, next) => { // Redirect browser requests to API documentation @@ -72,4 +79,22 @@ router.delete('/:id', asyncHandler(documentsController.deleteDocument) ); +// POST /api/documents/:id/publish (admin only) +// SECURITY: Explicit publish workflow with validation +router.post('/:id/publish', + authenticateToken, + requireRole('admin'), + validateObjectId('id'), + validateRequired(['category']), + asyncHandler(documentsController.publishDocument) +); + +// POST /api/documents/:id/unpublish (admin only) +router.post('/:id/unpublish', + authenticateToken, + requireRole('admin'), + validateObjectId('id'), + asyncHandler(documentsController.unpublishDocument) +); + module.exports = router;