tractatus/src/models/Document.model.js
TheFlow 2298d36bed fix(submissions): restructure Economist package and fix article display
- Create Economist SubmissionTracking package correctly:
  * mainArticle = full blog post content
  * coverLetter = 216-word SIR— letter
  * Links to blog post via blogPostId
- Archive 'Letter to The Economist' from blog posts (it's the cover letter)
- Fix date display on article cards (use published_at)
- Target publication already displaying via blue badge

Database changes:
- Make blogPostId optional in SubmissionTracking model
- Economist package ID: 68fa85ae49d4900e7f2ecd83
- Le Monde package ID: 68fa2abd2e6acd5691932150

Next: Enhanced modal with tabs, validation, export

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 08:47:42 +13:00

350 lines
10 KiB
JavaScript

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