UNPKG

adpa-enterprise-framework-automation

Version:

Modular, standards-compliant Node.js/TypeScript automation framework for enterprise requirements, project, and data management. Provides CLI and API for BABOK v3, PMBOK 7th Edition, and DMBOK 2.0 (in progress). Production-ready Express.js API with TypeSpe

707 lines 29.9 kB
import { v4 as uuidv4 } from 'uuid'; import { DocumentReviewModel } from '../models/Review.js'; import { ReviewerProfileModel } from '../models/ReviewerProfile.js'; import { ReviewWorkflowConfigModel } from '../models/ReviewWorkflow.js'; import { logger } from '../utils/logger.js'; export class ReviewService { /** * Create a new document review request */ async createReview(request) { try { logger.info(`Creating review for document: ${request.documentName}`); // Run automated checks first const automatedChecks = await this.runAutomatedChecks(request.documentPath, request.documentType); // Find applicable workflow const workflow = await this.findApplicableWorkflow(request.documentType, request.workflowId); // Calculate due date const dueDate = request.dueDate || this.calculateDueDate(workflow?.defaultDueDays || 5); // Create review document const review = new DocumentReviewModel({ documentId: request.documentId, documentName: request.documentName, documentType: request.documentType, documentPath: request.documentPath, projectId: request.projectId, priority: request.priority, dueDate, assignedReviewers: [], reviewRounds: [], currentRound: 0, metadata: { ...request.metadata, automatedChecks, complianceScore: this.calculateInitialComplianceScore(automatedChecks) } }); const savedReview = await review.save(); // Auto-assign reviewers if enabled in workflow if (workflow?.autoAssignment) { await this.autoAssignReviewers(savedReview, workflow, request.requiredRoles, request.specificReviewers); } logger.info(`Review created successfully: ${savedReview.id}`); return savedReview.toJSON(); } catch (error) { logger.error('Error creating review:', error); throw new Error(`Failed to create review: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Assign a reviewer to a review */ async assignReviewer(request) { try { logger.info(`Assigning reviewer ${request.reviewerId} to review ${request.reviewId}`); const review = await DocumentReviewModel.findById(request.reviewId); if (!review) { throw new Error('Review not found'); } const reviewer = await ReviewerProfileModel.findOne({ userId: request.reviewerId }); if (!reviewer) { throw new Error('Reviewer not found'); } // Check if reviewer can take on this review const reviewerWithMethods = reviewer; if (typeof reviewerWithMethods.canTakeReview === 'function' && !reviewerWithMethods.canTakeReview(request.estimatedHours)) { throw new Error('Reviewer is not available for this review'); } // Check if reviewer is already assigned const existingAssignment = review.assignedReviewers.find(r => r.reviewerId === request.reviewerId); if (existingAssignment) { throw new Error('Reviewer is already assigned to this review'); } // Create reviewer assignment const assignment = { reviewerId: request.reviewerId, reviewerName: reviewer.name, reviewerEmail: reviewer.email, role: request.role, expertise: reviewer.expertise, assignedAt: new Date(), status: 'assigned', estimatedHours: request.estimatedHours }; review.assignedReviewers.push(assignment); // Update review status if this is the first assignment if (review.status === 'pending_assignment') { review.status = 'assigned'; review.currentReviewer = request.reviewerId; } const updatedReview = await review.save(); // Send notification to reviewer await this.sendReviewAssignmentNotification(updatedReview, assignment); logger.info(`Reviewer assigned successfully: ${request.reviewerId}`); return updatedReview.toJSON(); } catch (error) { logger.error('Error assigning reviewer:', error); throw new Error(`Failed to assign reviewer: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Submit feedback for a review */ async submitFeedback(request) { try { logger.info(`Submitting feedback for review ${request.reviewId}, round ${request.roundNumber}`); const review = await DocumentReviewModel.findById(request.reviewId); if (!review) { throw new Error('Review not found'); } const reviewer = review.assignedReviewers.find(r => r.reviewerId === review.currentReviewer); if (!reviewer) { throw new Error('Current reviewer not found'); } // Create feedback entries with IDs const feedback = request.feedback.map(f => ({ ...f, id: uuidv4(), createdAt: new Date(), updatedAt: new Date() })); // Create review round const reviewRound = { roundNumber: request.roundNumber, startedAt: reviewer.acceptedAt || reviewer.assignedAt, completedAt: new Date(), reviewerId: reviewer.reviewerId, reviewerName: reviewer.reviewerName, feedback, decision: request.decision, overallComments: request.overallComments, qualityScore: request.qualityScore, attachments: [], // Handle file uploads separately references: [] }; review.reviewRounds.push(reviewRound); review.currentRound = request.roundNumber; // Update review status based on decision switch (request.decision) { case 'approve': review.status = 'approved'; review.completedAt = new Date(); break; case 'reject': review.status = 'rejected'; review.completedAt = new Date(); break; case 'request_revision': review.status = 'revision_requested'; break; } // Update reviewer assignment status reviewer.status = 'completed'; const updatedReview = await review.save(); // Update reviewer metrics await this.updateReviewerMetrics(reviewer.reviewerId, reviewRound); // Handle next steps based on decision await this.handleReviewDecision(updatedReview, request.decision); logger.info(`Feedback submitted successfully for review ${request.reviewId}`); return updatedReview.toJSON(); } catch (error) { logger.error('Error submitting feedback:', error); throw new Error(`Failed to submit feedback: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Update review status */ async updateReviewStatus(request) { try { const review = await DocumentReviewModel.findById(request.reviewId); if (!review) { throw new Error('Review not found'); } const oldStatus = review.status; review.status = request.status; if (request.status === 'completed' || request.status === 'approved') { review.completedAt = new Date(); } const updatedReview = await review.save(); logger.info(`Review status updated from ${oldStatus} to ${request.status}`); return updatedReview.toJSON(); } catch (error) { logger.error('Error updating review status:', error); throw new Error(`Failed to update review status: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get review by ID */ async getReview(reviewId) { try { const review = await DocumentReviewModel.findById(reviewId); return review ? review.toJSON() : null; } catch (error) { logger.error('Error getting review:', error); throw new Error(`Failed to get review: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Search reviews with filters */ async searchReviews(params) { try { const filter = {}; if (params.status?.length) { filter.status = { $in: params.status }; } if (params.priority?.length) { filter.priority = { $in: params.priority }; } if (params.documentType?.length) { filter.documentType = { $in: params.documentType }; } if (params.projectId) { filter.projectId = params.projectId; } if (params.reviewerId) { filter['assignedReviewers.reviewerId'] = params.reviewerId; } if (params.dateFrom || params.dateTo) { filter.createdAt = {}; if (params.dateFrom) { filter.createdAt.$gte = params.dateFrom; } if (params.dateTo) { filter.createdAt.$lte = params.dateTo; } } const sortField = params.sortBy || 'createdAt'; const sortOrder = params.sortOrder === 'asc' ? 1 : -1; const limit = params.limit || 20; const offset = params.offset || 0; const [reviews, total] = await Promise.all([ DocumentReviewModel.find(filter) .sort({ [sortField]: sortOrder }) .limit(limit) .skip(offset) .exec(), DocumentReviewModel.countDocuments(filter) ]); return { reviews: reviews.map(r => r.toJSON()), total }; } catch (error) { logger.error('Error searching reviews:', error); throw new Error(`Failed to search reviews: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get reviewer dashboard data */ async getReviewerDashboard(reviewerId) { try { const reviewer = await ReviewerProfileModel.findOne({ userId: reviewerId }); if (!reviewer) { throw new Error('Reviewer not found'); } // Get reviews assigned to this reviewer const assignedReviews = await DocumentReviewModel.find({ 'assignedReviewers.reviewerId': reviewerId, status: { $in: ['assigned', 'in_review', 'feedback_provided'] } }); const pendingReviews = assignedReviews.filter(r => r.status === 'assigned' || r.status === 'in_review'); const completedReviews = await DocumentReviewModel.find({ 'assignedReviewers.reviewerId': reviewerId, status: { $in: ['approved', 'rejected', 'completed'] } }).limit(10).sort({ completedAt: -1 }); const overdueReviews = assignedReviews.filter(r => r.dueDate && new Date() > r.dueDate && !['approved', 'rejected', 'completed'].includes(r.status)); const dashboard = { userId: reviewerId, assignedReviews: assignedReviews.map(r => r.toJSON()), pendingReviews: pendingReviews.map(r => r.toJSON()), completedReviews: completedReviews.map(r => r.toJSON()), overdueReviews: overdueReviews.map(r => r.toJSON()), personalMetrics: reviewer.metrics, currentWorkload: assignedReviews.length, upcomingDeadlines: assignedReviews .filter(r => r.dueDate) .map(r => r.dueDate) .sort((a, b) => a.getTime() - b.getTime()) .slice(0, 5), unreadNotifications: [], // Would be populated from notification service quickActions: this.generateQuickActions(assignedReviews.map(r => r.toJSON())) }; return dashboard; } catch (error) { logger.error('Error getting reviewer dashboard:', error); throw new Error(`Failed to get reviewer dashboard: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get review analytics */ async getReviewAnalytics(startDate, endDate) { try { const filter = { createdAt: { $gte: startDate, $lte: endDate } }; const reviews = await DocumentReviewModel.find(filter); const completedReviews = reviews.filter(r => ['approved', 'rejected', 'completed'].includes(r.status)); const overdueReviews = reviews.filter(r => r.dueDate && new Date() > r.dueDate && !['approved', 'rejected', 'completed'].includes(r.status)); // Calculate metrics const totalReviewTime = completedReviews.reduce((total, review) => { if (review.completedAt && review.createdAt) { return total + (review.completedAt.getTime() - review.createdAt.getTime()); } return total; }, 0); const averageReviewTime = completedReviews.length > 0 ? totalReviewTime / completedReviews.length / (1000 * 60 * 60) // Convert to hours : 0; const onTimeReviews = completedReviews.filter(r => !r.dueDate || (r.completedAt && r.completedAt <= r.dueDate)); const onTimeCompletionRate = completedReviews.length > 0 ? (onTimeReviews.length / completedReviews.length) * 100 : 0; const qualityScores = completedReviews .flatMap(r => r.reviewRounds) .map(round => round.qualityScore) .filter(score => score !== undefined); const averageQualityScore = qualityScores.length > 0 ? qualityScores.reduce((sum, score) => sum + score, 0) / qualityScores.length : 0; // Document type breakdown const documentTypeBreakdown = {}; reviews.forEach(review => { documentTypeBreakdown[review.documentType] = (documentTypeBreakdown[review.documentType] || 0) + 1; }); const analytics = { period: { start: startDate, end: endDate }, totalReviews: reviews.length, completedReviews: completedReviews.length, pendingReviews: reviews.length - completedReviews.length, overdueReviews: overdueReviews.length, averageReviewTime, onTimeCompletionRate, averageQualityScore, activeReviewers: 0, // Would be calculated from reviewer activity reviewerUtilization: 0, // Would be calculated from reviewer workload topPerformers: [], // Would be populated from reviewer metrics documentTypeBreakdown, qualityTrends: [], // Would be calculated from historical data commonIssues: [], // Would be analyzed from feedback patterns improvementAreas: [] // Would be derived from analytics }; return analytics; } catch (error) { logger.error('Error getting review analytics:', error); throw new Error(`Failed to get review analytics: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Run automated checks on a document */ async runAutomatedChecks(documentPath, documentType) { const checks = []; try { // File existence check checks.push({ checkType: 'file_existence', checkName: 'Document File Exists', status: 'passed', // Would actually check file system details: 'Document file exists and is accessible', executedAt: new Date() }); // Basic format validation checks.push({ checkType: 'format_validation', checkName: 'Document Format Validation', status: 'passed', details: 'Document format is valid', executedAt: new Date() }); // Content length check checks.push({ checkType: 'content_validation', checkName: 'Minimum Content Length', status: 'passed', score: 85, details: 'Document meets minimum content requirements', recommendations: ['Consider adding more detailed examples'], executedAt: new Date() }); // PMBOK compliance check (if applicable) if (documentType.includes('pmbok') || documentType.includes('project')) { checks.push({ checkType: 'compliance_check', checkName: 'PMBOK Compliance', status: 'warning', score: 75, details: 'Document partially complies with PMBOK standards', recommendations: [ 'Add stakeholder identification section', 'Include risk management considerations' ], executedAt: new Date() }); } } catch (error) { logger.error('Error running automated checks:', error); checks.push({ checkType: 'system_error', checkName: 'Automated Check System', status: 'failed', details: `Error running automated checks: ${error instanceof Error ? error.message : 'Unknown error'}`, executedAt: new Date() }); } return checks; } /** * Find applicable workflow for document type */ async findApplicableWorkflow(documentType, workflowId) { try { if (workflowId) { return await ReviewWorkflowConfigModel.findById(workflowId); } return await ReviewWorkflowConfigModel.findOne({ documentTypes: documentType, isActive: true }); } catch (error) { logger.error('Error finding applicable workflow:', error); return null; } } /** * Calculate due date based on business days */ calculateDueDate(days) { const date = new Date(); let addedDays = 0; while (addedDays < days) { date.setDate(date.getDate() + 1); // Skip weekends (Saturday = 6, Sunday = 0) if (date.getDay() !== 0 && date.getDay() !== 6) { addedDays++; } } return date; } /** * Calculate initial compliance score from automated checks */ calculateInitialComplianceScore(checks) { if (checks.length === 0) return 0; const scores = checks .filter(check => check.score !== undefined) .map(check => check.score); if (scores.length === 0) { // If no scores, base on pass/fail status const passedChecks = checks.filter(check => check.status === 'passed').length; return (passedChecks / checks.length) * 100; } return scores.reduce((sum, score) => sum + score, 0) / scores.length; } /** * Auto-assign reviewers based on workflow and availability */ async autoAssignReviewers(review, workflow, requiredRoles, specificReviewers) { try { const rolesToAssign = requiredRoles || workflow.requiredRoles; for (const role of rolesToAssign) { let reviewer = null; // Try to assign specific reviewer if provided if (specificReviewers?.length) { reviewer = await ReviewerProfileModel.findOne({ userId: { $in: specificReviewers }, roles: role, isActive: true }); } // If no specific reviewer found, find best available reviewer if (!reviewer) { reviewer = await this.findBestAvailableReviewer(role, review.documentType); } if (reviewer) { const assignment = { reviewerId: reviewer.userId, reviewerName: reviewer.name, reviewerEmail: reviewer.email, role, expertise: reviewer.expertise, assignedAt: new Date(), status: 'assigned', estimatedHours: workflow.reviewStages[0]?.estimatedHours || 8 }; review.assignedReviewers.push(assignment); if (!review.currentReviewer) { review.currentReviewer = reviewer.userId; review.status = 'assigned'; } } } await review.save(); } catch (error) { logger.error('Error auto-assigning reviewers:', error); } } /** * Find best available reviewer for a role and document type */ async findBestAvailableReviewer(role, documentType) { try { const reviewers = await ReviewerProfileModel.find({ roles: role, isActive: true, $or: [ { 'preferences.preferredDocumentTypes': documentType }, { 'preferences.preferredDocumentTypes': { $size: 0 } } ] }).sort({ 'metrics.averageQualityScore': -1, 'metrics.onTimeCompletionRate': -1 }); // Find reviewer with capacity for (const reviewerDoc of reviewers) { const reviewer = reviewerDoc; if (typeof reviewer.canTakeReview === 'function' && reviewer.canTakeReview()) { return reviewer; } } return null; } catch (error) { logger.error('Error finding best available reviewer:', error); return null; } } /** * Send review assignment notification */ async sendReviewAssignmentNotification(review, assignment) { try { // This would integrate with a notification service logger.info(`Sending assignment notification to ${assignment.reviewerEmail} for review ${review.id}`); // Placeholder for notification logic // await notificationService.send({ // to: assignment.reviewerEmail, // subject: `New Document Review Assignment: ${review.documentName}`, // template: 'review_assignment', // data: { review, assignment } // }); } catch (error) { logger.error('Error sending assignment notification:', error); } } /** * Update reviewer metrics after completing a review */ async updateReviewerMetrics(reviewerId, reviewRound) { try { const reviewerDoc = await ReviewerProfileModel.findOne({ userId: reviewerId }); if (!reviewerDoc) return; const reviewer = reviewerDoc; const reviewTime = reviewRound.completedAt && reviewRound.startedAt ? (reviewRound.completedAt.getTime() - reviewRound.startedAt.getTime()) / (1000 * 60 * 60) : 8; // Default 8 hours if time not tracked const qualityScore = reviewRound.qualityScore || 75; const onTime = true; // Would check against due date const feedbackQuality = this.assessFeedbackQuality(reviewRound.feedback); const thoroughness = this.assessThoroughness(reviewRound.feedback); if (typeof reviewer.updateMetrics === 'function') { await reviewer.updateMetrics({ reviewTime, qualityScore, onTime, feedbackQuality, thoroughness }); } } catch (error) { logger.error('Error updating reviewer metrics:', error); } } /** * Handle review decision and next steps */ async handleReviewDecision(review, decision) { try { switch (decision) { case 'approve': // Document is approved, notify stakeholders await this.notifyReviewCompletion(review, 'approved'); break; case 'reject': // Document is rejected, notify author await this.notifyReviewCompletion(review, 'rejected'); break; case 'request_revision': // Request revision, notify author with feedback await this.notifyRevisionRequest(review); break; } } catch (error) { logger.error('Error handling review decision:', error); } } /** * Assess feedback quality */ assessFeedbackQuality(feedback) { if (feedback.length === 0) return 50; let score = 0; let totalWeight = 0; feedback.forEach(f => { let itemScore = 60; // Base score // Add points for detailed description if (f.description.length > 50) itemScore += 10; if (f.description.length > 100) itemScore += 10; // Add points for suggestions if (f.suggestion) itemScore += 15; // Add points for specific location if (f.section || f.lineNumber) itemScore += 10; // Weight by severity const weight = f.severity === 'critical' ? 3 : f.severity === 'major' ? 2 : 1; score += itemScore * weight; totalWeight += weight; }); return totalWeight > 0 ? Math.min(score / totalWeight, 100) : 75; } /** * Assess thoroughness of review */ assessThoroughness(feedback) { const feedbackTypes = new Set(feedback.map(f => f.type)); const severityLevels = new Set(feedback.map(f => f.severity)); let score = 50; // Base score // Points for covering different feedback types score += feedbackTypes.size * 5; // Points for identifying different severity levels score += severityLevels.size * 5; // Points for number of feedback items (diminishing returns) score += Math.min(feedback.length * 2, 20); return Math.min(score, 100); } /** * Generate quick actions for dashboard */ generateQuickActions(reviews) { const actions = []; const pendingReviews = reviews.filter(r => r.status === 'assigned' || r.status === 'in_review'); const overdueReviews = reviews.filter(r => r.dueDate && new Date() > r.dueDate && !['approved', 'rejected', 'completed'].includes(r.status)); if (overdueReviews.length > 0) { actions.push({ id: 'overdue_reviews', type: 'review', title: `${overdueReviews.length} Overdue Reviews`, description: 'Reviews that are past their due date', url: '/reviews?filter=overdue', priority: 'high' }); } if (pendingReviews.length > 0) { actions.push({ id: 'pending_reviews', type: 'review', title: `${pendingReviews.length} Pending Reviews`, description: 'Reviews waiting for your feedback', url: '/reviews?filter=pending', priority: 'medium' }); } return actions; } /** * Notify review completion */ async notifyReviewCompletion(review, status) { logger.info(`Notifying review completion: ${review.id} - ${status}`); // Placeholder for notification logic } /** * Notify revision request */ async notifyRevisionRequest(review) { logger.info(`Notifying revision request: ${review.id}`); // Placeholder for notification logic } } //# sourceMappingURL=ReviewService.js.map