diff --git a/apps/backend/src/allocations/allocations.entity.ts b/apps/backend/src/allocations/allocations.entity.ts index a5a3c734..5a031df1 100644 --- a/apps/backend/src/allocations/allocations.entity.ts +++ b/apps/backend/src/allocations/allocations.entity.ts @@ -6,6 +6,7 @@ import { JoinColumn, } from 'typeorm'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { Order } from '../orders/order.entity'; @Entity('allocations') export class Allocation { @@ -15,6 +16,10 @@ export class Allocation { @Column({ name: 'order_id', type: 'int' }) orderId: number; + @ManyToOne(() => Order, (order) => order.allocations) + @JoinColumn({ name: 'order_id' }) + order: Order; + @Column({ name: 'item_id', type: 'int', nullable: false }) itemId: number; diff --git a/apps/backend/src/foodRequests/dtos/order-details.dto.ts b/apps/backend/src/foodRequests/dtos/order-details.dto.ts new file mode 100644 index 00000000..21d360ec --- /dev/null +++ b/apps/backend/src/foodRequests/dtos/order-details.dto.ts @@ -0,0 +1,15 @@ +import { FoodType } from '../../donationItems/types'; +import { OrderStatus } from '../../orders/types'; + +export class OrderItemDetailsDto { + name: string; + quantity: number; + foodType: FoodType; +} + +export class OrderDetailsDto { + orderId: number; + status: OrderStatus; + foodManufacturerName: string; + items: OrderItemDetailsDto[]; +} diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 71c35ca4..51f8d987 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -8,6 +8,8 @@ import { Readable } from 'stream'; import { FoodRequest } from './request.entity'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; +import { FoodType } from '../donationItems/types'; +import { OrderDetailsDto } from './dtos/order-details.dto'; const mockRequestsService = mock(); const mockOrdersService = mock(); @@ -26,6 +28,7 @@ describe('RequestsController', () => { mockRequestsService.find.mockReset(); mockRequestsService.create.mockReset(); mockRequestsService.updateDeliveryDetails?.mockReset(); + mockRequestsService.getOrderDetails.mockReset(); mockAWSS3Service.upload.mockReset(); mockOrdersService.updateStatus.mockReset(); @@ -91,6 +94,55 @@ describe('RequestsController', () => { }); }); + describe('GET /get-all-order-details/:requestId', () => { + it('should call requestsService.getOrderDetails and return all associated orders and their details', async () => { + const mockOrderDetails = [ + { + orderId: 10, + status: OrderStatus.DELIVERED, + foodManufacturerName: 'Test Manufacturer', + items: [ + { + name: 'Rice', + quantity: 5, + foodType: FoodType.GRANOLA, + }, + { + name: 'Beans', + quantity: 3, + foodType: FoodType.DRIED_BEANS, + }, + ], + }, + { + orderId: 11, + status: OrderStatus.PENDING, + foodManufacturerName: 'Another Manufacturer', + items: [ + { + name: 'Milk', + quantity: 2, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + }, + ], + }, + ]; + + const requestId = 1; + + mockRequestsService.getOrderDetails.mockResolvedValueOnce( + mockOrderDetails as OrderDetailsDto[], + ); + + const result = await controller.getAllOrderDetailsFromRequest(requestId); + + expect(result).toEqual(mockOrderDetails); + expect(mockRequestsService.getOrderDetails).toHaveBeenCalledWith( + requestId, + ); + }); + }); + describe('POST /create', () => { it('should call requestsService.create and return the created food request', async () => { const createBody: Partial = { @@ -107,7 +159,7 @@ describe('RequestsController', () => { requestId: 1, ...createBody, requestedAt: new Date(), - order: null, + orders: null, }; mockRequestsService.create.mockResolvedValueOnce( @@ -181,7 +233,7 @@ describe('RequestsController', () => { mockRequestsService.findOne.mockResolvedValue({ requestId, pantryId: 1, - order: { orderId: 99 }, + orders: [{ orderId: 99 }], } as FoodRequest); mockOrdersService.updateStatus.mockResolvedValue(); @@ -230,7 +282,7 @@ describe('RequestsController', () => { mockRequestsService.findOne.mockResolvedValue({ requestId, pantryId: 1, - order: { orderId: 100 }, + orders: [{ orderId: 100 }], } as FoodRequest); mockOrdersService.updateStatus.mockResolvedValue(); @@ -275,7 +327,7 @@ describe('RequestsController', () => { mockRequestsService.findOne.mockResolvedValue({ requestId, pantryId: 1, - order: { orderId: 101 }, + orders: [{ orderId: 101 }], } as FoodRequest); mockOrdersService.updateStatus.mockResolvedValue(); diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 1f449491..1fc402aa 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -16,9 +16,9 @@ import { AWSS3Service } from '../aws/aws-s3.service'; import { FilesInterceptor } from '@nestjs/platform-express'; import * as multer from 'multer'; import { OrdersService } from '../orders/order.service'; -import { Order } from '../orders/order.entity'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; +import { OrderDetailsDto } from './dtos/order-details.dto'; @Controller('requests') // @UseInterceptors() @@ -43,6 +43,13 @@ export class RequestsController { return this.requestsService.find(pantryId); } + @Get('/get-all-order-details/:requestId') + async getAllOrderDetailsFromRequest( + @Param('requestId', ParseIntPipe) requestId: number, + ): Promise { + return this.requestsService.getOrderDetails(requestId); + } + @Post('/create') @ApiBody({ description: 'Details for creating a food request', @@ -158,9 +165,11 @@ export class RequestsController { ); const request = await this.requestsService.findOne(requestId); - await this.ordersService.updateStatus( - request.order.orderId, - OrderStatus.DELIVERED, + + await Promise.all( + request.orders.map((order) => + this.ordersService.updateStatus(order.orderId, OrderStatus.DELIVERED), + ), ); return this.requestsService.updateDeliveryDetails( diff --git a/apps/backend/src/foodRequests/request.entity.ts b/apps/backend/src/foodRequests/request.entity.ts index 06c2ce79..f745b804 100644 --- a/apps/backend/src/foodRequests/request.entity.ts +++ b/apps/backend/src/foodRequests/request.entity.ts @@ -46,6 +46,6 @@ export class FoodRequest { @Column({ name: 'photos', type: 'text', array: true, nullable: true }) photos: string[]; - @OneToMany(() => Order, (order) => order.request, { nullable: true }) - order: Order; + @OneToMany(() => Order, (order) => order.request) + orders: Order[]; } diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index cc69a5a3..165cb036 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -8,9 +8,14 @@ import { Pantry } from '../pantries/pantries.entity'; import { RequestSize } from './types'; import { Order } from '../orders/order.entity'; import { OrderStatus } from '../orders/types'; +import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; +import { FoodType } from '../donationItems/types'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { Allocation } from '../allocations/allocations.entity'; const mockRequestsRepository = mock>(); const mockPantryRepository = mock>(); +const mockOrdersRepository = mock>(); const mockRequest: Partial = { requestId: 1, @@ -21,7 +26,7 @@ const mockRequest: Partial = { dateReceived: null, feedback: null, photos: null, - order: null, + orders: null, }; describe('RequestsService', () => { @@ -46,6 +51,10 @@ describe('RequestsService', () => { provide: getRepositoryToken(Pantry), useValue: mockPantryRepository, }, + { + provide: getRepositoryToken(Order), + useValue: mockOrdersRepository, + }, ], }).compile(); @@ -74,7 +83,7 @@ describe('RequestsService', () => { expect(result).toEqual(mockRequest); expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); }); @@ -89,7 +98,134 @@ describe('RequestsService', () => { expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], + }); + }); + }); + + describe('getOrderDetails', () => { + it('should return mapped order details for a valid requestId', async () => { + const requestId = 1; + + const mockOrders: Partial[] = [ + { + orderId: 10, + status: OrderStatus.DELIVERED, + foodManufacturer: { + foodManufacturerName: 'Test Manufacturer', + } as FoodManufacturer, + allocations: [ + { + allocatedQuantity: 5, + item: { + itemName: 'Rice', + foodType: FoodType.GRANOLA, + } as DonationItem, + } as Allocation, + { + allocatedQuantity: 3, + item: { + itemName: 'Beans', + foodType: FoodType.DRIED_BEANS, + } as DonationItem, + } as Allocation, + ], + }, + { + orderId: 11, + status: OrderStatus.SHIPPED, + foodManufacturer: { + foodManufacturerName: 'Another Manufacturer', + } as FoodManufacturer, + allocations: [ + { + allocatedQuantity: 2, + item: { + itemName: 'Milk', + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + } as DonationItem, + } as Allocation, + ], + }, + ]; + + mockOrdersRepository.find.mockResolvedValueOnce(mockOrders as Order[]); + + mockRequestsRepository.findOne.mockResolvedValueOnce( + mockRequest as FoodRequest, + ); + + const result = await service.getOrderDetails(requestId); + + expect(result).toEqual([ + { + orderId: 10, + status: OrderStatus.DELIVERED, + foodManufacturerName: 'Test Manufacturer', + items: [ + { + name: 'Rice', + quantity: 5, + foodType: FoodType.GRANOLA, + }, + { + name: 'Beans', + quantity: 3, + foodType: FoodType.DRIED_BEANS, + }, + ], + }, + { + orderId: 11, + status: OrderStatus.SHIPPED, + foodManufacturerName: 'Another Manufacturer', + items: [ + { + name: 'Milk', + quantity: 2, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + }, + ], + }, + ]); + + expect(mockOrdersRepository.find).toHaveBeenCalledWith({ + where: { requestId }, + relations: { + foodManufacturer: true, + allocations: { + item: true, + }, + }, + }); + }); + + it('should throw an error if the request id is not found', async () => { + const requestId = 999; + + await expect(service.getOrderDetails(requestId)).rejects.toThrow( + `Request ${requestId} not found`, + ); + }); + + it('should return empty list if no associated orders', async () => { + const requestId = 1; + + mockRequestsRepository.findOne.mockResolvedValueOnce( + mockRequest as FoodRequest, + ); + mockOrdersRepository.find.mockResolvedValueOnce([]); + + const result = await service.getOrderDetails(requestId); + expect(result).toEqual([]); + expect(mockOrdersRepository.find).toHaveBeenCalledWith({ + where: { requestId }, + relations: { + foodManufacturer: true, + allocations: { + item: true, + }, + }, }); }); }); @@ -166,7 +302,7 @@ describe('RequestsService', () => { dateReceived: null, feedback: null, photos: null, - order: null, + orders: null, }, { requestId: 3, @@ -178,7 +314,7 @@ describe('RequestsService', () => { dateReceived: null, feedback: null, photos: null, - order: null, + orders: null, }, ]; const pantryId = 1; @@ -191,7 +327,7 @@ describe('RequestsService', () => { expect(result).toEqual(mockRequests.slice(0, 2)); expect(mockRequestsRepository.find).toHaveBeenCalledWith({ where: { pantryId }, - relations: ['order'], + relations: ['orders'], }); }); }); @@ -213,7 +349,7 @@ describe('RequestsService', () => { const mockRequest2: Partial = { ...mockRequest, - order: mockOrder as Order, + orders: [mockOrder] as Order[], }; const requestId = 1; @@ -224,15 +360,15 @@ describe('RequestsService', () => { mockRequestsRepository.findOne.mockResolvedValueOnce( mockRequest2 as FoodRequest, ); + + const updatedOrder = { ...mockOrder, status: OrderStatus.DELIVERED }; + mockRequestsRepository.save.mockResolvedValueOnce({ ...mockRequest, dateReceived: deliveryDate, feedback, photos, - order: { - ...(mockOrder as Order), - status: OrderStatus.DELIVERED, - } as Order, + orders: [updatedOrder], } as FoodRequest); const result = await service.updateDeliveryDetails( @@ -247,12 +383,12 @@ describe('RequestsService', () => { dateReceived: deliveryDate, feedback, photos, - order: { ...mockOrder, status: 'delivered' }, + orders: [updatedOrder], }); expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); expect(mockRequestsRepository.save).toHaveBeenCalledWith({ @@ -260,7 +396,7 @@ describe('RequestsService', () => { dateReceived: deliveryDate, feedback, photos, - order: { ...mockOrder, status: 'delivered' }, + orders: [updatedOrder], }); }); @@ -283,7 +419,7 @@ describe('RequestsService', () => { expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); }); @@ -304,11 +440,11 @@ describe('RequestsService', () => { feedback, photos, ), - ).rejects.toThrow('No associated order found for this request'); + ).rejects.toThrow('No associated orders found for this request'); expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); }); @@ -327,7 +463,7 @@ describe('RequestsService', () => { }; const mockRequest2: Partial = { ...mockRequest, - order: mockOrder as Order, + orders: [mockOrder] as Order[], }; const requestId = 1; @@ -346,11 +482,13 @@ describe('RequestsService', () => { feedback, photos, ), - ).rejects.toThrow('No associated food manufacturer found for this order'); + ).rejects.toThrow( + 'No associated food manufacturer found for an associated order', + ); expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); }); }); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 32e600ba..51b726dd 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -10,12 +10,15 @@ import { validateId } from '../utils/validation.utils'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; import { Pantry } from '../pantries/pantries.entity'; +import { Order } from '../orders/order.entity'; +import { OrderDetailsDto } from './dtos/order-details.dto'; @Injectable() export class RequestsService { constructor( @InjectRepository(FoodRequest) private repo: Repository, @InjectRepository(Pantry) private pantryRepo: Repository, + @InjectRepository(Order) private orderRepo: Repository, ) {} async findOne(requestId: number): Promise { @@ -23,7 +26,7 @@ export class RequestsService { const request = await this.repo.findOne({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); if (!request) { @@ -32,6 +35,43 @@ export class RequestsService { return request; } + async getOrderDetails(requestId: number): Promise { + validateId(requestId, 'Request'); + + const requestExists = await this.repo.findOne({ + where: { requestId }, + }); + + if (!requestExists) { + throw new Error(`Request ${requestId} not found`); + } + + const orders = await this.orderRepo.find({ + where: { requestId }, + relations: { + foodManufacturer: true, + allocations: { + item: true, + }, + }, + }); + + if (!orders.length) { + return []; + } + + return orders.map((order) => ({ + orderId: order.orderId, + status: order.status, + foodManufacturerName: order.foodManufacturer.foodManufacturerName, + items: order.allocations.map((allocation) => ({ + name: allocation.item.itemName, + quantity: allocation.allocatedQuantity, + foodType: allocation.item.foodType, + })), + })); + } + async create( pantryId: number, requestedSize: RequestSize, @@ -66,7 +106,7 @@ export class RequestsService { return await this.repo.find({ where: { pantryId }, - relations: ['order'], + relations: ['orders'], }); } @@ -80,29 +120,35 @@ export class RequestsService { const request = await this.repo.findOne({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); if (!request) { throw new NotFoundException('Invalid request ID'); } - if (!request.order) { - throw new ConflictException('No associated order found for this request'); + if (!request.orders || request.orders.length == 0) { + throw new ConflictException( + 'No associated orders found for this request', + ); } - const order = request.order; + const orders = request.orders; - if (!order.shippedBy) { - throw new ConflictException( - 'No associated food manufacturer found for this order', - ); + for (const order of orders) { + if (!order.shippedBy) { + throw new ConflictException( + 'No associated food manufacturer found for an associated order', + ); + } } request.feedback = feedback; request.dateReceived = deliveryDate; request.photos = photos; - request.order.status = OrderStatus.DELIVERED; + request.orders.forEach((order) => { + order.status = OrderStatus.DELIVERED; + }); return await this.repo.save(request); } diff --git a/apps/backend/src/orders/order.entity.ts b/apps/backend/src/orders/order.entity.ts index 4c38457b..7c40fdb4 100644 --- a/apps/backend/src/orders/order.entity.ts +++ b/apps/backend/src/orders/order.entity.ts @@ -5,11 +5,13 @@ import { CreateDateColumn, ManyToOne, JoinColumn, + OneToMany, } from 'typeorm'; import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; import { OrderStatus } from './types'; +import { Allocation } from '../allocations/allocations.entity'; @Entity('orders') export class Order { @@ -72,4 +74,7 @@ export class Order { nullable: true, }) deliveredAt: Date | null; + + @OneToMany(() => Allocation, (allocation) => allocation.order) + allocations: Allocation[]; } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index f62efd8f..97f997a1 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -11,6 +11,7 @@ import { Pantry, PantryApplicationDto, UserDto, + OrderDetails, } from 'types/types'; const defaultBaseUrl = @@ -193,6 +194,14 @@ export class ApiClient { return this.axiosInstance.get(`api/orders/${orderId}`) as Promise; } + public async getOrderDetailsListFromRequest( + requestId: number, + ): Promise { + return this.axiosInstance.get( + `api/requests/get-all-order-details/${requestId}`, + ) as Promise; + } + async getAllAllocationsByOrder(orderId: number): Promise { return this.axiosInstance .get(`api/orders/${orderId}/allocations`) diff --git a/apps/frontend/src/containers/FormRequests.tsx b/apps/frontend/src/containers/FormRequests.tsx index 50d20601..f00b8b96 100644 --- a/apps/frontend/src/containers/FormRequests.tsx +++ b/apps/frontend/src/containers/FormRequests.tsx @@ -150,32 +150,35 @@ const FormRequests: React.FC = () => { - {request.order?.orderId ? ( + {request.orders?.[0]?.orderId ? ( ) : ( 'N/A' )} {formatDate(request.requestedAt)} - {request.order?.status ?? 'pending'} - {request.order?.status === 'pending' + {request.orders?.[0]?.status ?? 'pending'} + + + {request.orders?.[0]?.status === 'pending' ? 'N/A' - : request.order?.shippedBy ?? 'N/A'} + : request.orders?.[0]?.shippedBy ?? 'N/A'} {formatReceivedDate(request.dateReceived)} - {!request.order || request.order?.status === 'pending' ? ( + {!request.orders?.[0] || + request.orders?.[0]?.status === 'pending' ? ( Awaiting Order Assignment - ) : request.order?.status === 'delivered' ? ( + ) : request.orders?.[0]?.status === 'delivered' ? ( Food Request is Already Delivered ) : (