From e463fa092e2ce1a2097a567ac19d96c250f9535a Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:11:10 -0700 Subject: [PATCH 1/2] add endpoint and food request page --- .../src/foodRequests/request.controller.ts | 5 +- .../src/foodRequests/request.service.spec.ts | 588 +++++++++++------- .../src/foodRequests/request.service.ts | 2 +- .../src/pantries/pantries.controller.spec.ts | 2 +- .../src/pantries/pantries.controller.ts | 2 +- .../volunteers/volunteers.controller.spec.ts | 31 +- .../src/volunteers/volunteers.controller.ts | 15 + .../src/volunteers/volunteers.module.ts | 2 + .../src/volunteers/volunteers.service.spec.ts | 91 ++- .../src/volunteers/volunteers.service.ts | 16 + apps/frontend/src/api/apiClient.ts | 5 + apps/frontend/src/app.tsx | 9 + apps/frontend/src/containers/homepage.tsx | 22 + .../containers/volunteerRequestManagement.tsx | 361 +++++++++++ 14 files changed, 912 insertions(+), 239 deletions(-) create mode 100644 apps/frontend/src/containers/volunteerRequestManagement.tsx diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 0ff738b09..ebbdfb521 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -5,11 +5,7 @@ import { ParseIntPipe, Post, Body, - UploadedFiles, - UseInterceptors, - NotFoundException, ValidationPipe, - BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { RequestsService } from './request.service'; @@ -40,6 +36,7 @@ export class RequestsController { return this.requestsService.find(pantryId); } + @Roles(Role.VOLUNTEER, Role.PANTRY, Role.ADMIN) @Get('/:requestId/order-details') async getAllOrderDetailsFromRequest( @Param('requestId', ParseIntPipe) requestId: number, diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index c89712500..c9343057a 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -1,56 +1,49 @@ 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 { FoodRequestStatus, RequestSize } from './types'; import { Order } from '../orders/order.entity'; 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'; +import { testDataSource } from '../config/typeormTestDataSource'; +import { NotFoundException } from '@nestjs/common'; -const mockRequestsRepository = mock>(); -const mockPantryRepository = mock>(); -const mockOrdersRepository = mock>(); - -const mockRequest = { - requestId: 1, - pantryId: 1, - requestedItems: ['Canned Goods', 'Vegetables'], - additionalInformation: 'No onions, please.', - requestedAt: new Date(), - orders: null, -} as FoodRequest; +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(); @@ -58,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', () => { @@ -73,244 +75,388 @@ 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[] = [ + const expectedItems = [ + { + id: 1, + name: 'Peanut Butter (16oz)', + quantity: 10, + foodType: 'Seed Butters (Peanut Butter Alternative)', + }, { - 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, - ], + id: 3, + name: 'Canned Green Beans', + quantity: 5, + foodType: 'Refrigerated Meals', }, { - 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, - ], + id: 2, + name: 'Whole Wheat Bread', + quantity: 25, + foodType: 'Gluten-Free Bread', }, ]; - mockOrdersRepository.find.mockResolvedValueOnce(mockOrders as Order[]); + 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', + trackingLink: 'www.samplelink/samplelink', + items: expectedItems, + }); + }); - mockRequestsRepository.findOne.mockResolvedValueOnce( - mockRequest as FoodRequest, + it('should throw NotFoundException for non-existent request', async () => { + await expect(service.getOrderDetails(999)).rejects.toThrow( + new NotFoundException('Request 999 not found'), ); + }); - const result = await service.getOrderDetails(requestId); + it('should return empty list if no associated orders', 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 orderDetails = await service.getOrderDetails(requestId); + expect(orderDetails).toEqual([]); + }); + }); - 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, - }, - ], - }, + describe('create', () => { + it('should successfully create and return a new food request', async () => { + const pantryId = 1; + const result = await service.create( + pantryId, + RequestSize.MEDIUM, + [FoodType.DRIED_BEANS, FoodType.REFRIGERATED_MEALS], + 'Additional info', + ); + 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(mockOrdersRepository.find).toHaveBeenCalledWith({ - where: { requestId }, - relations: { - foodManufacturer: true, - allocations: { - item: true, - }, - }, + it('should successfully create and return new food request w/o additional info', async () => { + const pantryId = 1; + const result = await service.create(pantryId, RequestSize.LARGE, [ + FoodType.GRANOLA, + FoodType.NUT_FREE_GRANOLA_BARS, + ]); + expect(result).toBeDefined(); + expect(result.pantryId).toBe(pantryId); + expect(result.requestedSize).toBe(RequestSize.LARGE); + expect(result.requestedFoodTypes).toEqual([ + FoodType.GRANOLA, + FoodType.NUT_FREE_GRANOLA_BARS, + ]); + expect(result.additionalInformation).toBeNull(); + }); + + it('should throw NotFoundException for non-existent pantry', async () => { + await expect( + service.create( + 999, + RequestSize.MEDIUM, + [FoodType.DRIED_BEANS, FoodType.REFRIGERATED_MEALS], + 'Additional info', + ), + ).rejects.toThrow(new NotFoundException('Pantry 999 not found')); + }); + }); + + describe('find', () => { + it('should return all food requests for a specific pantry with pantry details', async () => { + const pantryId = 1; + const result = await service.find(pantryId); + + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result.every((r) => r.pantryId === pantryId)).toBe(true); + result.forEach((request) => { + expect(request.orders).toBeDefined(); }); + expect(result.every((r) => r.pantry)).toBeDefined(); + }); + + it('should return empty array for pantry with no requests', async () => { + const pantryId = 5; + const result = await service.find(pantryId); + + expect(result).toBeDefined(); + expect(result).toEqual([]); + }); + }); + + describe('updateRequestStatus', () => { + it('should update request status to closed since all orders are delivered', async () => { + const requestId = 1; + + await service.updateRequestStatus(requestId); + + const request = await service.findOne(requestId); + expect(request.status).toBe(FoodRequestStatus.CLOSED); }); - it('should throw an error if the request id is not found', async () => { + it('should update request status to active since all orders are not delivered', async () => { + const requestId = 3; + + await service.updateRequestStatus(requestId); + + const request = await service.findOne(requestId); + expect(request.status).toBe(FoodRequestStatus.ACTIVE); + }); + + it('should update status to active for request with no orders', async () => { + const pantryId = 1; + const result = await service.create(pantryId, RequestSize.MEDIUM, [ + FoodType.DRIED_BEANS, + FoodType.REFRIGERATED_MEALS, + ]); + const requestId = result.requestId; + + await service.updateRequestStatus(requestId); + + const request = await service.findOne(requestId); + expect(request.status).toBe(FoodRequestStatus.ACTIVE); + }); + + it('should throw NotFoundException for non-existent request', async () => { const requestId = 999; - await expect(service.getOrderDetails(requestId)).rejects.toThrow( - `Request ${requestId} not found`, + await expect(service.updateRequestStatus(requestId)).rejects.toThrow( + new NotFoundException('Request 999 not found'), ); }); + }); - it('should return empty list if no associated orders', async () => { + 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('should correctly match manufacturers based on requested food types and available stock', 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); + } + + 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); + } + }); - mockRequestsRepository.findOne.mockResolvedValueOnce( - mockRequest as FoodRequest, + it('no manufacturer appears in both matchingManufacturers and nonMatchingManufacturers', async () => { + const requestId = 1; + const result = await service.getMatchingManufacturers(requestId); + + const matchingIds = result.matchingManufacturers.map( + (fm) => fm.foodManufacturerId, + ); + const nonMatchingIds = result.nonMatchingManufacturers.map( + (fm) => fm.foodManufacturerId, ); - mockOrdersRepository.find.mockResolvedValueOnce([]); + const intersection = matchingIds.filter((id) => + nonMatchingIds.includes(id), + ); + expect(intersection).toEqual([]); + }); - const result = await service.getOrderDetails(requestId); - expect(result).toEqual([]); - expect(mockOrdersRepository.find).toHaveBeenCalledWith({ - where: { requestId }, - relations: { - foodManufacturer: true, - allocations: { - item: true, - }, - }, - }); + 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); + } }); }); - 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, - ]); + 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); + } + }); - const result = await service.create( - mockRequest.pantryId, - mockRequest.requestedSize, - mockRequest.requestedItems, - mockRequest.additionalInformation ?? undefined, + 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); - expect(result).toEqual(mockRequest); - expect(mockRequestsRepository.create).toHaveBeenCalledWith({ - pantryId: mockRequest.pantryId, - requestedSize: mockRequest.requestedSize, - requestedItems: mockRequest.requestedItems, - additionalInformation: mockRequest.additionalInformation, + const allItems = [...result.matchingItems, ...result.nonMatchingItems]; + allItems.forEach((item) => { + expect(item.availableQuantity).toBeGreaterThan(0); }); - expect(mockRequestsRepository.save).toHaveBeenCalledWith(mockRequest); }); - it('should throw an error if the pantry ID does not exist', async () => { - const invalidPantryId = 999; + it('returned items conform to MatchingItemsDto', async () => { + const result = await service.getAvailableItems(1, 1); + + expect(result).toHaveProperty('matchingItems'); + expect(result).toHaveProperty('nonMatchingItems'); + expect(Array.isArray(result.matchingItems)).toBe(true); + expect(Array.isArray(result.nonMatchingItems)).toBe(true); + + const allItems = [...result.matchingItems, ...result.nonMatchingItems]; + + if (allItems.length > 0) { + allItems.forEach((item) => { + expect(item).toHaveProperty('itemId'); + expect(item).toHaveProperty('itemName'); + expect(item).toHaveProperty('foodType'); + expect(item).toHaveProperty('availableQuantity'); + + expect(typeof item.itemId).toBe('number'); + expect(typeof item.itemName).toBe('string'); + expect(typeof item.foodType).toBe('string'); + expect(Object.values(FoodType)).toContain(item.foodType); + expect(typeof item.availableQuantity).toBe('number'); + }); + } + }); - await expect( - service.create( - invalidPantryId, - RequestSize.MEDIUM, - ['Canned Goods', 'Vegetables'], - 'Additional info', - ), - ).rejects.toThrow(`Pantry ${invalidPantryId} not found`); + it('returns empty arrays for no available items', async () => { + await testDataSource.query(` + UPDATE donation_items + SET reserved_quantity = quantity + `); - expect(mockRequestsRepository.create).not.toHaveBeenCalled(); - expect(mockRequestsRepository.save).not.toHaveBeenCalled(); + const result = await service.getAvailableItems(1, 1); + + expect(result.matchingItems).toEqual([]); + expect(result.nonMatchingItems).toEqual([]); }); - }); - 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: new Date(), - orders: null, - }, - { - requestId: 3, - pantryId: 2, - requestedSize: RequestSize.SMALL, - requestedItems: ['Fruits', 'Snacks'], - additionalInformation: 'No nuts, please.', - requestedAt: new Date(), - orders: null, - }, - ]; - const pantryId = 1; - mockRequestsRepository.find.mockResolvedValueOnce( - mockRequests.slice(0, 2) as FoodRequest[], - ); + it('returns empty matchingItems array for no available matching items', async () => { + const result = await service.getAvailableItems(2, 3); + expect(result.matchingItems).toHaveLength(0); + }); - const result = await service.find(pantryId); + it('returns empty nonMatchingItems array for no available non-matching items', async () => { + const result = await service.getAvailableItems(1, 2); + expect(result.nonMatchingItems).toHaveLength(0); + }); - expect(result).toEqual(mockRequests.slice(0, 2)); - expect(mockRequestsRepository.find).toHaveBeenCalledWith({ - where: { pantryId }, - relations: ['orders'], - }); + 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 48138251c..8bd4fe2d8 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -104,7 +104,7 @@ export class RequestsService { return await this.repo.find({ where: { pantryId }, - relations: ['orders'], + relations: ['orders', 'pantry'], }); } diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index 5e1f3bc9f..d1b766b4c 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 { NotFoundException } from '@nestjs/common'; import { User } from '../users/user.entity'; import { AuthenticatedRequest } from '../auth/authenticated-request'; diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index 746e67aa7..d35dcdedf 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -55,7 +55,7 @@ export class PantriesController { return this.pantriesService.getPendingPantries(); } - @Roles(Role.PANTRY, Role.ADMIN) + @Roles(Role.PANTRY, Role.ADMIN, Role.VOLUNTEER) @Get('/:pantryId') async getPantry( @Param('pantryId', ParseIntPipe) pantryId: number, diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index 5a50f637d..3708bf08a 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -1,13 +1,12 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; import { VolunteersController } from './volunteers.controller'; -import { UsersController } from '../users/users.controller'; -import { UsersService } from '../users/users.service'; import { User } from '../users/user.entity'; import { Role } from '../users/types'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; import { Pantry } from '../pantries/pantries.entity'; import { VolunteersService } from './volunteers.service'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; const mockVolunteersService = mock(); @@ -156,11 +155,33 @@ describe('VolunteersController', () => { expect(result).toEqual(updatedUser); expect(result.pantries).toHaveLength(2); - expect(result.pantries![0].pantryId).toBe(1); - expect(result.pantries![1].pantryId).toBe(3); + expect(result.pantries?.[0].pantryId).toBe(1); + expect(result.pantries?.[1].pantryId).toBe(3); expect( mockVolunteersService.assignPantriesToVolunteer, ).toHaveBeenCalledWith(3, pantryIds); }); }); + + describe('GET /me/assigned-requests', () => { + it('returns assigned requests when req.currentUser is present', async () => { + const req = { user: { id: 1 } }; + const foodRequests: Partial = [ + { requestId: 10 } as FoodRequest, + { requestId: 5 } as FoodRequest, + ]; + mockVolunteersService.findRequestsByVolunteer.mockResolvedValueOnce( + foodRequests as FoodRequest[], + ); + + const result = await controller.getAssignedRequests( + req as AuthenticatedRequest, + ); + + expect(result).toEqual(foodRequests); + expect( + mockVolunteersService.findRequestsByVolunteer, + ).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index ce6b9d62d..63f22f5a8 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -5,10 +5,15 @@ import { ParseIntPipe, Post, Body, + Req, } from '@nestjs/common'; import { User } from '../users/user.entity'; import { Pantry } from '../pantries/pantries.entity'; import { VolunteersService } from './volunteers.service'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../users/types'; @Controller('volunteers') export class VolunteersController { @@ -40,4 +45,14 @@ export class VolunteersController { ): Promise { return this.volunteersService.assignPantriesToVolunteer(id, pantryIds); } + + @Roles(Role.VOLUNTEER) + @Get('/me/assigned-requests') + async getAssignedRequests( + @Req() req: AuthenticatedRequest, + ): Promise { + const currentUser = req.user; + + return this.volunteersService.findRequestsByVolunteer(currentUser.id); + } } diff --git a/apps/backend/src/volunteers/volunteers.module.ts b/apps/backend/src/volunteers/volunteers.module.ts index c7147fcff..80d455ea7 100644 --- a/apps/backend/src/volunteers/volunteers.module.ts +++ b/apps/backend/src/volunteers/volunteers.module.ts @@ -6,6 +6,7 @@ import { AuthModule } from '../auth/auth.module'; import { VolunteersController } from './volunteers.controller'; import { VolunteersService } from './volunteers.service'; import { UsersModule } from '../users/users.module'; +import { RequestsModule } from '../foodRequests/request.module'; @Module({ imports: [ @@ -13,6 +14,7 @@ import { UsersModule } from '../users/users.module'; UsersModule, forwardRef(() => PantriesModule), forwardRef(() => AuthModule), + RequestsModule, ], controllers: [VolunteersController], providers: [VolunteersService], diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index fb8f343bd..0f39fb7c7 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -8,6 +8,9 @@ import { testDataSource } from '../config/typeormTestDataSource'; import { UsersService } from '../users/users.service'; import { PantriesService } from '../pantries/pantries.service'; import { AuthService } from '../auth/auth.service'; +import { RequestsService } from '../foodRequests/request.service'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { Order } from '../orders/order.entity'; jest.setTimeout(60000); @@ -25,6 +28,7 @@ describe('VolunteersService', () => { VolunteersService, UsersService, PantriesService, + RequestsService, { provide: AuthService, useValue: {}, @@ -37,6 +41,14 @@ describe('VolunteersService', () => { provide: getRepositoryToken(Pantry), useValue: testDataSource.getRepository(Pantry), }, + { + provide: getRepositoryToken(FoodRequest), + useValue: testDataSource.getRepository(FoodRequest), + }, + { + provide: getRepositoryToken(Order), + useValue: testDataSource.getRepository(Order), + }, ], }).compile(); @@ -177,8 +189,8 @@ describe('VolunteersService', () => { expect(beforePantryIds).toEqual([2, 3]); const result = await service.assignPantriesToVolunteer(7, [1, 4]); - expect(result.pantries!).toHaveLength(4); - const afterPantryIds = result.pantries!.map((p) => p.pantryId); + expect(result.pantries).toHaveLength(4); + const afterPantryIds = result.pantries?.map((p) => p.pantryId); expect(afterPantryIds).toEqual([2, 3, 1, 4]); }); @@ -191,8 +203,8 @@ describe('VolunteersService', () => { expect(beforeAssignment).toEqual([]); const result = await service.assignPantriesToVolunteer(6, [2, 3]); - expect(result.pantries!).toHaveLength(2); - const pantryIds = result.pantries!.map((p) => p.pantryId); + expect(result.pantries).toHaveLength(2); + const pantryIds = result.pantries?.map((p) => p.pantryId); expect(pantryIds).toEqual([2, 3]); }); @@ -203,9 +215,76 @@ describe('VolunteersService', () => { expect(beforePantryIds).toEqual([2, 3]); const result = await service.assignPantriesToVolunteer(7, [2, 3]); - expect(result.pantries!).toHaveLength(2); - const pantryIds = result.pantries!.map((p) => p.pantryId); + expect(result.pantries).toHaveLength(2); + const pantryIds = result.pantries?.map((p) => p.pantryId); expect(pantryIds).toEqual([2, 3]); }); }); + + describe('findRequestsByVolunteer', () => { + it('returned requests include pantry info', async () => { + const requests = await service.findRequestsByVolunteer(7); + requests.forEach((request) => { + expect(request.pantry).toBeDefined(); + expect(request.pantry).toHaveProperty('pantryName'); + }); + }); + + it('returns requests only from assigned pantries', async () => { + const volunteerId = 6; + + const assignedPantries = await service.getVolunteerPantries(volunteerId); + const assignedPantryIds = assignedPantries.map((p) => p.pantryId); + + const requests = await service.findRequestsByVolunteer(volunteerId); + requests.forEach((request) => { + expect(assignedPantryIds).toContain(request.pantryId); + }); + }); + + it('returns empty array when volunteer has no assigned pantries', async () => { + const volunteerId = await testDataSource + .query( + ` + INSERT INTO users (first_name, last_name, email, phone, role) + VALUES ('Test', 'Volunteer', 'test@volunteer.com', '537-280-1238', 'volunteer') + RETURNING user_id + `, + ) + .then((rows) => rows[0].user_id); + + const result = await service.findRequestsByVolunteer(volunteerId); + expect(result).toEqual([]); + }); + + it('returns empty array when assigned pantries have no requests', async () => { + const volunteerId = 8; + + const assignedPantries = await service.getVolunteerPantries(volunteerId); + const assignedPantryIds = assignedPantries.map((p) => p.pantryId); + await testDataSource.query( + `DELETE FROM allocations + WHERE order_id IN ( + SELECT o.order_id FROM orders o + JOIN food_requests fr ON o.request_id = fr.request_id + WHERE fr.pantry_id = ANY($1) + )`, + [assignedPantryIds], + ); + await testDataSource.query( + `DELETE FROM orders + WHERE request_id IN ( + SELECT request_id FROM food_requests WHERE pantry_id = ANY($1) + )`, + [assignedPantryIds], + ); + await testDataSource.query( + `DELETE FROM food_requests WHERE pantry_id = ANY($1)`, + [assignedPantryIds], + ); + + const requests = await service.findRequestsByVolunteer(volunteerId); + expect(requests).toEqual([]); + }); + }); }); diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index ba6c5a10f..ed9209656 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -7,6 +7,8 @@ import { validateId } from '../utils/validation.utils'; import { Pantry } from '../pantries/pantries.entity'; import { PantriesService } from '../pantries/pantries.service'; import { UsersService } from '../users/users.service'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { RequestsService } from '../foodRequests/request.service'; @Injectable() export class VolunteersService { @@ -15,6 +17,7 @@ export class VolunteersService { private repo: Repository, private usersService: UsersService, private pantriesService: PantriesService, + private requestsService: RequestsService, ) {} async findOne(id: number): Promise { @@ -74,4 +77,17 @@ export class VolunteersService { volunteer.pantries = [...existingPantries, ...newPantries]; return this.repo.save(volunteer); } + + async findRequestsByVolunteer(volunteerId: number): Promise { + validateId(volunteerId, 'Volunteer'); + + const pantries = await this.getVolunteerPantries(volunteerId); + const pantryIds = pantries.map((p) => p.pantryId); + + const requestArrays = await Promise.all( + pantryIds.map((id) => this.requestsService.find(id)), + ); + + return requestArrays.flat(); + } } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index a22e2cd55..d475330a6 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -279,6 +279,11 @@ export class ApiClient { return data as FoodRequest[]; } + public async getVolunteerAssignedRequests(): Promise { + const data = await this.get(`/api/volunteers/me/assigned-requests`); + return data as FoodRequest[]; + } + public async confirmDelivery( requestId: number, data: FormData, diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index c844de298..d921ddb07 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -29,6 +29,7 @@ import Unauthorized from '@containers/unauthorized'; import { Authenticator } from '@aws-amplify/ui-react'; import FoodManufacturerApplication from '@containers/foodManufacturerApplication'; import { submitManufacturerApplicationForm } from '@components/forms/manufacturerApplicationForm'; +import VolunteerRequestManagement from '@containers/volunteerRequestManagement'; Amplify.configure(CognitoAuthConfig); @@ -198,6 +199,14 @@ const router = createBrowserRouter([ ), }, + { + path: '/volunteer-request-management', + element: ( + + + + ), + }, { path: '/confirm-delivery', action: submitDeliveryConfirmationFormModal, diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index 74604c688..05e9a29aa 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -89,6 +89,28 @@ const Homepage: React.FC = () => { + + + Volunteer View + + + + + + Assigned Pantries + + + + + + + Food Request Management + + + + + + Admin View diff --git a/apps/frontend/src/containers/volunteerRequestManagement.tsx b/apps/frontend/src/containers/volunteerRequestManagement.tsx new file mode 100644 index 000000000..e8165efe4 --- /dev/null +++ b/apps/frontend/src/containers/volunteerRequestManagement.tsx @@ -0,0 +1,361 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + Table, + Heading, + Pagination, + IconButton, + VStack, + ButtonGroup, + Checkbox, +} from '@chakra-ui/react'; +import { ArrowDownUp, ChevronRight, ChevronLeft, Funnel } from 'lucide-react'; +import { capitalize, formatDate } from '@utils/utils'; +import ApiClient from '@api/apiClient'; +import { FloatingAlert } from '@components/floatingAlert'; +import { FoodRequest, FoodRequestStatus } from '../types/types'; +import RequestDetailsModal from '@components/forms/requestDetailsModal'; + +const VolunteerRequestManagement: React.FC = () => { + const [requests, setRequests] = useState([]); + const [sortAsc, setSortAsc] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [selectedPantries, setSelectedPantries] = useState([]); + const [selectedRequest, setSelectedRequest] = useState( + null, + ); + + const [alertMessage, setAlertMessage] = useState(''); + + useEffect(() => { + const fetchRequests = async () => { + try { + const data = await ApiClient.getVolunteerAssignedRequests(); + setRequests(data); + } catch (error) { + setAlertMessage('Error fetching requests' + error); + } + }; + fetchRequests(); + }, []); + + useEffect(() => { + setCurrentPage(1); + }, [selectedPantries]); + + const pantryOptions = [ + ...new Set( + requests + .map((r) => r.pantry?.pantryName) + .filter((name): name is string => !!name), + ), + ].sort((a, b) => a.localeCompare(b)); + + const handleFilterChange = (pantry: string, checked: boolean) => { + if (checked) { + setSelectedPantries([...selectedPantries, pantry]); + } else { + setSelectedPantries(selectedPantries.filter((p) => p !== pantry)); + } + }; + + const filteredRequests = requests + .filter((r) => { + const matchesFilter = + selectedPantries.length === 0 || + (r.pantry && selectedPantries.includes(r.pantry?.pantryName)); + return matchesFilter; + }) + .sort((a, b) => + sortAsc + ? a.requestedAt.localeCompare(b.requestedAt) + : b.requestedAt.localeCompare(a.requestedAt), + ); + + const itemsPerPage = 10; + const totalPages = Math.ceil(filteredRequests.length / itemsPerPage); + const paginatedRequests = filteredRequests.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage, + ); + + const tableHeaderStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'neutral.800', + fontFamily: 'inter', + fontWeight: '600', + fontSize: 'sm', + }; + + const tableCellStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'black', + fontFamily: "'Inter', sans-serif", + fontSize: 'sm', + py: 0, + }; + + return ( + + + Food Request Management + + {alertMessage && ( + + )} + + + + + {isFilterOpen && ( + <> + setIsFilterOpen(false)} + zIndex={10} + /> + + + {pantryOptions.map((pantry) => ( + + handleFilterChange(pantry, e.checked) + } + color="black" + size="sm" + > + + + {pantry} + + ))} + + + + )} + + + + + + + + Request # + + + Status + + + Pantry + + + Date Requested + + + Action Required + + + + + {paginatedRequests.map((request, index) => ( + + + + {selectedRequest && ( + setSelectedRequest(null)} + pantryId={request.pantryId} + /> + )} + + + + {capitalize(request.status)} + + + + {request.pantry.pantryName} + + + {formatDate(request.requestedAt)} + + {/* TODO*/} + + ))} + + + + {totalPages > 1 && ( + setCurrentPage(e.page)} + > + + + + + + ( + + {page.value} + + )} + /> + + + + + + + )} + + ); +}; + +export default VolunteerRequestManagement; From a287beb1772055be800e7b8a4b91dea01d44bd15 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:25:08 -0400 Subject: [PATCH 2/2] frontend fixes --- .../volunteers/volunteers.controller.spec.ts | 10 +++--- .../frontend/src/containers/adminDonation.tsx | 23 +++++++------ .../src/containers/adminOrderManagement.tsx | 29 ++++++++-------- .../foodManufacturerDonationManagement.tsx | 10 +++--- .../containers/volunteerRequestManagement.tsx | 34 ++++++++++--------- 5 files changed, 56 insertions(+), 50 deletions(-) diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index 3708bf08a..6b745042e 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -165,10 +165,12 @@ describe('VolunteersController', () => { describe('GET /me/assigned-requests', () => { it('returns assigned requests when req.currentUser is present', async () => { - const req = { user: { id: 1 } }; - const foodRequests: Partial = [ - { requestId: 10 } as FoodRequest, - { requestId: 5 } as FoodRequest, + const req: AuthenticatedRequest = { + user: { id: 1 }, + } as AuthenticatedRequest; + const foodRequests: Partial[] = [ + { requestId: 10 }, + { requestId: 5 }, ]; mockVolunteersService.findRequestsByVolunteer.mockResolvedValueOnce( foodRequests as FoodRequest[], diff --git a/apps/frontend/src/containers/adminDonation.tsx b/apps/frontend/src/containers/adminDonation.tsx index 9c9a761f3..8eb1a376f 100644 --- a/apps/frontend/src/containers/adminDonation.tsx +++ b/apps/frontend/src/containers/adminDonation.tsx @@ -10,6 +10,7 @@ import { Checkbox, VStack, ButtonGroup, + Link, } from '@chakra-ui/react'; import { Donation } from 'types/types'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; @@ -225,20 +226,13 @@ const AdminDonation: React.FC = () => { borderRightColor="neutral.100" py={0} > - - {selectedDonation && ( - setSelectedDonation(null)} - /> - )} + { ))} + {selectedDonation && ( + setSelectedDonation(null)} + /> + )} diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index 772861e4e..62fc77fa0 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -10,6 +10,7 @@ import { ButtonGroup, Checkbox, Input, + Link, } from '@chakra-ui/react'; import { ArrowDownUp, @@ -619,21 +620,13 @@ const OrderStatusSection: React.FC = ({ borderRight="1px solid" borderRightColor="neutral.100" > - - {selectedOrderId === order.orderId && ( - onOrderSelect(null)} - /> - )} + = ({ color={colors[1]} display="inline-block" fontWeight="500" - my={2} - py={1} + fontSize="12px" + my={3} + py={0.5} px={3} > {capitalize(order.status)} @@ -713,6 +707,13 @@ const OrderStatusSection: React.FC = ({ ); })} + {selectedOrderId && ( + onOrderSelect(null)} + /> + )} diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 6e2c3aa49..d295e2c5f 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -7,6 +7,7 @@ import { Pagination, IconButton, ButtonGroup, + Link, } from '@chakra-ui/react'; import { ChevronRight, ChevronLeft, Mail, CircleCheck } from 'lucide-react'; import { capitalize, formatDate } from '@utils/utils'; @@ -286,14 +287,13 @@ const DonationStatusSection: React.FC = ({ borderRight="1px solid" borderRightColor="neutral.100" > - + {selectedDonationId === donation.donationId && ( { _hover={{ bg: 'gray.50' }} > - - {selectedRequest && ( - setSelectedRequest(null)} - pantryId={request.pantryId} - /> - )} + { } display="inline-block" fontWeight="500" - my={2} - py={1} + fontSize="12px" + my={3} + py={0.5} px={3} > {capitalize(request.status)} @@ -305,6 +298,15 @@ const VolunteerRequestManagement: React.FC = () => { {/* TODO*/} ))} + + {selectedRequest && ( + setSelectedRequest(null)} + pantryId={selectedRequest.pantryId} + /> + )}