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
106 changes: 87 additions & 19 deletions backend/src/reports/reports.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import {
import { ConfigService } from '@nestjs/config';
import {
DynamoDBClient,
ScanCommand,
GetItemCommand,
UpdateItemCommand,
DynamoDBServiceException,
PutItemCommand,
QueryCommand,
} from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
import { Report, ReportStatus } from './models/report.model';
Expand Down Expand Up @@ -60,10 +60,10 @@ export class ReportsService {
}

try {
// If the table has a GSI for userId, use QueryCommand instead
const command = new ScanCommand({
// Use QueryCommand instead of ScanCommand since userId is the partition key
const command = new QueryCommand({
TableName: this.tableName,
FilterExpression: 'userId = :userId',
KeyConditionExpression: 'userId = :userId',
ExpressionAttributeValues: marshall({
':userId': userId,
}),
Expand Down Expand Up @@ -105,23 +105,21 @@ export class ReportsService {
typeof queryDto.limit === 'string' ? parseInt(queryDto.limit, 10) : queryDto.limit || 10;

try {
// If the table has a GSI for userId, use QueryCommand instead
const command = new ScanCommand({
// Use the GSI userIdCreatedAtIndex with QueryCommand for efficient retrieval
// This is much more efficient than a ScanCommand
const command = new QueryCommand({
TableName: this.tableName,
FilterExpression: 'userId = :userId',
IndexName: 'userIdCreatedAtIndex', // Use the GSI for efficient queries
KeyConditionExpression: 'userId = :userId',
ExpressionAttributeValues: marshall({
':userId': userId,
}),
Limit: limit * 5, // Fetch more items since we'll filter by userId
ScanIndexForward: false, // Get items in descending order (newest first)
Limit: limit, // Only fetch the number of items we need
});

const response = await this.dynamoClient.send(command);
const reports = (response.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);
return (response.Items || []).map(item => unmarshall(item) as Report);
} catch (error: unknown) {
this.logger.error(`Error fetching latest reports for user ${userId}:`);
this.logger.error(error);
Expand All @@ -131,6 +129,26 @@ export class ReportsService {
throw new InternalServerErrorException(
`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,
}),
});

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 @@ -330,25 +348,75 @@ export class ReportsService {
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 {
// Since filePath isn't a key attribute, we need to scan with filter
const command = new ScanCommand({
const command = new QueryCommand({
TableName: this.tableName,
FilterExpression: 'filePath = :filePath AND userId = :userId',
KeyConditionExpression: 'userId = :userId',
FilterExpression: 'filePath = :filePath',
ExpressionAttributeValues: marshall({
':filePath': filePath,
':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;
}

return unmarshall(response.Items[0]) as Report;
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);
Expand Down
88 changes: 68 additions & 20 deletions frontend/src/common/api/reportService.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,41 @@
import axios, { AxiosProgressEvent } from 'axios';
import { MedicalReport } from '../models/medicalReport';
import { MedicalReport, ReportCategory, ReportStatus } 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 || '';

// Mock data for testing and development
const mockReports: MedicalReport[] = [
{
id: '1',
userId: 'user1',
title: 'Blood Test Report',
category: ReportCategory.GENERAL,
bookmarked: false,
isProcessed: true,
labValues: [],
summary: 'Blood test results within normal range',
status: ReportStatus.UNREAD,
filePath: '/reports/blood-test.pdf',
createdAt: '2023-04-15T12:30:00Z',
updatedAt: '2023-04-15T12:30:00Z',
},
{
id: '2',
userId: 'user1',
title: 'Heart Checkup',
category: ReportCategory.HEART,
bookmarked: true,
isProcessed: true,
labValues: [],
summary: 'Heart functioning normally',
status: ReportStatus.READ,
filePath: '/reports/heart-checkup.pdf',
createdAt: '2023-04-10T10:15:00Z',
updatedAt: '2023-04-10T10:15:00Z',
},
];

/**
* Interface for upload progress callback
*/
Expand All @@ -14,16 +46,22 @@ export interface UploadProgressCallback {
/**
* Creates an authenticated request config with bearer token
*/
export const getAuthConfig = async (signal?: AbortSignal): Promise<{ headers: { Accept: string, 'Content-Type': string, Authorization: string }, signal?: AbortSignal, onUploadProgress?: (progressEvent: AxiosProgressEvent) => void }> => {
export const getAuthConfig = async (
signal?: AbortSignal,
): Promise<{
headers: { Accept: string; 'Content-Type': string; Authorization: string };
signal?: AbortSignal;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
}> => {
const session = await fetchAuthSession();
const idToken = session.tokens?.idToken?.toString() || '';
return {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: idToken ? `Bearer ${idToken}` : ''
},
signal
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: idToken ? `Bearer ${idToken}` : '',
},
signal,
};
};

Expand All @@ -47,7 +85,7 @@ export class ReportError extends Error {
export const uploadReport = async (
file: File,
onProgress?: UploadProgressCallback,
signal?: AbortSignal
signal?: AbortSignal,
): Promise<MedicalReport> => {
try {
// Import s3StorageService dynamically to avoid circular dependency
Expand All @@ -58,7 +96,7 @@ export const uploadReport = async (
file,
'reports',
onProgress as (progress: number) => void,
signal
signal,
);

// Then create the report record with the S3 key
Expand All @@ -70,7 +108,7 @@ export const uploadReport = async (
{
filePath: s3Key,
},
config
config,
);

return response.data;
Expand All @@ -79,7 +117,7 @@ export const uploadReport = async (
if (signal?.aborted) {
throw new DOMException('The operation was aborted', 'AbortError');
}

if (axios.isAxiosError(error)) {
console.error('API Error Details:', error.response?.data, error.response?.headers);
throw new ReportError(`Failed to upload report: ${error.message}`);
Expand All @@ -95,7 +133,10 @@ export const uploadReport = async (
*/
export const fetchLatestReports = async (limit = 3): Promise<MedicalReport[]> => {
try {
const response = await axios.get(`${API_URL}/api/reports/latest?limit=${limit}`, await getAuthConfig());
const response = await axios.get(
`${API_URL}/api/reports/latest?limit=${limit}`,
await getAuthConfig(),
);
console.log('response', response.data);
console.log('API_URL', API_URL);
return response.data;
Expand All @@ -113,7 +154,7 @@ export const fetchLatestReports = async (limit = 3): Promise<MedicalReport[]> =>
*/
export const fetchAllReports = async (): Promise<MedicalReport[]> => {
try {
const response = await axios.get(`${API_URL}/api/reports`, await getAuthConfig() );
const response = await axios.get(`${API_URL}/api/reports`, await getAuthConfig());
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
Expand All @@ -131,7 +172,7 @@ export const fetchAllReports = async (): Promise<MedicalReport[]> => {
export const markReportAsRead = async (reportId: string): Promise<MedicalReport> => {
try {
const response = await axios.patch(`${API_URL}/api/reports/${reportId}`, {
status: 'READ'
status: 'READ',
});

return response.data;
Expand All @@ -149,17 +190,24 @@ export const markReportAsRead = async (reportId: string): Promise<MedicalReport>
* @param isBookmarked - Boolean indicating if the report should be bookmarked or not
* @returns Promise with the updated report
*/
export const toggleReportBookmark = async (reportId: string, isBookmarked: boolean): Promise<MedicalReport> => {
export const toggleReportBookmark = async (
reportId: string,
isBookmarked: boolean,
): Promise<MedicalReport> => {
try {
await axios.patch(`${API_URL}/api/reports/${reportId}/bookmark`, {
bookmarked: isBookmarked
}, await getAuthConfig());
await axios.patch(
`${API_URL}/api/reports/${reportId}/bookmark`,
{
bookmarked: isBookmarked,
},
await getAuthConfig(),
);

// In a real implementation, this would return the response from the API
// return response.data;

// For now, we'll mock the response
const report = mockReports.find(r => r.id === reportId);
const report = mockReports.find((r) => r.id === reportId);

if (!report) {
throw new Error(`Report with ID ${reportId} not found`);
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/common/components/Router/TabNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import ChatPage from 'pages/Chat/ChatPage';
import UploadPage from 'pages/Upload/UploadPage';
import ReportDetailPage from 'pages/Reports/ReportDetailPage';
import ReportsListPage from 'pages/Reports/ReportsListPage';
import Processing from 'pages/Processing/Processing';

/**
* The `TabNavigation` component provides a router outlet for all of the
Expand Down Expand Up @@ -90,6 +91,9 @@ const TabNavigation = (): JSX.Element => {
<Route exact path="/tabs/reports/:reportId">
<ReportDetailPage />
</Route>
<Route exact path="/tabs/processing">
<Processing />
</Route>
<Route exact path="/">
<Redirect to="/tabs/home" />
</Route>
Expand Down
Loading