feat: Implement comprehensive CIM Review editing and admin features
- Add inline editing for CIM Review template with auto-save functionality - Implement CSV export with comprehensive data formatting - Add automated file naming (YYYYMMDD_CompanyName_CIM_Review.pdf/csv) - Create admin role system for jpressnell@bluepointcapital.com - Hide analytics/monitoring tabs from non-admin users - Add email sharing functionality via mailto links - Implement save status indicators and last saved timestamps - Add backend endpoints for CIM Review save/load and CSV export - Create admin service for role-based access control - Update document viewer with save/export handlers - Add proper error handling and user feedback Backup: Live version preserved in backup-live-version-e0a37bf-clean branch
This commit is contained in:
@@ -90,6 +90,116 @@ router.get('/:id', validateUUID('id'), documentController.getDocument);
|
||||
router.get('/:id/progress', validateUUID('id'), documentController.getDocumentProgress);
|
||||
router.delete('/:id', validateUUID('id'), documentController.deleteDocument);
|
||||
|
||||
// CIM Review data endpoints
|
||||
router.post('/:id/review', validateUUID('id'), async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.uid;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: 'User not authenticated',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const reviewData = req.body;
|
||||
|
||||
if (!reviewData) {
|
||||
return res.status(400).json({
|
||||
error: 'Review data is required',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Check if document exists and user has access
|
||||
const document = await DocumentModel.findById(id);
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
error: 'Document not found',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
if (document.user_id !== userId) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Update the document with new analysis data
|
||||
await DocumentModel.updateAnalysisResults(id, reviewData);
|
||||
|
||||
logger.info('CIM Review data saved successfully', {
|
||||
documentId: id,
|
||||
userId,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'CIM Review data saved successfully',
|
||||
correlationId: req.correlationId || undefined
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to save CIM Review data', {
|
||||
error,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: 'Failed to save CIM Review data',
|
||||
correlationId: req.correlationId || undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id/review', validateUUID('id'), async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.uid;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: 'User not authenticated',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if document exists and user has access
|
||||
const document = await DocumentModel.findById(id);
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
error: 'Document not found',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
if (document.user_id !== userId) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
reviewData: document.analysis_data || {},
|
||||
correlationId: req.correlationId || undefined
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to get CIM Review data', {
|
||||
error,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: 'Failed to get CIM Review data',
|
||||
correlationId: req.correlationId || undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Download endpoint (keeping this)
|
||||
router.get('/:id/download', validateUUID('id'), async (req, res) => {
|
||||
try {
|
||||
@@ -144,8 +254,17 @@ router.get('/:id/download', validateUUID('id'), async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Generate standardized filename
|
||||
const companyName = document.analysis_data?.dealOverview?.targetCompanyName || 'Unknown';
|
||||
const date = new Date().toISOString().split('T')[0].replace(/-/g, ''); // YYYYMMDD
|
||||
const sanitizedCompanyName = companyName
|
||||
.replace(/[^a-zA-Z0-9\s]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.toUpperCase();
|
||||
const filename = `${date}_${sanitizedCompanyName}_CIM_Review.pdf`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${document.original_file_name.replace(/\.[^/.]+$/, '')}_cim_review.pdf"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('x-correlation-id', req.correlationId || 'unknown');
|
||||
return res.send(pdfBuffer);
|
||||
|
||||
@@ -172,6 +291,84 @@ router.get('/:id/download', validateUUID('id'), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// CSV Export endpoint
|
||||
router.get('/:id/export-csv', validateUUID('id'), async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.uid;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: 'User not authenticated',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
error: 'Document ID is required',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
const document = await DocumentModel.findById(id);
|
||||
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
error: 'Document not found',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
if (document.user_id !== userId) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Check if document has analysis data
|
||||
if (!document.analysis_data) {
|
||||
return res.status(404).json({
|
||||
error: 'No analysis data available for CSV export',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Generate CSV
|
||||
try {
|
||||
const { default: CSVExportService } = await import('../services/csvExportService');
|
||||
const companyName = document.analysis_data?.dealOverview?.targetCompanyName || 'Unknown';
|
||||
const csvContent = CSVExportService.generateCIMReviewCSV(document.analysis_data, companyName);
|
||||
const filename = CSVExportService.generateCSVFilename(companyName);
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('x-correlation-id', req.correlationId || 'unknown');
|
||||
return res.send(csvContent);
|
||||
|
||||
} catch (csvError) {
|
||||
logger.error('CSV generation failed', {
|
||||
error: csvError,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: 'CSV generation failed',
|
||||
correlationId: req.correlationId || undefined
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('CSV export failed', {
|
||||
error,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: 'CSV export failed',
|
||||
correlationId: req.correlationId || undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ONLY OPTIMIZED AGENTIC RAG PROCESSING ROUTE - All other processing routes disabled
|
||||
router.post('/:id/process-optimized-agentic-rag', validateUUID('id'), async (req, res) => {
|
||||
try {
|
||||
|
||||
242
backend/src/services/csvExportService.ts
Normal file
242
backend/src/services/csvExportService.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface CIMReviewData {
|
||||
dealOverview: {
|
||||
targetCompanyName: string;
|
||||
industrySector: string;
|
||||
geography: string;
|
||||
dealSource: string;
|
||||
transactionType: string;
|
||||
dateCIMReceived: string;
|
||||
dateReviewed: string;
|
||||
reviewers: string;
|
||||
cimPageCount: string;
|
||||
statedReasonForSale: string;
|
||||
employeeCount: string;
|
||||
};
|
||||
businessDescription: {
|
||||
coreOperationsSummary: string;
|
||||
keyProductsServices: string;
|
||||
uniqueValueProposition: string;
|
||||
customerBaseOverview: {
|
||||
keyCustomerSegments: string;
|
||||
customerConcentrationRisk: string;
|
||||
typicalContractLength: string;
|
||||
};
|
||||
keySupplierOverview: {
|
||||
dependenceConcentrationRisk: string;
|
||||
};
|
||||
};
|
||||
marketIndustryAnalysis: {
|
||||
estimatedMarketSize: string;
|
||||
estimatedMarketGrowthRate: string;
|
||||
keyIndustryTrends: string;
|
||||
competitiveLandscape: {
|
||||
keyCompetitors: string;
|
||||
targetMarketPosition: string;
|
||||
basisOfCompetition: string;
|
||||
};
|
||||
barriersToEntry: string;
|
||||
};
|
||||
financialSummary: {
|
||||
financials: {
|
||||
fy3: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
fy2: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
fy1: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
ltm: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
};
|
||||
qualityOfEarnings: string;
|
||||
revenueGrowthDrivers: string;
|
||||
marginStabilityAnalysis: string;
|
||||
capitalExpenditures: string;
|
||||
workingCapitalIntensity: string;
|
||||
freeCashFlowQuality: string;
|
||||
};
|
||||
managementTeamOverview: {
|
||||
keyLeaders: string;
|
||||
managementQualityAssessment: string;
|
||||
postTransactionIntentions: string;
|
||||
organizationalStructure: string;
|
||||
};
|
||||
preliminaryInvestmentThesis: {
|
||||
keyAttractions: string;
|
||||
potentialRisks: string;
|
||||
valueCreationLevers: string;
|
||||
alignmentWithFundStrategy: string;
|
||||
};
|
||||
keyQuestionsNextSteps: {
|
||||
criticalQuestions: string;
|
||||
missingInformation: string;
|
||||
preliminaryRecommendation: string;
|
||||
rationaleForRecommendation: string;
|
||||
proposedNextSteps: string;
|
||||
};
|
||||
}
|
||||
|
||||
class CSVExportService {
|
||||
/**
|
||||
* Convert CIM Review data to CSV format
|
||||
*/
|
||||
static generateCIMReviewCSV(reviewData: CIMReviewData, companyName: string = 'Unknown'): string {
|
||||
try {
|
||||
const csvRows: string[] = [];
|
||||
|
||||
// Add header
|
||||
csvRows.push('BPCP CIM Review Summary');
|
||||
csvRows.push(`Company: ${companyName}`);
|
||||
csvRows.push(`Generated: ${new Date().toISOString()}`);
|
||||
csvRows.push(''); // Empty row
|
||||
|
||||
// Deal Overview Section
|
||||
csvRows.push('DEAL OVERVIEW');
|
||||
csvRows.push('Field,Value');
|
||||
if (reviewData.dealOverview) {
|
||||
Object.entries(reviewData.dealOverview).forEach(([key, value]) => {
|
||||
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
|
||||
});
|
||||
}
|
||||
csvRows.push(''); // Empty row
|
||||
|
||||
// Business Description Section
|
||||
csvRows.push('BUSINESS DESCRIPTION');
|
||||
csvRows.push('Field,Value');
|
||||
if (reviewData.businessDescription) {
|
||||
Object.entries(reviewData.businessDescription).forEach(([key, value]) => {
|
||||
if (typeof value === 'object') {
|
||||
// Handle nested objects like customerBaseOverview
|
||||
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
|
||||
csvRows.push(`${this.formatFieldName(key)} - ${this.formatFieldName(nestedKey)},${this.escapeCSVValue(nestedValue)}`);
|
||||
});
|
||||
} else {
|
||||
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
csvRows.push(''); // Empty row
|
||||
|
||||
// Market & Industry Analysis Section
|
||||
csvRows.push('MARKET & INDUSTRY ANALYSIS');
|
||||
csvRows.push('Field,Value');
|
||||
if (reviewData.marketIndustryAnalysis) {
|
||||
Object.entries(reviewData.marketIndustryAnalysis).forEach(([key, value]) => {
|
||||
if (typeof value === 'object') {
|
||||
// Handle nested objects like competitiveLandscape
|
||||
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
|
||||
csvRows.push(`${this.formatFieldName(key)} - ${this.formatFieldName(nestedKey)},${this.escapeCSVValue(nestedValue)}`);
|
||||
});
|
||||
} else {
|
||||
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
csvRows.push(''); // Empty row
|
||||
|
||||
// Financial Summary Section
|
||||
csvRows.push('FINANCIAL SUMMARY');
|
||||
csvRows.push('Period,Revenue,Revenue Growth,Gross Profit,Gross Margin,EBITDA,EBITDA Margin');
|
||||
if (reviewData.financialSummary?.financials) {
|
||||
Object.entries(reviewData.financialSummary.financials).forEach(([period, financials]) => {
|
||||
csvRows.push(`${period.toUpperCase()},${this.escapeCSVValue(financials.revenue)},${this.escapeCSVValue(financials.revenueGrowth)},${this.escapeCSVValue(financials.grossProfit)},${this.escapeCSVValue(financials.grossMargin)},${this.escapeCSVValue(financials.ebitda)},${this.escapeCSVValue(financials.ebitdaMargin)}`);
|
||||
});
|
||||
}
|
||||
csvRows.push(''); // Empty row
|
||||
|
||||
// Additional Financial Metrics
|
||||
csvRows.push('ADDITIONAL FINANCIAL METRICS');
|
||||
csvRows.push('Field,Value');
|
||||
if (reviewData.financialSummary) {
|
||||
const additionalMetrics = [
|
||||
'qualityOfEarnings',
|
||||
'revenueGrowthDrivers',
|
||||
'marginStabilityAnalysis',
|
||||
'capitalExpenditures',
|
||||
'workingCapitalIntensity',
|
||||
'freeCashFlowQuality'
|
||||
];
|
||||
|
||||
additionalMetrics.forEach(metric => {
|
||||
if (reviewData.financialSummary[metric]) {
|
||||
csvRows.push(`${this.formatFieldName(metric)},${this.escapeCSVValue(reviewData.financialSummary[metric])}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
csvRows.push(''); // Empty row
|
||||
|
||||
// Management Team Overview Section
|
||||
csvRows.push('MANAGEMENT TEAM OVERVIEW');
|
||||
csvRows.push('Field,Value');
|
||||
if (reviewData.managementTeamOverview) {
|
||||
Object.entries(reviewData.managementTeamOverview).forEach(([key, value]) => {
|
||||
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
|
||||
});
|
||||
}
|
||||
csvRows.push(''); // Empty row
|
||||
|
||||
// Preliminary Investment Thesis Section
|
||||
csvRows.push('PRELIMINARY INVESTMENT THESIS');
|
||||
csvRows.push('Field,Value');
|
||||
if (reviewData.preliminaryInvestmentThesis) {
|
||||
Object.entries(reviewData.preliminaryInvestmentThesis).forEach(([key, value]) => {
|
||||
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
|
||||
});
|
||||
}
|
||||
csvRows.push(''); // Empty row
|
||||
|
||||
// Key Questions & Next Steps Section
|
||||
csvRows.push('KEY QUESTIONS & NEXT STEPS');
|
||||
csvRows.push('Field,Value');
|
||||
if (reviewData.keyQuestionsNextSteps) {
|
||||
Object.entries(reviewData.keyQuestionsNextSteps).forEach(([key, value]) => {
|
||||
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
|
||||
});
|
||||
}
|
||||
|
||||
return csvRows.join('\n');
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate CSV from CIM Review data', { error });
|
||||
throw new Error('Failed to generate CSV export');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field names for better readability
|
||||
*/
|
||||
private static formatFieldName(fieldName: string): string {
|
||||
return fieldName
|
||||
.replace(/([A-Z])/g, ' $1') // Add space before capital letters
|
||||
.replace(/^./, str => str.toUpperCase()) // Capitalize first letter
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape CSV values to handle commas, quotes, and newlines
|
||||
*/
|
||||
private static escapeCSVValue(value: string): string {
|
||||
if (!value) return '';
|
||||
|
||||
// Replace newlines with spaces and trim
|
||||
const cleanValue = value.replace(/\n/g, ' ').replace(/\r/g, ' ').trim();
|
||||
|
||||
// If value contains comma, quote, or newline, wrap in quotes and escape internal quotes
|
||||
if (cleanValue.includes(',') || cleanValue.includes('"') || cleanValue.includes('\n')) {
|
||||
return `"${cleanValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return cleanValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate standardized filename for CSV export
|
||||
*/
|
||||
static generateCSVFilename(companyName: string): string {
|
||||
const date = new Date().toISOString().split('T')[0].replace(/-/g, ''); // YYYYMMDD
|
||||
const sanitizedCompanyName = companyName
|
||||
.replace(/[^a-zA-Z0-9\s]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.toUpperCase();
|
||||
|
||||
return `${date}_${sanitizedCompanyName}_CIM_Data.csv`;
|
||||
}
|
||||
}
|
||||
|
||||
export default CSVExportService;
|
||||
Reference in New Issue
Block a user