diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index f02d1c1..67351eb 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -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 { diff --git a/backend/src/services/csvExportService.ts b/backend/src/services/csvExportService.ts new file mode 100644 index 0000000..ded0306 --- /dev/null +++ b/backend/src/services/csvExportService.ts @@ -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; \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bede9ae..e330430 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import Analytics from './components/Analytics'; import UploadMonitoringDashboard from './components/UploadMonitoringDashboard'; import LogoutButton from './components/LogoutButton'; import { documentService, GCSErrorHandler, GCSError } from './services/documentService'; +import { adminService } from './services/adminService'; // import { debugAuth, testAPIAuth } from './utils/authDebug'; import { @@ -34,6 +35,9 @@ const Dashboard: React.FC = () => { const [searchTerm, setSearchTerm] = useState(''); const [activeTab, setActiveTab] = useState<'overview' | 'documents' | 'upload' | 'analytics' | 'monitoring'>('overview'); + // Check if user is admin + const isAdmin = adminService.isAdmin(user?.email); + // Map backend status to frontend status const mapBackendStatus = (backendStatus: string): string => { switch (backendStatus) { @@ -466,30 +470,34 @@ const Dashboard: React.FC = () => { Upload - - + {isAdmin && ( + <> + + + + )} @@ -675,13 +683,32 @@ const Dashboard: React.FC = () => { )} - {activeTab === 'analytics' && ( + {activeTab === 'analytics' && isAdmin && ( )} - {activeTab === 'monitoring' && ( + {activeTab === 'monitoring' && isAdmin && ( )} + + {/* Redirect non-admin users away from admin tabs */} + {activeTab === 'analytics' && !isAdmin && ( +
+
+

Access Denied

+

You don't have permission to view analytics.

+
+
+ )} + + {activeTab === 'monitoring' && !isAdmin && ( +
+
+

Access Denied

+

You don't have permission to view monitoring.

+
+
+ )} diff --git a/frontend/src/components/CIMReviewTemplate.tsx b/frontend/src/components/CIMReviewTemplate.tsx index 502d5e3..59a9f96 100644 --- a/frontend/src/components/CIMReviewTemplate.tsx +++ b/frontend/src/components/CIMReviewTemplate.tsx @@ -190,6 +190,9 @@ const CIMReviewTemplate: React.FC = ({ }); const [activeSection, setActiveSection] = useState('deal-overview'); + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + const [lastSaved, setLastSaved] = useState(null); + const [autoSaveTimeout, setAutoSaveTimeout] = useState(null); // Merge cimReviewData with existing data when it changes useEffect(() => { @@ -243,10 +246,56 @@ const CIMReviewTemplate: React.FC = ({ })); }; - const handleSave = () => { - onSave?.(data); + const handleSave = async () => { + if (readOnly) return; + + try { + setSaveStatus('saving'); + await onSave?.(data); + setSaveStatus('saved'); + setLastSaved(new Date()); + + // Clear saved status after 3 seconds + setTimeout(() => { + setSaveStatus('idle'); + }, 3000); + } catch (error) { + console.error('Save failed:', error); + setSaveStatus('error'); + + // Clear error status after 5 seconds + setTimeout(() => { + setSaveStatus('idle'); + }, 5000); + } }; + // Auto-save functionality + const triggerAutoSave = () => { + if (readOnly) return; + + // Clear existing timeout + if (autoSaveTimeout) { + clearTimeout(autoSaveTimeout); + } + + // Set new timeout for auto-save (2 seconds after last change) + const timeout = setTimeout(() => { + handleSave(); + }, 2000); + + setAutoSaveTimeout(timeout); + }; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (autoSaveTimeout) { + clearTimeout(autoSaveTimeout); + } + }; + }, [autoSaveTimeout]); + const handleExport = () => { onExport?.(data); }; @@ -294,7 +343,10 @@ const CIMReviewTemplate: React.FC = ({ {type === 'textarea' ? (