From 558c36d9ea79b2578b099a64169ab7ffc8e81657 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 26 May 2026 22:42:17 -0700 Subject: [PATCH 1/4] Final commit --- .../allocations/allocations.service.spec.ts | 24 --- .../src/allocations/allocations.service.ts | 13 -- apps/backend/src/auth/ownership.decorator.ts | 6 +- apps/backend/src/auth/ownership.guard.ts | 3 +- .../donationItems/donationItems.controller.ts | 15 ++ .../donations/donations.controller.spec.ts | 12 -- .../src/donations/donations.controller.ts | 21 +-- .../manufacturers.controller.spec.ts | 20 +- .../manufacturers.controller.ts | 70 ++++--- .../src/foodRequests/request.controller.ts | 8 - .../src/orders/order.controller.spec.ts | 29 --- apps/backend/src/orders/order.controller.ts | 75 +++++--- .../src/pantries/pantries.controller.spec.ts | 24 ++- .../src/pantries/pantries.controller.ts | 29 ++- apps/backend/src/users/users.controller.ts | 24 ++- .../src/volunteers/volunteers.controller.ts | 23 ++- apps/frontend/src/api/apiClient.ts | 46 +---- .../forms/orderInformationModal.tsx | 86 --------- .../src/containers/donationManagement.tsx | 174 ------------------ .../foodManufacturerDonationManagement.tsx | 10 +- apps/frontend/src/containers/formRequests.tsx | 2 +- .../src/containers/pantryDashboard.tsx | 4 +- .../src/containers/pantryOrderManagement.tsx | 3 +- 23 files changed, 224 insertions(+), 497 deletions(-) delete mode 100644 apps/frontend/src/components/forms/orderInformationModal.tsx delete mode 100644 apps/frontend/src/containers/donationManagement.tsx diff --git a/apps/backend/src/allocations/allocations.service.spec.ts b/apps/backend/src/allocations/allocations.service.spec.ts index cfb8d7bdb..606813189 100644 --- a/apps/backend/src/allocations/allocations.service.spec.ts +++ b/apps/backend/src/allocations/allocations.service.spec.ts @@ -56,30 +56,6 @@ describe('AllocationsService', () => { expect(service).toBeDefined(); }); - describe('getAllAllocationsByOrder', () => { - it('should return empty array for order with no allocations', async () => { - await testDataSource.query(`DELETE FROM allocations WHERE order_id = 1`); - - const result = await service.getAllAllocationsByOrder(1); - - expect(result).toEqual([]); - }); - - it('should return all allocations for a given order', async () => { - const result = await service.getAllAllocationsByOrder(2); - - expect(result).toHaveLength(3); - const quantities = result - .map((a) => a.allocatedQuantity) - .sort((a, b) => a! - b!); - expect(quantities).toEqual([15, 20, 30]); - result.forEach((a) => { - expect(a.allocationId).toBeDefined(); - expect(a.item).toBeDefined(); - }); - }); - }); - describe('createMultiple', () => { it('should create a single allocation and increment reservedQuantity', async () => { const orderId = 1; diff --git a/apps/backend/src/allocations/allocations.service.ts b/apps/backend/src/allocations/allocations.service.ts index bd951892c..cba5e6923 100644 --- a/apps/backend/src/allocations/allocations.service.ts +++ b/apps/backend/src/allocations/allocations.service.ts @@ -13,19 +13,6 @@ export class AllocationsService { private donationItemRepo: Repository, ) {} - async getAllAllocationsByOrder( - orderId: number, - ): Promise[]> { - return this.repo.find({ - where: { orderId }, - relations: ['item'], - select: { - allocationId: true, - allocatedQuantity: true, - }, - }); - } - // This function assumes that orderId and itemAllocations were already correctly validated (see call in create method of OrdersService) async createMultiple( orderId: number, diff --git a/apps/backend/src/auth/ownership.decorator.ts b/apps/backend/src/auth/ownership.decorator.ts index 80c520ed2..40e2c40ce 100644 --- a/apps/backend/src/auth/ownership.decorator.ts +++ b/apps/backend/src/auth/ownership.decorator.ts @@ -1,13 +1,17 @@ import { SetMetadata, Type } from '@nestjs/common'; import { Role } from '../users/types'; +import { User } from '../users/users.entity'; // Resolver function type to get the owner user ID for a given entity ID // Should return the user IDs of the users who are authorized to call the -// endpoint that the decorator is attached to +// endpoint that the decorator is attached to. The current authenticated user +// is optionally provided so resolvers can branch on role when different roles +// need different ownership rules (e.g. PANTRY -> pantry rep, VOLUNTEER -> assigned volunteers). // If the resolver returns null, it will be treated as if the user is not authorized export type OwnerIdResolver = (params: { entityId: number; services: ServiceRegistry; + user?: User; }) => Promise; // Registry of services that can be easily resolved diff --git a/apps/backend/src/auth/ownership.guard.ts b/apps/backend/src/auth/ownership.guard.ts index 5d184e7a7..4235ea205 100644 --- a/apps/backend/src/auth/ownership.guard.ts +++ b/apps/backend/src/auth/ownership.guard.ts @@ -44,7 +44,7 @@ export class OwnershipGuard implements CanActivate { return true; } - // Specified roles bypass ownership checks + // Specified roles bypass ownership checks for other roles if (config.bypassRoles?.includes(user.role as Role)) { return true; } @@ -63,6 +63,7 @@ export class OwnershipGuard implements CanActivate { const ownerIds = await config.resolver({ entityId, services, + user, }); if ( diff --git a/apps/backend/src/donationItems/donationItems.controller.ts b/apps/backend/src/donationItems/donationItems.controller.ts index b4fd4f531..0557ce44d 100644 --- a/apps/backend/src/donationItems/donationItems.controller.ts +++ b/apps/backend/src/donationItems/donationItems.controller.ts @@ -8,12 +8,27 @@ import { import { DonationItemsService } from './donationItems.service'; import { DonationItem } from './donationItems.entity'; import { AuthGuard } from '@nestjs/passport'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../users/types'; +import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { DonationService } from '../donations/donations.service'; +import { Donation } from '../donations/donations.entity'; @Controller('donation-items') @UseGuards(AuthGuard('jwt')) export class DonationItemsController { constructor(private donationItemsService: DonationItemsService) {} + @CheckOwnership({ + idParam: 'donationId', + resolver: async ({ entityId, services }) => + pipeNullable( + () => services.get(DonationService).findOne(entityId), + (donation: Donation) => [donation.foodManufacturer.foodManufacturerId], + ), + bypassRoles: [Role.ADMIN], + }) + @Roles(Role.ADMIN, Role.FOODMANUFACTURER) @Get('/:donationId/all') async getAllDonationItemsForDonation( @Param('donationId', ParseIntPipe) donationId: number, diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 20400d45a..0fb26dddc 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -120,18 +120,6 @@ describe('DonationsController', () => { }); }); - describe('PATCH /:donationId/fulfill', () => { - it('should call donationService.fulfill', async () => { - const donationId = 1; - - mockDonationService.fulfill.mockResolvedValueOnce(undefined); - - await controller.fulfillDonation(donationId); - - expect(mockDonationService.fulfill).toHaveBeenCalledWith(donationId); - }); - }); - describe('PATCH /:donationId/item-details', () => { it('calls updateDonationItemDetails with the correct donationId and body, returns result', async () => { const donationId = 1; diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 19657b089..378b7bb26 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -21,13 +21,12 @@ import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-i import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; -import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; -import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @Controller('donations') export class DonationsController { constructor(private donationService: DonationService) {} + @Roles(Role.ADMIN) @Get() async getAllDonations(): Promise { return this.donationService.getAll(); @@ -45,6 +44,7 @@ export class DonationsController { return this.donationService.findOne(donationId); } + @Roles(Role.FOODMANUFACTURER) @Post() @ApiBody({ description: 'Details for creating a donation', @@ -100,26 +100,13 @@ export class DonationsController { return this.donationService.create(body); } - @Patch('/:donationId/fulfill') - async fulfillDonation( - @Param('donationId', ParseIntPipe) donationId: number, - ): Promise { - await this.donationService.fulfill(donationId); - } - - @Roles(Role.ADMIN, Role.FOODMANUFACTURER) + @Roles(Role.FOODMANUFACTURER) @CheckOwnership({ idParam: 'donationId', resolver: async ({ entityId, services }) => { return pipeNullable( () => services.get(DonationService).findOne(entityId), - (donation: Donation) => - services - .get(FoodManufacturersService) - .findOne(donation.foodManufacturer.foodManufacturerId), - (manufacturer: FoodManufacturer) => [ - manufacturer.foodManufacturerRepresentative.id, - ], + (donation: Donation) => [donation.foodManufacturer.foodManufacturerId], ); }, }) diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index f59dd7c77..ac7d46fa5 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -91,7 +91,7 @@ describe('FoodManufacturersController', () => { }); }); - describe('GET /:foodManufacturerId/donations', () => { + describe('GET /me/donations', () => { it('should return donation details for a given food manufacturer', async () => { const mockDonations: Partial[] = [ { @@ -135,16 +135,19 @@ describe('FoodManufacturersController', () => { const req = { user: { id: 1 } }; + mockManufacturersService.findByUserId.mockResolvedValueOnce({ + foodManufacturerId: 1, + } as FoodManufacturer); mockManufacturersService.getFMDonations.mockResolvedValue( mockDonationDetails, ); const result = await controller.getFoodManufacturerDonations( req as AuthenticatedRequest, - 1, ); expect(result).toBe(mockDonationDetails); + expect(mockManufacturersService.findByUserId).toHaveBeenCalledWith(1); expect(mockManufacturersService.getFMDonations).toHaveBeenCalledWith( 1, 1, @@ -152,8 +155,9 @@ describe('FoodManufacturersController', () => { }); }); - describe('GET /:foodManufacturerId/next-two-reminders', () => { - it('should return the next two upcoming donation reminders for a given food manufacturer', async () => { + describe('GET /me/next-two-reminders', () => { + it('should return the next two upcoming donation reminders for the authenticated manufacturer', async () => { + const req = { user: { id: 3 } }; const mockDonationReminders: DonationReminderDto[] = [ { donation: { @@ -171,13 +175,19 @@ describe('FoodManufacturersController', () => { }, ]; + mockManufacturersService.findByUserId.mockResolvedValueOnce({ + foodManufacturerId: 1, + } as FoodManufacturer); mockManufacturersService.getUpcomingDonationReminders.mockResolvedValue( mockDonationReminders, ); - const result = await controller.getNextTwoDonationReminders(1); + const result = await controller.getNextTwoDonationReminders( + req as AuthenticatedRequest, + ); expect(result).toEqual(mockDonationReminders); + expect(mockManufacturersService.findByUserId).toHaveBeenCalledWith(3); expect( mockManufacturersService.getUpcomingDonationReminders, ).toHaveBeenCalledWith(1); diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts index b56ad4a86..0bb5b40b5 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -29,6 +29,7 @@ import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; export class FoodManufacturersController { constructor(private foodManufacturersService: FoodManufacturersService) {} + @Roles(Role.ADMIN) @Get('/pending') async getPendingManufacturers(): Promise { return this.foodManufacturersService.getPendingManufacturers(); @@ -45,6 +46,18 @@ export class FoodManufacturersController { return manufacturer.foodManufacturerId; } + @Roles(Role.ADMIN, Role.FOODMANUFACTURER) + @CheckOwnership({ + idParam: 'foodManufacturerId', + resolver: async ({ entityId, services }) => + pipeNullable( + () => services.get(FoodManufacturersService).findOne(entityId), + (manufacturer: FoodManufacturer) => [ + manufacturer.foodManufacturerRepresentative.id, + ], + ), + bypassRoles: [Role.ADMIN], + }) @Get('/:foodManufacturerId') async getFoodManufacturer( @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, @@ -53,34 +66,29 @@ export class FoodManufacturersController { } @Roles(Role.FOODMANUFACTURER) - @Get('/:foodManufacturerId/donations') + @Get('/me/donations') async getFoodManufacturerDonations( @Req() req: AuthenticatedRequest, - @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, ): Promise { + const manufacturer = await this.foodManufacturersService.findByUserId( + req.user.id, + ); return this.foodManufacturersService.getFMDonations( - foodManufacturerId, + manufacturer.foodManufacturerId, req.user.id, ); } - @CheckOwnership({ - idParam: 'foodManufacturerId', - resolver: async ({ entityId, services }) => - pipeNullable( - () => services.get(FoodManufacturersService).findOne(entityId), - (manufacturer: FoodManufacturer) => [ - manufacturer.foodManufacturerRepresentative.id, - ], - ), - }) @Roles(Role.FOODMANUFACTURER) - @Get('/:foodManufacturerId/next-two-reminders') + @Get('/me/next-two-reminders') async getNextTwoDonationReminders( - @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, + @Req() req: AuthenticatedRequest, ): Promise { + const manufacturer = await this.foodManufacturersService.findByUserId( + req.user.id, + ); return this.foodManufacturersService.getUpcomingDonationReminders( - foodManufacturerId, + manufacturer.foodManufacturerId, ); } @@ -215,31 +223,43 @@ export class FoodManufacturersController { } @Roles(Role.FOODMANUFACTURER) - @Patch('/:manufacturerId/application') + @CheckOwnership({ + idParam: 'foodManufacturerId', + resolver: async ({ entityId, services }) => + pipeNullable( + () => services.get(FoodManufacturersService).findOne(entityId), + (manufacturer: FoodManufacturer) => [ + manufacturer.foodManufacturerRepresentative.id, + ], + ), + }) + @Patch('/:foodManufacturerId/application') async updateFoodManufacturerApplication( @Req() req: AuthenticatedRequest, - @Param('manufacturerId', ParseIntPipe) manufacturerId: number, + @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, @Body(new ValidationPipe()) foodManufacturerData: UpdateFoodManufacturerApplicationDto, ): Promise { return this.foodManufacturersService.updateFoodManufacturerApplication( - manufacturerId, + foodManufacturerId, foodManufacturerData, req.user.id, ); } - @Patch('/:manufacturerId/approve') + @Roles(Role.ADMIN) + @Patch('/:foodManufacturerId/approve') async approveManufacturer( - @Param('manufacturerId', ParseIntPipe) manufacturerId: number, + @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, ): Promise { - return this.foodManufacturersService.approve(manufacturerId); + return this.foodManufacturersService.approve(foodManufacturerId); } - @Patch('/:manufacturerId/deny') + @Roles(Role.ADMIN) + @Patch('/:foodManufacturerId/deny') async denyManufacturer( - @Param('manufacturerId', ParseIntPipe) manufacturerId: number, + @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, ): Promise { - return this.foodManufacturersService.deny(manufacturerId); + return this.foodManufacturersService.deny(foodManufacturerId); } } diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 1a494d3cc..1d166318e 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -35,14 +35,6 @@ export class RequestsController { return this.requestsService.getAll(); } - @Roles(Role.PANTRY, Role.ADMIN, Role.VOLUNTEER) - @Get('/:requestId') - async getRequest( - @Param('requestId', ParseIntPipe) requestId: number, - ): Promise { - return this.requestsService.findOne(requestId); - } - @Roles(Role.VOLUNTEER, Role.PANTRY, Role.ADMIN) @Get('/:requestId/order-details') async getAllOrderDetailsFromRequest( diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index e645fdd72..471fdd472 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -168,20 +168,6 @@ describe('OrdersController', () => { }); }); - describe('getPantryFromOrder', () => { - it('should call ordersService.findOrderPantry and return pantry', async () => { - const orderId = 1; - mockOrdersService.findOrderPantry.mockResolvedValueOnce( - mockPantries[0] as Pantry, - ); - - const result = await controller.getPantryFromOrder(orderId); - - expect(result).toEqual(mockPantries[0] as Pantry); - expect(mockOrdersService.findOrderPantry).toHaveBeenCalledWith(orderId); - }); - }); - describe('getRequestFromOrder', () => { it('should call ordersService.findOrderFoodRequest and return food request', async () => { const orderId = 1; @@ -214,21 +200,6 @@ describe('OrdersController', () => { }); }); - describe('getAllAllocationsByOrder', () => { - it('should call allocationsService.getAllAllocationsByOrder and return allocations', async () => { - const orderId = 1; - mockAllocationsService.getAllAllocationsByOrder.mockResolvedValueOnce( - mockAllocations.slice(0, 2) as Allocation[], - ); - - const result = await controller.getAllAllocationsByOrder(orderId); - - expect(result).toEqual(mockAllocations.slice(0, 2) as Allocation[]); - expect( - mockAllocationsService.getAllAllocationsByOrder, - ).toHaveBeenCalledWith(orderId); - }); - }); describe('confirmDelivery', () => { beforeEach(() => { mockAWSS3Service.upload.mockReset(); diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 5f00b9ec0..433188532 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -19,9 +19,12 @@ import { OrdersService } from './order.service'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; -import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { + CheckOwnership, + OwnerIdResolver, + pipeNullable, +} from '../auth/ownership.decorator'; import { PantriesService } from '../pantries/pantries.service'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; @@ -36,17 +39,40 @@ import { AuthenticatedRequest } from '../auth/authenticated-request'; import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; +// Ownership resolver for order-scoped reads (request + order details). +// PANTRY users must own the pantry tied to the order's food request. +// VOLUNTEER users must be the assignee on the order itself. +// ADMIN bypasses in the guard. +const resolveOrderAuthorizedUserIds: OwnerIdResolver = ({ + entityId, + services, + user, +}) => { + if (user?.role === Role.VOLUNTEER) { + return pipeNullable( + () => services.get(OrdersService).findOne(entityId), + (order: Order) => [order.assigneeId], + ); + } + return pipeNullable( + () => services.get(OrdersService).findOrderFoodRequest(entityId), + (request: FoodRequestSummaryDto) => + services.get(PantriesService).findOne(request.pantry.pantryId), + (pantry: Pantry) => [pantry.pantryUser.id], + ); +}; + @Controller('orders') export class OrdersController { constructor( private readonly ordersService: OrdersService, - private readonly allocationsService: AllocationsService, private readonly awsS3Service: AWSS3Service, ) {} // Called like: /?status=pending&pantryName=Test%20Pantry&pantryName=Test%20Pantry%202 // %20 is the URL encoded space character // This gets all orders where the status is pending and the pantry name is either Test Pantry or Test Pantry 2 + @Roles(Role.ADMIN) @Get('/') async getAllOrders( @Query('status') status?: string, @@ -68,25 +94,9 @@ export class OrdersController { return this.ordersService.getPastOrders(); } - @Get('/:orderId/pantry') - async getPantryFromOrder( - @Param('orderId', ParseIntPipe) orderId: number, - ): Promise { - return this.ordersService.findOrderPantry(orderId); - } - - // Test endpoint for right now @CheckOwnership({ idParam: 'orderId', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(OrdersService).findOrderFoodRequest(entityId), - (request: FoodRequestSummaryDto) => - services.get(PantriesService).findOne(request.pantry.pantryId), - (pantry: Pantry) => [pantry.pantryUser.id], - ); - }, - bypassRoles: [Role.VOLUNTEER, Role.ADMIN], + resolver: resolveOrderAuthorizedUserIds, }) @Roles(Role.VOLUNTEER, Role.PANTRY, Role.ADMIN) @Get('/:orderId/request') @@ -103,6 +113,11 @@ export class OrdersController { return this.ordersService.findOrderFoodManufacturer(orderId); } + @CheckOwnership({ + idParam: 'orderId', + resolver: resolveOrderAuthorizedUserIds, + }) + @Roles(Role.VOLUNTEER, Role.PANTRY, Role.ADMIN) @Get('/:orderId') async getOrder( @Param('orderId', ParseIntPipe) orderId: number, @@ -110,13 +125,7 @@ export class OrdersController { return this.ordersService.findOrderDetails(orderId); } - @Get('/:orderId/allocations') - async getAllAllocationsByOrder( - @Param('orderId', ParseIntPipe) orderId: number, - ) { - return this.allocationsService.getAllAllocationsByOrder(orderId); - } - + @Roles(Role.ADMIN, Role.VOLUNTEER) @Post('/') @ApiBody({ description: 'Details for creating a order', @@ -210,6 +219,18 @@ export class OrdersController { return this.ordersService.bulkUpdateTrackingCostInfo(dto); } + @CheckOwnership({ + idParam: 'orderId', + resolver: async ({ entityId, services }) => { + return pipeNullable( + () => services.get(OrdersService).findOrderFoodRequest(entityId), + (request: FoodRequestSummaryDto) => + services.get(PantriesService).findOne(request.pantry.pantryId), + (pantry: Pantry) => [pantry.pantryUser.id], + ); + }, + }) + @Roles(Role.PANTRY) @Patch('/:orderId/confirm-delivery') @ApiBody({ description: 'Details for a confirmation of order delivery form', diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index bc2c83f7b..9cbd1a752 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -353,8 +353,9 @@ describe('PantriesController', () => { }); describe('getOrders', () => { - it('should return orders for a pantry', async () => { - const pantryId = 24; + it('should return orders for the authenticated pantry user', async () => { + const req = { user: { id: 5 } }; + const pantry: Partial = { pantryId: 24 }; const mockOrders: Partial[] = [ { @@ -365,16 +366,18 @@ describe('PantriesController', () => { }, ]; + mockPantriesService.findByUserId.mockResolvedValueOnce(pantry as Pantry); mockOrdersService.getOrdersByPantry.mockResolvedValue( mockOrders as OrderSummary[], ); - const result = await controller.getOrders(pantryId); + const result = await controller.getOrders(req as AuthenticatedRequest); expect(result).toEqual(mockOrders); expect(result).toHaveLength(2); expect(result[0].orderId).toBe(26); expect(result[1].orderId).toBe(27); + expect(mockPantriesService.findByUserId).toHaveBeenCalledWith(5); expect(mockOrdersService.getOrdersByPantry).toHaveBeenCalledWith(24); }); }); @@ -538,7 +541,9 @@ describe('PantriesController', () => { }); describe('getFoodRequests', () => { - it('should call requestsService.find and return all food requests for a specific pantry', async () => { + it('should call requestsService.find and return all food requests for the authenticated pantry user', async () => { + const req = { user: { id: 7 } }; + const pantry: Partial = { pantryId: 1 }; const foodRequests: Partial[] = [ foodRequest1, { @@ -546,18 +551,19 @@ describe('PantriesController', () => { pantryId: 1, }, ]; - const pantryId = 1; + mockPantriesService.findByUserId.mockResolvedValueOnce(pantry as Pantry); mockRequestsService.findAllForPantry.mockResolvedValueOnce( foodRequests as FoodRequest[], ); - const result = await controller.getFoodRequests(pantryId); + const result = await controller.getFoodRequests( + req as AuthenticatedRequest, + ); expect(result).toEqual(foodRequests); - expect(mockRequestsService.findAllForPantry).toHaveBeenCalledWith( - pantryId, - ); + expect(mockPantriesService.findByUserId).toHaveBeenCalledWith(7); + expect(mockRequestsService.findAllForPantry).toHaveBeenCalledWith(1); }); }); }); diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index b8286b0d4..b02d5fd61 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -118,20 +118,20 @@ export class PantriesController { return this.pantriesService.findOne(pantryId); } - @Roles(Role.ADMIN, Role.PANTRY) - @Get('/:pantryId/orders') - async getOrders( - @Param('pantryId', ParseIntPipe) pantryId: number, - ): Promise { - return this.ordersService.getOrdersByPantry(pantryId); + @Roles(Role.PANTRY) + @Get('/me/orders') + async getOrders(@Req() req: AuthenticatedRequest): Promise { + const pantry = await this.pantriesService.findByUserId(req.user.id); + return this.ordersService.getOrdersByPantry(pantry.pantryId); } - @Roles(Role.PANTRY, Role.ADMIN) - @Get('/:pantryId/requests') + @Roles(Role.PANTRY) + @Get('/me/requests') async getFoodRequests( - @Param('pantryId', ParseIntPipe) pantryId: number, + @Req() req: AuthenticatedRequest, ): Promise { - return this.requestsService.findAllForPantry(pantryId); + const pantry = await this.pantriesService.findByUserId(req.user.id); + return this.requestsService.findAllForPantry(pantry.pantryId); } @ApiBody({ @@ -378,6 +378,15 @@ export class PantriesController { return this.pantriesService.addPantry(pantryData); } + @CheckOwnership({ + idParam: 'pantryId', + resolver: async ({ entityId, services }) => { + return pipeNullable( + () => services.get(PantriesService).findOne(entityId), + (pantry: Pantry) => [pantry.pantryUser.id], + ); + }, + }) @Roles(Role.PANTRY) @Patch('/:pantryId/application') async updatePantryApplication( diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 8da88631b..e69eb45ce 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -8,7 +8,6 @@ import { Body, Patch, Req, - UseGuards, } from '@nestjs/common'; import { UsersService } from './users.service'; import { User } from './users.entity'; @@ -16,17 +15,16 @@ 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 { AdminVolunteerStats } from './dtos/admin-volunteer-stats.dto'; import { PantryStatsDto } from '../pantries/dtos/pantry-stats.dto'; import { ManufacturerStatsDto } from '../foodManufacturers/dtos/manufacturer-stats.dto'; import { Roles } from '../auth/roles.decorator'; +import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; @Controller('users') export class UsersController { constructor(private usersService: UsersService) {} - @UseGuards(JwtAuthGuard) @Get('/me') getCurrentUser(@Req() req: AuthenticatedRequest): Promise { return this.usersService.findOne(req.user.id); @@ -37,6 +35,15 @@ export class UsersController { return this.usersService.findOne(userId); } + @CheckOwnership({ + idParam: 'id', + resolver: async ({ entityId, services }) => { + return pipeNullable( + () => services.get(UsersService).findOne(entityId), + (user: User) => [user.id], + ); + }, + }) @Get('/:id/stats') async getUserDashboardStats( @Param('id', ParseIntPipe) userId: number, @@ -50,11 +57,21 @@ export class UsersController { return this.usersService.getRecentPendingApplications(); } + @Roles(Role.ADMIN) @Delete('/:id') removeUser(@Param('id', ParseIntPipe) userId: number): Promise { return this.usersService.remove(userId); } + @CheckOwnership({ + idParam: 'id', + resolver: async ({ entityId, services }) => { + return pipeNullable( + () => services.get(UsersService).findOne(entityId), + (user: User) => [user.id], + ); + }, + }) @Patch('/:id') async updateInfo( @Param('id', ParseIntPipe) id: number, @@ -63,6 +80,7 @@ export class UsersController { return this.usersService.update(id, dto); } + @Roles(Role.ADMIN) @Post('/') async createUser(@Body() createUserDto: userSchemaDto): Promise { return this.usersService.create(createUserDto); diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index 7e6c08f08..8be3e2adf 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -16,6 +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'; @Controller('volunteers') export class VolunteersController { @@ -30,7 +31,16 @@ export class VolunteersController { return this.volunteersService.getVolunteersAndPantryAssignments(); } - @Roles(Role.VOLUNTEER, Role.ADMIN) + @CheckOwnership({ + idParam: 'id', + resolver: async ({ entityId, services }) => { + return pipeNullable( + () => services.get(VolunteersService).findOne(entityId), + (user: User) => [user.id], + ); + }, + }) + @Roles(Role.VOLUNTEER) @Get('/:id/pantries') async getVolunteerPantries( @Param('id', ParseIntPipe) id: number, @@ -69,8 +79,15 @@ export class VolunteersController { return this.volunteersService.getRecentOrders(req.user.id); } - // returns all orders globally - // only includes actionCompletion for orders assigned to the requesting volunteer + @CheckOwnership({ + idParam: 'id', + resolver: async ({ entityId, services }) => { + return pipeNullable( + () => services.get(VolunteersService).findOne(entityId), + (user: User) => [user.id], + ); + }, + }) @Roles(Role.VOLUNTEER) @Get('/:id/orders') async getVolunteerOrders( diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index d3ca7f72f..7f7dd1227 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -114,24 +114,12 @@ export class ApiClient { .then((response) => response.data); } - public async getAllDonationsByFoodManufacturer( - foodManufacturerId: number, - ): Promise { + public async getAllDonationsByFoodManufacturer(): Promise { return this.axiosInstance - .get(`/api/manufacturers/${foodManufacturerId}/donations`) + .get('/api/manufacturers/me/donations') .then((response) => response.data); } - public async fulfillDonation( - donationId: number, - body?: unknown, - ): Promise { - await this.axiosInstance.patch( - `/api/donations/${donationId}/fulfill`, - body ?? {}, - ); - } - public async getRepresentativeUser(userId: number): Promise { return this.axiosInstance .get(`/api/users/${userId}`) @@ -170,15 +158,9 @@ export class ApiClient { .then((response) => response.data); } - public async getPantryFromOrder(orderId: number): Promise { - return this.axiosInstance - .get(`/api/orders/${orderId}/pantry`) - .then((response) => response.data); - } - - public async getPantryOrders(pantryId: number): Promise { + public async getPantryOrders(): Promise { return this.axiosInstance - .get(`/api/pantries/${pantryId}/orders`) + .get('/api/pantries/me/orders') .then((response) => response.data); } @@ -288,12 +270,6 @@ export class ApiClient { .then((response) => response.data); } - public async getFoodRequest(requestId: number): Promise { - return this.axiosInstance - .get(`/api/requests/${requestId}`) - .then((response) => response.data); - } - public async getDonation(donationId: number): Promise { return this.axiosInstance .get(`/api/donations/${donationId}`) @@ -376,14 +352,6 @@ export class ApiClient { .then((response) => response.data); } - public async getAllAllocationsByOrder( - orderId: number, - ): Promise { - return this.axiosInstance - .get(`/api/orders/${orderId}/allocations`) - .then((response) => response.data); - } - public async updateOrderStatus( orderId: number, newStatus: 'shipped' | 'delivered', @@ -442,11 +410,9 @@ export class ApiClient { ); } - public async getPantryRequests( - pantryId: number, - ): Promise { + public async getPantryRequests(): Promise { return this.axiosInstance - .get(`/api/pantries/${pantryId}/requests`) + .get('/api/pantries/me/requests') .then((response) => response.data); } diff --git a/apps/frontend/src/components/forms/orderInformationModal.tsx b/apps/frontend/src/components/forms/orderInformationModal.tsx deleted file mode 100644 index c38b62a55..000000000 --- a/apps/frontend/src/components/forms/orderInformationModal.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { VStack, Text, Dialog } from '@chakra-ui/react'; -import { useState, useEffect } from 'react'; -import ApiClient from '@api/apiClient'; -import { Allocation, Pantry } from 'types/types'; -import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; - -interface OrderInformationModalProps { - orderId: number; - isOpen: boolean; - onClose: () => void; -} - -const OrderInformationModal: React.FC = ({ - orderId, - isOpen, - onClose, -}) => { - useModalBodyCleanup(); - const [pantry, setPantry] = useState(null); - const [allocationItems, setAllocationItems] = useState([]); - - useEffect(() => { - if (isOpen) { - const fetchData = async () => { - try { - const pantryData = await ApiClient.getPantryFromOrder(orderId); - const allocationItemData = await ApiClient.getAllAllocationsByOrder( - orderId, - ); - - setPantry(pantryData); - setAllocationItems(allocationItemData); - } catch { - console.error('Error fetching order details:'); - } - }; - - fetchData(); - } - }, [isOpen, orderId]); - - return ( - { - if (!e.open) onClose(); - }} - closeOnInteractOutside - > - - - - Order Details - - {pantry ? ( - - - Pantry Name: {pantry.pantryName} - - - Order Items: - {allocationItems.length > 0 ? ( - allocationItems.map((allocation) => ( - - - {allocation.allocatedQuantity}{' '} - {allocation.item.itemName} - - )) - ) : ( - No order contents available - )} - - - ) : ( - No data to load - )} - - - - - - ); -}; - -export default OrderInformationModal; diff --git a/apps/frontend/src/containers/donationManagement.tsx b/apps/frontend/src/containers/donationManagement.tsx deleted file mode 100644 index e7d038f4c..000000000 --- a/apps/frontend/src/containers/donationManagement.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Center, - Table, - Button, - Box, - Text, - useDisclosure, -} from '@chakra-ui/react'; -import ApiClient from '@api/apiClient'; -import NewDonationFormModal from '@components/forms/newDonationFormModal'; -import { formatDate } from '@utils/utils'; -import { Donation, DonationItem } from 'types/types'; -import { FloatingAlert } from '@components/floatingAlert'; -import { useAlert } from '../hooks/alert'; - -const DonationManagement: React.FC = () => { - const { open, onOpen, onClose } = useDisclosure(); - const [alertState, setAlertMessage] = useAlert(); - const [donations, setDonations] = useState([]); - const [expandedDonationIds, setExpandedDonationIds] = useState([]); - const [donationItems, setDonationItems] = useState<{ - [key: number]: DonationItem[]; - }>({}); - const [donationItemStock, setDonationItemStock] = useState<{ - [key: number]: number; - }>({}); - const [manufacturerId, setManufacturerId] = useState(null); - - useEffect(() => { - ApiClient.getCurrentUserFoodManufacturerId() - .then(setManufacturerId) - .catch(() => setManufacturerId(null)); - }, []); - - const fetchDonations = async () => { - try { - const data = await ApiClient.getAllDonations(); - const sortedDonations = data.sort((a, b) => { - if (a.status === 'fulfilled' && b.status !== 'fulfilled') return 1; - if (a.status !== 'fulfilled' && b.status === 'fulfilled') return -1; - return 0; - }); - setDonations(sortedDonations); - } catch { - setAlertMessage('Error fetching donations'); - } - }; - - const fetchDonationItems = async (donationId: number) => { - try { - const items = await ApiClient.getDonationItemsByDonationId(donationId); - setDonationItems((prev) => ({ - ...prev, - [donationId]: items, - })); - - items.forEach((item: DonationItem) => { - setDonationItemStock((prev) => ({ - ...prev, - [item.itemId]: item.quantity - item.reservedQuantity, - })); - }); - } catch { - setAlertMessage('Error fetching donation items'); - } - }; - - const toggleDropdown = (donationId: number) => { - if (expandedDonationIds.includes(donationId)) { - setExpandedDonationIds((prev) => prev.filter((id) => id !== donationId)); - } else { - setExpandedDonationIds((prev) => [...prev, donationId]); - if (!donationItems[donationId]) { - fetchDonationItems(donationId); - } - } - }; - - const fulfillDonation = async (donationId: number) => { - try { - await ApiClient.fulfillDonation(donationId); - fetchDonations(); - } catch { - setAlertMessage('Failed to fulfill donation'); - } - }; - - useEffect(() => { - fetchDonations(); - }, []); - - return ( -
- {alertState && ( - - )} - - {manufacturerId !== null && ( - - )} - - - - Donation ID - Date Donated - Status - Remaining Stock - Actions - - - - {donations.map((donation) => ( - - {donation.donationId} - {formatDate(donation.dateDonated)} - {donation.status} - - {expandedDonationIds.includes(donation.donationId) && - donationItems[donation.donationId]?.map((item) => ( - - - Item Name: {item.itemName} - - - Food Type: {item.foodType} - - - Remaining Stock:{' '} - {donationItemStock[item.itemId]} - - - ))} - toggleDropdown(donation.donationId)} - mt={2} - > - {expandedDonationIds.includes(donation.donationId) - ? 'Hide Information' - : 'Show Information'} - - - - {donation.status !== 'fulfilled' && ( - - )} - - - ))} - - -
- ); -}; - -export default DonationManagement; diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index a5b1b2a85..4f85ccb3c 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -52,9 +52,9 @@ const FoodManufacturerDonationManagement: React.FC = () => { }); // Fetch all donations on component mount and sorts them into their appropriate status lists - const fetchDonations = async (fmId: number) => { + const fetchDonations = async () => { try { - const data = await ApiClient.getAllDonationsByFoodManufacturer(fmId); + const data = await ApiClient.getAllDonationsByFoodManufacturer(); const grouped: Record = { [DonationStatus.AVAILABLE]: [], @@ -94,7 +94,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { try { const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); setManufacturerId(fmId); - await fetchDonations(fmId); + await fetchDonations(); } catch { setErrorMessage('Error initializing donation management'); } @@ -151,7 +151,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { {isLogDonationOpen && manufacturerId !== null && ( fetchDonations(manufacturerId)} + onDonationSuccess={() => fetchDonations()} isOpen={isLogDonationOpen} onClose={() => setIsLogDonationOpen(false)} /> @@ -164,7 +164,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { onClose={() => setSelectedActionDonation(null)} onSuccess={() => { setSelectedActionDonation(null); - if (manufacturerId !== null) fetchDonations(manufacturerId); + fetchDonations(); setSuccessMessage( 'Your details have been saved. Actions are complete once all shipment and item details are confirmed.', ); diff --git a/apps/frontend/src/containers/formRequests.tsx b/apps/frontend/src/containers/formRequests.tsx index 5dc980bc7..ea4caeac9 100644 --- a/apps/frontend/src/containers/formRequests.tsx +++ b/apps/frontend/src/containers/formRequests.tsx @@ -49,7 +49,7 @@ const FormRequests: React.FC = () => { setPantryId(pantryId); if (pantryId) { try { - const data = await ApiClient.getPantryRequests(pantryId); + const data = await ApiClient.getPantryRequests(); const sortedData = data .slice() .sort((a, b) => b.requestId - a.requestId); diff --git a/apps/frontend/src/containers/pantryDashboard.tsx b/apps/frontend/src/containers/pantryDashboard.tsx index 182c9a9ef..8085b4716 100644 --- a/apps/frontend/src/containers/pantryDashboard.tsx +++ b/apps/frontend/src/containers/pantryDashboard.tsx @@ -36,7 +36,7 @@ const PantryDashboard: React.FC = () => { } try { - const pantryFoodRequests = await ApiClient.getPantryRequests(pantryId); + const pantryFoodRequests = await ApiClient.getPantryRequests(); const sortedFoodRequests = pantryFoodRequests.sort( (a: FoodRequestSummaryDto, b: FoodRequestSummaryDto) => new Date(b.requestedAt).getTime() - @@ -48,7 +48,7 @@ const PantryDashboard: React.FC = () => { } try { - const pantryOrders = await ApiClient.getPantryOrders(pantryId); + const pantryOrders = await ApiClient.getPantryOrders(); const sortedOrders = pantryOrders.sort( (a: OrderSummary, b: OrderSummary) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index fea47c905..f614d5bab 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -87,8 +87,7 @@ const PantryOrderManagement: React.FC = () => { const fetchOrders = useCallback(async () => { try { - const pantryId = await ApiClient.getCurrentUserPantryId(); - const data = await ApiClient.getPantryOrders(pantryId); + const data = await ApiClient.getPantryOrders(); const grouped: Record = { [OrderStatus.SHIPPED]: [], From 084ed63e3d3b74bfde0917c82e2fb22439ef06ca Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 26 May 2026 22:47:59 -0700 Subject: [PATCH 2/4] Removed controller test --- .../src/foodRequests/request.controller.spec.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 6e77d179d..489c8357b 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -78,21 +78,6 @@ describe('RequestsController', () => { }); }); - describe('GET /:requestId', () => { - it('should call requestsService.findOne and return a specific food request', async () => { - const requestId = 1; - - mockRequestsService.findOne.mockResolvedValueOnce( - foodRequest1 as FoodRequest, - ); - - const result = await controller.getRequest(requestId); - - expect(result).toEqual(foodRequest1); - expect(mockRequestsService.findOne).toHaveBeenCalledWith(requestId); - }); - }); - describe('GET /:requestId/order-details', () => { it('should call requestsService.getOrderDetails and return all associated orders and their details', async () => { const mockOrderDetails: OrderDetailsDto[] = [ From 59223db7a3e4245144c58fec75287ea664f39781 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 26 May 2026 23:00:14 -0700 Subject: [PATCH 3/4] Added guarding for a few more endpoints --- apps/backend/src/donations/donations.controller.ts | 11 +++++++++++ apps/backend/src/orders/order.controller.ts | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 378b7bb26..47e4c6015 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -127,6 +127,17 @@ export class DonationsController { await this.donationService.replaceDonationItems(donationId, body); } + @Roles(Role.FOODMANUFACTURER) + @CheckOwnership({ + idParam: 'donationId', + resolver: async ({ entityId, services }) => { + return pipeNullable( + () => services.get(DonationService).findOne(entityId), + (donation: Donation) => [donation.foodManufacturer.foodManufacturerId], + ); + }, + }) + @Roles(Role.FOODMANUFACTURER) @Delete('/:donationId') async deleteDonation( @Param('donationId', ParseIntPipe) donationId: number, diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 433188532..81f5b4724 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -200,6 +200,15 @@ export class OrdersController { ); } + @Roles(Role.VOLUNTEER) + @CheckOwnership({ + idParam: 'orderId', + resolver: async ({ entityId, services }) => + pipeNullable( + () => services.get(OrdersService).findOne(entityId), + (order: Order) => [order.assigneeId], + ), + }) @Patch('/update-status/:orderId') async updateStatus( @Param('orderId', ParseIntPipe) orderId: number, From b4954e455597019e4be1a1bb1e4ff71eb156f02b Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 26 May 2026 23:15:59 -0700 Subject: [PATCH 4/4] Modularized resolvers --- .../donationItems/donationItems.controller.ts | 23 +++++++++---- .../src/donations/donations.controller.ts | 31 +++++++++-------- .../src/donations/donations.service.ts | 5 ++- .../manufacturers.controller.ts | 33 ++++++++++--------- apps/backend/src/orders/order.controller.ts | 23 +++---------- .../src/pantries/pantries.controller.ts | 29 ++++++++-------- apps/backend/src/users/users.controller.ts | 29 ++++++++-------- .../src/volunteers/volunteers.controller.ts | 29 ++++++++-------- 8 files changed, 109 insertions(+), 93 deletions(-) diff --git a/apps/backend/src/donationItems/donationItems.controller.ts b/apps/backend/src/donationItems/donationItems.controller.ts index 0557ce44d..ca2cf168b 100644 --- a/apps/backend/src/donationItems/donationItems.controller.ts +++ b/apps/backend/src/donationItems/donationItems.controller.ts @@ -10,10 +10,25 @@ import { DonationItem } from './donationItems.entity'; import { AuthGuard } from '@nestjs/passport'; import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; -import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { + CheckOwnership, + OwnerIdResolver, + pipeNullable, +} from '../auth/ownership.decorator'; import { DonationService } from '../donations/donations.service'; import { Donation } from '../donations/donations.entity'; +const resolveDonationAuthorizedUserIds: OwnerIdResolver = ({ + entityId, + services, +}) => + pipeNullable( + () => services.get(DonationService).findOne(entityId), + (donation: Donation) => [ + donation.foodManufacturer.foodManufacturerRepresentative.id, + ], + ); + @Controller('donation-items') @UseGuards(AuthGuard('jwt')) export class DonationItemsController { @@ -21,11 +36,7 @@ export class DonationItemsController { @CheckOwnership({ idParam: 'donationId', - resolver: async ({ entityId, services }) => - pipeNullable( - () => services.get(DonationService).findOne(entityId), - (donation: Donation) => [donation.foodManufacturer.foodManufacturerId], - ), + resolver: resolveDonationAuthorizedUserIds, bypassRoles: [Role.ADMIN], }) @Roles(Role.ADMIN, Role.FOODMANUFACTURER) diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 47e4c6015..1224a8a37 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -20,7 +20,22 @@ import { FoodType } from '../donationItems/types'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; -import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { + CheckOwnership, + OwnerIdResolver, + pipeNullable, +} from '../auth/ownership.decorator'; + +const resolveDonationAuthorizedUserIds: OwnerIdResolver = ({ + entityId, + services, +}) => + pipeNullable( + () => services.get(DonationService).findOne(entityId), + (donation: Donation) => [ + donation.foodManufacturer.foodManufacturerRepresentative.id, + ], + ); @Controller('donations') export class DonationsController { @@ -103,12 +118,7 @@ export class DonationsController { @Roles(Role.FOODMANUFACTURER) @CheckOwnership({ idParam: 'donationId', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(DonationService).findOne(entityId), - (donation: Donation) => [donation.foodManufacturer.foodManufacturerId], - ); - }, + resolver: resolveDonationAuthorizedUserIds, }) @Patch('/:donationId/item-details') async updateDonationItemDetails( @@ -130,12 +140,7 @@ export class DonationsController { @Roles(Role.FOODMANUFACTURER) @CheckOwnership({ idParam: 'donationId', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(DonationService).findOne(entityId), - (donation: Donation) => [donation.foodManufacturer.foodManufacturerId], - ); - }, + resolver: resolveDonationAuthorizedUserIds, }) @Roles(Role.FOODMANUFACTURER) @Delete('/:donationId') diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 88263b43c..0930db486 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -40,7 +40,10 @@ export class DonationService { const donation = await this.repo.findOne({ where: { donationId }, - relations: ['foodManufacturer'], + relations: [ + 'foodManufacturer', + 'foodManufacturer.foodManufacturerRepresentative', + ], }); if (!donation) { throw new NotFoundException(`Donation ${donationId} not found`); diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts index 0bb5b40b5..ce4495fb1 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -23,7 +23,22 @@ import { DonationDetailsDto, DonationReminderDto, } from './dtos/donation-details-dto'; -import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { + CheckOwnership, + OwnerIdResolver, + pipeNullable, +} from '../auth/ownership.decorator'; + +const resolveFoodManufacturerAuthorizedUserIds: OwnerIdResolver = ({ + entityId, + services, +}) => + pipeNullable( + () => services.get(FoodManufacturersService).findOne(entityId), + (manufacturer: FoodManufacturer) => [ + manufacturer.foodManufacturerRepresentative.id, + ], + ); @Controller('manufacturers') export class FoodManufacturersController { @@ -49,13 +64,7 @@ export class FoodManufacturersController { @Roles(Role.ADMIN, Role.FOODMANUFACTURER) @CheckOwnership({ idParam: 'foodManufacturerId', - resolver: async ({ entityId, services }) => - pipeNullable( - () => services.get(FoodManufacturersService).findOne(entityId), - (manufacturer: FoodManufacturer) => [ - manufacturer.foodManufacturerRepresentative.id, - ], - ), + resolver: resolveFoodManufacturerAuthorizedUserIds, bypassRoles: [Role.ADMIN], }) @Get('/:foodManufacturerId') @@ -225,13 +234,7 @@ export class FoodManufacturersController { @Roles(Role.FOODMANUFACTURER) @CheckOwnership({ idParam: 'foodManufacturerId', - resolver: async ({ entityId, services }) => - pipeNullable( - () => services.get(FoodManufacturersService).findOne(entityId), - (manufacturer: FoodManufacturer) => [ - manufacturer.foodManufacturerRepresentative.id, - ], - ), + resolver: resolveFoodManufacturerAuthorizedUserIds, }) @Patch('/:foodManufacturerId/application') async updateFoodManufacturerApplication( diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 81f5b4724..903d34df7 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -39,7 +39,7 @@ import { AuthenticatedRequest } from '../auth/authenticated-request'; import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; -// Ownership resolver for order-scoped reads (request + order details). +// Ownership resolver for order-scoped endpoints. // PANTRY users must own the pantry tied to the order's food request. // VOLUNTEER users must be the assignee on the order itself. // ADMIN bypasses in the guard. @@ -203,11 +203,7 @@ export class OrdersController { @Roles(Role.VOLUNTEER) @CheckOwnership({ idParam: 'orderId', - resolver: async ({ entityId, services }) => - pipeNullable( - () => services.get(OrdersService).findOne(entityId), - (order: Order) => [order.assigneeId], - ), + resolver: resolveOrderAuthorizedUserIds, }) @Patch('/update-status/:orderId') async updateStatus( @@ -230,14 +226,7 @@ export class OrdersController { @CheckOwnership({ idParam: 'orderId', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(OrdersService).findOrderFoodRequest(entityId), - (request: FoodRequestSummaryDto) => - services.get(PantriesService).findOne(request.pantry.pantryId), - (pantry: Pantry) => [pantry.pantryUser.id], - ); - }, + resolver: resolveOrderAuthorizedUserIds, }) @Roles(Role.PANTRY) @Patch('/:orderId/confirm-delivery') @@ -305,11 +294,7 @@ export class OrdersController { @CheckOwnership({ idParam: 'orderId', - resolver: async ({ entityId, services }) => - pipeNullable( - () => services.get(OrdersService).findOne(entityId), - (order: Order) => [order.assigneeId], - ), + resolver: resolveOrderAuthorizedUserIds, }) @Roles(Role.VOLUNTEER) @Patch('/:orderId/complete-action') diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index b02d5fd61..8ce646c85 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -30,7 +30,11 @@ import { OrderSummary, } from './types'; import { OrdersService } from '../orders/order.service'; -import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { + CheckOwnership, + OwnerIdResolver, + pipeNullable, +} from '../auth/ownership.decorator'; import { Public } from '../auth/public.decorator'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; @@ -38,6 +42,15 @@ import { UpdatePantryVolunteersDto } from './dtos/update-pantry-volunteers-dto'; import { RequestsService } from '../foodRequests/request.service'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; +const resolvePantryAuthorizedUserIds: OwnerIdResolver = ({ + entityId, + services, +}) => + pipeNullable( + () => services.get(PantriesService).findOne(entityId), + (pantry: Pantry) => [pantry.pantryUser.id], + ); + @Controller('pantries') export class PantriesController { constructor( @@ -103,12 +116,7 @@ export class PantriesController { @CheckOwnership({ idParam: 'pantryId', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(PantriesService).findOne(entityId), - (pantry: Pantry) => [pantry.pantryUser.id], - ); - }, + resolver: resolvePantryAuthorizedUserIds, }) @Roles(Role.PANTRY, Role.ADMIN) @Get('/:pantryId') @@ -380,12 +388,7 @@ export class PantriesController { @CheckOwnership({ idParam: 'pantryId', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(PantriesService).findOne(entityId), - (pantry: Pantry) => [pantry.pantryUser.id], - ); - }, + resolver: resolvePantryAuthorizedUserIds, }) @Roles(Role.PANTRY) @Patch('/:pantryId/application') diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index e69eb45ce..70ad7d160 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -19,7 +19,20 @@ import { AdminVolunteerStats } from './dtos/admin-volunteer-stats.dto'; import { PantryStatsDto } from '../pantries/dtos/pantry-stats.dto'; import { ManufacturerStatsDto } from '../foodManufacturers/dtos/manufacturer-stats.dto'; import { Roles } from '../auth/roles.decorator'; -import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { + CheckOwnership, + OwnerIdResolver, + pipeNullable, +} from '../auth/ownership.decorator'; + +const resolveUserAuthorizedUserIds: OwnerIdResolver = ({ + entityId, + services, +}) => + pipeNullable( + () => services.get(UsersService).findOne(entityId), + (user: User) => [user.id], + ); @Controller('users') export class UsersController { @@ -37,12 +50,7 @@ export class UsersController { @CheckOwnership({ idParam: 'id', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(UsersService).findOne(entityId), - (user: User) => [user.id], - ); - }, + resolver: resolveUserAuthorizedUserIds, }) @Get('/:id/stats') async getUserDashboardStats( @@ -65,12 +73,7 @@ export class UsersController { @CheckOwnership({ idParam: 'id', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(UsersService).findOne(entityId), - (user: User) => [user.id], - ); - }, + resolver: resolveUserAuthorizedUserIds, }) @Patch('/:id') async updateInfo( diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index 8be3e2adf..2928a5b94 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -16,7 +16,20 @@ 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 { + CheckOwnership, + OwnerIdResolver, + pipeNullable, +} from '../auth/ownership.decorator'; + +const resolveVolunteerAuthorizedUserIds: OwnerIdResolver = ({ + entityId, + services, +}) => + pipeNullable( + () => services.get(VolunteersService).findOne(entityId), + (user: User) => [user.id], + ); @Controller('volunteers') export class VolunteersController { @@ -33,12 +46,7 @@ export class VolunteersController { @CheckOwnership({ idParam: 'id', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(VolunteersService).findOne(entityId), - (user: User) => [user.id], - ); - }, + resolver: resolveVolunteerAuthorizedUserIds, }) @Roles(Role.VOLUNTEER) @Get('/:id/pantries') @@ -81,12 +89,7 @@ export class VolunteersController { @CheckOwnership({ idParam: 'id', - resolver: async ({ entityId, services }) => { - return pipeNullable( - () => services.get(VolunteersService).findOne(entityId), - (user: User) => [user.id], - ); - }, + resolver: resolveVolunteerAuthorizedUserIds, }) @Roles(Role.VOLUNTEER) @Get('/:id/orders')