diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 4cd5c221a..ff50167f1 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -29,7 +29,6 @@ import { DonationService } from '../donations/donations.service'; import { PantriesService } from '../pantries/pantries.service'; import { Pantry } from '../pantries/pantries.entity'; import { Allocation } from '../allocations/allocations.entity'; -import { AllocationsService } from '../allocations/allocations.service'; jest.setTimeout(60000); diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 135609d50..dfd76b647 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(); mockRequestsService.update.mockReset(); @@ -94,28 +93,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 5939c3edd..9505d0aaf 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -43,14 +43,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 b0be5b454..13be82b49 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -249,10 +249,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( @@ -275,10 +276,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); @@ -303,7 +305,7 @@ describe('RequestsService', () => { ), ); - const requests = await service.find(pantryId); + const requests = await service.findAllForPantry(pantryId); expect(requests.length).toBe(3); }); @@ -319,10 +321,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); @@ -332,7 +334,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 615a18f55..70001a274 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -265,7 +265,7 @@ export class RequestsService { return foodRequest; } - async find(pantryId: number): Promise { + async findAllForPantry(pantryId: number): Promise { 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 d32a328ce..753c4f893 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -196,6 +196,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; @@ -371,6 +410,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 () => { @@ -810,13 +851,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 ab73160a1..ff41610fa 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -121,6 +121,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]) }, @@ -428,8 +466,11 @@ export class OrdersService { const qb = this.repo .createQueryBuilder('order') .leftJoinAndSelect('order.request', 'request') + .leftJoin('request.pantry', 'pantry') + .addSelect('pantry.pantryName') .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 ce8ae094d..5a7233a3c 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(); @@ -427,6 +444,7 @@ describe('PantriesController', () => { ); }); }); + describe('getCurrentUserPantryId', () => { it('returns pantryId for authenticated user', async () => { const req = { user: { id: 1 } }; @@ -525,4 +543,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 e6d04f282..21df33214 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -35,12 +35,16 @@ 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'; @Controller('pantries') export class PantriesController { constructor( private pantriesService: PantriesService, private ordersService: OrdersService, + private requestsService: RequestsService, ) {} @Roles(Role.ADMIN) @@ -123,6 +127,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..9261d6129 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), + forwardRef(() => 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 10f5480e9..2cb8545de 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 c52031e92..aca78d981 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 08f9f7fc0..b2868620f 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 6c20d7e27..cc4105afd 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 { 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'; @Controller('volunteers') export class VolunteersController { @@ -30,6 +32,7 @@ export class VolunteersController { return this.volunteersService.getVolunteersAndPantryAssignments(); } + @Roles(Role.VOLUNTEER, Role.ADMIN) @Get('/:id/pantries') async getVolunteerPantries( @Param('id', ParseIntPipe) id: number, @@ -42,6 +45,24 @@ 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( + @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 76c6c346a..ec145bbab 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,7 +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 { DataSource } from 'typeorm'; +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); @@ -23,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: [ @@ -34,6 +41,11 @@ describe('VolunteersService', () => { UsersService, PantriesService, RequestsService, + OrdersService, + FoodManufacturersService, + DonationItemsService, + AllocationsService, + DonationService, { provide: DataSource, useValue: testDataSource, @@ -78,6 +90,10 @@ describe('VolunteersService', () => { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), }, + { + provide: getRepositoryToken(Allocation), + useValue: testDataSource.getRepository(Allocation), + }, ], }).compile(); @@ -91,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`); }); @@ -293,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 @@ -302,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) )`, @@ -317,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 954c48939..689bdf7fa 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -7,8 +7,9 @@ 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 { RequestsService } from '../foodRequests/request.service'; +import { OrdersService } from '../orders/order.service'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; @Injectable() @@ -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[], @@ -86,7 +93,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().map((r) => ({ diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index ffed2ae0d..d9ca8653b 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -423,7 +423,7 @@ export class ApiClient { pantryId: number, ): Promise { return this.axiosInstance - .get(`/api/requests/${pantryId}/all`) + .get(`/api/pantries/${pantryId}/requests`) .then((response) => response.data); }