diff --git a/apps/backend/src/donations/donations.entity.ts b/apps/backend/src/donations/donations.entity.ts index 7f9bf7d0..2cd936c4 100644 --- a/apps/backend/src/donations/donations.entity.ts +++ b/apps/backend/src/donations/donations.entity.ts @@ -5,9 +5,11 @@ import { CreateDateColumn, JoinColumn, ManyToOne, + OneToMany, } from 'typeorm'; import { DonationStatus, RecurrenceEnum } from './types'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { DonationItem } from '../donationItems/donationItems.entity'; @Entity('donations') export class Donation { @@ -58,4 +60,7 @@ export class Donation { @Column({ name: 'occurrences_remaining', type: 'int', nullable: true }) occurrencesRemaining!: number | null; + + @OneToMany(() => DonationItem, (item) => item.donation) + donationItems!: DonationItem[]; } diff --git a/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts b/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts new file mode 100644 index 00000000..c1f220e8 --- /dev/null +++ b/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts @@ -0,0 +1,21 @@ +import { FoodType } from '../../donationItems/types'; +import { Donation } from '../../donations/donations.entity'; + +export class DonationItemWithAllocatedQuantityDto { + itemId!: number; + itemName!: string; + foodType!: FoodType; + allocatedQuantity!: number; +} + +export class DonationOrderDetailsDto { + orderId!: number; + pantryId!: number; + pantryName!: string; +} + +export class DonationDetailsDto { + donation!: Donation; + associatedPendingOrders!: DonationOrderDetailsDto[]; + relevantDonationItems!: DonationItemWithAllocatedQuantityDto[]; +} diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index cb47bebd..204d32bd 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -10,6 +10,8 @@ import { Donation } from '../donations/donations.entity'; import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto'; import { NotFoundException } from '@nestjs/common'; import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { DonationDetailsDto } from './dtos/donation-details-dto'; +import { FoodType } from '../donationItems/types'; const mockManufacturersService = mock(); @@ -87,7 +89,7 @@ describe('FoodManufacturersController', () => { }); describe('GET /:foodManufacturerId/donations', () => { - it('should return donations for a given food manufacturer', async () => { + it('should return donation details for a given food manufacturer', async () => { const mockDonations: Partial[] = [ { donationId: 1, @@ -98,14 +100,48 @@ describe('FoodManufacturersController', () => { foodManufacturer: { foodManufacturerId: 1 } as FoodManufacturer, }, ]; + const mockDonationDetails: DonationDetailsDto[] = [ + { + donation: mockDonations[0] as Donation, + associatedPendingOrders: [ + { + orderId: 1, + pantryId: 2, + pantryName: 'Community Food Pantry', + }, + ], + relevantDonationItems: [ + { + itemId: 1, + itemName: 'Almond Breeze Almond Milk', + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + allocatedQuantity: 10, + }, + ], + }, + { + donation: mockDonations[1] as Donation, + associatedPendingOrders: [], + relevantDonationItems: [], + }, + ]; + + const req = { user: { id: 1 } }; + mockManufacturersService.getFMDonations.mockResolvedValue( - mockDonations as Donation[], + mockDonationDetails, ); - const result = await controller.getFoodManufacturerDonations(1); + const result = await controller.getFoodManufacturerDonations( + req as AuthenticatedRequest, + 1, + ); - expect(result).toBe(mockDonations); - expect(mockManufacturersService.getFMDonations).toHaveBeenCalledWith(1); + expect(result).toBe(mockDonationDetails); + expect(mockManufacturersService.getFMDonations).toHaveBeenCalledWith( + 1, + 1, + ); }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts index e6b91c60..198fcf41 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -14,12 +14,12 @@ import { FoodManufacturer } from './manufacturers.entity'; import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; import { ApiBody } from '@nestjs/swagger'; import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; -import { Donation } from '../donations/donations.entity'; import { Public } from '../auth/public.decorator'; import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto'; import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { DonationDetailsDto } from './dtos/donation-details-dto'; @Controller('manufacturers') export class FoodManufacturersController { @@ -37,11 +37,16 @@ export class FoodManufacturersController { return this.foodManufacturersService.findOne(foodManufacturerId); } + @Roles(Role.FOODMANUFACTURER) @Get('/:foodManufacturerId/donations') async getFoodManufacturerDonations( + @Req() req: AuthenticatedRequest, @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, - ): Promise { - return this.foodManufacturersService.getFMDonations(foodManufacturerId); + ): Promise { + return this.foodManufacturersService.getFMDonations( + foodManufacturerId, + req.user.id, + ); } @ApiBody({ diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 847a7ac8..cfe30601 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -3,6 +3,7 @@ import { FoodManufacturersService } from './manufacturers.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { FoodManufacturer } from './manufacturers.entity'; import { + BadRequestException, ConflictException, InternalServerErrorException, NotFoundException, @@ -24,6 +25,8 @@ import { PantriesService } from '../pantries/pantries.service'; import { mock } from 'jest-mock-extended'; import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; +import { FoodType } from '../donationItems/types'; +import { DonationStatus } from '../donations/types'; jest.setTimeout(60000); @@ -362,24 +365,156 @@ describe('FoodManufacturersService', () => { }); describe('getFMDonations', () => { - it('returns donations for an existing manufacturer', async () => { - const donations = await service.getFMDonations(1); - expect(Array.isArray(donations)).toBe(true); + const fmRepId1 = 3; + const fmRepId2 = 4; + const fmId1 = 1; + const fmId2 = 2; + const availableDonationId = 1; + const fulfilledDonationId = 4; + const matchingDonationId = 3; + + it('throws NotFoundException for non-existent manufacturer', async () => { + await expect(service.getFMDonations(9999, fmRepId1)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); }); - it('returns empty array for manufacturer with no donations', async () => { - await service.addFoodManufacturer(dto); - const saved = await testDataSource - .getRepository(FoodManufacturer) - .findOne({ where: { foodManufacturerName: 'Test Manufacturer' } }); - const donations = await service.getFMDonations(saved!.foodManufacturerId); - expect(donations).toEqual([]); + it('throws BadRequestException when user is not the representative of the food manufacturer', async () => { + await expect(service.getFMDonations(fmId1, fmRepId2)).rejects.toThrow( + new BadRequestException( + `User ${fmRepId2} is not allowed to access donations for Food Manufacturer ${fmId1}`, + ), + ); }); - it('throws NotFoundException for non-existent manufacturer', async () => { - await expect(service.getFMDonations(9999)).rejects.toThrow( - new NotFoundException('Food Manufacturer 9999 not found'), + it('returns empty array when manufacturer has no matched donations', async () => { + const result = await service.getFMDonations(fmId1, fmRepId1); + expect(result).toEqual([]); + }); + + it('returns matched donations with empty orders and items when no pending orders exist', async () => { + await testDataSource.query( + `UPDATE public.donations SET status = 'matched' + WHERE donation_id = $1`, + [availableDonationId], ); + + const result = await service.getFMDonations(fmId1, fmRepId1); + + expect(result).toHaveLength(1); + expect(result[0].associatedPendingOrders).toEqual([]); + expect(result[0].relevantDonationItems).toEqual([]); + }); + + it('returns pending orders with correct pantry info for matched donations', async () => { + await testDataSource.query( + `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, + [fulfilledDonationId], + ); + + const result = await service.getFMDonations(fmId1, fmRepId1); + + expect(result).toHaveLength(1); + expect(result[0].associatedPendingOrders).toHaveLength(1); + + const order = result[0].associatedPendingOrders[0]; + expect(order.pantryName).toBe('Community Food Pantry Downtown'); + expect(order.pantryId).toBe(1); + expect(order.orderId).toBeDefined(); + }); + + it('returns unconfirmed donation items used in pending orders', async () => { + await testDataSource.query( + `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, + [fulfilledDonationId], + ); + + const result = await service.getFMDonations(fmId1, fmRepId1); + + expect(result[0].relevantDonationItems).toHaveLength(1); + const item = result[0].relevantDonationItems[0]; + expect(item.itemName).toBe('Cereal Boxes'); + expect(item.allocatedQuantity).toBe(75); + expect(item.foodType).toBe(FoodType.GLUTEN_FREE_BREAD); + }); + + it('excludes donation items where detailsConfirmed is true', async () => { + await testDataSource.query( + `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, + [fulfilledDonationId], + ); + + await testDataSource.query( + `UPDATE public.donation_items SET details_confirmed = true + WHERE item_name = 'Cereal Boxes'`, + ); + + const result = await service.getFMDonations(fmId1, fmRepId1); + + expect(result[0].relevantDonationItems).toEqual([]); + }); + + it('excludes donation items not used in any pending order', async () => { + await testDataSource.query( + `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, + [availableDonationId], + ); + + const result = await service.getFMDonations(fmId1, fmRepId1); + + expect(result[0].relevantDonationItems).toEqual([]); + }); + + it('correctly sums allocatedQuantity across multiple pending orders for the same item', async () => { + await testDataSource.query( + `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, + [matchingDonationId], + ); + + const almondMilkItemId = ( + await testDataSource.query( + `SELECT item_id FROM public.donation_items WHERE item_name = 'Almond Milk' ORDER BY item_id DESC LIMIT 1`, + ) + )[0].item_id; + + const requestId = ( + await testDataSource.query( + `SELECT request_id FROM public.food_requests + WHERE additional_information LIKE '%breakfast items%' LIMIT 1`, + ) + )[0].request_id; + + const newOrder = await testDataSource.query( + `INSERT INTO public.orders (request_id, food_manufacturer_id, status, created_at) + VALUES ($1, $2, 'pending', NOW()) RETURNING order_id`, + [requestId, fmId2], + ); + + await testDataSource.query( + `INSERT INTO public.allocations (order_id, item_id, allocated_quantity) + VALUES ($1, $2, 5)`, + [newOrder[0].order_id, almondMilkItemId], + ); + + const result = await service.getFMDonations(fmId2, fmRepId2); + + const almond = result[0].relevantDonationItems.find( + (i) => i.itemName === 'Almond Milk', + ); + expect(almond?.allocatedQuantity).toBe(15); // 10 + 5 + }); + + it('only returns matched donations, not available or fulfilled ones', async () => { + await testDataSource.query( + `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, + [fulfilledDonationId], + ); + + const result = await service.getFMDonations(fmId1, fmRepId1); + + expect(result).toHaveLength(1); + expect(result[0].donation.donationId).toBe(fulfilledDonationId); + expect(result[0].donation.status).toBe(DonationStatus.MATCHED); }); }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index fd4d87d7..191d8cc3 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -19,6 +19,13 @@ import { Donation } from '../donations/donations.entity'; import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto'; import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; import { EmailsService } from '../emails/email.service'; +import { + DonationDetailsDto, + DonationItemWithAllocatedQuantityDto, + DonationOrderDetailsDto, +} from './dtos/donation-details-dto'; +import { OrderStatus } from '../orders/types'; +import { DonationStatus } from '../donations/types'; @Injectable() export class FoodManufacturersService { @@ -49,11 +56,16 @@ export class FoodManufacturersService { return foodManufacturer; } - async getFMDonations(foodManufacturerId: number): Promise { + async getFMDonations( + foodManufacturerId: number, + currentUserId: number, + ): Promise { validateId(foodManufacturerId, 'Food Manufacturer'); + validateId(currentUserId, 'User'); const manufacturer = await this.repo.findOne({ where: { foodManufacturerId }, + relations: ['foodManufacturerRepresentative'], }); if (!manufacturer) { @@ -62,9 +74,69 @@ export class FoodManufacturersService { ); } - return this.donationsRepo.find({ - where: { foodManufacturer: { foodManufacturerId } }, - relations: ['foodManufacturer'], + if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) { + throw new BadRequestException( + `User ${currentUserId} is not allowed to access donations for Food Manufacturer ${foodManufacturerId}`, + ); + } + + const donations = await this.donationsRepo + .createQueryBuilder('donation') + .leftJoinAndSelect('donation.foodManufacturer', 'foodManufacturer') + .leftJoinAndSelect('donation.donationItems', 'donationItem') + .leftJoinAndSelect('donationItem.allocations', 'allocation') + .leftJoinAndSelect('allocation.order', 'order') + .leftJoinAndSelect('order.request', 'request') + .leftJoinAndSelect('request.pantry', 'pantry') + .where('donation.food_manufacturer_id = :foodManufacturerId', { + foodManufacturerId, + }) + .andWhere(`donation.status = :status`, { + status: DonationStatus.MATCHED, + }) + .getMany(); + + return donations.map((donation) => { + const orderMap = new Map(); + + const relevantDonationItems: DonationItemWithAllocatedQuantityDto[] = []; + + donation.donationItems?.forEach((item) => { + const pendingAllocations = item.allocations.filter( + (a) => a.order.status === OrderStatus.PENDING, + ); + + if (pendingAllocations.length === 0) return; + + if (!item.detailsConfirmed) { + relevantDonationItems.push({ + itemId: item.itemId, + itemName: item.itemName, + foodType: item.foodType, + allocatedQuantity: pendingAllocations.reduce( + (sum, a) => sum + a.allocatedQuantity, + 0, + ), + }); + } + + pendingAllocations.forEach((a) => { + const order = a.order; + if (!orderMap.has(order.orderId)) { + orderMap.set(order.orderId, { + orderId: order.orderId, + pantryId: order.request.pantry.pantryId, + pantryName: order.request.pantry.pantryName, + }); + } + }); + }); + + return { + donation, + associatedPendingOrders: Array.from(orderMap.values()), + relevantDonationItems, + }; }); } @@ -137,7 +209,7 @@ export class FoodManufacturersService { manufacturerMessage.subject, manufacturerMessage.bodyHTML, ); - } catch (error) { + } catch { throw new InternalServerErrorException( 'Failed to send food manufacturer application submitted confirmation email to representative', ); @@ -150,7 +222,7 @@ export class FoodManufacturersService { adminMessage.subject, adminMessage.bodyHTML, ); - } catch (error) { + } catch { throw new InternalServerErrorException( 'Failed to send new food manufacturer application notification email to SSF', );