Skip to main content
Glama
DocumentLifecycleService.ts22.2 kB
import { Logger } from 'winston'; import Database from 'better-sqlite3'; import { promisify } from 'util'; import { v4 as uuidv4 } from 'uuid'; import { DocumentLifecycleService as IDocumentLifecycleService, DocumentMetadata, DocumentLifecycleState } from '../types/enhanced.types.js'; /** * Document Lifecycle Management Service * * Manages the complete lifecycle of documentation from creation to archival. * Tracks states, schedules reviews, and maintains audit trails. * * Created: 2025-07-29 */ export class DocumentLifecycleService implements IDocumentLifecycleService { private db: any; private dbPath: string; private logger: Logger; constructor(databasePath: string, logger: Logger, mockDb?: any) { this.dbPath = databasePath; this.logger = logger; // Allow mock database injection for testing if (mockDb) { this.db = mockDb; } } /** * Initialize the database and create tables */ async initialize(): Promise<void> { try { // Only create real database if mock not provided if (!this.db) { this.db = new Database(this.dbPath); // Promisify database methods only for real database const dbRun = promisify(this.db.run.bind(this.db)); const dbAll = promisify(this.db.all.bind(this.db)); const dbGet = promisify(this.db.get.bind(this.db)); this.db.run = dbRun; this.db.all = dbAll; this.db.get = dbGet; } // When mockDb is provided, this.db is already set in constructor and doesn't need promisification // Create documents table await this.db.run(` CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, filePath TEXT NOT NULL UNIQUE, title TEXT NOT NULL, category TEXT NOT NULL, version TEXT DEFAULT '1.0.0', state TEXT NOT NULL, tags TEXT DEFAULT '[]', dependencies TEXT DEFAULT '[]', workConnections TEXT DEFAULT '[]', lastModified TEXT NOT NULL, lastReviewed TEXT, nextReviewDue TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, aiQualityScore REAL, duplicateDetectionHash TEXT ) `); // Create document_history table for audit trail await this.db.run(` CREATE TABLE IF NOT EXISTS document_history ( id TEXT PRIMARY KEY, documentId TEXT NOT NULL, action TEXT NOT NULL, previousState TEXT, newState TEXT, comment TEXT, timestamp TEXT NOT NULL, FOREIGN KEY (documentId) REFERENCES documents (id) ) `); // Create scheduled_reviews table await this.db.run(` CREATE TABLE IF NOT EXISTS scheduled_reviews ( id TEXT PRIMARY KEY, documentId TEXT NOT NULL, reviewDate TEXT NOT NULL, reviewType TEXT, assignedTo TEXT, status TEXT DEFAULT 'pending', FOREIGN KEY (documentId) REFERENCES documents (id) ) `); // Create indexes for performance await this.db.run(`CREATE INDEX IF NOT EXISTS idx_documents_state ON documents(state)`); await this.db.run(`CREATE INDEX IF NOT EXISTS idx_documents_category ON documents(category)`); await this.db.run(`CREATE INDEX IF NOT EXISTS idx_documents_nextReview ON documents(nextReviewDue)`); await this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_document ON document_history(documentId)`); this.logger.info('Document lifecycle database initialized'); } catch (error) { this.logger.error('Failed to initialize document lifecycle service:', error); throw error; } } /** * Track a new document in the system */ async trackDocument(metadata: Omit<DocumentMetadata, 'id' | 'createdAt' | 'updatedAt'>): Promise<DocumentMetadata> { try { const now = new Date().toISOString(); const document: DocumentMetadata = { id: uuidv4(), ...metadata, createdAt: now, updatedAt: now }; await this.db.run(` INSERT INTO documents ( id, filePath, title, category, version, state, tags, dependencies, workConnections, lastModified, lastReviewed, nextReviewDue, createdAt, updatedAt, aiQualityScore, duplicateDetectionHash ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ document.id, document.filePath, document.title, document.category, document.version, document.state, JSON.stringify(document.tags), JSON.stringify(document.dependencies), JSON.stringify(document.workConnections), document.lastModified, document.lastReviewed, document.nextReviewDue, document.createdAt, document.updatedAt, document.aiQualityScore, document.duplicateDetectionHash ]); // Log the action await this.logDocumentAction(document.id, 'created', null, document.state); this.logger.info(`Document tracked: ${document.title} (${document.id})`); return document; } catch (error) { this.logger.error('Failed to track document:', error); throw error; } } /** * Update document lifecycle state */ async updateDocumentState(id: string, state: DocumentLifecycleState): Promise<void> { try { // Get current state const currentDoc = await this.getDocumentById(id); if (!currentDoc) { throw new Error(`Document not found: ${id}`); } const now = new Date().toISOString(); await this.db.run(` UPDATE documents SET state = ?, updatedAt = ? WHERE id = ? `, [state, now, id]); // Log the state change await this.logDocumentAction(id, 'state_changed', currentDoc.state, state); this.logger.info(`Document state updated: ${id} -> ${state}`); } catch (error) { this.logger.error('Failed to update document state:', error); throw error; } } /** * Get documents by state */ async getDocumentsByState(state: DocumentLifecycleState): Promise<DocumentMetadata[]> { try { const rows = await this.db.all(` SELECT * FROM documents WHERE state = ? `, [state]); return rows.map(this.mapRowToDocument); } catch (error) { this.logger.error('Failed to get documents by state:', error); throw error; } } /** * Schedule document review */ async scheduleReview(id: string, reviewDate: Date | string, reviewTypeOrReviewerId?: string, assignedTo?: string): Promise<void> { try { // Validate input parameters if (!id) { throw new Error('Document ID is required'); } if (!reviewDate || reviewDate === null || reviewDate === undefined) { throw new Error('Review date is required and must be valid'); } // Handle different argument patterns let dateStr: string; let reviewType: string | undefined; let reviewer: string | undefined; if (reviewDate instanceof Date) { dateStr = reviewDate.toISOString(); } else { const parsedDate = new Date(reviewDate); if (isNaN(parsedDate.getTime())) { throw new Error('Invalid review date format'); } dateStr = parsedDate.toISOString(); } // Determine argument pattern if (assignedTo) { // Three argument pattern: (id, date, reviewType, assignedTo) reviewType = reviewTypeOrReviewerId; reviewer = assignedTo; } else { // Two argument pattern: (id, date, reviewerId) reviewer = reviewTypeOrReviewerId; } const now = new Date().toISOString(); if (reviewType && reviewer) { // Insert into scheduled_reviews table (test pattern) const reviewId = uuidv4(); await this.db.run(` INSERT INTO scheduled_reviews (id, documentId, reviewDate, reviewType, assignedTo, status) VALUES (?, ?, ?, ?, ?, ?) `, [reviewId, id, dateStr, reviewType, reviewer, 'pending']); this.logger.info(`Review scheduled for document ${id} on ${dateStr}`); } else { // Update documents table (original pattern) await this.db.run(` UPDATE documents SET nextReviewDue = ?, updatedAt = ? WHERE id = ? `, [dateStr, now, id]); // Log the review scheduling const comment = reviewer ? `Review scheduled for ${dateStr} by ${reviewer}` : `Review scheduled for ${dateStr}`; await this.logDocumentAction(id, 'review_scheduled', null, null, comment); this.logger.info(`Review scheduled for document ${id}: ${dateStr}`); } } catch (error) { this.logger.error('Failed to schedule review:', error); throw error; } } /** * Create a new document (test-compatible version that returns ID) */ async createDocument(metadata: any): Promise<string> { try { // Validate input metadata if (!metadata || typeof metadata !== 'object') { throw new Error('Document metadata is required and must be an object'); } if (!metadata.filePath || metadata.filePath === '') { throw new Error('Document file path is required'); } if (!metadata.title || metadata.title === '') { throw new Error('Document title is required'); } if (!metadata.author || metadata.author === '') { throw new Error('Document author is required'); } // Create document with default state const docId = uuidv4(); const now = new Date().toISOString(); await this.db.run(` INSERT INTO documents ( id, filePath, title, category, version, state, tags, dependencies, workConnections, lastModified, lastReviewed, nextReviewDue, createdAt, updatedAt, aiQualityScore, duplicateDetectionHash ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ docId, metadata.filePath, metadata.title, metadata.category || 'General', metadata.version || '1.0.0', 'draft', JSON.stringify(metadata.tags || []), JSON.stringify(metadata.dependencies || []), JSON.stringify(metadata.workConnections || []), now, null, null, now, now, metadata.aiQualityScore || null, metadata.duplicateDetectionHash || null ]); // Log the action await this.logDocumentAction(docId, 'created', null, 'draft'); this.logger.info(`Document created with ID: ${docId}`); return docId; } catch (error) { this.logger.error('Failed to create document:', error); throw error; } } /** * Get document by ID */ async getDocumentById(id: string): Promise<DocumentMetadata | null> { try { await this.ensureInitialized(); const row = await this.db.get(` SELECT * FROM documents WHERE id = ? `, [id]); return row ? this.mapRowToDocument(row) : null; } catch (error) { this.logger.error('Failed to get document by ID:', error); throw error; } } /** * Ensure database is initialized before operations */ private async ensureInitialized(): Promise<void> { if (!this.db) { this.logger.info('Auto-initializing DocumentLifecycleService database...'); await this.initialize(); } } /** * Get all documents */ async getAllDocuments(): Promise<DocumentMetadata[]> { try { await this.ensureInitialized(); const rows = await this.db.all(` SELECT * FROM documents ORDER BY createdAt DESC `); return rows.map(this.mapRowToDocument); } catch (error) { this.logger.error('Failed to get all documents:', error); throw error; } } /** * Get documents due for review */ async getDocumentsDueForReview(): Promise<DocumentMetadata[]> { try { const now = new Date().toISOString(); const rows = await this.db.all(` SELECT * FROM documents WHERE nextReviewDue IS NOT NULL AND nextReviewDue <= ? ORDER BY nextReviewDue ASC `, [now]); return rows.map(this.mapRowToDocument); } catch (error) { this.logger.error('Failed to get documents due for review:', error); throw error; } } /** * Update document metadata */ async updateDocumentMetadata(id: string, updates: Partial<DocumentMetadata>): Promise<void> { try { const now = new Date().toISOString(); const setClause = Object.keys(updates) .filter(key => key !== 'id' && key !== 'createdAt') .map(key => `${key} = ?`) .join(', '); if (!setClause) { return; // No updates to make } const values = Object.keys(updates) .filter(key => key !== 'id' && key !== 'createdAt') .map(key => { const value = (updates as any)[key]; return Array.isArray(value) ? JSON.stringify(value) : value; }); values.push(now, id); // Add updatedAt and id for WHERE clause await this.db.run(` UPDATE documents SET ${setClause}, updatedAt = ? WHERE id = ? `, values); // Log the update await this.logDocumentAction(id, 'metadata_updated', null, null, `Updated: ${Object.keys(updates).join(', ')}`); this.logger.info(`Document metadata updated: ${id}`); } catch (error) { this.logger.error('Failed to update document metadata:', error); throw error; } } /** * Get document history */ async getDocumentHistory(id: string): Promise<any[]> { try { const rows = await this.db.all(` SELECT * FROM document_history WHERE documentId = ? ORDER BY timestamp DESC `, [id]); return rows; } catch (error) { this.logger.error('Failed to get document history:', error); throw error; } } /** * Search documents */ async searchDocuments(query: string): Promise<DocumentMetadata[]> { try { const searchTerm = `%${query}%`; const rows = await this.db.all(` SELECT * FROM documents WHERE title LIKE ? OR filePath LIKE ? OR tags LIKE ? ORDER BY updatedAt DESC `, [searchTerm, searchTerm, searchTerm]); return rows.map(this.mapRowToDocument); } catch (error) { this.logger.error('Failed to search documents:', error); throw error; } } /** * Get document statistics */ async getDocumentStatistics(): Promise<any> { try { const stats = { total: 0, byState: {} as Record<DocumentLifecycleState, number>, byCategory: {} as Record<string, number>, dueForReview: 0, avgQualityScore: 0 }; // Total count const totalResult = await this.db.get(`SELECT COUNT(*) as count FROM documents`); stats.total = totalResult.count; // By state const stateResults = await this.db.all(` SELECT state, COUNT(*) as count FROM documents GROUP BY state `); for (const result of stateResults) { stats.byState[result.state as DocumentLifecycleState] = result.count; } // By category const categoryResults = await this.db.all(` SELECT category, COUNT(*) as count FROM documents GROUP BY category `); for (const result of categoryResults) { stats.byCategory[result.category] = result.count; } // Due for review const now = new Date().toISOString(); const reviewResult = await this.db.get(` SELECT COUNT(*) as count FROM documents WHERE nextReviewDue IS NOT NULL AND nextReviewDue <= ? `, [now]); stats.dueForReview = reviewResult.count; // Average quality score const qualityResult = await this.db.get(` SELECT AVG(aiQualityScore) as avg FROM documents WHERE aiQualityScore IS NOT NULL `); stats.avgQualityScore = qualityResult.avg || 0; return stats; } catch (error) { this.logger.error('Failed to get document statistics:', error); throw error; } } /** * Update document state (alias for updateDocumentState for test compatibility) */ async updateState(id: string, state: DocumentLifecycleState, userId: string): Promise<void> { try { // Get current state for history const currentDoc = await this.getDocumentById(id); const previousState = currentDoc?.state; await this.updateDocumentState(id, state); // Log the state change with user await this.logDocumentAction(id, 'state_changed', previousState, state, `Updated by: ${userId}`); this.logger.info(`Document state updated: ${id} -> ${state}`); } catch (error) { this.logger.error('Failed to update document state:', error); throw error; } } /** * Get due reviews (alias for getDocumentsDueForReview for test compatibility) */ async getDueReviews(dueDate?: Date): Promise<any[]> { try { if (dueDate) { // Test pattern - query scheduled_reviews table const rows = await this.db.all(` SELECT * FROM scheduled_reviews WHERE reviewDate <= ? AND status = ? ORDER BY reviewDate ASC `, [dueDate.toISOString(), 'pending']); return rows; } else { // Original pattern - query documents table const cutoffDate = new Date(); const rows = await this.db.all(` SELECT * FROM documents WHERE nextReviewDue IS NOT NULL AND nextReviewDue <= ? ORDER BY nextReviewDue ASC `, [cutoffDate.toISOString()]); return rows.map(this.mapRowToDocument); } } catch (error) { this.logger.error('Failed to get due reviews:', error); throw error; } } /** * Get document history (alias for getDocumentHistory for test compatibility) */ async getHistory(id: string): Promise<any[]> { return this.getDocumentHistory(id); } /** * Archive document */ async archiveDocument(id: string, userId: string): Promise<void> { try { const now = new Date().toISOString(); await this.db.run(` UPDATE documents SET state = ?, updatedAt = ? WHERE id = ? `, ['archived', now, id]); // Log the archival await this.logDocumentAction(id, 'archived', null, 'archived', `Archived by: ${userId}`); this.logger.info(`Document archived: ${id}`); } catch (error) { this.logger.error('Failed to archive document:', error); throw error; } } /** * Add document history entry */ async addDocumentHistory(documentId: string, action: string, metadata: any): Promise<string> { try { const historyId = uuidv4(); await this.db.run(` INSERT INTO document_history (id, documentId, action, comment, timestamp) VALUES (?, ?, ?, ?, ?) `, [ historyId, documentId, action, JSON.stringify(metadata), new Date().toISOString() ]); return historyId; } catch (error) { this.logger.error('Failed to add document history:', error); throw error; } } /** * Close database connection */ async close(): Promise<void> { if (this.db) { // For mock databases, use the close method directly if it exists if (typeof this.db.close === 'function') { try { await this.db.close(); } catch (error) { // If mock close fails, ignore and continue if (error && !(error as Error).message?.includes('mock')) { throw error; } } } } } /** * Shutdown the service (alias for close for test compatibility) */ async shutdown(): Promise<void> { try { if (this.db) { await this.close(); this.logger.info('Document lifecycle database closed'); } else { this.logger.info('Database not initialized, nothing to close'); } } catch (error) { this.logger.error('Error closing document lifecycle database:', error); } } // ============================================================================= // PRIVATE HELPER METHODS // ============================================================================= /** * Log document action to history */ private async logDocumentAction( documentId: string, action: string, previousState?: string | null, newState?: string | null, comment?: string ): Promise<void> { try { await this.db.run(` INSERT INTO document_history (id, documentId, action, previousState, newState, comment, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?) `, [ uuidv4(), documentId, action, previousState, newState, comment, new Date().toISOString() ]); } catch (error) { this.logger.warn('Failed to log document action:', error); // Don't throw - logging failure shouldn't break main operations } } /** * Map database row to DocumentMetadata */ private mapRowToDocument(row: any): DocumentMetadata { return { id: row.id, filePath: row.filePath, title: row.title, category: row.category, version: row.version, state: row.state as DocumentLifecycleState, tags: JSON.parse(row.tags || '[]'), dependencies: JSON.parse(row.dependencies || '[]'), workConnections: JSON.parse(row.workConnections || '[]'), lastModified: row.lastModified, lastReviewed: row.lastReviewed, nextReviewDue: row.nextReviewDue, createdAt: row.createdAt, updatedAt: row.updatedAt, aiQualityScore: row.aiQualityScore, duplicateDetectionHash: row.duplicateDetectionHash }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ghostseller/CastPlan_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server