/** * Document Model * Technical papers, framework documentation, specifications */ const { ObjectId } = require('mongodb'); 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, // 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 || [], security_classification: data.security_classification || { contains_credentials: false, contains_financial_info: false, contains_vulnerability_info: false, contains_infrastructure_details: false, requires_authentication: false }, metadata: { author: data.metadata?.author || 'John Stroh', date_created: new Date(), date_updated: new Date(), version: data.metadata?.version || '1.0', document_code: data.metadata?.document_code, related_documents: data.metadata?.related_documents || [], tags: data.metadata?.tags || [] }, translations: data.translations || {}, search_index: data.search_index || '', download_formats: data.download_formats || {} }; const result = await collection.insertOne(document); return { ...document, _id: result.insertedId }; } /** * Find document by ID */ static async findById(id) { const collection = await getCollection('documents'); return await collection.findOne({ _id: new ObjectId(id) }); } /** * Find document by slug */ static async findBySlug(slug) { const collection = await getCollection('documents'); return await collection.findOne({ slug }); } /** * Find documents by quadrant */ static async findByQuadrant(quadrant, options = {}) { const collection = await getCollection('documents'); const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 }, publicOnly = false } = options; const filter = { quadrant }; if (publicOnly) { filter.visibility = 'public'; } return await collection .find(filter) .sort(sort) .skip(skip) .limit(limit) .toArray(); } /** * Find documents by audience */ static async findByAudience(audience, options = {}) { const collection = await getCollection('documents'); const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 }, publicOnly = false } = options; const filter = { audience }; if (publicOnly) { filter.visibility = 'public'; } return await collection .find(filter) .sort(sort) .skip(skip) .limit(limit) .toArray(); } /** * Search documents */ static async search(query, options = {}) { const collection = await getCollection('documents'); const { limit = 20, skip = 0, publicOnly = false } = options; const filter = { $text: { $search: query } }; if (publicOnly) { filter.visibility = 'public'; } return await collection .find( filter, { score: { $meta: 'textScore' } } ) .sort({ score: { $meta: 'textScore' } }) .skip(skip) .limit(limit) .toArray(); } /** * Update document */ static async update(id, updates) { const collection = await getCollection('documents'); // If updates contains metadata, merge date_updated into it // Otherwise set it as a separate field const updatePayload = { ...updates }; if (updatePayload.metadata) { updatePayload.metadata = { ...updatePayload.metadata, date_updated: new Date() }; } const result = await collection.updateOne( { _id: new ObjectId(id) }, { $set: { ...updatePayload, ...(updatePayload.metadata ? {} : { 'metadata.date_updated': new Date() }) } } ); return result.modifiedCount > 0; } /** * Delete document */ static async delete(id) { const collection = await getCollection('documents'); const result = await collection.deleteOne({ _id: new ObjectId(id) }); 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 */ static async list(options = {}) { const collection = await getCollection('documents'); const { limit = 50, skip = 0, sort = { order: 1, 'metadata.date_created': -1 }, filter = {} } = options; return await collection .find(filter) .sort(sort) .skip(skip) .limit(limit) .toArray(); } /** * List archived documents */ static async listArchived(options = {}) { const collection = await getCollection('documents'); const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 } } = options; return await collection .find({ visibility: 'archived' }) .sort(sort) .skip(skip) .limit(limit) .toArray(); } /** * Count documents */ static async count(filter = {}) { const collection = await getCollection('documents'); return await collection.countDocuments(filter); } } module.exports = Document;