Add comprehensive research timeline (STO-REF-0011) tracing intellectual evolution from SyDigital through Tractatus to sovereign governance. Add sidebar filter UI to docs page (document type + audience dropdowns with URL parameter support). Extend Document model with document_type and status fields in create method and summary projection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
379 lines
12 KiB
JavaScript
379 lines
12 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', // researcher, implementer, leader, general
|
|
visibility, // SECURITY: Defaults to 'internal', explicit required for 'public'
|
|
category: data.category || 'none', // getting-started, resources, research-theory, technical-reference, advanced-topics, business-leadership
|
|
document_type: data.document_type || 'guide', // working-paper, case-study, technical-report, guide, reference, brief, internal-note
|
|
status: data.status || 'draft', // current, needs-update, under-review, superseded, historical, draft
|
|
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, resources, research-theory, technical-reference, advanced-topics, business-leadership, archives'
|
|
};
|
|
}
|
|
|
|
// Validate category is in allowed list
|
|
const validCategories = [
|
|
'getting-started', 'resources', 'research-theory', 'technical-reference',
|
|
'advanced-topics', '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
|
|
* @param {object} options
|
|
* @param {boolean} options.summary - If true, return only summary fields (for sidebar/listing)
|
|
*/
|
|
static async list(options = {}) {
|
|
const collection = await getCollection('documents');
|
|
const { limit = 50, skip = 0, sort = { order: 1, 'metadata.date_created': -1 }, filter = {}, summary = false } = options;
|
|
|
|
const query = collection.find(filter);
|
|
|
|
if (summary) {
|
|
query.project({
|
|
title: 1,
|
|
slug: 1,
|
|
category: 1,
|
|
document_type: 1,
|
|
status: 1,
|
|
order: 1,
|
|
visibility: 1,
|
|
quadrant: 1,
|
|
persistence: 1,
|
|
audience: 1,
|
|
download_formats: 1,
|
|
'metadata.date_created': 1,
|
|
'metadata.date_updated': 1,
|
|
'metadata.version': 1,
|
|
'metadata.tags': 1,
|
|
'metadata.document_code': 1,
|
|
'translations.de.title': 1,
|
|
'translations.fr.title': 1,
|
|
'translations.mi.title': 1
|
|
});
|
|
}
|
|
|
|
return await query
|
|
.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;
|