Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions backend/src/reports/reports.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Req,
UnauthorizedException,
Post,
NotFoundException,
} from '@nestjs/common';
import {
ApiTags,
Expand All @@ -20,7 +21,7 @@ import {
ApiBody,
} from '@nestjs/swagger';
import { ReportsService } from './reports.service';
import { Report } from './models/report.model';
import { ProcessingStatus, Report } from './models/report.model';
import { GetReportsQueryDto } from './dto/get-reports.dto';
import { UpdateReportStatusDto } from './dto/update-report-status.dto';
import { RequestWithUser } from '../auth/auth.middleware';
Expand Down Expand Up @@ -80,7 +81,17 @@ export class ReportsController {
@Get(':id')
async getReport(@Param('id') id: string, @Req() request: RequestWithUser): Promise<Report> {
const userId = this.extractUserId(request);
return this.reportsService.findOne(id, userId);
const report = await this.reportsService.findOne(id, userId);

if (!report) {
throw new NotFoundException('Report not found');
}

if (report.processingStatus === ProcessingStatus.FAILED) {
throw new NotFoundException('Processing failed');
}

return report;
}

@ApiOperation({ summary: 'Update report status' })
Expand Down
101 changes: 80 additions & 21 deletions backend/src/reports/reports.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DynamoDBServiceException,
PutItemCommand,
QueryCommand,
DeleteItemCommand,
} from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
import { Report, ReportStatus, ProcessingStatus } from './models/report.model';
Expand Down Expand Up @@ -54,19 +55,24 @@ export class ReportsService {
this.tableName = this.configService.get<string>('dynamodbReportsTable')!;
}

async findAll(userId: string): Promise<Report[]> {
async findAll(userId: string, withFailed = false): Promise<Report[]> {
if (!userId) {
throw new ForbiddenException('User ID is required');
}

try {
// Use QueryCommand instead of ScanCommand since userId is the partition key
const expressionAttributeValues: any = { ':userId': userId };
const processingStatusFilter = 'processingStatus <> :failedStatus';

if (!withFailed) {
expressionAttributeValues[':failedStatus'] = ProcessingStatus.FAILED;
}

const command = new QueryCommand({
TableName: this.tableName,
KeyConditionExpression: 'userId = :userId',
ExpressionAttributeValues: marshall({
':userId': userId,
}),
FilterExpression: !withFailed ? processingStatusFilter : undefined,
ExpressionAttributeValues: marshall(expressionAttributeValues),
});

const response = await this.dynamoClient.send(command);
Expand All @@ -91,7 +97,11 @@ export class ReportsService {
}
}

async findLatest(queryDto: GetReportsQueryDto, userId: string): Promise<Report[]> {
async findLatest(
queryDto: GetReportsQueryDto,
userId: string,
withFailed = false,
): Promise<Report[]> {
this.logger.log(
`Running findLatest with params: ${JSON.stringify(queryDto)} for user ${userId}`,
);
Expand All @@ -100,22 +110,26 @@ export class ReportsService {
throw new ForbiddenException('User ID is required');
}

// Convert limit to a number to avoid serialization errors
const limit =
typeof queryDto.limit === 'string' ? parseInt(queryDto.limit, 10) : queryDto.limit || 10;

const expressionAttributeValues: any = { ':userId': userId };

try {
// Use the GSI userIdCreatedAtIndex with QueryCommand for efficient retrieval
// This is much more efficient than a ScanCommand
const processingStatusFilter = 'processingStatus <> :failedStatus';

if (!withFailed) {
expressionAttributeValues[':failedStatus'] = ProcessingStatus.FAILED;
}

const command = new QueryCommand({
TableName: this.tableName,
IndexName: 'userIdCreatedAtIndex', // Use the GSI for efficient queries
IndexName: 'userIdCreatedAtIndex',
KeyConditionExpression: 'userId = :userId',
ExpressionAttributeValues: marshall({
':userId': userId,
}),
ScanIndexForward: false, // Get items in descending order (newest first)
Limit: limit, // Only fetch the number of items we need
FilterExpression: !withFailed ? processingStatusFilter : undefined,
ExpressionAttributeValues: marshall(expressionAttributeValues),
ScanIndexForward: false,
Limit: limit,
});

const response = await this.dynamoClient.send(command);
Expand All @@ -130,22 +144,17 @@ export class ReportsService {
`Table "${this.tableName}" not found. Please check your database configuration.`,
);
} else if (error.name === 'ValidationException') {
// This could happen if the GSI doesn't exist
this.logger.warn('GSI validation error, falling back to standard query');

// Fallback to standard query and sort in memory if GSI has issues
const fallbackCommand = new QueryCommand({
TableName: this.tableName,
KeyConditionExpression: 'userId = :userId',
ExpressionAttributeValues: marshall({
':userId': userId,
}),
ExpressionAttributeValues: marshall(expressionAttributeValues),
});

const fallbackResponse = await this.dynamoClient.send(fallbackCommand);
const reports = (fallbackResponse.Items || []).map(item => unmarshall(item) as Report);

// Sort by createdAt in descending order
return reports
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit);
Expand Down Expand Up @@ -469,4 +478,54 @@ export class ReportsService {
throw new InternalServerErrorException(`Failed to toggle bookmark for report ID ${id}`);
}
}

/**
* Delete a report by ID
* @param reportId Report ID
* @param userId User ID
* @returns A confirmation message
*/
async deleteReport(reportId: string, userId: string): Promise<string> {
if (!reportId) {
throw new NotFoundException('Report ID is required');
}

if (!userId) {
throw new ForbiddenException('User ID is required');
}

try {
const command = new DeleteItemCommand({
TableName: this.tableName,
Key: marshall({
userId,
id: reportId,
}),
ConditionExpression: 'userId = :userId',
ExpressionAttributeValues: marshall({
':userId': userId,
}),
});

await this.dynamoClient.send(command);
this.logger.log(`Successfully deleted report with ID ${reportId} for user ${userId}`);

return `Report with ID ${reportId} successfully deleted`;
} catch (error: unknown) {
this.logger.error(`Error deleting report with ID ${reportId}:`);
this.logger.error(error);

if (error instanceof DynamoDBServiceException) {
if (error.name === 'ConditionalCheckFailedException') {
throw new ForbiddenException('You do not have permission to delete this report');
} else if (error.name === 'ResourceNotFoundException') {
throw new InternalServerErrorException(
`Table "${this.tableName}" not found. Please check your database configuration.`,
);
}
}

throw new InternalServerErrorException(`Failed to delete report with ID ${reportId}`);
}
}
}
2 changes: 1 addition & 1 deletion backend/src/services/perplexity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export class PerplexityService {
'Your goal is to help patients understand their medical reports by translating medical jargon into plain language.\n' +
'You must be accurate, concise, comprehensive, and easy to understand. Use everyday analogies when helpful.\n';

const userPrompt = `Please explain the following medical text in simple terms, in a single paragraph that's between 100 to 500 characters:\n\n${medicalText}`;
const userPrompt = `Please explain the following medical text in simple terms, in a single paragraph that's between 10 to 200 words, all in normal text NOT .md style, the more concise the better:\n\n${medicalText}`;

const messages: PerplexityMessage[] = [
{ role: 'system', content: systemPrompt },
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Reports/components/ReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const ReportHeader: React.FC<ReportHeaderProps> = ({ reportData, onClose }) => {
{/* Category & Title */}
<div className="report-detail-page__category-wrapper">
<span className="report-detail-page__category">
{reportData.category && t(`list.${reportData.category}Category`, { ns: 'report' })}
{reportData.category && t(`list.${reportData.category}Category`, { ns: 'reportDetail' })}
</span>
<button className="report-detail-page__bookmark-button">
{reportData.bookmarked ? (
Expand Down