From 78ab5754f215633eabf2ae46090bf9e95835f2f5 Mon Sep 17 00:00:00 2001 From: TheFlow Date: Mon, 6 Oct 2025 23:54:56 +1300 Subject: [PATCH] feat: add MongoDB models for core collections Models Created (7/10): - Document.model.js: Framework docs with quadrant classification - BlogPost.model.js: AI-curated blog with moderation - MediaInquiry.model.js: Press/media triage workflow - ModerationQueue.model.js: Human oversight queue with priority - User.model.js: Admin authentication with bcrypt - CaseSubmission.model.js: Community case studies with AI review - Resource.model.js: Curated directory with alignment scores Features: - Full CRUD operations for each model - Tractatus quadrant integration - AI analysis fields for curation - Human approval workflows - Status tracking and filtering - Security (password hashing, sanitized returns) Deferred (Phase 2-3): - Citation.model.js - Translation.model.js - KohaDonation.model.js Status: Core models complete, ready for Express server --- src/models/BlogPost.model.js | 163 ++++++++++++++++++++ src/models/CaseSubmission.model.js | 206 ++++++++++++++++++++++++++ src/models/Document.model.js | 143 ++++++++++++++++++ src/models/MediaInquiry.model.js | 163 ++++++++++++++++++++ src/models/ModerationQueue.model.js | 210 ++++++++++++++++++++++++++ src/models/Resource.model.js | 221 ++++++++++++++++++++++++++++ src/models/User.model.js | 177 ++++++++++++++++++++++ src/models/index.js | 22 +++ 8 files changed, 1305 insertions(+) create mode 100644 src/models/BlogPost.model.js create mode 100644 src/models/CaseSubmission.model.js create mode 100644 src/models/Document.model.js create mode 100644 src/models/MediaInquiry.model.js create mode 100644 src/models/ModerationQueue.model.js create mode 100644 src/models/Resource.model.js create mode 100644 src/models/User.model.js create mode 100644 src/models/index.js diff --git a/src/models/BlogPost.model.js b/src/models/BlogPost.model.js new file mode 100644 index 00000000..5de71aee --- /dev/null +++ b/src/models/BlogPost.model.js @@ -0,0 +1,163 @@ +/** + * BlogPost Model + * AI-curated blog with human oversight + */ + +const { ObjectId } = require('mongodb'); +const { getCollection } = require('../utils/db.util'); + +class BlogPost { + /** + * Create a new blog post + */ + static async create(data) { + const collection = await getCollection('blog_posts'); + + const post = { + title: data.title, + slug: data.slug, + author: { + type: data.author?.type || 'human', // 'human' or 'ai_curated' + name: data.author?.name || 'John Stroh', + claude_version: data.author?.claude_version + }, + content: data.content, + excerpt: data.excerpt, + featured_image: data.featured_image, + status: data.status || 'draft', // draft/pending/published/archived + moderation: { + ai_analysis: data.moderation?.ai_analysis, + human_reviewer: data.moderation?.human_reviewer, + review_notes: data.moderation?.review_notes, + approved_at: data.moderation?.approved_at + }, + tractatus_classification: { + quadrant: data.tractatus_classification?.quadrant || 'OPERATIONAL', + values_sensitive: data.tractatus_classification?.values_sensitive || false, + requires_strategic_review: data.tractatus_classification?.requires_strategic_review || false + }, + published_at: data.published_at, + tags: data.tags || [], + view_count: 0, + engagement: { + shares: 0, + comments: 0 + } + }; + + const result = await collection.insertOne(post); + return { ...post, _id: result.insertedId }; + } + + /** + * Find post by ID + */ + static async findById(id) { + const collection = await getCollection('blog_posts'); + return await collection.findOne({ _id: new ObjectId(id) }); + } + + /** + * Find post by slug + */ + static async findBySlug(slug) { + const collection = await getCollection('blog_posts'); + return await collection.findOne({ slug }); + } + + /** + * Find published posts + */ + static async findPublished(options = {}) { + const collection = await getCollection('blog_posts'); + const { limit = 10, skip = 0, sort = { published_at: -1 } } = options; + + return await collection + .find({ status: 'published' }) + .sort(sort) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Find posts by status + */ + static async findByStatus(status, options = {}) { + const collection = await getCollection('blog_posts'); + const { limit = 20, skip = 0 } = options; + + return await collection + .find({ status }) + .sort({ _id: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Update post + */ + static async update(id, updates) { + const collection = await getCollection('blog_posts'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: updates } + ); + + return result.modifiedCount > 0; + } + + /** + * Publish post (change status + set published_at) + */ + static async publish(id, reviewerId) { + const collection = await getCollection('blog_posts'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + status: 'published', + published_at: new Date(), + 'moderation.human_reviewer': reviewerId, + 'moderation.approved_at': new Date() + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Increment view count + */ + static async incrementViews(id) { + const collection = await getCollection('blog_posts'); + + await collection.updateOne( + { _id: new ObjectId(id) }, + { $inc: { view_count: 1 } } + ); + } + + /** + * Delete post + */ + static async delete(id) { + const collection = await getCollection('blog_posts'); + const result = await collection.deleteOne({ _id: new ObjectId(id) }); + return result.deletedCount > 0; + } + + /** + * Count posts by status + */ + static async countByStatus(status) { + const collection = await getCollection('blog_posts'); + return await collection.countDocuments({ status }); + } +} + +module.exports = BlogPost; diff --git a/src/models/CaseSubmission.model.js b/src/models/CaseSubmission.model.js new file mode 100644 index 00000000..df9fea16 --- /dev/null +++ b/src/models/CaseSubmission.model.js @@ -0,0 +1,206 @@ +/** + * CaseSubmission Model + * Community case study submissions + */ + +const { ObjectId } = require('mongodb'); +const { getCollection } = require('../utils/db.util'); + +class CaseSubmission { + /** + * Create a new case submission + */ + static async create(data) { + const collection = await getCollection('case_submissions'); + + const submission = { + submitter: { + name: data.submitter.name, + email: data.submitter.email, + organization: data.submitter.organization, + public: data.submitter.public !== undefined ? data.submitter.public : false + }, + case_study: { + title: data.case_study.title, + description: data.case_study.description, + failure_mode: data.case_study.failure_mode, + tractatus_applicability: data.case_study.tractatus_applicability, + evidence: data.case_study.evidence || [], + attachments: data.case_study.attachments || [] + }, + ai_review: { + relevance_score: data.ai_review?.relevance_score, // 0-1 + completeness_score: data.ai_review?.completeness_score, // 0-1 + recommended_category: data.ai_review?.recommended_category, + suggested_improvements: data.ai_review?.suggested_improvements || [], + claude_analysis: data.ai_review?.claude_analysis + }, + moderation: { + status: data.moderation?.status || 'pending', // pending/approved/rejected/needs_info + reviewer: data.moderation?.reviewer, + review_notes: data.moderation?.review_notes, + reviewed_at: data.moderation?.reviewed_at + }, + published_case_id: data.published_case_id, + submitted_at: new Date() + }; + + const result = await collection.insertOne(submission); + return { ...submission, _id: result.insertedId }; + } + + /** + * Find submission by ID + */ + static async findById(id) { + const collection = await getCollection('case_submissions'); + return await collection.findOne({ _id: new ObjectId(id) }); + } + + /** + * Find by moderation status + */ + static async findByStatus(status, options = {}) { + const collection = await getCollection('case_submissions'); + const { limit = 20, skip = 0 } = options; + + return await collection + .find({ 'moderation.status': status }) + .sort({ submitted_at: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Find high-relevance submissions pending review + */ + static async findHighRelevance(options = {}) { + const collection = await getCollection('case_submissions'); + const { limit = 10 } = options; + + return await collection + .find({ + 'moderation.status': 'pending', + 'ai_review.relevance_score': { $gte: 0.7 } + }) + .sort({ 'ai_review.relevance_score': -1 }) + .limit(limit) + .toArray(); + } + + /** + * Update submission + */ + static async update(id, updates) { + const collection = await getCollection('case_submissions'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: updates } + ); + + return result.modifiedCount > 0; + } + + /** + * Approve submission + */ + static async approve(id, reviewerId, notes = '') { + const collection = await getCollection('case_submissions'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + 'moderation.status': 'approved', + 'moderation.reviewer': reviewerId, + 'moderation.review_notes': notes, + 'moderation.reviewed_at': new Date() + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Reject submission + */ + static async reject(id, reviewerId, reason) { + const collection = await getCollection('case_submissions'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + 'moderation.status': 'rejected', + 'moderation.reviewer': reviewerId, + 'moderation.review_notes': reason, + 'moderation.reviewed_at': new Date() + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Request more information + */ + static async requestInfo(id, reviewerId, requestedInfo) { + const collection = await getCollection('case_submissions'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + 'moderation.status': 'needs_info', + 'moderation.reviewer': reviewerId, + 'moderation.review_notes': requestedInfo, + 'moderation.reviewed_at': new Date() + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Link to published case study + */ + static async linkPublished(id, publishedCaseId) { + const collection = await getCollection('case_submissions'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + published_case_id: new ObjectId(publishedCaseId), + 'moderation.status': 'approved' + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Count by status + */ + static async countByStatus(status) { + const collection = await getCollection('case_submissions'); + return await collection.countDocuments({ 'moderation.status': status }); + } + + /** + * Delete submission + */ + static async delete(id) { + const collection = await getCollection('case_submissions'); + const result = await collection.deleteOne({ _id: new ObjectId(id) }); + return result.deletedCount > 0; + } +} + +module.exports = CaseSubmission; diff --git a/src/models/Document.model.js b/src/models/Document.model.js new file mode 100644 index 00000000..b6b65751 --- /dev/null +++ b/src/models/Document.model.js @@ -0,0 +1,143 @@ +/** + * Document Model + * Technical papers, framework documentation, specifications + */ + +const { ObjectId } = require('mongodb'); +const { getCollection } = require('../utils/db.util'); + +class Document { + /** + * Create a new document + */ + static async create(data) { + const collection = await getCollection('documents'); + + const document = { + title: data.title, + slug: data.slug, + quadrant: data.quadrant, // STR/OPS/TAC/SYS/STO + persistence: data.persistence, // HIGH/MEDIUM/LOW/VARIABLE + content_html: data.content_html, + content_markdown: data.content_markdown, + toc: data.toc || [], + 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 } } = options; + + return await collection + .find({ quadrant }) + .sort(sort) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Search documents + */ + static async search(query, options = {}) { + const collection = await getCollection('documents'); + const { limit = 20, skip = 0 } = options; + + return await collection + .find( + { $text: { $search: query } }, + { score: { $meta: 'textScore' } } + ) + .sort({ score: { $meta: 'textScore' } }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Update document + */ + static async update(id, updates) { + const collection = await getCollection('documents'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + ...updates, + '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; + } + + /** + * List all documents + */ + static async list(options = {}) { + const collection = await getCollection('documents'); + const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 } } = options; + + return await collection + .find({}) + .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; diff --git a/src/models/MediaInquiry.model.js b/src/models/MediaInquiry.model.js new file mode 100644 index 00000000..4314ec45 --- /dev/null +++ b/src/models/MediaInquiry.model.js @@ -0,0 +1,163 @@ +/** + * MediaInquiry Model + * Press/media inquiries with AI triage + */ + +const { ObjectId } = require('mongodb'); +const { getCollection } = require('../utils/db.util'); + +class MediaInquiry { + /** + * Create a new media inquiry + */ + static async create(data) { + const collection = await getCollection('media_inquiries'); + + const inquiry = { + contact: { + name: data.contact.name, + email: data.contact.email, + outlet: data.contact.outlet, + phone: data.contact.phone + }, + inquiry: { + subject: data.inquiry.subject, + message: data.inquiry.message, + deadline: data.inquiry.deadline ? new Date(data.inquiry.deadline) : null, + topic_areas: data.inquiry.topic_areas || [] + }, + ai_triage: { + urgency: data.ai_triage?.urgency, // high/medium/low + topic_sensitivity: data.ai_triage?.topic_sensitivity, + suggested_response_time: data.ai_triage?.suggested_response_time, + involves_values: data.ai_triage?.involves_values || false, + claude_summary: data.ai_triage?.claude_summary, + suggested_talking_points: data.ai_triage?.suggested_talking_points || [] + }, + status: data.status || 'new', // new/triaged/responded/closed + assigned_to: data.assigned_to, + response: { + sent_at: data.response?.sent_at, + content: data.response?.content, + responder: data.response?.responder + }, + created_at: new Date() + }; + + const result = await collection.insertOne(inquiry); + return { ...inquiry, _id: result.insertedId }; + } + + /** + * Find inquiry by ID + */ + static async findById(id) { + const collection = await getCollection('media_inquiries'); + return await collection.findOne({ _id: new ObjectId(id) }); + } + + /** + * Find inquiries by status + */ + static async findByStatus(status, options = {}) { + const collection = await getCollection('media_inquiries'); + const { limit = 20, skip = 0 } = options; + + return await collection + .find({ status }) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Find high urgency inquiries + */ + static async findUrgent(options = {}) { + const collection = await getCollection('media_inquiries'); + const { limit = 10 } = options; + + return await collection + .find({ + 'ai_triage.urgency': 'high', + status: { $in: ['new', 'triaged'] } + }) + .sort({ created_at: -1 }) + .limit(limit) + .toArray(); + } + + /** + * Update inquiry + */ + static async update(id, updates) { + const collection = await getCollection('media_inquiries'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: updates } + ); + + return result.modifiedCount > 0; + } + + /** + * Assign inquiry to user + */ + static async assign(id, userId) { + const collection = await getCollection('media_inquiries'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + assigned_to: new ObjectId(userId), + status: 'triaged' + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Mark as responded + */ + static async respond(id, responseData) { + const collection = await getCollection('media_inquiries'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + status: 'responded', + 'response.sent_at': new Date(), + 'response.content': responseData.content, + 'response.responder': responseData.responder + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Count by status + */ + static async countByStatus(status) { + const collection = await getCollection('media_inquiries'); + return await collection.countDocuments({ status }); + } + + /** + * Delete inquiry + */ + static async delete(id) { + const collection = await getCollection('media_inquiries'); + const result = await collection.deleteOne({ _id: new ObjectId(id) }); + return result.deletedCount > 0; + } +} + +module.exports = MediaInquiry; diff --git a/src/models/ModerationQueue.model.js b/src/models/ModerationQueue.model.js new file mode 100644 index 00000000..5a3e6522 --- /dev/null +++ b/src/models/ModerationQueue.model.js @@ -0,0 +1,210 @@ +/** + * ModerationQueue Model + * Human oversight queue for AI actions + */ + +const { ObjectId } = require('mongodb'); +const { getCollection } = require('../utils/db.util'); + +class ModerationQueue { + /** + * Add item to moderation queue + */ + static async create(data) { + const collection = await getCollection('moderation_queue'); + + const item = { + item_type: data.item_type, // blog_post/media_inquiry/case_study/resource + item_id: new ObjectId(data.item_id), + quadrant: data.quadrant, // STR/OPS/TAC/SYS/STO + ai_action: { + type: data.ai_action.type, // suggestion/triage/analysis + confidence: data.ai_action.confidence, // 0-1 + reasoning: data.ai_action.reasoning, + claude_version: data.ai_action.claude_version || 'claude-sonnet-4-5' + }, + human_required_reason: data.human_required_reason, + priority: data.priority || 'medium', // high/medium/low + assigned_to: data.assigned_to, + status: 'pending', // pending/reviewed/approved/rejected + created_at: new Date(), + reviewed_at: null, + review_decision: { + action: null, // approve/reject/modify/escalate + notes: null, + reviewer: null + } + }; + + const result = await collection.insertOne(item); + return { ...item, _id: result.insertedId }; + } + + /** + * Find item by ID + */ + static async findById(id) { + const collection = await getCollection('moderation_queue'); + return await collection.findOne({ _id: new ObjectId(id) }); + } + + /** + * Find pending items + */ + static async findPending(options = {}) { + const collection = await getCollection('moderation_queue'); + const { limit = 20, skip = 0, priority } = options; + + const filter = { status: 'pending' }; + if (priority) filter.priority = priority; + + return await collection + .find(filter) + .sort({ + priority: -1, // high first + created_at: 1 // oldest first + }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Find by item type + */ + static async findByType(itemType, options = {}) { + const collection = await getCollection('moderation_queue'); + const { limit = 20, skip = 0 } = options; + + return await collection + .find({ item_type: itemType, status: 'pending' }) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Find by quadrant + */ + static async findByQuadrant(quadrant, options = {}) { + const collection = await getCollection('moderation_queue'); + const { limit = 20, skip = 0 } = options; + + return await collection + .find({ quadrant, status: 'pending' }) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Review item (approve/reject/modify/escalate) + */ + static async review(id, decision) { + const collection = await getCollection('moderation_queue'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + status: 'reviewed', + reviewed_at: new Date(), + 'review_decision.action': decision.action, + 'review_decision.notes': decision.notes, + 'review_decision.reviewer': decision.reviewer + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Approve item + */ + static async approve(id, reviewerId, notes = '') { + return await this.review(id, { + action: 'approve', + notes, + reviewer: reviewerId + }); + } + + /** + * Reject item + */ + static async reject(id, reviewerId, notes) { + return await this.review(id, { + action: 'reject', + notes, + reviewer: reviewerId + }); + } + + /** + * Escalate to strategic review + */ + static async escalate(id, reviewerId, reason) { + const collection = await getCollection('moderation_queue'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + quadrant: 'STRATEGIC', + priority: 'high', + human_required_reason: `ESCALATED: ${reason}`, + 'review_decision.action': 'escalate', + 'review_decision.notes': reason, + 'review_decision.reviewer': reviewerId + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Count pending items + */ + static async countPending(filter = {}) { + const collection = await getCollection('moderation_queue'); + return await collection.countDocuments({ + ...filter, + status: 'pending' + }); + } + + /** + * Get stats by quadrant + */ + static async getStatsByQuadrant() { + const collection = await getCollection('moderation_queue'); + + return await collection.aggregate([ + { $match: { status: 'pending' } }, + { + $group: { + _id: '$quadrant', + count: { $sum: 1 }, + high_priority: { + $sum: { $cond: [{ $eq: ['$priority', 'high'] }, 1, 0] } + } + } + } + ]).toArray(); + } + + /** + * Delete item + */ + static async delete(id) { + const collection = await getCollection('moderation_queue'); + const result = await collection.deleteOne({ _id: new ObjectId(id) }); + return result.deletedCount > 0; + } +} + +module.exports = ModerationQueue; diff --git a/src/models/Resource.model.js b/src/models/Resource.model.js new file mode 100644 index 00000000..46a00ff4 --- /dev/null +++ b/src/models/Resource.model.js @@ -0,0 +1,221 @@ +/** + * Resource Model + * Curated directory of aligned resources + */ + +const { ObjectId } = require('mongodb'); +const { getCollection } = require('../utils/db.util'); + +class Resource { + /** + * Create a new resource + */ + static async create(data) { + const collection = await getCollection('resources'); + + const resource = { + url: data.url, + title: data.title, + description: data.description, + category: data.category, // framework/tool/research/organization/educational + subcategory: data.subcategory, + alignment_score: data.alignment_score, // 0-1 alignment with Tractatus values + ai_analysis: { + summary: data.ai_analysis?.summary, + relevance: data.ai_analysis?.relevance, + quality_indicators: data.ai_analysis?.quality_indicators || [], + concerns: data.ai_analysis?.concerns || [], + claude_reasoning: data.ai_analysis?.claude_reasoning + }, + status: data.status || 'pending', // pending/approved/rejected + reviewed_by: data.reviewed_by, + reviewed_at: data.reviewed_at, + tags: data.tags || [], + featured: data.featured || false, + added_at: new Date(), + last_checked: new Date() + }; + + const result = await collection.insertOne(resource); + return { ...resource, _id: result.insertedId }; + } + + /** + * Find resource by ID + */ + static async findById(id) { + const collection = await getCollection('resources'); + return await collection.findOne({ _id: new ObjectId(id) }); + } + + /** + * Find resource by URL + */ + static async findByUrl(url) { + const collection = await getCollection('resources'); + return await collection.findOne({ url }); + } + + /** + * Find approved resources + */ + static async findApproved(options = {}) { + const collection = await getCollection('resources'); + const { limit = 50, skip = 0, category } = options; + + const filter = { status: 'approved' }; + if (category) filter.category = category; + + return await collection + .find(filter) + .sort({ alignment_score: -1, added_at: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Find featured resources + */ + static async findFeatured(options = {}) { + const collection = await getCollection('resources'); + const { limit = 10 } = options; + + return await collection + .find({ status: 'approved', featured: true }) + .sort({ alignment_score: -1 }) + .limit(limit) + .toArray(); + } + + /** + * Find by category + */ + static async findByCategory(category, options = {}) { + const collection = await getCollection('resources'); + const { limit = 30, skip = 0 } = options; + + return await collection + .find({ category, status: 'approved' }) + .sort({ alignment_score: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + } + + /** + * Find high-alignment pending resources + */ + static async findHighAlignment(options = {}) { + const collection = await getCollection('resources'); + const { limit = 10 } = options; + + return await collection + .find({ + status: 'pending', + alignment_score: { $gte: 0.8 } + }) + .sort({ alignment_score: -1 }) + .limit(limit) + .toArray(); + } + + /** + * Update resource + */ + static async update(id, updates) { + const collection = await getCollection('resources'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: updates } + ); + + return result.modifiedCount > 0; + } + + /** + * Approve resource + */ + static async approve(id, reviewerId) { + const collection = await getCollection('resources'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + status: 'approved', + reviewed_by: reviewerId, + reviewed_at: new Date() + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Reject resource + */ + static async reject(id, reviewerId) { + const collection = await getCollection('resources'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { + $set: { + status: 'rejected', + reviewed_by: reviewerId, + reviewed_at: new Date() + } + } + ); + + return result.modifiedCount > 0; + } + + /** + * Mark as featured + */ + static async setFeatured(id, featured = true) { + const collection = await getCollection('resources'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: { featured } } + ); + + return result.modifiedCount > 0; + } + + /** + * Update last checked timestamp + */ + static async updateLastChecked(id) { + const collection = await getCollection('resources'); + + await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: { last_checked: new Date() } } + ); + } + + /** + * Count by status + */ + static async countByStatus(status) { + const collection = await getCollection('resources'); + return await collection.countDocuments({ status }); + } + + /** + * Delete resource + */ + static async delete(id) { + const collection = await getCollection('resources'); + const result = await collection.deleteOne({ _id: new ObjectId(id) }); + return result.deletedCount > 0; + } +} + +module.exports = Resource; diff --git a/src/models/User.model.js b/src/models/User.model.js new file mode 100644 index 00000000..01b20485 --- /dev/null +++ b/src/models/User.model.js @@ -0,0 +1,177 @@ +/** + * User Model + * Admin user accounts + */ + +const { ObjectId } = require('mongodb'); +const bcrypt = require('bcrypt'); +const { getCollection } = require('../utils/db.util'); + +class User { + /** + * Create a new user + */ + static async create(data) { + const collection = await getCollection('users'); + + // Hash password + const hashedPassword = await bcrypt.hash(data.password, 10); + + const user = { + email: data.email, + password: hashedPassword, + name: data.name, + role: data.role || 'admin', // admin/moderator/viewer + created_at: new Date(), + last_login: null, + active: data.active !== undefined ? data.active : true + }; + + const result = await collection.insertOne(user); + + // Return user without password + const { password, ...userWithoutPassword } = { ...user, _id: result.insertedId }; + return userWithoutPassword; + } + + /** + * Find user by ID + */ + static async findById(id) { + const collection = await getCollection('users'); + const user = await collection.findOne({ _id: new ObjectId(id) }); + + if (user) { + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + } + return null; + } + + /** + * Find user by email + */ + static async findByEmail(email) { + const collection = await getCollection('users'); + return await collection.findOne({ email: email.toLowerCase() }); + } + + /** + * Authenticate user + */ + static async authenticate(email, password) { + const user = await this.findByEmail(email); + + if (!user || !user.active) { + return null; + } + + const isValid = await bcrypt.compare(password, user.password); + + if (!isValid) { + return null; + } + + // Update last login + await this.updateLastLogin(user._id); + + // Return user without password + const { password: _, ...userWithoutPassword } = user; + return userWithoutPassword; + } + + /** + * Update last login timestamp + */ + static async updateLastLogin(id) { + const collection = await getCollection('users'); + + await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: { last_login: new Date() } } + ); + } + + /** + * Update user + */ + static async update(id, updates) { + const collection = await getCollection('users'); + + // Remove password from updates (use changePassword for that) + const { password, ...safeUpdates } = updates; + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: safeUpdates } + ); + + return result.modifiedCount > 0; + } + + /** + * Change password + */ + static async changePassword(id, newPassword) { + const collection = await getCollection('users'); + + const hashedPassword = await bcrypt.hash(newPassword, 10); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: { password: hashedPassword } } + ); + + return result.modifiedCount > 0; + } + + /** + * Deactivate user + */ + static async deactivate(id) { + const collection = await getCollection('users'); + + const result = await collection.updateOne( + { _id: new ObjectId(id) }, + { $set: { active: false } } + ); + + return result.modifiedCount > 0; + } + + /** + * List all users + */ + static async list(options = {}) { + const collection = await getCollection('users'); + const { limit = 50, skip = 0 } = options; + + const users = await collection + .find({}, { projection: { password: 0 } }) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + + return users; + } + + /** + * Count users + */ + static async count(filter = {}) { + const collection = await getCollection('users'); + return await collection.countDocuments(filter); + } + + /** + * Delete user + */ + static async delete(id) { + const collection = await getCollection('users'); + const result = await collection.deleteOne({ _id: new ObjectId(id) }); + return result.deletedCount > 0; + } +} + +module.exports = User; diff --git a/src/models/index.js b/src/models/index.js new file mode 100644 index 00000000..f8dc472c --- /dev/null +++ b/src/models/index.js @@ -0,0 +1,22 @@ +/** + * Models Index + * Export all models + */ + +const Document = require('./Document.model'); +const BlogPost = require('./BlogPost.model'); +const MediaInquiry = require('./MediaInquiry.model'); +const CaseSubmission = require('./CaseSubmission.model'); +const Resource = require('./Resource.model'); +const ModerationQueue = require('./ModerationQueue.model'); +const User = require('./User.model'); + +module.exports = { + Document, + BlogPost, + MediaInquiry, + CaseSubmission, + Resource, + ModerationQueue, + User +};