From 2410aa16854b97f0db2870906a64c95003809e97 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:53:30 -0500 Subject: [PATCH 1/3] volunteer order matching flow and devx things --- apps/backend/README.md | 8 +- .../src/allocations/allocations.entity.ts | 6 - .../src/allocations/allocations.service.ts | 2 - apps/backend/src/config/migrations.ts | 2 + .../src/foodRequests/dtos/matching.dto.ts | 19 + .../foodRequests/request.controller.spec.ts | 92 ++- .../src/foodRequests/request.controller.ts | 33 +- .../src/foodRequests/request.entity.ts | 19 +- .../src/foodRequests/request.module.ts | 10 +- .../src/foodRequests/request.service.spec.ts | 606 +++++++++--------- .../src/foodRequests/request.service.ts | 126 +++- ...821377918-CleanupRequestsAndAllocations.ts | 35 + .../src/orders/order.controller.spec.ts | 158 +---- .../src/pantries/pantries.controller.spec.ts | 14 +- apps/frontend/src/app.tsx | 22 - .../components/forms/newDonationFormModal.tsx | 3 +- .../components/forms/orderDetailsModal.tsx | 2 +- .../components/forms/requestDetailsModal.tsx | 20 +- .../src/components/forms/requestFormModal.tsx | 26 +- apps/frontend/src/containers/homepage.tsx | 42 +- apps/frontend/src/containers/landingPage.tsx | 14 - .../src/containers/pantryDashboard.tsx | 4 +- .../src/containers/pantryOverview.tsx | 5 - apps/frontend/src/containers/unauthorized.tsx | 6 + apps/frontend/src/types/types.ts | 23 +- package.json | 2 +- 26 files changed, 673 insertions(+), 626 deletions(-) create mode 100644 apps/backend/src/foodRequests/dtos/matching.dto.ts create mode 100644 apps/backend/src/migrations/1771821377918-CleanupRequestsAndAllocations.ts delete mode 100644 apps/frontend/src/containers/landingPage.tsx delete mode 100644 apps/frontend/src/containers/pantryOverview.tsx diff --git a/apps/backend/README.md b/apps/backend/README.md index ebe04a3c..26ab9096 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -57,14 +57,14 @@ We have a few environment variables that we utilize to access several AWS servic - There, you can validate the information in `auth/aws_exports.ts` (the `userPoolClientId`), as well as copy the client secret into your env file 5. Creating a new user within AWS Cognito - There are 2 ways you can create a new user in AWS Cognito. The simplest, is through loading the up, going to the landing page, and creating a new account there. If you choose to do it alternatively through the console, follow these steps: + There are 2 ways you can create a new user in AWS Cognito. The simplest, is through loading the app, going to the signup page, and creating a new account there. If you choose to do it alternatively through the console, follow these steps: - Navigate to AWS Cognito - - Make sure you are on "United States (N. Virginia) as your region + - Make sure you are on "United States (N. Virginia)" as your region - Go into User pools and click on the one that says "ssf" - Go to Users - - If you do not already see your email there, create a new User, setting an email in password (this will be what you login with on the frontend) + - If you do not already see your email there, create a new User, setting an email and password (this will be what you login with on the frontend) - Click 'Create User' - - Load up the app, and go to the landing page + - Load up the app, and go to the login page - Verify you are able to login with these new credentials you created ### Running backend tests diff --git a/apps/backend/src/allocations/allocations.entity.ts b/apps/backend/src/allocations/allocations.entity.ts index 26985283..43f4f470 100644 --- a/apps/backend/src/allocations/allocations.entity.ts +++ b/apps/backend/src/allocations/allocations.entity.ts @@ -29,10 +29,4 @@ export class Allocation { @Column({ name: 'allocated_quantity', type: 'int' }) allocatedQuantity: number; - - @Column({ name: 'reserved_at', type: 'timestamp' }) - reservedAt: Date; - - @Column({ name: 'fulfilled_at', type: 'timestamp' }) - fulfilledAt: Date; } diff --git a/apps/backend/src/allocations/allocations.service.ts b/apps/backend/src/allocations/allocations.service.ts index b68b50ae..23634f67 100644 --- a/apps/backend/src/allocations/allocations.service.ts +++ b/apps/backend/src/allocations/allocations.service.ts @@ -18,8 +18,6 @@ export class AllocationsService { select: { allocationId: true, allocatedQuantity: true, - reservedAt: true, - fulfilledAt: true, }, }); } diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 0385198c..56efdf36 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -31,6 +31,7 @@ import { UpdateManufacturerEntity1768680807820 } from '../migrations/17686808078 import { AddUserPoolId1769189327767 } from '../migrations/1769189327767-AddUserPoolId'; import { UpdateOrderEntity1769990652833 } from '../migrations/1769990652833-UpdateOrderEntity'; import { RenameDonationMatchingStatus1771260403657 } from '../migrations/1771260403657-RenameDonationMatchingStatus'; +import { CleanupRequestsAndAllocations1771821377918 } from '../migrations/1771821377918-CleanupRequestsAndAllocations'; const schemaMigrations = [ User1725726359198, @@ -66,6 +67,7 @@ const schemaMigrations = [ AddUserPoolId1769189327767, UpdateOrderEntity1769990652833, RenameDonationMatchingStatus1771260403657, + CleanupRequestsAndAllocations1771821377918, ]; export default schemaMigrations; diff --git a/apps/backend/src/foodRequests/dtos/matching.dto.ts b/apps/backend/src/foodRequests/dtos/matching.dto.ts new file mode 100644 index 00000000..73c832a7 --- /dev/null +++ b/apps/backend/src/foodRequests/dtos/matching.dto.ts @@ -0,0 +1,19 @@ +import { FoodType } from '../../donationItems/types'; +import { FoodManufacturer } from '../../foodManufacturers/manufacturers.entity'; + +export class MatchingManufacturersDto { + matchingManufacturers: FoodManufacturer[]; + nonMatchingManufacturers: FoodManufacturer[]; +} + +export class MatchingItemsDto { + matchingItems: DonationItemDetailsDto[]; + nonMatchingItems: DonationItemDetailsDto[]; +} + +export class DonationItemDetailsDto { + itemId: number; + itemName: string; + foodType: FoodType; + availableQuantity: number; +} diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index de62ab82..1144164f 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -11,6 +11,12 @@ import { OrderStatus } from '../orders/types'; import { FoodType } from '../donationItems/types'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { Order } from '../orders/order.entity'; +import { + DonationItemDetailsDto, + MatchingItemsDto, + MatchingManufacturersDto, +} from './dtos/matching.dto'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; const mockRequestsService = mock(); const mockOrdersService = mock(); @@ -149,7 +155,10 @@ describe('RequestsController', () => { const createBody: Partial = { pantryId: 1, requestedSize: RequestSize.MEDIUM, - requestedItems: ['Test item 1', 'Test item 2'], + requestedFoodTypes: [ + FoodType.DAIRY_FREE_ALTERNATIVES, + FoodType.DRIED_BEANS, + ], additionalInformation: 'Test information.', dateReceived: null, feedback: null, @@ -173,7 +182,7 @@ describe('RequestsController', () => { expect(mockRequestsService.create).toHaveBeenCalledWith( createBody.pantryId, createBody.requestedSize, - createBody.requestedItems, + createBody.requestedFoodTypes, createBody.additionalInformation, createBody.dateReceived, createBody.feedback, @@ -379,4 +388,83 @@ describe('RequestsController', () => { ).rejects.toThrow('Invalid date format for deliveryDate'); }); }); + + describe('GET /:requestId/matching-manufacturers', () => { + it('should call requestsService.getMatchingManufacturers and return grouped manufacturers', async () => { + const requestId = 1; + + const mockResult: MatchingManufacturersDto = { + matchingManufacturers: [ + { + foodManufacturerId: 1, + foodManufacturerName: 'Test Manufacturer 1', + } as FoodManufacturer, + { + foodManufacturerId: 2, + foodManufacturerName: 'Test Manufacturer 2', + } as FoodManufacturer, + ], + nonMatchingManufacturers: [ + { + foodManufacturerId: 3, + foodManufacturerName: 'Non-Matching Manufacturer', + } as FoodManufacturer, + ], + }; + mockRequestsService.getMatchingManufacturers.mockResolvedValueOnce( + mockResult, + ); + + const result = await controller.getMatchingManufacturers(requestId); + + expect(result).toEqual(mockResult); + expect(mockRequestsService.getMatchingManufacturers).toHaveBeenCalledWith( + requestId, + ); + }); + }); + + describe('GET /:requestId/matching-manufacturers/:foodManufacturerId/available-items', () => { + it('should call requestsService.getAvailableItems and return grouped items', async () => { + const requestId = 1; + const foodManufacturerId = 1; + + const mockResult: MatchingItemsDto = { + matchingItems: [ + { + itemId: 1, + itemName: 'Granola', + foodType: FoodType.GRANOLA, + availableQuantity: 10, + } as DonationItemDetailsDto, + { + itemId: 2, + itemName: 'Dried Beans', + foodType: FoodType.DRIED_BEANS, + availableQuantity: 5, + } as DonationItemDetailsDto, + ], + nonMatchingItems: [ + { + itemId: 3, + itemName: 'Dairy Free Alternatives', + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + availableQuantity: 8, + } as DonationItemDetailsDto, + ], + }; + mockRequestsService.getAvailableItems.mockResolvedValueOnce(mockResult); + + const result = await controller.getAvailableItemsForManufacturer( + requestId, + foodManufacturerId, + ); + + expect(result).toEqual(mockResult); + expect(mockRequestsService.getAvailableItems).toHaveBeenCalledWith( + requestId, + foodManufacturerId, + ); + }); + }); }); diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 230eb0c4..8933a19f 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -22,6 +22,12 @@ import { OrdersService } from '../orders/order.service'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; import { OrderDetailsDto } from './dtos/order-details.dto'; +import { FoodType } from '../donationItems/types'; +import { + MatchingItemsDto, + MatchingManufacturersDto, +} from './dtos/matching.dto'; +import { Public } from '../auth/public.decorator'; @Controller('requests') export class RequestsController { @@ -54,6 +60,23 @@ export class RequestsController { return this.requestsService.getOrderDetails(requestId); } + @Public() + @Get('/:requestId/matching-manufacturers') + async getMatchingManufacturers( + @Param('requestId', ParseIntPipe) requestId: number, + ): Promise { + return this.requestsService.getMatchingManufacturers(requestId); + } + + @Public() + @Get('/:requestId/matching-manufacturers/:manufacturerId/available-items') + async getAvailableItemsForManufacturer( + @Param('requestId', ParseIntPipe) requestId: number, + @Param('manufacturerId', ParseIntPipe) manufacturerId: number, + ): Promise { + return this.requestsService.getAvailableItems(requestId, manufacturerId); + } + @Post('/create') @ApiBody({ description: 'Details for creating a food request', @@ -66,10 +89,10 @@ export class RequestsController { enum: Object.values(RequestSize), example: RequestSize.LARGE, }, - requestedItems: { + requestedFoodTypes: { type: 'array', - items: { type: 'string' }, - example: ['Rice Noodles', 'Quinoa'], + items: { type: 'string', enum: Object.values(FoodType) }, + example: [FoodType.DAIRY_FREE_ALTERNATIVES, FoodType.DRIED_BEANS], }, additionalInformation: { type: 'string', @@ -97,7 +120,7 @@ export class RequestsController { body: { pantryId: number; requestedSize: RequestSize; - requestedItems: string[]; + requestedFoodTypes: FoodType[]; additionalInformation: string; dateReceived: Date; feedback: string; @@ -112,7 +135,7 @@ export class RequestsController { return this.requestsService.create( body.pantryId, body.requestedSize, - body.requestedItems, + body.requestedFoodTypes, body.additionalInformation, body.dateReceived, body.feedback, diff --git a/apps/backend/src/foodRequests/request.entity.ts b/apps/backend/src/foodRequests/request.entity.ts index 25ba8e66..de1832f2 100644 --- a/apps/backend/src/foodRequests/request.entity.ts +++ b/apps/backend/src/foodRequests/request.entity.ts @@ -10,6 +10,7 @@ import { import { Order } from '../orders/order.entity'; import { RequestSize } from './types'; import { Pantry } from '../pantries/pantries.entity'; +import { FoodType } from '../donationItems/types'; @Entity('food_requests') export class FoodRequest { @@ -31,8 +32,22 @@ export class FoodRequest { }) requestedSize: RequestSize; - @Column({ name: 'requested_items', type: 'text', array: true }) - requestedItems: string[]; + @Column({ + name: 'requested_food_types', + type: 'text', + array: true, + transformer: { + to: (value: FoodType[]) => value, + from: (value: string | string[]) => + Array.isArray(value) + ? value + : value + .slice(1, -1) + .match(/("([^"]*)")|([^,]+)/g) + ?.map((v) => v.replace(/(^"|"$)/g, '').trim()) || [], + }, + }) + requestedFoodTypes: FoodType[]; @Column({ name: 'additional_information', type: 'text', nullable: true }) additionalInformation: string; diff --git a/apps/backend/src/foodRequests/request.module.ts b/apps/backend/src/foodRequests/request.module.ts index 0e5dc280..68345746 100644 --- a/apps/backend/src/foodRequests/request.module.ts +++ b/apps/backend/src/foodRequests/request.module.ts @@ -9,12 +9,20 @@ import { AuthModule } from '../auth/auth.module'; import { OrdersService } from '../orders/order.service'; import { Order } from '../orders/order.entity'; import { Pantry } from '../pantries/pantries.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { DonationItem } from '../donationItems/donationItems.entity'; @Module({ imports: [ AWSS3Module, MulterModule.register({ dest: './uploads' }), - TypeOrmModule.forFeature([FoodRequest, Order, Pantry]), + TypeOrmModule.forFeature([ + FoodRequest, + Order, + Pantry, + FoodManufacturer, + DonationItem, + ]), AuthModule, ], controllers: [RequestsController], diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 1eb21b69..e5fcc93d 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -1,9 +1,7 @@ import { Test } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { FoodRequest } from './request.entity'; import { RequestsService } from './request.service'; -import { mock } from 'jest-mock-extended'; import { Pantry } from '../pantries/pantries.entity'; import { RequestSize } from './types'; import { Order } from '../orders/order.entity'; @@ -11,49 +9,41 @@ import { OrderStatus } from '../orders/types'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.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, - pantryId: 1, - requestedItems: ['Canned Goods', 'Vegetables'], - additionalInformation: 'No onions, please.', - requestedAt: null, - dateReceived: null, - feedback: null, - photos: null, - orders: null, -}; +import { testDataSource } from '../config/typeormTestDataSource'; +import { NotFoundException } from '@nestjs/common'; + +jest.setTimeout(60000); describe('RequestsService', () => { let service: RequestsService; beforeAll(async () => { - // Reset the mock repository before compiling module - mockRequestsRepository.findOne.mockReset(); - mockRequestsRepository.create.mockReset(); - mockRequestsRepository.save.mockReset(); - mockRequestsRepository.find.mockReset(); - mockPantryRepository.findOneBy.mockReset(); + if (!testDataSource.isInitialized) { + await testDataSource.initialize(); + } const module = await Test.createTestingModule({ providers: [ RequestsService, { provide: getRepositoryToken(FoodRequest), - useValue: mockRequestsRepository, + useValue: testDataSource.getRepository(FoodRequest), }, { provide: getRepositoryToken(Pantry), - useValue: mockPantryRepository, + useValue: testDataSource.getRepository(Pantry), }, { provide: getRepositoryToken(Order), - useValue: mockOrdersRepository, + useValue: testDataSource.getRepository(Order), + }, + { + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), + }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), }, ], }).compile(); @@ -61,12 +51,21 @@ describe('RequestsService', () => { service = module.get(RequestsService); }); - beforeEach(() => { - mockRequestsRepository.findOne.mockReset(); - mockRequestsRepository.create.mockReset(); - mockRequestsRepository.save.mockReset(); - mockRequestsRepository.find.mockReset(); - mockPantryRepository.findOneBy.mockReset(); + beforeEach(async () => { + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + await testDataSource.runMigrations(); + }); + + afterEach(async () => { + await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + }); + + afterAll(async () => { + if (testDataSource.isInitialized) { + await testDataSource.destroy(); + } }); it('should be defined', () => { @@ -76,297 +75,123 @@ describe('RequestsService', () => { describe('findOne', () => { it('should return a food request with the corresponding id', async () => { const requestId = 1; - mockRequestsRepository.findOne.mockResolvedValueOnce( - mockRequest as FoodRequest, - ); const result = await service.findOne(requestId); - expect(result).toEqual(mockRequest); - expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ - where: { requestId }, - relations: ['orders'], - }); + expect(result).toBeDefined(); + expect(result.requestId).toBe(requestId); + expect(result.orders).toBeDefined(); + expect(result.orders).toHaveLength(1); }); - it('should throw an error if the request id is not found', async () => { - const requestId = 999; - - mockRequestsRepository.findOne.mockResolvedValueOnce(null); - - await expect(service.findOne(requestId)).rejects.toThrow( - `Request ${requestId} not found`, + it('should throw NotFoundException for non-existent request', async () => { + await expect(service.findOne(999)).rejects.toThrow( + new NotFoundException('Request 999 not found'), ); - - expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ - where: { requestId }, - 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, - }, - }, + const result = await service.getOrderDetails(1); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + orderId: 1, + status: OrderStatus.DELIVERED, + foodManufacturerName: 'FoodCorp Industries', + items: expect.any(Array), }); + expect(result[0].items).toHaveLength(3); }); - 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 throw NotFoundException for non-existent request', async () => { + await expect(service.getOrderDetails(999)).rejects.toThrow( + new NotFoundException('Request 999 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, - }, - }, - }); + const result = await testDataSource.query(` + INSERT INTO food_requests (pantry_id, requested_size, requested_food_types, requested_at) + VALUES ( + (SELECT pantry_id FROM pantries LIMIT 1), + 'Small (2-5 boxes)', + ARRAY[]::food_type_enum[], + NOW() + ) + RETURNING request_id + `); + const requestId = result[0].request_id; + const orderDetails = await service.getOrderDetails(requestId); + expect(orderDetails).toEqual([]); }); }); describe('create', () => { it('should successfully create and return a new food request', async () => { - mockPantryRepository.findOneBy.mockResolvedValueOnce({ - pantryId: 1, - } as unknown as Pantry); - mockRequestsRepository.create.mockReturnValueOnce( - mockRequest as FoodRequest, - ); - mockRequestsRepository.save.mockResolvedValueOnce( - mockRequest as FoodRequest, - ); - mockRequestsRepository.find.mockResolvedValueOnce([ - mockRequest as FoodRequest, - ]); - + const pantryId = 1; const result = await service.create( - mockRequest.pantryId, - mockRequest.requestedSize, - mockRequest.requestedItems, - mockRequest.additionalInformation, - mockRequest.dateReceived, - mockRequest.feedback, - mockRequest.photos, + pantryId, + RequestSize.MEDIUM, + [FoodType.DRIED_BEANS, FoodType.REFRIGERATED_MEALS], + 'Additional info', + null, + null, + null, ); - - expect(result).toEqual(mockRequest); - expect(mockRequestsRepository.create).toHaveBeenCalledWith({ - pantryId: mockRequest.pantryId, - requestedSize: mockRequest.requestedSize, - requestedItems: mockRequest.requestedItems, - additionalInformation: mockRequest.additionalInformation, - dateReceived: mockRequest.dateReceived, - feedback: mockRequest.feedback, - photos: mockRequest.photos, - }); - expect(mockRequestsRepository.save).toHaveBeenCalledWith(mockRequest); + expect(result).toBeDefined(); + expect(result.pantryId).toBe(pantryId); + expect(result.requestedSize).toBe(RequestSize.MEDIUM); + expect(result.requestedFoodTypes).toEqual([ + FoodType.DRIED_BEANS, + FoodType.REFRIGERATED_MEALS, + ]); + expect(result.additionalInformation).toBe('Additional info'); + expect(result.dateReceived).toBeNull(); + expect(result.feedback).toBeNull(); + expect(result.photos).toBeNull(); }); - it('should throw an error if the pantry ID does not exist', async () => { - const invalidPantryId = 999; - + it('should throw NotFoundException for non-existent pantry', async () => { await expect( service.create( - invalidPantryId, + 999, RequestSize.MEDIUM, - ['Canned Goods', 'Vegetables'], + [FoodType.DRIED_BEANS, FoodType.REFRIGERATED_MEALS], 'Additional info', null, null, null, ), - ).rejects.toThrow(`Pantry ${invalidPantryId} not found`); - - expect(mockRequestsRepository.create).not.toHaveBeenCalled(); - expect(mockRequestsRepository.save).not.toHaveBeenCalled(); + ).rejects.toThrow(new NotFoundException('Pantry 999 not found')); }); }); describe('find', () => { it('should return all food requests for a specific pantry', async () => { - const mockRequests: Partial[] = [ - mockRequest, - { - requestId: 2, - pantryId: 1, - requestedSize: RequestSize.LARGE, - requestedItems: ['Rice', 'Beans'], - additionalInformation: 'Gluten-free items only.', - requestedAt: null, - dateReceived: null, - feedback: null, - photos: null, - orders: null, - }, - { - requestId: 3, - pantryId: 2, - requestedSize: RequestSize.SMALL, - requestedItems: ['Fruits', 'Snacks'], - additionalInformation: 'No nuts, please.', - requestedAt: null, - dateReceived: null, - feedback: null, - photos: null, - orders: null, - }, - ]; const pantryId = 1; - mockRequestsRepository.find.mockResolvedValueOnce( - mockRequests.slice(0, 2) as FoodRequest[], - ); + const result = await service.find(pantryId); + + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result.every((r) => r.pantryId === pantryId)).toBe(true); + }); + it('should return empty array for pantry with no requests', async () => { + const pantryId = 5; const result = await service.find(pantryId); - expect(result).toEqual(mockRequests.slice(0, 2)); - expect(mockRequestsRepository.find).toHaveBeenCalledWith({ - where: { pantryId }, - relations: ['orders'], - }); + expect(result).toBeDefined(); + expect(result).toEqual([]); }); }); describe('updateDeliveryDetails', () => { it('should update and return the food request with new delivery details', async () => { - const mockOrder: Partial = { - orderId: 1, - request: null, - status: OrderStatus.SHIPPED, - createdAt: new Date(), - shippedAt: new Date(), - deliveredAt: null, - }; - - const mockRequest2: Partial = { - ...mockRequest, - orders: [mockOrder] as Order[], - }; - const requestId = 1; const deliveryDate = new Date(); const feedback = 'Good delivery!'; const photos = ['photo1.jpg', 'photo2.jpg']; - mockRequestsRepository.findOne.mockResolvedValueOnce( - mockRequest2 as FoodRequest, - ); - - const updatedOrder = { ...mockOrder, status: OrderStatus.DELIVERED }; - - mockRequestsRepository.save.mockResolvedValueOnce({ - ...mockRequest, - dateReceived: deliveryDate, - feedback, - photos, - orders: [updatedOrder], - } as FoodRequest); - const result = await service.updateDeliveryDetails( requestId, deliveryDate, @@ -374,36 +199,19 @@ describe('RequestsService', () => { photos, ); - expect(result).toEqual({ - ...mockRequest, - dateReceived: deliveryDate, - feedback, - photos, - orders: [updatedOrder], - }); - - expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ - where: { requestId }, - relations: ['orders'], - }); - - expect(mockRequestsRepository.save).toHaveBeenCalledWith({ - ...mockRequest, - dateReceived: deliveryDate, - feedback, - photos, - orders: [mockOrder], - }); + expect(result).toBeDefined(); + expect(result.requestId).toBe(requestId); + expect(result.dateReceived).toEqual(deliveryDate); + expect(result.feedback).toBe(feedback); + expect(result.photos).toEqual(photos); }); - it('should throw an error if the request ID is invalid', async () => { + it('should throw NotFoundException for non-existent request', async () => { const requestId = 999; const deliveryDate = new Date(); const feedback = 'Good delivery!'; const photos = ['photo1.jpg', 'photo2.jpg']; - mockRequestsRepository.findOne.mockResolvedValueOnce(null); - await expect( service.updateDeliveryDetails( requestId, @@ -411,23 +219,25 @@ describe('RequestsService', () => { feedback, photos, ), - ).rejects.toThrow('Invalid request ID'); - - expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ - where: { requestId }, - relations: ['orders'], - }); + ).rejects.toThrow(new NotFoundException('Request 999 not found')); }); - it('should throw an error if there is no associated order', async () => { - const requestId = 1; + it('should throw NotFoundException if there are no associated orders', async () => { const deliveryDate = new Date(); const feedback = 'Good delivery!'; const photos = ['photo1.jpg', 'photo2.jpg']; - mockRequestsRepository.findOne.mockResolvedValueOnce( - mockRequest as FoodRequest, - ); + const result = await testDataSource.query(` + INSERT INTO food_requests (pantry_id, requested_size, requested_food_types, requested_at) + VALUES ( + (SELECT pantry_id FROM pantries LIMIT 1), + 'Small (2-5 boxes)', + ARRAY[]::food_type_enum[], + NOW() + ) + RETURNING request_id + `); + const requestId = result[0].request_id; await expect( service.updateDeliveryDetails( @@ -436,12 +246,186 @@ describe('RequestsService', () => { feedback, photos, ), - ).rejects.toThrow('No associated orders found for this request'); + ).rejects.toThrow( + new NotFoundException('No associated orders found for this request'), + ); + }); + }); + + describe('getMatchingManufacturers', () => { + it('throws NotFoundException when request does not exist', async () => { + await expect(service.getMatchingManufacturers(999)).rejects.toThrow( + new NotFoundException('Request 999 not found'), + ); + }); + + it('every manufacturer in matchingManufacturers has at least one item matching a requested food type', async () => { + const requestId = 1; + const request = await service.findOne(requestId); + const result = await service.getMatchingManufacturers(requestId); + + for (const fm of result.matchingManufacturers) { + const items = await testDataSource.query( + ` + SELECT 1 FROM donations d + JOIN donation_items di ON di.donation_id = d.donation_id + WHERE d.food_manufacturer_id = $1 + AND di.food_type = ANY($2) + AND di.reserved_quantity < di.quantity + LIMIT 1 + `, + [fm.foodManufacturerId, request.requestedFoodTypes], + ); + expect(items.length).toBe(1); + } + }); + + it('every manufacturer in nonMatchingManufacturers has no items matching a requested food type', async () => { + const requestId = 1; + const request = await service.findOne(requestId); + const result = await service.getMatchingManufacturers(requestId); + + for (const fm of result.nonMatchingManufacturers) { + const items = await testDataSource.query( + ` + SELECT 1 FROM donations d + JOIN donation_items di ON di.donation_id = d.donation_id + WHERE d.food_manufacturer_id = $1 + AND di.food_type = ANY($2) + AND di.reserved_quantity < di.quantity + LIMIT 1 + `, + [fm.foodManufacturerId, request.requestedFoodTypes], + ); + expect(items.length).toBe(0); + } + }); + + it('no manufacturer appears in both matchingManufacturers and nonMatchingManufacturers', async () => { + const requestId = 1; + const result = await service.getMatchingManufacturers(requestId); - expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ - where: { requestId }, - relations: ['orders'], + const matchingIds = result.matchingManufacturers.map( + (fm) => fm.foodManufacturerId, + ); + const nonMatchingIds = result.nonMatchingManufacturers.map( + (fm) => fm.foodManufacturerId, + ); + const intersection = matchingIds.filter((id) => + nonMatchingIds.includes(id), + ); + expect(intersection).toEqual([]); + }); + + it(`doesn't include manufacturers with no donation items in either list`, async () => { + const requestId = 1; + const result = await service.getMatchingManufacturers(requestId); + + for (const fm of [ + ...result.matchingManufacturers, + ...result.nonMatchingManufacturers, + ]) { + const items = await testDataSource.query( + ` + SELECT 1 FROM donations d + JOIN donation_items di ON di.donation_id = d.donation_id + WHERE d.food_manufacturer_id = $1 + LIMIT 1 + `, + [fm.foodManufacturerId], + ); + expect(items.length).toBe(1); + } + }); + + it('returns empty matching list when no food types are requested', async () => { + const result = await testDataSource.query(` + INSERT INTO food_requests (pantry_id, requested_size, requested_food_types, requested_at) + VALUES ( + (SELECT pantry_id FROM pantries LIMIT 1), + 'Small (2-5 boxes)', + ARRAY[]::food_type_enum[], + NOW() + ) + RETURNING request_id + `); + const requestId = result[0].request_id; + + const { matchingManufacturers } = await service.getMatchingManufacturers( + requestId, + ); + expect(matchingManufacturers).toHaveLength(0); + }); + }); + + describe('getAvailableItems', () => { + it('all items belong to the specified manufacturer', async () => { + const manufacturerId = 1; + const result = await service.getAvailableItems(1, manufacturerId); + const allItems = [...result.matchingItems, ...result.nonMatchingItems]; + + for (const item of allItems) { + const donation = await testDataSource.query( + ` + SELECT 1 FROM donation_items di + JOIN donations d ON d.donation_id = di.donation_id + WHERE di.item_id = $1 + AND d.food_manufacturer_id = $2 + LIMIT 1 + `, + [item.itemId, manufacturerId], + ); + expect(donation.length).toBe(1); + } + }); + + it('all items in matchingItems match a requested food type, and all items in nonMatchingItems do not match any requested food types', async () => { + const requestId = 1; + const request = await service.findOne(requestId); + const requestedFoodTypes = request.requestedFoodTypes; + + const result = await service.getAvailableItems(requestId, 1); + + for (const item of result.matchingItems) { + expect(requestedFoodTypes).toContain(item.foodType); + } + + for (const item of result.nonMatchingItems) { + expect(requestedFoodTypes).not.toContain(item.foodType); + } + }); + + it('no item appears in both matchingItems and nonMatchingItems', async () => { + const requestId = 1; + const result = await service.getAvailableItems(requestId, 1); + + const matchingIds = result.matchingItems.map((item) => item.itemId); + const nonMatchingIds = result.nonMatchingItems.map((item) => item.itemId); + const intersection = matchingIds.filter((id) => + nonMatchingIds.includes(id), + ); + expect(intersection).toEqual([]); + }); + + it('only returns items where reserved_quantity < quantity', async () => { + const result = await service.getAvailableItems(1, 1); + + const allItems = [...result.matchingItems, ...result.nonMatchingItems]; + allItems.forEach((item) => { + expect(item.availableQuantity).toBeGreaterThan(0); }); }); + + it('throws NotFoundException for non-existent request', async () => { + await expect(service.getAvailableItems(999, 1)).rejects.toThrow( + new NotFoundException('Request 999 not found'), + ); + }); + + it('throws NotFoundException for non-existent manufacturer', async () => { + await expect(service.getAvailableItems(1, 999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 999 not found'), + ); + }); }); }); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index c74b2f87..fd69dbe4 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -7,6 +7,13 @@ import { RequestSize } from './types'; import { Pantry } from '../pantries/pantries.entity'; import { Order } from '../orders/order.entity'; import { OrderDetailsDto } from './dtos/order-details.dto'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { + MatchingItemsDto, + MatchingManufacturersDto, +} from './dtos/matching.dto'; +import { FoodType } from '../donationItems/types'; +import { DonationItem } from '../donationItems/donationItems.entity'; @Injectable() export class RequestsService { @@ -14,6 +21,10 @@ export class RequestsService { @InjectRepository(FoodRequest) private repo: Repository, @InjectRepository(Pantry) private pantryRepo: Repository, @InjectRepository(Order) private orderRepo: Repository, + @InjectRepository(FoodManufacturer) + private foodManufacturerRepo: Repository, + @InjectRepository(DonationItem) + private donationItemRepo: Repository, ) {} async findOne(requestId: number): Promise { @@ -51,16 +62,6 @@ export class RequestsService { }, }); - if (!orders) { - throw new NotFoundException( - 'No associated orders found for this request', - ); - } - - if (!orders.length) { - return []; - } - return orders.map((order) => ({ orderId: order.orderId, status: order.status, @@ -73,10 +74,109 @@ export class RequestsService { })); } + async getMatchingManufacturers( + requestId: number, + ): Promise { + validateId(requestId, 'Request'); + + const request = await this.repo.findOne({ where: { requestId } }); + if (!request) { + throw new NotFoundException(`Request ${requestId} not found`); + } + + const requestedFoodTypes = request.requestedFoodTypes; + + const rows: (FoodManufacturer & { matching: boolean })[] = + await this.foodManufacturerRepo + .createQueryBuilder('fm') + .addSelect( + `EXISTS ( + SELECT 1 + FROM donations d + JOIN donation_items di ON d.donation_id = di.donation_id + WHERE d.food_manufacturer_id = fm.food_manufacturer_id + AND di.food_type = ANY(:requestedFoodTypes) + AND di.reserved_quantity < di.quantity + )`, + 'matching', + ) + .where( + `EXISTS ( + SELECT 1 + FROM donations d + JOIN donation_items di ON d.donation_id = di.donation_id + WHERE d.food_manufacturer_id = fm.food_manufacturer_id + AND di.reserved_quantity < di.quantity + )`, + { requestedFoodTypes }, + ) + .getRawAndEntities() + .then(({ raw, entities }) => + entities.map((fm, i) => ({ + ...fm, + matching: raw[i].matching as boolean, + })), + ); + + const matchingManufacturers = rows.filter((fm) => fm.matching); + const nonMatchingManufacturers = rows.filter((fm) => !fm.matching); + + return { + matchingManufacturers, + nonMatchingManufacturers, + }; + } + + async getAvailableItems( + requestId: number, + foodManufacturerId: number, + ): Promise { + validateId(requestId, 'Request'); + validateId(foodManufacturerId, 'Manufacturer'); + + const request = await this.repo.findOne({ where: { requestId } }); + if (!request) { + throw new NotFoundException(`Request ${requestId} not found`); + } + + const manufacturer = await this.foodManufacturerRepo.findOne({ + where: { foodManufacturerId }, + }); + if (!manufacturer) { + throw new NotFoundException( + `Food Manufacturer ${foodManufacturerId} not found`, + ); + } + + const availableItems = await this.donationItemRepo + .createQueryBuilder('di') + .select([ + 'di.item_id AS "itemId"', + 'di.item_name AS "itemName"', + 'di.food_type AS "foodType"', + '(di.quantity - di.reserved_quantity) AS "availableQuantity"', + ]) + .innerJoin('di.donation', 'd') + .where('d.food_manufacturer_id = :foodManufacturerId', { + foodManufacturerId, + }) + .andWhere('di.reserved_quantity < di.quantity') + .getRawMany(); + + const matchingItems = availableItems.filter((item) => + request.requestedFoodTypes.includes(item.foodType), + ); + const nonMatchingItems = availableItems.filter( + (item) => !request.requestedFoodTypes.includes(item.foodType), + ); + + return { matchingItems, nonMatchingItems }; + } + async create( pantryId: number, requestedSize: RequestSize, - requestedItems: string[], + requestedFoodTypes: FoodType[], additionalInformation: string | undefined, dateReceived: Date | undefined, feedback: string | undefined, @@ -92,7 +192,7 @@ export class RequestsService { const foodRequest = this.repo.create({ pantryId, requestedSize, - requestedItems, + requestedFoodTypes, additionalInformation, dateReceived, feedback, @@ -125,7 +225,7 @@ export class RequestsService { }); if (!request) { - throw new NotFoundException('Invalid request ID'); + throw new NotFoundException(`Request ${requestId} not found`); } if (!request.orders || request.orders.length == 0) { diff --git a/apps/backend/src/migrations/1771821377918-CleanupRequestsAndAllocations.ts b/apps/backend/src/migrations/1771821377918-CleanupRequestsAndAllocations.ts new file mode 100644 index 00000000..d53188e0 --- /dev/null +++ b/apps/backend/src/migrations/1771821377918-CleanupRequestsAndAllocations.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CleanupRequestsAndAllocations1771821377918 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE food_requests + RENAME COLUMN requested_items TO requested_food_types; + + ALTER TABLE food_requests + ALTER COLUMN requested_food_types TYPE food_type_enum[] + USING requested_food_types::food_type_enum[]; + + ALTER TABLE allocations + DROP COLUMN IF EXISTS reserved_at, + DROP COLUMN IF EXISTS fulfilled_at; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE food_requests + ALTER COLUMN requested_food_types TYPE TEXT[] + USING requested_food_types::text[]; + + ALTER TABLE food_requests + RENAME COLUMN requested_food_types TO requested_items; + + ALTER TABLE allocations + ADD COLUMN reserved_at TIMESTAMP NOT NULL DEFAULT NOW(), + ADD COLUMN fulfilled_at TIMESTAMP; + `); + } +} diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index 4706f451..9f84f195 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -9,7 +9,7 @@ import { OrderStatus } from './types'; import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; const mockOrdersService = mock(); @@ -135,20 +135,6 @@ describe('OrdersController', () => { expect(result).toEqual(mockPantries[0] as Pantry); expect(mockOrdersService.findOrderPantry).toHaveBeenCalledWith(orderId); }); - - it('should propagate NotFoundException when request not found', async () => { - const orderId = 999; - mockOrdersService.findOrderPantry.mockRejectedValueOnce( - new NotFoundException(`Request for order ${orderId} not found`), - ); - - const promise = controller.getPantryFromOrder(orderId); - await expect(promise).rejects.toBeInstanceOf(NotFoundException); - await expect(promise).rejects.toThrow( - `Request for order ${orderId} not found`, - ); - expect(mockOrdersService.findOrderPantry).toHaveBeenCalledWith(orderId); - }); }); describe('getRequestFromOrder', () => { @@ -165,20 +151,6 @@ describe('OrdersController', () => { orderId, ); }); - - it('should propagate NotFoundException when order not found', async () => { - const orderId = 999; - mockOrdersService.findOrderFoodRequest.mockRejectedValueOnce( - new NotFoundException(`Order ${orderId} not found`), - ); - - const promise = controller.getRequestFromOrder(orderId); - await expect(promise).rejects.toBeInstanceOf(NotFoundException); - await expect(promise).rejects.toThrow(`Order ${orderId} not found`); - expect(mockOrdersService.findOrderFoodRequest).toHaveBeenCalledWith( - orderId, - ); - }); }); describe('getManufacturerFromOrder', () => { @@ -195,20 +167,6 @@ describe('OrdersController', () => { orderId, ); }); - - it('should propagate NotFoundException when order not found', async () => { - const orderId = 999; - mockOrdersService.findOrderFoodManufacturer.mockRejectedValueOnce( - new NotFoundException(`Order ${orderId} not found`), - ); - - const promise = controller.getManufacturerFromOrder(orderId); - await expect(promise).rejects.toBeInstanceOf(NotFoundException); - await expect(promise).rejects.toThrow(`Order ${orderId} not found`); - expect(mockOrdersService.findOrderFoodManufacturer).toHaveBeenCalledWith( - orderId, - ); - }); }); describe('getOrder', () => { @@ -221,18 +179,6 @@ describe('OrdersController', () => { expect(result).toEqual(mockOrders[0] as Order); expect(mockOrdersService.findOne).toHaveBeenCalledWith(orderId); }); - - it('should propagate NotFoundException when order not found', async () => { - const orderId = 999; - mockOrdersService.findOne.mockRejectedValueOnce( - new NotFoundException(`Order ${orderId} not found`), - ); - - const promise = controller.getOrder(orderId); - await expect(promise).rejects.toBeInstanceOf(NotFoundException); - await expect(promise).rejects.toThrow(`Order ${orderId} not found`); - expect(mockOrdersService.findOne).toHaveBeenCalledWith(orderId); - }); }); describe('getOrderByRequestId', () => { @@ -249,22 +195,6 @@ describe('OrdersController', () => { requestId, ); }); - - it('should propagate NotFoundException when order not found', async () => { - const requestId = 999; - mockOrdersService.findOrderByRequest.mockRejectedValueOnce( - new NotFoundException(`Order with request ID ${requestId} not found`), - ); - - const promise = controller.getOrderByRequestId(requestId); - await expect(promise).rejects.toBeInstanceOf(NotFoundException); - await expect(promise).rejects.toThrow( - `Order with request ID ${requestId} not found`, - ); - expect(mockOrdersService.findOrderByRequest).toHaveBeenCalledWith( - requestId, - ); - }); }); describe('getAllAllocationsByOrder', () => { @@ -320,91 +250,5 @@ describe('OrdersController', () => { dto, ); }); - - it('should propagate BadRequestException when neither tracking link nor shipping cost is provided', async () => { - const orderId = 1; - - mockOrdersService.updateTrackingCostInfo.mockRejectedValueOnce( - new BadRequestException( - 'At least one of tracking link or shipping cost must be provided', - ), - ); - - const promise = controller.updateTrackingCostInfo(orderId, {}); - await expect(promise).rejects.toBeInstanceOf(BadRequestException); - await expect(promise).rejects.toThrow( - 'At least one of tracking link or shipping cost must be provided', - ); - expect(mockOrdersService.updateTrackingCostInfo).toHaveBeenCalledWith( - orderId, - {}, - ); - }); - - it('should propagate NotFoundException when order not found', async () => { - const orderId = 999; - const trackingLink = 'www.samplelink/samplelink'; - const shippingCost = 15.99; - const dto: TrackingCostDto = { trackingLink, shippingCost }; - - mockOrdersService.updateTrackingCostInfo.mockRejectedValueOnce( - new NotFoundException(`Order ${orderId} not found`), - ); - - const promise = controller.updateTrackingCostInfo(orderId, dto); - await expect(promise).rejects.toBeInstanceOf(NotFoundException); - await expect(promise).rejects.toThrow(`Order ${orderId} not found`); - expect(mockOrdersService.updateTrackingCostInfo).toHaveBeenCalledWith( - orderId, - dto, - ); - }); - - it('should propagate BadRequestException for delivered order', async () => { - const dto: TrackingCostDto = { - trackingLink: 'testtracking.com', - shippingCost: 7.5, - }; - const orderId = 2; - - mockOrdersService.updateTrackingCostInfo.mockRejectedValueOnce( - new BadRequestException( - 'Can only update tracking info for pending or shipped orders', - ), - ); - - const promise = controller.updateTrackingCostInfo(orderId, dto); - await expect(promise).rejects.toBeInstanceOf(BadRequestException); - await expect(promise).rejects.toThrow( - 'Can only update tracking info for pending or shipped orders', - ); - expect(mockOrdersService.updateTrackingCostInfo).toHaveBeenCalledWith( - orderId, - dto, - ); - }); - - it('throws when both fields are not provided for first time setting', async () => { - const dto: TrackingCostDto = { - trackingLink: 'testtracking.com', - }; - const orderId = 4; - - mockOrdersService.updateTrackingCostInfo.mockRejectedValueOnce( - new BadRequestException( - 'Must provide both tracking link and shipping cost on initial assignment', - ), - ); - - const promise = controller.updateTrackingCostInfo(orderId, dto); - await expect(promise).rejects.toBeInstanceOf(BadRequestException); - await expect(promise).rejects.toThrow( - 'Must provide both tracking link and shipping cost on initial assignment', - ); - expect(mockOrdersService.updateTrackingCostInfo).toHaveBeenCalledWith( - orderId, - dto, - ); - }); }); }); diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index baeb8766..0e354b23 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -16,7 +16,7 @@ import { } from './types'; import { EmailsService } from '../emails/email.service'; import { ApplicationStatus } from '../shared/types'; -import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { UnauthorizedException } from '@nestjs/common'; import { User } from '../users/user.entity'; const mockPantriesService = mock(); @@ -270,17 +270,5 @@ describe('PantriesController', () => { new UnauthorizedException('Not authenticated'), ); }); - - it('propagates NotFoundException from service', async () => { - const req = { user: { id: 999 } }; - mockPantriesService.findByUserId.mockRejectedValueOnce( - new NotFoundException('Pantry for User 999 not found'), - ); - - const promise = controller.getCurrentUserPantryId(req); - await expect(promise).rejects.toBeInstanceOf(NotFoundException); - await expect(promise).rejects.toThrow('Pantry for User 999 not found'); - expect(mockPantriesService.findByUserId).toHaveBeenCalledWith(999); - }); }); }); diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index c844de29..3c6631d3 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -1,8 +1,6 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import Root from '@containers/root'; import NotFound from '@containers/404'; -import LandingPage from '@containers/landingPage'; -import PantryOverview from '@containers/pantryOverview'; import PantryPastOrders from '@containers/pantryPastOrders'; import Pantries from '@containers/pantries'; import Orders from '@containers/orders'; @@ -43,10 +41,6 @@ const router = createBrowserRouter([ index: true, element: , }, - { - path: '/landing-page', - element: , - }, { path: '/login', element: , @@ -78,14 +72,6 @@ const router = createBrowserRouter([ element: , }, // Private routes (protected by auth) - { - path: '/pantry-overview', - element: ( - - - - ), - }, { path: '/pantry-past-orders', element: ( @@ -102,14 +88,6 @@ const router = createBrowserRouter([ ), }, - { - path: '/pantry-overview', - element: ( - - - - ), - }, { path: '/pantry-dashboard', element: ( diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index 91ad1472..5d11c90c 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -20,7 +20,6 @@ import ApiClient from '@api/apiClient'; import { DayOfWeek, FoodType, - FoodTypes, RecurrenceEnum, RepeatOnState, } from '../../types/types'; @@ -416,7 +415,7 @@ const NewDonationFormModal: React.FC = ({ handleChange(row.id, 'foodType', e.target.value) } > - {FoodTypes.map((type) => ( + {Object.values(FoodType).map((type) => ( diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index 309e1599..8f2411c8 100644 --- a/apps/frontend/src/components/forms/orderDetailsModal.tsx +++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx @@ -85,7 +85,7 @@ const OrderDetailsModal: React.FC = ({ Food Type(s) - + diff --git a/apps/frontend/src/components/forms/requestDetailsModal.tsx b/apps/frontend/src/components/forms/requestDetailsModal.tsx index 6b285109..cb3c7a10 100644 --- a/apps/frontend/src/components/forms/requestDetailsModal.tsx +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -1,7 +1,7 @@ import apiClient from '@api/apiClient'; import { FoodRequest, - FoodTypes, + FoodType, OrderDetails, OrderItemDetails, } from 'types/types'; @@ -42,7 +42,7 @@ const RequestDetailsModal: React.FC = ({ const [currentPage, setCurrentPage] = useState(1); const requestedSize = request.requestedSize; - const selectedItems = request.requestedItems; + const selectedFoodTypes = request.requestedFoodTypes; const additionalNotes = request.additionalInformation; useEffect(() => { @@ -85,12 +85,13 @@ const RequestDetailsModal: React.FC = ({ if (!currentOrder) return {}; return currentOrder.items.reduce( - (acc: Record<(typeof FoodTypes)[number], OrderItemDetails[]>, item) => { - if (!acc[item.foodType]) acc[item.foodType] = []; - acc[item.foodType].push(item); + (acc: Partial>, item) => { + const existing = acc[item.foodType]; + if (existing) existing.push(item); + else acc[item.foodType] = []; return acc; }, - {} as Record<(typeof FoodTypes)[number], OrderItemDetails[]>, + {} as Partial>, ); }, [currentOrder]); @@ -176,7 +177,7 @@ const RequestDetailsModal: React.FC = ({ - + @@ -233,9 +234,8 @@ const RequestDetailsModal: React.FC = ({ )} {Object.entries( - groupedOrderItemsByType as Record< - string, - OrderItemDetails[] + groupedOrderItemsByType as Partial< + Record >, ).map(([foodType, items]) => ( diff --git a/apps/frontend/src/components/forms/requestFormModal.tsx b/apps/frontend/src/components/forms/requestFormModal.tsx index 04d8cfb6..0c10ab19 100644 --- a/apps/frontend/src/components/forms/requestFormModal.tsx +++ b/apps/frontend/src/components/forms/requestFormModal.tsx @@ -13,7 +13,7 @@ import { import { CreateFoodRequestBody, FoodRequest, - FoodTypes, + FoodType, RequestSize, } from '../../types/types'; import { ChevronDownIcon } from 'lucide-react'; @@ -35,17 +35,17 @@ const FoodRequestFormModal: React.FC = ({ pantryId, onSuccess, }) => { - const [selectedItems, setSelectedItems] = useState([]); + const [selectedFoodTypes, setSelectedFoodTypes] = useState([]); const [requestedSize, setRequestedSize] = useState(''); const [additionalNotes, setAdditionalNotes] = useState(''); const [alertMessage, setAlertMessage] = useState(''); - const isFormValid = requestedSize !== '' && selectedItems.length > 0; + const isFormValid = requestedSize !== '' && selectedFoodTypes.length > 0; useEffect(() => { if (isOpen && previousRequest) { - setSelectedItems(previousRequest.requestedItems || []); + setSelectedFoodTypes(previousRequest.requestedFoodTypes || []); setRequestedSize(previousRequest.requestedSize || ''); setAdditionalNotes( previousRequest.additionalInformation || @@ -59,7 +59,7 @@ const FoodRequestFormModal: React.FC = ({ pantryId, requestedSize: requestedSize as RequestSize, additionalInformation: additionalNotes || '', - requestedItems: selectedItems, + requestedFoodTypes: selectedFoodTypes, dateReceived: null, feedback: null, photos: [], @@ -179,7 +179,7 @@ const FoodRequestFormModal: React.FC = ({ justifyContent="space-between" textStyle="p2" > - {selectedItems.length > 0 + {selectedFoodTypes.length > 0 ? `Select more food types` : 'Select food types'} @@ -188,16 +188,16 @@ const FoodRequestFormModal: React.FC = ({ - {FoodTypes.map((allergen) => { - const isChecked = selectedItems.includes(allergen); + {Object.values(FoodType).map((allergen) => { + const isChecked = selectedFoodTypes.includes(allergen); return ( { - setSelectedItems((prev) => + setSelectedFoodTypes((prev) => checked - ? [...prev, allergen] + ? [...prev, allergen as FoodType] : prev.filter((i) => i !== allergen), ); }} @@ -231,9 +231,11 @@ const FoodRequestFormModal: React.FC = ({ - setSelectedItems((prev) => prev.filter((i) => i !== value)) + setSelectedFoodTypes((prev) => + prev.filter((i) => i !== value), + ) } /> diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index 74604c68..36efcb8e 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -11,8 +11,11 @@ import { Text, Alert, } from '@chakra-ui/react'; +import { useAuthenticator } from '@aws-amplify/ui-react'; const Homepage: React.FC = () => { + const { user } = useAuthenticator((context) => [context.user]); + return ( @@ -128,23 +131,25 @@ const Homepage: React.FC = () => { - - - Other Pages - - - - - Landing Page - - - - - Pantry Overview - - - - + {!user && ( + + + Other Pages + + + + + Login + + + + + Sign Up + + + + + )} { Note: This is a temporary navigation page for development purposes. - - Routes with parameters are using default values (e.g., ID: 1) - diff --git a/apps/frontend/src/containers/landingPage.tsx b/apps/frontend/src/containers/landingPage.tsx deleted file mode 100644 index 7ce581d6..00000000 --- a/apps/frontend/src/containers/landingPage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import SignOutButton from '@components/signOutButton'; -import { useAuthenticator } from '@aws-amplify/ui-react'; - -const LandingPage: React.FC = () => { - const { user } = useAuthenticator((context) => [context.user]); - return ( - <> - Landing page - {user && } - - ); -}; - -export default LandingPage; diff --git a/apps/frontend/src/containers/pantryDashboard.tsx b/apps/frontend/src/containers/pantryDashboard.tsx index a5bc636b..3335747c 100644 --- a/apps/frontend/src/containers/pantryDashboard.tsx +++ b/apps/frontend/src/containers/pantryDashboard.tsx @@ -85,7 +85,7 @@ const PantryDashboard: React.FC = () => { @@ -101,7 +101,7 @@ const PantryDashboard: React.FC = () => { diff --git a/apps/frontend/src/containers/pantryOverview.tsx b/apps/frontend/src/containers/pantryOverview.tsx deleted file mode 100644 index aceb09a5..00000000 --- a/apps/frontend/src/containers/pantryOverview.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const PantryOverview: React.FC = () => { - return <>Pantry overview; -}; - -export default PantryOverview; diff --git a/apps/frontend/src/containers/unauthorized.tsx b/apps/frontend/src/containers/unauthorized.tsx index 83c687b7..39dc5d84 100644 --- a/apps/frontend/src/containers/unauthorized.tsx +++ b/apps/frontend/src/containers/unauthorized.tsx @@ -3,6 +3,12 @@ export const Unauthorized: React.FC = () => {

Oops!

You are not an authorized user for this page!

+

+ Return to{' '} + + home page + +

); }; diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index ce85f3b4..db84fedb 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -140,23 +140,6 @@ export interface DonationItem { foodRescue?: boolean; } -export const FoodTypes = [ - 'Dairy-Free Alternatives', - 'Dried Beans (Gluten-Free, Nut-Free)', - 'Gluten-Free Baking/Pancake Mixes', - 'Gluten-Free Bread', - 'Gluten-Free Tortillas', - 'Granola', - 'Masa Harina Flour', - 'Nut-Free Granola Bars', - 'Olive Oil', - 'Refrigerated Meals', - 'Rice Noodles', - 'Seed Butters (Peanut Butter Alternative)', - 'Whole-Grain Cookies', - 'Quinoa', -] as const; - export enum FoodType { DAIRY_FREE_ALTERNATIVES = 'Dairy-Free Alternatives', DRIED_BEANS = 'Dried Beans (Gluten-Free, Nut-Free)', @@ -197,7 +180,7 @@ export interface FoodRequest { pantryId: number; pantry: Pantry; requestedSize: string; - requestedItems: string[]; + requestedFoodTypes: FoodType[]; additionalInformation: string | null; requestedAt: string; dateReceived: string | null; @@ -266,7 +249,7 @@ export interface ManufacturerApplicationDto { export interface CreateFoodRequestBody { pantryId: number; requestedSize: string; - requestedItems: string[]; + requestedFoodTypes: FoodType[]; additionalInformation: string | null | undefined; dateReceived: string | null | undefined; feedback: string | null | undefined; @@ -292,8 +275,6 @@ export interface Allocation { itemId: number; item: DonationItem; allocatedQuantity: number; - reservedAt: string; - fulfilledAt: string; } export enum Role { diff --git a/package.json b/package.json index 2e7aebc8..ccd7f050 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "format": "prettier --no-error-on-unmatched-pattern --write apps/{frontend,backend}/src/**/*.{js,ts,tsx}", "lint:check": "eslint apps/frontend --ext .ts,.tsx && eslint apps/backend --ext .ts,.tsx", "lint": "eslint apps/frontend --ext .ts,.tsx --fix && eslint apps/backend --ext .ts,.tsx --fix", - "test": "jest", + "test": "jest --runInBand", "prepush": "yarn run format:check && yarn run lint:check", "prepush:fix": "yarn run format && yarn run lint", "prepare": "husky install", From 8965b3117d19e4f25cc38e9e0516e6593e53ac9f Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:02:48 -0500 Subject: [PATCH 2/3] fix yaml file --- .github/workflows/backend-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 98c5a73e..26b4e0f4 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -42,4 +42,4 @@ jobs: node-version: 20 - run: yarn install --frozen-lockfile - run: yarn list strip-ansi string-width string-length - - run: npx jest \ No newline at end of file + - run: yarn test \ No newline at end of file From 310c8fe8862c741e4503fa4e7f09ebece27e7369 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:39:19 -0500 Subject: [PATCH 3/3] add roles to endpoints --- apps/backend/src/foodRequests/request.controller.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 8933a19f..97eefd96 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -27,7 +27,6 @@ import { MatchingItemsDto, MatchingManufacturersDto, } from './dtos/matching.dto'; -import { Public } from '../auth/public.decorator'; @Controller('requests') export class RequestsController { @@ -60,7 +59,7 @@ export class RequestsController { return this.requestsService.getOrderDetails(requestId); } - @Public() + @Roles(Role.ADMIN, Role.VOLUNTEER) @Get('/:requestId/matching-manufacturers') async getMatchingManufacturers( @Param('requestId', ParseIntPipe) requestId: number, @@ -68,7 +67,7 @@ export class RequestsController { return this.requestsService.getMatchingManufacturers(requestId); } - @Public() + @Roles(Role.ADMIN, Role.VOLUNTEER) @Get('/:requestId/matching-manufacturers/:manufacturerId/available-items') async getAvailableItemsForManufacturer( @Param('requestId', ParseIntPipe) requestId: number,