diff --git a/backend/src/document-processor/controllers/document-processor.controller.ts b/backend/src/document-processor/controllers/document-processor.controller.ts index 823f6f0f..8a1b7c69 100644 --- a/backend/src/document-processor/controllers/document-processor.controller.ts +++ b/backend/src/document-processor/controllers/document-processor.controller.ts @@ -25,6 +25,7 @@ import { RequestWithUser } from '../../auth/auth.middleware'; import { Readable } from 'stream'; import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; import { ConfigService } from '@nestjs/config'; +import { ProcessingStatus } from '../../reports/models/report.model'; @Controller('document-processor') export class DocumentProcessorController { @@ -38,7 +39,7 @@ export class DocumentProcessorController { @Post('upload') @UseInterceptors(FileInterceptor('file')) - async processDocument( + async uploadAndProcessReport( @UploadedFile() file: Express.Multer.File, @Body('userId') userId: string, @Body('debug') debug?: string, @@ -114,12 +115,12 @@ export class DocumentProcessorController { } @Post('process-file') - async processFileFromPath( - @Body('filePath') filePath: string, + async processReport( + @Body('reportId') reportId: string, @Req() request: RequestWithUser, - ): Promise { - if (!filePath) { - throw new BadRequestException('No filePath provided'); + ): Promise { + if (!reportId) { + throw new BadRequestException('No reportId provided'); } // Extract userId from the request (attached by auth middleware) @@ -128,25 +129,74 @@ export class DocumentProcessorController { throw new UnauthorizedException('User ID not found in request'); } - this.logger.log(`Processing document from file path: ${filePath}`); + this.logger.log(`Queueing document for processing, report ID: ${reportId}`); try { - // Fetch the associated report record from DynamoDB - const report = await this.reportsService.findByFilePath(filePath, userId); + // Fetch the associated report record from DynamoDB using findOne method + const report = await this.reportsService.findOne(reportId, userId); if (!report) { - throw new NotFoundException(`Report with filePath ${filePath} not found`); + throw new NotFoundException(`Report with ID ${reportId} not found`); + } + + // Make sure we have a filePath to retrieve the file + if (!report.filePath) { + throw new BadRequestException(`Report with ID ${reportId} has no associated file`); } + // Update report status to IN_PROGRESS before starting async processing + report.processingStatus = ProcessingStatus.IN_PROGRESS; + report.updatedAt = new Date().toISOString(); + await this.reportsService.updateReport(report); + + // Start async processing in background + this.processReportAsync(reportId, userId, report.filePath).catch(error => { + this.logger.error(`Async processing failed for report ${reportId}: ${error.message}`); + }); + + return { + success: true, + reportId: report.id, + status: ProcessingStatus.IN_PROGRESS, + message: 'Document processing started. Check the report status to know when it completes.', + }; + } catch (error: unknown) { + this.logger.error( + `Error queueing document for report ID ${reportId}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + throw error; + } + } + + /** + * Processes a report file asynchronously + * @param reportId - ID of the report to process + * @param userId - ID of the user who owns the report + * @param filePath - S3 path to the file + */ + private async processReportAsync( + reportId: string, + userId: string, + filePath: string, + ): Promise { + try { + this.logger.log(`Started async processing for report: ${reportId}`); + // Get the file from S3 const fileBuffer = await this.getFileFromS3(filePath); // Process the document const result = await this.documentProcessorService.processDocument(fileBuffer, userId); + // Fetch the report again to ensure we have the latest version + const report = await this.reportsService.findOne(reportId, userId); + if (!report) { + throw new Error(`Report ${reportId} not found during async processing`); + } + // Update the report with analysis results report.title = result.analysis.title || 'Untitled Report'; report.category = result.analysis.category || 'general'; - report.isProcessed = true; + report.processingStatus = ProcessingStatus.PROCESSED; // Extract lab values report.labValues = result.analysis.labValues || []; @@ -162,13 +212,26 @@ export class DocumentProcessorController { // Update the report in DynamoDB await this.reportsService.updateReport(report); - return { - success: true, - reportId: report.id, - }; - } catch (error: unknown) { + this.logger.log(`Completed async processing for report: ${reportId}`); + } catch (error) { + // If processing fails, update the report status to indicate failure + try { + const report = await this.reportsService.findOne(reportId, userId); + if (report) { + report.processingStatus = ProcessingStatus.FAILED; + report.updatedAt = new Date().toISOString(); + await this.reportsService.updateReport(report); + } + } catch (updateError: unknown) { + this.logger.error( + `Failed to update report status after processing error: ${ + updateError instanceof Error ? updateError.message : 'Unknown error' + }`, + ); + } + this.logger.error( - `Error processing document from path ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Error during async processing for report ${reportId}: ${error instanceof Error ? error.message : 'Unknown error'}`, ); throw error; } @@ -606,4 +669,39 @@ export class DocumentProcessorController { res.type('text/html').send(html); } + + @Get('report-status/:reportId') + async getReportStatus(@Req() request: RequestWithUser): Promise { + // Get reportId from path parameter + const reportId = request.params.reportId; + + if (!reportId) { + throw new BadRequestException('No reportId provided'); + } + + // Extract userId from the request (attached by auth middleware) + const userId = request.user?.sub; + if (!userId) { + throw new UnauthorizedException('User ID not found in request'); + } + + try { + // Fetch the associated report record from DynamoDB + const report = await this.reportsService.findOne(reportId, userId); + if (!report) { + throw new NotFoundException(`Report with ID ${reportId} not found`); + } + + return { + reportId: report.id, + status: report.processingStatus, + isComplete: report.processingStatus === ProcessingStatus.PROCESSED, + }; + } catch (error: unknown) { + this.logger.error( + `Error fetching report status for ${reportId}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + throw error; + } + } } diff --git a/backend/src/iac/backend-stack.ts b/backend/src/iac/backend-stack.ts index 15476159..1d43cdfd 100644 --- a/backend/src/iac/backend-stack.ts +++ b/backend/src/iac/backend-stack.ts @@ -405,7 +405,6 @@ export class BackendStack extends cdk.Stack { const integrationOptions = { connectionType: apigateway.ConnectionType.VPC_LINK, vpcLink: vpcLink, - timeout: cdk.Duration.seconds(300), // Adding 5-minute timeout (300 seconds) }; const getDocsIntegration = new apigateway.Integration({ diff --git a/backend/src/reports/models/report.model.ts b/backend/src/reports/models/report.model.ts index b2d72cc6..ab2fb1ce 100644 --- a/backend/src/reports/models/report.model.ts +++ b/backend/src/reports/models/report.model.ts @@ -5,6 +5,13 @@ export enum ReportStatus { READ = 'READ', } +export enum ProcessingStatus { + PROCESSED = 'processed', + UNPROCESSED = 'unprocessed', + IN_PROGRESS = 'in_progress', + FAILED = 'failed', +} + export class Report { @ApiProperty({ description: 'Unique identifier for the report' }) id: string; @@ -21,8 +28,12 @@ export class Report { @ApiProperty({ description: 'Category of the report' }) category: string; - @ApiProperty({ description: 'Whether the report has been processed' }) - isProcessed: boolean; + @ApiProperty({ + description: 'Processing status of the report', + enum: ProcessingStatus, + default: ProcessingStatus.UNPROCESSED, + }) + processingStatus: ProcessingStatus; @ApiProperty({ description: 'List of lab values' }) labValues: Array<{ diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts index 2e841ea6..20ad1db1 100644 --- a/backend/src/reports/reports.service.ts +++ b/backend/src/reports/reports.service.ts @@ -15,7 +15,7 @@ import { QueryCommand, } from '@aws-sdk/client-dynamodb'; import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; -import { Report, ReportStatus } from './models/report.model'; +import { Report, ReportStatus, ProcessingStatus } from './models/report.model'; import { GetReportsQueryDto } from './dto/get-reports.dto'; import { UpdateReportStatusDto } from './dto/update-report-status.dto'; import { v4 as uuidv4 } from 'uuid'; @@ -299,7 +299,7 @@ export class ReportsService { title: 'New Report', bookmarked: false, category: '', - isProcessed: false, + processingStatus: ProcessingStatus.UNPROCESSED, labValues: [], summary: '', status: ReportStatus.UNREAD, @@ -333,106 +333,6 @@ export class ReportsService { } } - /** - * Find a report by its filePath - * @param filePath The S3 path of the file - * @param userId User ID for authorization - * @returns Report record if found - */ - async findByFilePath(filePath: string, userId: string): Promise { - if (!filePath) { - throw new NotFoundException('File path is required'); - } - - if (!userId) { - throw new ForbiddenException('User ID is required'); - } - - // Log the actual filePath being searched for debugging - this.logger.log(`Searching for report with filePath: "${filePath}" for user ${userId}`); - - try { - const command = new QueryCommand({ - TableName: this.tableName, - KeyConditionExpression: 'userId = :userId', - FilterExpression: 'filePath = :filePath', - ExpressionAttributeValues: marshall({ - ':userId': userId, - ':filePath': filePath, - }), - Limit: 1, // We only want one record - }); - - this.logger.log('Executing QueryCommand with params:', { - TableName: this.tableName, - KeyConditionExpression: 'userId = :userId', - FilterExpression: 'filePath = :filePath', - Values: { - userId, - filePath, - }, - }); - - const response = await this.dynamoClient.send(command); - - this.logger.log(`Query response received, found ${response.Items?.length || 0} items`); - - if (!response.Items || response.Items.length === 0) { - // If no exact match, try with case-insensitive comparison as a fallback - this.logger.log('No exact match found, trying with case-insensitive search'); - - // Get all items for the user and filter manually for case-insensitive match - const allUserItemsCommand = new QueryCommand({ - TableName: this.tableName, - KeyConditionExpression: 'userId = :userId', - ExpressionAttributeValues: marshall({ - ':userId': userId, - }), - }); - - const allUserResponse = await this.dynamoClient.send(allUserItemsCommand); - - if (!allUserResponse.Items || allUserResponse.Items.length === 0) { - return null; - } - - // Convert items and find case-insensitive match - const allReports = allUserResponse.Items.map(item => unmarshall(item) as Report); - const matchingReport = allReports.find( - report => report.filePath.toLowerCase() === filePath.toLowerCase(), - ); - - if (matchingReport) { - this.logger.log( - `Found case-insensitive match for ${filePath}: ${matchingReport.filePath}`, - ); - - return matchingReport; - } - - return null; - } - - const result = unmarshall(response.Items[0]) as Report; - this.logger.log(`Found report with ID ${result.id}`); - - return result; - } catch (error: unknown) { - this.logger.error(`Error finding report with filePath ${filePath}:`); - this.logger.error(error); - - if (error instanceof DynamoDBServiceException) { - if (error.name === 'ResourceNotFoundException') { - throw new InternalServerErrorException( - `Table "${this.tableName}" not found. Please check your database configuration.`, - ); - } - } - - throw new InternalServerErrorException(`Failed to fetch report with filePath ${filePath}`); - } - } - /** * Update a report with new data * @param report Updated report object diff --git a/frontend/src/common/api/reportService.ts b/frontend/src/common/api/reportService.ts index ec4f3d5f..a39632f5 100644 --- a/frontend/src/common/api/reportService.ts +++ b/frontend/src/common/api/reportService.ts @@ -1,5 +1,5 @@ import axios, { AxiosProgressEvent } from 'axios'; -import { MedicalReport, ReportCategory, ReportStatus } from '../models/medicalReport'; +import { MedicalReport, ReportCategory, ReportStatus, ProcessingStatus } from '../models/medicalReport'; import { fetchAuthSession } from '@aws-amplify/auth'; // Get the API URL from environment variables const API_URL = import.meta.env.VITE_BASE_URL_API || ''; @@ -12,7 +12,7 @@ const mockReports: MedicalReport[] = [ title: 'Blood Test Report', category: ReportCategory.GENERAL, bookmarked: false, - isProcessed: true, + processingStatus: ProcessingStatus.PROCESSED, labValues: [], summary: 'Blood test results within normal range', status: ReportStatus.UNREAD, @@ -26,7 +26,7 @@ const mockReports: MedicalReport[] = [ title: 'Heart Checkup', category: ReportCategory.HEART, bookmarked: true, - isProcessed: true, + processingStatus: ProcessingStatus.PROCESSED, labValues: [], summary: 'Heart functioning normally', status: ReportStatus.READ, diff --git a/frontend/src/common/components/Upload/UploadModal.tsx b/frontend/src/common/components/Upload/UploadModal.tsx index d187a0fc..f2f5b3ee 100644 --- a/frontend/src/common/components/Upload/UploadModal.tsx +++ b/frontend/src/common/components/Upload/UploadModal.tsx @@ -63,10 +63,10 @@ const UploadModal = ({ isOpen, onClose, onUploadComplete }: UploadModalProps): J if (onUploadComplete) { onUploadComplete(result); } - // Navigate to the processing tab with filePath in state + // Navigate to the processing tab with reportId in state if (file) { history.push('/tabs/processing', { - filePath: result.filePath, + reportId: result.id, }); } else { history.push('/tabs/processing'); diff --git a/frontend/src/common/models/medicalReport.ts b/frontend/src/common/models/medicalReport.ts index 730f8b7c..5c7cf69d 100644 --- a/frontend/src/common/models/medicalReport.ts +++ b/frontend/src/common/models/medicalReport.ts @@ -15,8 +15,17 @@ export enum ReportCategory { * Status of a medical report. */ export enum ReportStatus { + UNREAD = 'UNREAD', READ = 'READ', - UNREAD = 'UNREAD' +} + +/** + * Processing status of a medical report. + */ +export enum ProcessingStatus { + PROCESSED = 'processed', + UNPROCESSED = 'unprocessed', + IN_PROGRESS = 'in_progress', } /** @@ -42,7 +51,7 @@ export interface MedicalReport { title: string; category: ReportCategory | string; bookmarked: boolean; - isProcessed: boolean; + processingStatus: ProcessingStatus; labValues: LabValue[]; summary: string; status: ReportStatus; diff --git a/frontend/src/pages/Processing/Processing.tsx b/frontend/src/pages/Processing/Processing.tsx index 27b0a152..6af0edf0 100644 --- a/frontend/src/pages/Processing/Processing.tsx +++ b/frontend/src/pages/Processing/Processing.tsx @@ -2,7 +2,7 @@ import { IonContent, IonPage } from '@ionic/react'; import { useCurrentUser } from '../../common/hooks/useAuth'; import Avatar from '../../common/components/Icon/Avatar'; import { useLocation, useHistory } from 'react-router-dom'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useAxios } from '../../common/hooks/useAxios'; import './Processing.scss'; import { getAuthConfig } from 'common/api/reportService'; @@ -21,22 +21,62 @@ const Processing: React.FC = () => { // States to track processing const [isProcessing, setIsProcessing] = useState(true); const [processingError, setProcessingError] = useState(null); + const statusCheckIntervalRef = useRef(null); - // Get the location state which may contain the filePath - const location = useLocation<{ filePath: string }>(); - const filePath = location.state?.filePath; - const [reportId, setReportId] = useState(null); + // Get the location state which may contain the reportId (previously filePath) + const location = useLocation<{ reportId: string }>(); + const reportId = location.state?.reportId; const [isFetching, setIsFetching] = useState(false); + // Check the status of the report processing + const checkReportStatus = async () => { + if (!reportId) return; + + try { + const response = await axios.get( + `${API_URL}/api/document-processor/report-status/${reportId}`, + await getAuthConfig(), + ); + + console.log('Report status:', response.data); + + // If processing is complete, clear the interval and redirect to the report page + if (response.data.isComplete) { + setIsProcessing(false); + + // Clear the interval + if (statusCheckIntervalRef.current) { + window.clearInterval(statusCheckIntervalRef.current); + statusCheckIntervalRef.current = null; + } + + console.log('Processing complete'); + + // Redirect to report detail page + history.push(`/tabs/reports/${reportId}`); + } + } catch (error) { + console.error('Error checking report status:', error); + setProcessingError('Failed to check the status of the report. Please try again.'); + setIsProcessing(false); + + // Clear the interval on error + if (statusCheckIntervalRef.current) { + window.clearInterval(statusCheckIntervalRef.current); + statusCheckIntervalRef.current = null; + } + } + }; + // Send the API request when component mounts useEffect(() => { - if (!filePath) { - setProcessingError('No file path provided'); + if (!reportId) { + setProcessingError('No report ID provided'); setIsProcessing(false); return; } - if (reportId && isFetching) { + if (isFetching) { return; } @@ -47,12 +87,17 @@ const Processing: React.FC = () => { // Send POST request to backend API const response = await axios.post( `${API_URL}/api/document-processor/process-file`, - { filePath }, + { reportId }, await getAuthConfig(), ); - setReportId(response.data.reportId); - console.log('File processed successfully:', response.data); + console.log('File processing started:', response.data); + + // Start checking the status every 5 seconds + statusCheckIntervalRef.current = window.setInterval(checkReportStatus, 5000); + + // Run the first status check immediately + checkReportStatus(); } catch (error) { console.error('Error processing file:', error); setProcessingError('Failed to process the file. Please try again.'); @@ -61,7 +106,14 @@ const Processing: React.FC = () => { }; processFile(); - }, [filePath, axios, history]); + + // Clean up the interval when the component unmounts + return () => { + if (statusCheckIntervalRef.current) { + window.clearInterval(statusCheckIntervalRef.current); + } + }; + }, [reportId, axios, history]); return ( diff --git a/frontend/src/pages/Upload/__tests__/UploadPage.test.tsx b/frontend/src/pages/Upload/__tests__/UploadPage.test.tsx index 06107e8a..3d06f407 100644 --- a/frontend/src/pages/Upload/__tests__/UploadPage.test.tsx +++ b/frontend/src/pages/Upload/__tests__/UploadPage.test.tsx @@ -2,7 +2,7 @@ import { vi, describe, test, expect, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import UploadPage from '../UploadPage'; import { MemoryRouter } from 'react-router-dom'; -import { MedicalReport, ReportCategory, ReportStatus } from 'common/models/medicalReport'; +import { MedicalReport, ReportCategory, ReportStatus, ProcessingStatus } from 'common/models/medicalReport'; import '@testing-library/jest-dom'; // Mock the dependencies @@ -48,7 +48,7 @@ vi.mock('common/components/Upload/UploadModal', () => { createdAt: '2023-01-01', status: ReportStatus.UNREAD, bookmarked: false, - isProcessed: true, + processingStatus: ProcessingStatus.PROCESSED, labValues: [], summary: 'Test report summary', filePath: '/reports/test-report.pdf',