From 4a78d761161bfd625e2681fd33598ad9d39dbcdd Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:37:19 -0400 Subject: [PATCH 1/4] dashboard backend --- .../manufacturers.service.spec.ts | 7 +- .../foodRequests/request.controller.spec.ts | 23 ----- .../src/foodRequests/request.controller.ts | 8 -- .../src/foodRequests/request.service.spec.ts | 18 ++-- .../src/foodRequests/request.service.ts | 4 +- apps/backend/src/orders/order.service.spec.ts | 54 ++++++++++- apps/backend/src/orders/order.service.ts | 40 ++++++++ .../src/pantries/pantries.controller.spec.ts | 42 ++++++++ .../src/pantries/pantries.controller.ts | 11 +++ apps/backend/src/pantries/pantries.module.ts | 2 + .../src/pantries/pantries.service.spec.ts | 8 +- apps/backend/src/users/types.ts | 7 ++ .../src/users/users.controller.spec.ts | 45 ++++++++- apps/backend/src/users/users.controller.ts | 10 +- apps/backend/src/users/users.module.ts | 11 ++- apps/backend/src/users/users.service.spec.ts | 96 +++++++++++++++++++ apps/backend/src/users/users.service.ts | 45 ++++++++- .../volunteers/volunteers.controller.spec.ts | 39 ++++++++ .../src/volunteers/volunteers.controller.ts | 8 ++ .../src/volunteers/volunteers.service.spec.ts | 65 ++++++++++++- .../src/volunteers/volunteers.service.ts | 11 ++- apps/frontend/src/api/apiClient.ts | 2 +- 22 files changed, 496 insertions(+), 60 deletions(-) diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 71503bc8d..a9cd3dfbb 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -21,9 +21,8 @@ import { EmailsService } from '../emails/email.service'; import { mock } from 'jest-mock-extended'; import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; -import { DataSource } from 'typeorm'; import { FoodType } from '../donationItems/types'; -import { Allocation } from '../allocations/allocations.entity'; +import { Pantry } from '../pantries/pantries.entity'; jest.setTimeout(60000); @@ -92,6 +91,10 @@ describe('FoodManufacturersService', () => { provide: getRepositoryToken(FoodRequest), useValue: testDataSource.getRepository(FoodRequest), }, + { + provide: getRepositoryToken(Pantry), + useValue: testDataSource.getRepository(Pantry), + }, ], }).compile(); diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 0c177293e..21961dbbd 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -42,7 +42,6 @@ describe('RequestsController', () => { beforeEach(async () => { mockRequestsService.findOne.mockReset(); - mockRequestsService.find.mockReset(); mockRequestsService.create.mockReset(); mockRequestsService.getOrderDetails.mockReset(); @@ -92,28 +91,6 @@ describe('RequestsController', () => { }); }); - describe('GET /:pantryId/all', () => { - it('should call requestsService.find and return all food requests for a specific pantry', async () => { - const foodRequests: Partial[] = [ - foodRequest1, - { - requestId: 2, - pantryId: 1, - }, - ]; - const pantryId = 1; - - mockRequestsService.find.mockResolvedValueOnce( - foodRequests as FoodRequest[], - ); - - const result = await controller.getAllPantryRequests(pantryId); - - expect(result).toEqual(foodRequests); - expect(mockRequestsService.find).toHaveBeenCalledWith(pantryId); - }); - }); - describe('GET /:requestId/order-details', () => { it('should call requestsService.getOrderDetails and return all associated orders and their details', async () => { const mockOrderDetails: OrderDetailsDto[] = [ diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 93a1db234..e41d8018a 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -40,14 +40,6 @@ export class RequestsController { return this.requestsService.findOne(requestId); } - @Roles(Role.PANTRY, Role.ADMIN) - @Get('/:pantryId/all') - async getAllPantryRequests( - @Param('pantryId', ParseIntPipe) pantryId: number, - ): Promise { - return this.requestsService.find(pantryId); - } - @Roles(Role.VOLUNTEER, Role.PANTRY, Role.ADMIN) @Get('/:requestId/order-details') async getAllOrderDetailsFromRequest( diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 6727723f6..20a04446b 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -248,10 +248,11 @@ describe('RequestsService', () => { FoodType.REFRIGERATED_MEALS, ]); + if (!pantry) throw new Error('Missing pantry test object'); const { subject, bodyHTML } = emailTemplates.pantrySubmitsFoodRequest({ - pantryName: pantry!.pantryName, + pantryName: pantry.pantryName, }); - const volunteerEmails = (pantry!.volunteers ?? []).map((v) => v.email); + const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( @@ -274,10 +275,11 @@ describe('RequestsService', () => { FoodType.REFRIGERATED_MEALS, ]); + if (!pantry) throw new Error('Missing pantry test object'); const { subject, bodyHTML } = emailTemplates.pantrySubmitsFoodRequest({ - pantryName: pantry!.pantryName, + pantryName: pantry.pantryName, }); - const volunteerEmails = (pantry!.volunteers ?? []).map((v) => v.email); + const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); expect(volunteerEmails).toEqual([]); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); @@ -302,7 +304,7 @@ describe('RequestsService', () => { ), ); - const requests = await service.find(pantryId); + const requests = await service.findAllForPantry(pantryId); expect(requests.length).toBe(3); }); @@ -318,10 +320,10 @@ describe('RequestsService', () => { }); }); - describe('find', () => { + describe('findAllForPantry', () => { it('should return all food requests for a specific pantry with pantry details', async () => { const pantryId = 1; - const result = await service.find(pantryId); + const result = await service.findAllForPantry(pantryId); expect(result).toBeDefined(); expect(result).toHaveLength(2); @@ -334,7 +336,7 @@ describe('RequestsService', () => { it('should return empty array for pantry with no requests', async () => { const pantryId = 5; - const result = await service.find(pantryId); + const result = await service.findAllForPantry(pantryId); expect(result).toBeDefined(); expect(result).toEqual([]); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index d63f234e7..7febeefee 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -253,7 +253,7 @@ export class RequestsService { message.subject, message.bodyHTML, ); - } catch (error) { + } catch { throw new InternalServerErrorException( 'Failed to send new food request notification email to volunteers', ); @@ -262,7 +262,7 @@ export class RequestsService { return foodRequest; } - async find(pantryId: number) { + async findAllForPantry(pantryId: number) { validateId(pantryId, 'Pantry'); return await this.repo.find({ diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index fea6912dd..0f3e7ea40 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -191,6 +191,45 @@ describe('OrdersService', () => { }); }); + describe('getRecentOrdersByAssignee', () => { + it('returns empty array when volunteer has no assigned orders', async () => { + // assign all seed orders away from volunteer 6 + await testDataSource.query( + `UPDATE orders SET assignee_id = (SELECT user_id FROM users WHERE role = 'volunteer' AND user_id != 6 LIMIT 1)`, + ); + + const result = await service.getRecentOrdersByAssignee(6); + expect(result).toEqual([]); + }); + + it('returns at most 2 orders even when volunteer has more', async () => { + // assign all seed orders to volunteer 6 + await testDataSource.query(`UPDATE orders SET assignee_id = 6`); + + const result = await service.getRecentOrdersByAssignee(6); + expect(result).toHaveLength(2); + }); + + it('returns correct shape of orders', async () => { + await testDataSource.query(`UPDATE orders SET assignee_id = 6`); + + const result = await service.getRecentOrdersByAssignee(6); + + expect(result[0].createdAt >= result[1].createdAt).toBe(true); + result.forEach((order) => { + expect(order.pantryName).toBeDefined(); + expect(order.assignee.id).toBe(6); + expect(order.assignee.firstName).toBe('James'); + expect(order.assignee.lastName).toBe('Thomas'); + expect(order.orderId).toBeDefined(); + expect(order.status).toBeDefined(); + expect(order.createdAt).toBeDefined(); + expect(order.shippedAt).toBeDefined(); + expect(order.deliveredAt).toBeDefined(); + }); + }); + }); + describe('findOrderDetails', () => { it('returns mapped OrderDetailsDto including allocations and manufacturer', async () => { const orderId = 1; @@ -382,6 +421,8 @@ describe('OrdersService', () => { expect(orders.length).toBe(2); expect(orders.every((order) => order.request)).toBeDefined(); expect(orders.every((order) => order.request.pantryId === 1)).toBe(true); + expect(orders.every((order) => order.request.pantry)).toBeDefined(); + expect(orders.every((order) => order.assignee)).toBeDefined(); }); it('returns empty list for pantry with no orderes', async () => { @@ -806,13 +847,20 @@ describe('OrdersService', () => { where: { itemId: 9 }, }); - expect(updatedDonationItem1!.reservedQuantity).toBe( + if ( + !updatedDonationItem1 || + !updatedDonationItem2 || + !updatedDonationItem3 + ) { + throw new Error('Missing donation item test object'); + } + expect(updatedDonationItem1.reservedQuantity).toBe( donationItem1.reservedQuantity + 10, ); - expect(updatedDonationItem2!.reservedQuantity).toBe( + expect(updatedDonationItem2.reservedQuantity).toBe( donationItem2.reservedQuantity + 3, ); - expect(updatedDonationItem3!.reservedQuantity).toBe( + expect(updatedDonationItem3.reservedQuantity).toBe( donationItem3.reservedQuantity + 5, ); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 378718e0e..d4d73cc57 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -118,6 +118,44 @@ export class OrdersService { }); } + async getRecentOrdersByAssignee( + volunteerId: number, + ): Promise { + validateId(volunteerId, 'Volunteer'); + + const orders = await this.repo + .createQueryBuilder('order') + .leftJoinAndSelect('order.request', 'request') + .leftJoinAndSelect('request.pantry', 'pantry') + .leftJoinAndSelect('order.assignee', 'assignee') + .select([ + 'order.orderId', + 'order.status', + 'order.createdAt', + 'order.shippedAt', + 'order.deliveredAt', + 'request.pantryId', + 'pantry.pantryName', + 'assignee.id', + 'assignee.firstName', + 'assignee.lastName', + ]) + .where('order.assigneeId = :volunteerId', { volunteerId }) + .orderBy('order.createdAt', 'DESC') + .take(2) + .getMany(); + + return orders.map((o) => ({ + orderId: o.orderId, + status: o.status, + createdAt: o.createdAt, + shippedAt: o.shippedAt, + deliveredAt: o.deliveredAt, + pantryName: o.request.pantry.pantryName, + assignee: o.assignee, + })); + } + async getCurrentOrders() { return this.repo.find({ where: { status: In([OrderStatus.PENDING, OrderStatus.SHIPPED]) }, @@ -441,8 +479,10 @@ export class OrdersService { const qb = this.repo .createQueryBuilder('order') .leftJoinAndSelect('order.request', 'request') + .leftJoinAndSelect('request.pantry', 'pantry') .leftJoinAndSelect('order.allocations', 'allocations') .leftJoinAndSelect('allocations.item', 'item') + .leftJoinAndSelect('order.assignee', 'assignee') .where('request.pantryId = :pantryId', { pantryId }); if (years && years.length > 0) { diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index 33c2b9949..a1eae77d2 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -22,10 +22,13 @@ import { ApplicationStatus } from '../shared/types'; import { User } from '../users/users.entity'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; +import { RequestsService } from '../foodRequests/request.service'; +import { FoodRequest } from '../foodRequests/request.entity'; const mockPantriesService = mock(); const mockOrdersService = mock(); const mockEmailsService = mock(); +const mockRequestsService = mock(); describe('PantriesController', () => { let controller: PantriesController; @@ -80,6 +83,16 @@ describe('PantriesController', () => { newsletterSubscription: true, } as PantryApplicationDto; + // Mock Food Request + const foodRequest1: Partial = { + requestId: 1, + pantryId: 1, + pantry: { + pantryId: 1, + pantryName: 'Test Pantry 1', + } as Pantry, + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [PantriesController], @@ -96,6 +109,10 @@ describe('PantriesController', () => { provide: EmailsService, useValue: mockEmailsService, }, + { + provide: RequestsService, + useValue: mockRequestsService, + }, ], }).compile(); @@ -375,6 +392,7 @@ describe('PantriesController', () => { ); }); }); + describe('getCurrentUserPantryId', () => { it('returns pantryId for authenticated user', async () => { const req = { user: { id: 1 } }; @@ -472,4 +490,28 @@ describe('PantriesController', () => { expect(mockPantriesService.getTotalStats).toHaveBeenCalledWith(years); }); }); + + describe('getFoodRequests', () => { + it('should call requestsService.find and return all food requests for a specific pantry', async () => { + const foodRequests: Partial[] = [ + foodRequest1, + { + requestId: 2, + pantryId: 1, + }, + ]; + const pantryId = 1; + + mockRequestsService.findAllForPantry.mockResolvedValueOnce( + foodRequests as FoodRequest[], + ); + + const result = await controller.getFoodRequests(pantryId); + + expect(result).toEqual(foodRequests); + expect(mockRequestsService.findAllForPantry).toHaveBeenCalledWith( + pantryId, + ); + }); + }); }); diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index 906d590a2..c3799302c 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -34,12 +34,15 @@ import { Public } from '../auth/public.decorator'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; import { UpdatePantryVolunteersDto } from './dtos/update-pantry-volunteers-dto'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { RequestsService } from '../foodRequests/request.service'; @Controller('pantries') export class PantriesController { constructor( private pantriesService: PantriesService, private ordersService: OrdersService, + private requestsService: RequestsService, ) {} @Roles(Role.ADMIN) @@ -106,6 +109,14 @@ export class PantriesController { return this.ordersService.getOrdersByPantry(pantryId); } + @Roles(Role.PANTRY, Role.ADMIN) + @Get('/:pantryId/requests') + async getFoodRequests( + @Param('pantryId', ParseIntPipe) pantryId: number, + ): Promise { + return this.requestsService.findAllForPantry(pantryId); + } + @ApiBody({ description: 'Details for submitting a pantry application', schema: { diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index f9ee5ce59..e78e42a67 100644 --- a/apps/backend/src/pantries/pantries.module.ts +++ b/apps/backend/src/pantries/pantries.module.ts @@ -9,6 +9,7 @@ import { EmailsModule } from '../emails/email.module'; import { User } from '../users/users.entity'; import { UsersModule } from '../users/users.module'; import { Order } from '../orders/order.entity'; +import { RequestsModule } from '../foodRequests/request.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { Order } from '../orders/order.entity'; forwardRef(() => UsersModule), EmailsModule, forwardRef(() => AuthModule), + RequestsModule, ], controllers: [PantriesController], providers: [PantriesService], diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index bbdf83127..478dd5c65 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -25,13 +25,11 @@ import { Donation } from '../donations/donations.entity'; import { UsersService } from '../users/users.service'; import { AuthService } from '../auth/auth.service'; import { User } from '../users/users.entity'; -import { AllocationsService } from '../allocations/allocations.service'; import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; import { EmailsService } from '../emails/email.service'; import { mock } from 'jest-mock-extended'; import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; -import { DataSource } from 'typeorm'; -import { Allocation } from '../allocations/allocations.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; jest.setTimeout(60000); @@ -138,6 +136,10 @@ describe('PantriesService', () => { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), }, + { + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), + }, ], }).compile(); diff --git a/apps/backend/src/users/types.ts b/apps/backend/src/users/types.ts index 695cbc442..b28dc6391 100644 --- a/apps/backend/src/users/types.ts +++ b/apps/backend/src/users/types.ts @@ -4,3 +4,10 @@ export enum Role { PANTRY = 'pantry', FOODMANUFACTURER = 'food_manufacturer', } + +export type PendingApplication = { + id: number; + name: string; + type: 'pantry' | 'food_manufacturer'; + dateApplied: Date; +}; diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index e54045d70..01dee35b7 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -1,7 +1,7 @@ import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { User } from './users.entity'; -import { Role } from './types'; +import { PendingApplication, Role } from './types'; import { userSchemaDto } from './dtos/userSchema.dto'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; @@ -29,6 +29,7 @@ describe('UsersController', () => { mockUserService.remove.mockReset(); mockUserService.update.mockReset(); mockUserService.create.mockReset(); + mockUserService.getRecentPendingApplications.mockReset(); const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], @@ -159,4 +160,46 @@ describe('UsersController', () => { ); }); }); + + describe('GET /admin/recent-pending-applications', () => { + it('returns the list of pending applications from the service', async () => { + const applications: PendingApplication[] = [ + { + id: 5, + name: 'Southside Pantry Network', + type: 'pantry', + dateApplied: new Date('2024-02-02'), + }, + { + id: 6, + name: 'Harbor Community Center', + type: 'pantry', + dateApplied: new Date('2024-02-01'), + }, + { + id: 1, + name: 'FoodCorp Industries', + type: 'food_manufacturer', + dateApplied: new Date('2024-01-20'), + }, + ]; + + mockUserService.getRecentPendingApplications.mockResolvedValueOnce( + applications, + ); + + const result = await controller.getRecentPendingApplications(); + + expect(result).toEqual(applications); + expect(mockUserService.getRecentPendingApplications).toHaveBeenCalled(); + }); + + it('returns empty array when there are no pending applications', async () => { + mockUserService.getRecentPendingApplications.mockResolvedValueOnce([]); + + const result = await controller.getRecentPendingApplications(); + + expect(result).toEqual([]); + }); + }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index c1f50cfa3..d09aae913 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -8,14 +8,16 @@ import { Body, Patch, Req, + UseGuards, } from '@nestjs/common'; import { UsersService } from './users.service'; import { User } from './users.entity'; import { userSchemaDto } from './dtos/userSchema.dto'; import { UpdateUserInfoDto } from './dtos/update-user-info.dto'; +import { PendingApplication, Role } from './types'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { UseGuards } from '@nestjs/common'; +import { Roles } from '../auth/roles.decorator'; @Controller('users') export class UsersController { @@ -32,6 +34,12 @@ export class UsersController { return this.usersService.findOne(userId); } + @Roles(Role.ADMIN) + @Get('/admin/recent-pending-applications') + async getRecentPendingApplications(): Promise { + return this.usersService.getRecentPendingApplications(); + } + @Delete('/:id') removeUser(@Param('id', ParseIntPipe) userId: number): Promise { return this.usersService.remove(userId); diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index ce5211d56..cf15bc8ed 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -9,10 +9,19 @@ import { EmailsModule } from '../emails/email.module'; import { FoodRequest } from '../foodRequests/request.entity'; import { Order } from '../orders/order.entity'; import { Donation } from '../donations/donations.entity'; +import { Pantry } from '../pantries/pantries.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([User, FoodRequest, Order, Donation]), + TypeOrmModule.forFeature([ + User, + FoodRequest, + Order, + Donation, + Pantry, + FoodManufacturer, + ]), forwardRef(() => PantriesModule), forwardRef(() => AuthModule), EmailsModule, diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 569e0b184..75548b222 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -516,4 +516,100 @@ describe('UsersService', () => { ); }); }); + + describe('getRecentPendingApplications', () => { + it('returns empty array when no pending applications exist', async () => { + await testDataSource.query( + `UPDATE pantries SET status = 'approved' WHERE status = 'pending'`, + ); + await testDataSource.query( + `UPDATE food_manufacturers SET status = 'approved' WHERE status = 'pending'`, + ); + + const result = await service.getRecentPendingApplications(); + + expect(result).toEqual([]); + }); + + it('returns only pending applications, not approved or denied ones', async () => { + // db has 2 pending pantries - approve one to confirm it's excluded + await testDataSource.query( + `UPDATE pantries SET status = 'approved' WHERE pantry_name = 'Harbor Community Center'`, + ); + // db has 3 pending FMs - approve two to confirm it's excluded + await testDataSource.query( + `UPDATE food_manufacturers SET status = 'approved' + WHERE food_manufacturer_name in ('FoodCorp Industries', 'Healthy Foods Co')`, + ); + + const result = await service.getRecentPendingApplications(); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Southside Pantry Network'); + expect(result[1].name).toBe('Organic Suppliers LLC'); + }); + + it('returns correct shape for pantry applications', async () => { + const result = await service.getRecentPendingApplications(); + + result + .filter((a) => a.type === 'pantry') + .forEach((a) => { + expect(a.id).toBeDefined(); + expect(a.name).toBeDefined(); + expect(a.dateApplied).toBeDefined(); + }); + }); + + it('returns correct shape for food_manufacturer applications', async () => { + await testDataSource.query( + `UPDATE food_manufacturers SET status = 'pending'`, + ); + + const result = await service.getRecentPendingApplications(); + + result + .filter((a) => a.type === 'food_manufacturer') + .forEach((a) => { + expect(a.id).toBeDefined(); + expect(a.name).toBeDefined(); + expect(a.dateApplied).toBeDefined(); + }); + }); + + it('returns at most 4 results even when more pending applications exist', async () => { + await testDataSource.query( + `UPDATE food_manufacturers SET status = 'pending'`, + ); + + // now we have 2 pending pantries + 3 pending FMs + const result = await service.getRecentPendingApplications(); + + expect(result).toHaveLength(4); + }); + + it('returns results sorted by dateApplied descending', async () => { + await testDataSource.query( + `UPDATE food_manufacturers SET status = 'pending'`, + ); + + const result = await service.getRecentPendingApplications(); + + for (let i = 0; i < result.length - 1; i++) { + expect(result[i].dateApplied >= result[i + 1].dateApplied).toBe(true); + } + }); + + it('mixes pantry and food_manufacturer results correctly', async () => { + await testDataSource.query( + `UPDATE food_manufacturers SET status = 'pending'`, + ); + + const result = await service.getRecentPendingApplications(); + + const types = result.map((a) => a.type); + expect(types).toContain('pantry'); + expect(types).toContain('food_manufacturer'); + }); + }); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 7083fae8c..7535c8fc0 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -7,7 +7,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Between, In, Repository } from 'typeorm'; import { User } from './users.entity'; -import { Role } from './types'; +import { PendingApplication, Role } from './types'; import { validateId } from '../utils/validation.utils'; import { UpdateUserInfoDto } from './dtos/update-user-info.dto'; import { AuthService } from '../auth/auth.service'; @@ -18,6 +18,9 @@ import { FoodRequest } from '../foodRequests/request.entity'; import { Order } from '../orders/order.entity'; import { Donation } from '../donations/donations.entity'; import { UserStatsDto } from './dtos/user-stats.dto'; +import { Pantry } from '../pantries/pantries.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { ApplicationStatus } from '../shared/types'; @Injectable() export class UsersService { @@ -30,6 +33,10 @@ export class UsersService { private orderRepo: Repository, @InjectRepository(Donation) private donationRepo: Repository, + @InjectRepository(Pantry) + private pantryRepo: Repository, + @InjectRepository(FoodManufacturer) + private fmRepo: Repository, private authService: AuthService, private emailsService: EmailsService, ) {} @@ -127,6 +134,42 @@ export class UsersService { return users; } + async getRecentPendingApplications(): Promise { + const [pendingPantries, pendingFMs] = await Promise.all([ + this.pantryRepo.find({ + where: { status: ApplicationStatus.PENDING }, + select: ['pantryId', 'pantryName', 'dateApplied'], + order: { dateApplied: 'DESC' }, + take: 4, + }), + this.fmRepo.find({ + where: { status: ApplicationStatus.PENDING }, + select: ['foodManufacturerId', 'foodManufacturerName', 'dateApplied'], + order: { dateApplied: 'DESC' }, + take: 4, + }), + ]); + + const combined: PendingApplication[] = [ + ...pendingPantries.map((p) => ({ + id: p.pantryId, + name: p.pantryName, + type: 'pantry' as const, + dateApplied: p.dateApplied, + })), + ...pendingFMs.map((fm) => ({ + id: fm.foodManufacturerId, + name: fm.foodManufacturerName, + type: 'food_manufacturer' as const, + dateApplied: fm.dateApplied, + })), + ]; + + return combined + .sort((a, b) => b.dateApplied.getTime() - a.dateApplied.getTime()) + .slice(0, 4); + } + async update(id: number, dto: UpdateUserInfoDto): Promise { validateId(id, 'User'); diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index a7b381c15..035dc2179 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -213,4 +213,43 @@ describe('VolunteersController', () => { ); }); }); + + describe('GET /:id/my-recent-orders', () => { + it('returns the 2 most recent orders for a volunteer', async () => { + const assignee = { id: 6, firstName: 'James', lastName: 'Thomas' }; + const recentOrders: Partial[] = [ + { + orderId: 4, + status: 'pending' as VolunteerOrder['status'], + pantryName: 'Community Food Pantry Downtown', + assignee, + }, + { + orderId: 3, + status: 'shipped' as VolunteerOrder['status'], + pantryName: 'North End Food Bank', + assignee, + }, + ]; + + mockVolunteersService.getRecentOrders.mockResolvedValueOnce( + recentOrders as VolunteerOrder[], + ); + + const result = await controller.getRecentOrders(6); + + expect(result).toEqual(recentOrders); + expect(result).toHaveLength(2); + expect(mockVolunteersService.getRecentOrders).toHaveBeenCalledWith(6); + }); + + it('returns empty array when volunteer has no assigned orders', async () => { + mockVolunteersService.getRecentOrders.mockResolvedValueOnce([]); + + const result = await controller.getRecentOrders(6); + + expect(result).toEqual([]); + expect(mockVolunteersService.getRecentOrders).toHaveBeenCalledWith(6); + }); + }); }); diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index a26aec111..f4f86f775 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -42,6 +42,14 @@ export class VolunteersController { return this.volunteersService.findOne(userId); } + @Roles(Role.VOLUNTEER, Role.ADMIN) + @Get('/:id/my-recent-orders') + async getRecentOrders( + @Param('id', ParseIntPipe) id: number, + ): Promise { + return this.volunteersService.getRecentOrders(id); + } + @Post('/:id/pantries') async assignPantries( @Param('id', ParseIntPipe) id: number, diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index a871d5b30..48a1a47f0 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -1,6 +1,7 @@ import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { User } from '../users/users.entity'; import { VolunteersService } from './volunteers.service'; import { Pantry } from '../pantries/pantries.entity'; @@ -15,6 +16,12 @@ import { EmailsService } from '../emails/email.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationItem } from '../donationItems/donationItems.entity'; import { Donation } from '../donations/donations.entity'; +import { OrdersService } from '../orders/order.service'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { AllocationsService } from '../allocations/allocations.service'; +import { DonationService } from '../donations/donations.service'; +import { Allocation } from '../allocations/allocations.entity'; jest.setTimeout(60000); @@ -22,10 +29,11 @@ describe('VolunteersService', () => { let service: VolunteersService; beforeAll(async () => { - // Initialize DataSource once if (!testDataSource.isInitialized) { await testDataSource.initialize(); } + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -33,6 +41,15 @@ describe('VolunteersService', () => { UsersService, PantriesService, RequestsService, + OrdersService, + FoodManufacturersService, + DonationItemsService, + AllocationsService, + DonationService, + { + provide: DataSource, + useValue: testDataSource, + }, { provide: AuthService, useValue: { @@ -73,6 +90,10 @@ describe('VolunteersService', () => { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), }, + { + provide: getRepositoryToken(Allocation), + useValue: testDataSource.getRepository(Allocation), + }, ], }).compile(); @@ -86,7 +107,6 @@ describe('VolunteersService', () => { }); afterEach(async () => { - // Drop the schema completely (cascades all tables) await testDataSource.query(`DROP SCHEMA public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); }); @@ -288,7 +308,7 @@ describe('VolunteersService', () => { const assignedPantries = await service.getVolunteerPantries(volunteerId); const assignedPantryIds = assignedPantries.map((p) => p.pantryId); await testDataSource.query( - `DELETE FROM allocations + `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 @@ -297,7 +317,7 @@ describe('VolunteersService', () => { [assignedPantryIds], ); await testDataSource.query( - `DELETE FROM orders + `DELETE FROM orders WHERE request_id IN ( SELECT request_id FROM food_requests WHERE pantry_id = ANY($1) )`, @@ -312,4 +332,41 @@ describe('VolunteersService', () => { expect(requests).toEqual([]); }); }); + + describe('getRecentOrders', () => { + it('returns empty array when volunteer has no assigned orders', async () => { + await testDataSource.query( + `UPDATE orders SET assignee_id = (SELECT user_id FROM users WHERE role = 'volunteer' AND user_id != 6 LIMIT 1)`, + ); + + const result = await service.getRecentOrders(6); + expect(result).toEqual([]); + }); + + it('returns at most 2 orders even when volunteer has more', async () => { + await testDataSource.query(`UPDATE orders SET assignee_id = 6`); + + const result = await service.getRecentOrders(6); + expect(result).toHaveLength(2); + }); + + it('returns correct shape of orders for the volunteer', async () => { + await testDataSource.query(`UPDATE orders SET assignee_id = 6`); + + const result = await service.getRecentOrders(6); + + expect(result[0].createdAt >= result[1].createdAt).toBe(true); + result.forEach((order) => { + expect(order.pantryName).toBeDefined(); + expect(order.assignee.id).toBe(6); + expect(order.assignee.firstName).toBe('James'); + expect(order.assignee.lastName).toBe('Thomas'); + expect(order.orderId).toBeDefined(); + expect(order.status).toBeDefined(); + expect(order.createdAt).toBeDefined(); + expect(order.shippedAt).toBeDefined(); + expect(order.deliveredAt).toBeDefined(); + }); + }); + }); }); diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index c167d98ca..bc053a333 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -7,9 +7,10 @@ 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 { Assignments } from './types'; +import { Assignments, VolunteerOrder } from './types'; import { FoodRequest } from '../foodRequests/request.entity'; import { RequestsService } from '../foodRequests/request.service'; +import { OrdersService } from '../orders/order.service'; @Injectable() export class VolunteersService { @@ -19,6 +20,7 @@ export class VolunteersService { private usersService: UsersService, private pantriesService: PantriesService, private requestsService: RequestsService, + private ordersService: OrdersService, ) {} async findOne(id: number): Promise { @@ -58,6 +60,11 @@ export class VolunteersService { return volunteer.pantries || []; } + async getRecentOrders(volunteerId: number): Promise { + validateId(volunteerId, 'Volunteer'); + return this.ordersService.getRecentOrdersByAssignee(volunteerId); + } + async assignPantriesToVolunteer( volunteerId: number, pantryIds: number[], @@ -84,7 +91,7 @@ export class VolunteersService { const pantryIds = pantries.map((p) => p.pantryId); const requestArrays = await Promise.all( - pantryIds.map((id) => this.requestsService.find(id)), + pantryIds.map((id) => this.requestsService.findAllForPantry(id)), ); return requestArrays.flat(); diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 58d7aa5ef..918a8d98c 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -335,7 +335,7 @@ export class ApiClient { } public async getPantryRequests(pantryId: number): Promise { - const data = await this.get(`/api/requests/${pantryId}/all`); + const data = await this.get(`/api/pantries/${pantryId}/requests`); return data as FoodRequest[]; } From 8e711370004f1b2969ec60fc05bd81ce673e5c4b Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:12:49 -0400 Subject: [PATCH 2/4] fixes --- apps/backend/src/orders/order.service.ts | 3 ++- apps/backend/src/pantries/pantries.module.ts | 2 +- apps/backend/src/volunteers/volunteers.controller.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index d4d73cc57..56fe82aec 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -479,7 +479,8 @@ export class OrdersService { const qb = this.repo .createQueryBuilder('order') .leftJoinAndSelect('order.request', 'request') - .leftJoinAndSelect('request.pantry', 'pantry') + .leftJoin('request.pantry', 'pantry') + .addSelect('pantry.pantryName') .leftJoinAndSelect('order.allocations', 'allocations') .leftJoinAndSelect('allocations.item', 'item') .leftJoinAndSelect('order.assignee', 'assignee') diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index e78e42a67..9261d6129 100644 --- a/apps/backend/src/pantries/pantries.module.ts +++ b/apps/backend/src/pantries/pantries.module.ts @@ -18,7 +18,7 @@ import { RequestsModule } from '../foodRequests/request.module'; forwardRef(() => UsersModule), EmailsModule, forwardRef(() => AuthModule), - RequestsModule, + forwardRef(() => RequestsModule), ], controllers: [PantriesController], providers: [PantriesService], diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index f4f86f775..e30ae637c 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -30,6 +30,7 @@ export class VolunteersController { return this.volunteersService.getVolunteersAndPantryAssignments(); } + @Roles(Role.VOLUNTEER, Role.ADMIN) @Get('/:id/pantries') async getVolunteerPantries( @Param('id', ParseIntPipe) id: number, From 225525b9a596146d898c272808cb9a7e4a3d0b86 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:00:54 -0400 Subject: [PATCH 3/4] comment --- apps/backend/src/volunteers/volunteers.controller.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index e30ae637c..7687caf1d 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -16,6 +16,8 @@ import { Assignments, VolunteerOrder } from './types'; import { FoodRequest } from '../foodRequests/request.entity'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { OrdersService } from '../orders/order.service'; +import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { UsersService } from '../users/users.service'; @Controller('volunteers') export class VolunteersController { @@ -43,6 +45,16 @@ export class VolunteersController { return this.volunteersService.findOne(userId); } + @CheckOwnership({ + idParam: 'id', + resolver: async ({ entityId, services }) => { + return pipeNullable( + () => services.get(UsersService).findOne(entityId), + (user: User) => [user.id], + ); + }, + bypassRoles: [Role.ADMIN], + }) @Roles(Role.VOLUNTEER, Role.ADMIN) @Get('/:id/my-recent-orders') async getRecentOrders( From 4fd10864223a4a3eeb1795b4c0ea63f2283a0a0f Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:46:19 -0400 Subject: [PATCH 4/4] fix resolver --- apps/backend/src/pantries/pantries.controller.ts | 1 - apps/backend/src/volunteers/volunteers.controller.ts | 10 ++-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index 21df33214..e7c886a18 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -35,7 +35,6 @@ import { Public } from '../auth/public.decorator'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; import { UpdatePantryVolunteersDto } from './dtos/update-pantry-volunteers-dto'; -import { FoodRequest } from '../foodRequests/request.entity'; import { RequestsService } from '../foodRequests/request.service'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index cc4105afd..ee330b44e 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -16,8 +16,7 @@ import { Assignments, VolunteerOrder } from './types'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { OrdersService } from '../orders/order.service'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; -import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; -import { UsersService } from '../users/users.service'; +import { CheckOwnership } from '../auth/ownership.decorator'; @Controller('volunteers') export class VolunteersController { @@ -47,12 +46,7 @@ export class VolunteersController { @CheckOwnership({ idParam: 'id', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(UsersService).findOne(entityId), - (user: User) => [user.id], - ); - }, + resolver: async ({ entityId }) => [entityId], bypassRoles: [Role.ADMIN], }) @Roles(Role.VOLUNTEER, Role.ADMIN)