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
JavaScript
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