From 6f4c9d55f09d73f76c8934b857f0ed65b27d27bc Mon Sep 17 00:00:00 2001 From: Justin Wang Date: Wed, 3 Jun 2026 10:42:28 -0400 Subject: [PATCH] backend --- .../donationItems.service.spec.ts | 134 ++++++++++++++++++ .../donationItems/donationItems.service.ts | 65 +++++++++ .../dtos/replace-donation-item.dto.ts | 49 +++++++ .../donations/donations.controller.spec.ts | 34 +++++ .../src/donations/donations.controller.ts | 26 ++++ .../src/donations/donations.service.spec.ts | 74 ++++++++++ .../src/donations/donations.service.ts | 48 +++++++ 7 files changed, 430 insertions(+) create mode 100644 apps/backend/src/donationItems/dtos/replace-donation-item.dto.ts diff --git a/apps/backend/src/donationItems/donationItems.service.spec.ts b/apps/backend/src/donationItems/donationItems.service.spec.ts index 847041741..c89128d74 100644 --- a/apps/backend/src/donationItems/donationItems.service.spec.ts +++ b/apps/backend/src/donationItems/donationItems.service.spec.ts @@ -9,6 +9,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { testDataSource } from '../config/typeormTestDataSource'; import { CreateDonationItemDto } from './dtos/create-donation-items.dto'; import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto'; +import { ReplaceDonationItemDto } from './dtos/replace-donation-item.dto'; jest.setTimeout(60000); @@ -562,4 +563,137 @@ describe('DonationItemsService', () => { expect(item?.detailsConfirmed).toBe(true); }); }); + + describe('editItems', () => { + const makeItem = ( + overrides: Partial = {}, + ): ReplaceDonationItemDto => ({ + itemName: 'Edited Item', + quantity: 20, + ozPerItem: 8, + estimatedValue: 3.5, + foodType: FoodType.QUINOA, + foodRescue: true, + ...overrides, + }); + + const donationId = 3; + const itemA = 7; + const itemB = 8; + + beforeEach(async () => { + await testDataSource.query( + `DELETE FROM allocations WHERE item_id IN ($1, $2)`, + [itemA, itemB], + ); + }); + + it('updates existing items, inserts new items, and deletes omitted items', async () => { + await testDataSource.transaction((tm) => + service.editItems( + donationId, + [ + makeItem({ + itemId: itemA, + itemName: 'Item A Updated', + quantity: 99, + }), + makeItem({ itemName: 'Brand New Item' }), + ], + tm, + ), + ); + + const items = await service.getAllDonationItems(donationId); + expect(items).toHaveLength(2); + + const names = items.map((i) => i.itemName).sort(); + expect(names).toEqual(['Brand New Item', 'Item A Updated']); + + const updated = items.find((i) => i.itemId === itemA) as DonationItem; + expect(updated.quantity).toBe(99); + expect(updated.foodRescue).toBe(true); + expect(updated.foodType).toBe(FoodType.QUINOA); + expect(Number(updated.ozPerItem)).toBe(8); + expect(Number(updated.estimatedValue)).toBe(3.5); + expect(updated.detailsConfirmed).toBe(true); + + const inserted = items.find( + (i) => i.itemName === 'Brand New Item', + ) as DonationItem; + expect(inserted.donationId).toBe(donationId); + expect(inserted.reservedQuantity).toBe(0); + expect(inserted.detailsConfirmed).toBe(true); + + await expect(service.findOne(itemB)).rejects.toThrow(); + }); + + it('deletes all existing items absent from the body', async () => { + await testDataSource.transaction((tm) => + service.editItems(donationId, [makeItem({ itemName: 'Only New' })], tm), + ); + + const items = await service.getAllDonationItems(donationId); + expect(items).toHaveLength(1); + expect(items[0].itemName).toBe('Only New'); + + await expect(service.findOne(itemA)).rejects.toThrow(); + await expect(service.findOne(itemB)).rejects.toThrow(); + }); + + it('throws BadRequestException when an itemId does not belong to the donation', async () => { + const foreignItemId = 11; + + await expect( + testDataSource.transaction((tm) => + service.editItems( + donationId, + [makeItem({ itemId: foreignItemId })], + tm, + ), + ), + ).rejects.toThrow( + new BadRequestException( + `Donation item ${foreignItemId} does not belong to Donation ${donationId}`, + ), + ); + }); + + it('throws BadRequestException when the same itemId appears twice', async () => { + await expect( + testDataSource.transaction((tm) => + service.editItems( + donationId, + [makeItem({ itemId: itemA }), makeItem({ itemId: itemA })], + tm, + ), + ), + ).rejects.toThrow( + new BadRequestException(`Duplicate itemId ${itemA} in request`), + ); + }); + + it('rolls back all changes when one item fails to persist within the transaction', async () => { + await expect( + testDataSource.transaction((tm) => + service.editItems( + donationId, + [ + makeItem({ itemId: itemA, itemName: 'Item A Updated' }), + makeItem({ itemName: 'a'.repeat(1000) }), // exceeds varchar(255) + ], + tm, + ), + ), + ).rejects.toThrow(); + + const items = await service.getAllDonationItems(donationId); + expect(items).toHaveLength(2); + + const a = items.find((i) => i.itemId === itemA); + expect(a?.itemName).toBe('Rice (5lb bag)'); + const b = items.find((i) => i.itemId === itemB); + expect(b).toBeDefined(); + }); + }); }); diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 916210174..5f8504b14 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -11,6 +11,7 @@ import { FoodType } from './types'; import { Donation } from '../donations/donations.entity'; import { CreateDonationItemDto } from './dtos/create-donation-items.dto'; import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto'; +import { ReplaceDonationItemDto } from './dtos/replace-donation-item.dto'; @Injectable() export class DonationItemsService { @@ -131,6 +132,70 @@ export class DonationItemsService { return confirmedDetailsForAnItem; } + async editItems( + donationId: number, + body: ReplaceDonationItemDto[], + transactionManager: EntityManager, + ): Promise { + const itemRepo = transactionManager.getRepository(DonationItem); + + const existingItems = await itemRepo.find({ where: { donationId } }); + const existingIds = new Set(existingItems.map((item) => item.itemId)); + + const providedIds = new Set(); + for (const dto of body) { + if (dto.itemId === undefined) continue; + + if (providedIds.has(dto.itemId)) { + throw new BadRequestException( + `Duplicate itemId ${dto.itemId} in request`, + ); + } + providedIds.add(dto.itemId); + + if (!existingIds.has(dto.itemId)) { + throw new BadRequestException( + `Donation item ${dto.itemId} does not belong to Donation ${donationId}`, + ); + } + } + + const idsToDelete = existingItems + .map((item) => item.itemId) + .filter((id) => !providedIds.has(id)); + + if (idsToDelete.length > 0) { + await itemRepo.delete({ itemId: In(idsToDelete) }); + } + + for (const dto of body) { + if (dto.itemId !== undefined) { + await itemRepo.update(dto.itemId, { + itemName: dto.itemName, + quantity: dto.quantity, + ozPerItem: dto.ozPerItem, + estimatedValue: dto.estimatedValue, + foodType: dto.foodType, + foodRescue: dto.foodRescue, + detailsConfirmed: true, + }); + } else { + const newItem = itemRepo.create({ + donationId, + itemName: dto.itemName, + quantity: dto.quantity, + reservedQuantity: 0, + ozPerItem: dto.ozPerItem, + estimatedValue: dto.estimatedValue, + foodType: dto.foodType, + foodRescue: dto.foodRescue, + detailsConfirmed: true, + }); + await itemRepo.save(newItem); + } + } + } + async createMultiple( savedDonation: Donation, items: CreateDonationItemDto[], diff --git a/apps/backend/src/donationItems/dtos/replace-donation-item.dto.ts b/apps/backend/src/donationItems/dtos/replace-donation-item.dto.ts new file mode 100644 index 000000000..8ed03679b --- /dev/null +++ b/apps/backend/src/donationItems/dtos/replace-donation-item.dto.ts @@ -0,0 +1,49 @@ +import { + IsNumber, + IsString, + Min, + IsEnum, + IsNotEmpty, + Length, + IsOptional, + IsInt, + IsBoolean, +} from 'class-validator'; +import { FoodType } from '../types'; + +// itemId present = update row, else add +export class ReplaceDonationItemDto { + @IsOptional() + @IsInt() + @Min(1) + itemId?: number; + + @IsString() + @IsNotEmpty() + @Length(1, 255) + itemName!: string; + + @IsInt() + @Min(1) + quantity!: number; + + @IsNumber( + { maxDecimalPlaces: 2 }, + { message: 'ozPerItem must have at most 2 decimal places' }, + ) + @Min(0.01) + ozPerItem!: number; + + @IsNumber( + { maxDecimalPlaces: 2 }, + { message: 'estimatedValue must have at most 2 decimal places' }, + ) + @Min(0.01) + estimatedValue!: number; + + @IsEnum(FoodType) + foodType!: FoodType; + + @IsBoolean() + foodRescue!: boolean; +} diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 554afe7d6..c42c66c8c 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -7,6 +7,8 @@ import { CreateDonationDto } from './dtos/create-donation.dto'; import { CreateDonationItemDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationStatus, RecurrenceEnum } from './types'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; +import { ReplaceDonationItemDto } from '../donationItems/dtos/replace-donation-item.dto'; +import { FoodType } from '../donationItems/types'; const mockDonationService = mock(); @@ -130,6 +132,38 @@ describe('DonationsController', () => { }); }); + describe('PATCH /:donationId/item', () => { + it('calls editDonationItems with the correct donationId and body', async () => { + const donationId = 1; + const body: ReplaceDonationItemDto[] = [ + { + itemName: 'Brand New Item', + quantity: 10, + ozPerItem: 5, + estimatedValue: 2, + foodType: FoodType.QUINOA, + foodRescue: false, + }, + { + itemId: 3, + itemName: 'Existing Item Updated', + quantity: 5, + ozPerItem: 8, + estimatedValue: 3, + foodType: FoodType.GRANOLA, + foodRescue: true, + }, + ]; + + await controller.editDonationItems(donationId, body); + + expect(mockDonationService.editDonationItems).toHaveBeenCalledWith( + donationId, + body, + ); + }); + }); + describe('DELETE /:donationId', () => { it('should call donationService.delete with the correct id', async () => { const donationId = 1; diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index ab493edd5..fa41b6376 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -15,6 +15,7 @@ import { DonationService } from './donations.service'; import { RecurrenceEnum } from './types'; import { CreateDonationDto } from './dtos/create-donation.dto'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; +import { ReplaceDonationItemDto } from '../donationItems/dtos/replace-donation-item.dto'; import { FoodType } from '../donationItems/types'; import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; @@ -118,6 +119,31 @@ export class DonationsController { await this.donationService.updateDonationItemDetails(donationId, body); } + @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, + ], + ); + }, + }) + @Patch('/:donationId/item') + async editDonationItems( + @Param('donationId', ParseIntPipe) donationId: number, + @Body(new ParseArrayPipe({ items: ReplaceDonationItemDto })) + body: ReplaceDonationItemDto[], + ): Promise { + await this.donationService.editDonationItems(donationId, body); + } + @Delete('/:donationId') async deleteDonation( @Param('donationId', ParseIntPipe) donationId: number, diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 6fa0bcda3..573e29224 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -9,6 +9,7 @@ import { testDataSource } from '../config/typeormTestDataSource'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { DonationItem } from '../donationItems/donationItems.entity'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; +import { ReplaceDonationItemDto } from '../donationItems/dtos/replace-donation-item.dto'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { Allocation } from '../allocations/allocations.entity'; import { DataSource, In } from 'typeorm'; @@ -1440,4 +1441,77 @@ describe('DonationService', () => { expect(dbDonation.status).toBe(DonationStatus.FULFILLED); }); }); + + describe('editDonationItems', () => { + const makeItem = ( + overrides: Partial = {}, + ): ReplaceDonationItemDto => ({ + itemName: 'Edited Item', + quantity: 20, + ozPerItem: 8, + estimatedValue: 3.5, + foodType: FoodType.QUINOA, + foodRescue: true, + ...overrides, + }); + + const donationId = 3; + const itemA = 7; + const itemB = 8; + + beforeEach(async () => { + await testDataSource.query( + `DELETE FROM allocations WHERE item_id IN ($1, $2)`, + [itemA, itemB], + ); + }); + + it('replaces the donation items for an available donation', async () => { + await service.editDonationItems(donationId, [ + makeItem({ itemId: itemA, itemName: 'Item A Updated' }), + makeItem({ itemName: 'Brand New Item' }), + ]); + + const items = await donationItemService.getAllDonationItems(donationId); + expect(items).toHaveLength(2); + const names = items.map((i) => i.itemName).sort(); + expect(names).toEqual(['Brand New Item', 'Item A Updated']); + }); + + it('throws BadRequestException when donation status is MATCHED', async () => { + await expect(service.editDonationItems(2, [makeItem()])).rejects.toThrow( + new BadRequestException( + 'Donation 2 items can only be edited while the donation is AVAILABLE', + ), + ); + }); + + it('throws BadRequestException when donation status is FULFILLED', async () => { + await expect(service.editDonationItems(4, [makeItem()])).rejects.toThrow( + new BadRequestException( + 'Donation 4 items can only be edited while the donation is AVAILABLE', + ), + ); + }); + + it('throws BadRequestException when orders have already drawn from the donation', async () => { + await expect(service.editDonationItems(1, [makeItem()])).rejects.toThrow( + new BadRequestException( + 'Cannot edit items for donation 1 because orders have already drawn from it', + ), + ); + }); + + it('throws BadRequestException when the resulting item list would be empty', async () => { + await expect(service.editDonationItems(donationId, [])).rejects.toThrow( + new BadRequestException('A donation must have at least one item'), + ); + }); + + it('throws NotFoundException when the donation does not exist', async () => { + await expect( + service.editDonationItems(9999, [makeItem()]), + ).rejects.toThrow(new NotFoundException('Donation 9999 not found')); + }); + }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 7fe8664b3..f24f62301 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -15,6 +15,7 @@ import { calculateNextDonationDate } from './recurrence.utils'; import { CreateDonationDto, RepeatOnDaysDto } from './dtos/create-donation.dto'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; +import { ReplaceDonationItemDto } from '../donationItems/dtos/replace-donation-item.dto'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { DonationItem } from '../donationItems/donationItems.entity'; import { Allocation } from '../allocations/allocations.entity'; @@ -393,6 +394,53 @@ export class DonationService { }); } + async editDonationItems( + donationId: number, + body: ReplaceDonationItemDto[], + ): Promise { + validateId(donationId, 'Donation'); + + if (body.length < 1) { + throw new BadRequestException('A donation must have at least one item'); + } + + await this.dataSource.transaction(async (transactionManager) => { + const donationTransactionRepo = + transactionManager.getRepository(Donation); + + const donation = await donationTransactionRepo.findOne({ + where: { donationId }, + relations: ['donationItems'], + }); + + if (!donation) { + throw new NotFoundException(`Donation ${donationId} not found`); + } + + if (donation.status !== DonationStatus.AVAILABLE) { + throw new BadRequestException( + `Donation ${donationId} items can only be edited while the donation is AVAILABLE`, + ); + } + + const hasAllocations = await transactionManager + .getRepository(Allocation) + .exists({ where: { item: { donation: { donationId } } } }); + + if (hasAllocations) { + throw new BadRequestException( + `Cannot edit items for donation ${donationId} because orders have already drawn from it`, + ); + } + + await this.donationItemsService.editItems( + donationId, + body, + transactionManager, + ); + }); + } + async checkAndFulfillDonation( donation: Donation, transactionManager?: EntityManager,