diff --git a/apps/backend/src/allocations/allocations.entity.ts b/apps/backend/src/allocations/allocations.entity.ts index 27d2e3b3e..2b3377d97 100644 --- a/apps/backend/src/allocations/allocations.entity.ts +++ b/apps/backend/src/allocations/allocations.entity.ts @@ -23,7 +23,9 @@ export class Allocation { @Column({ name: 'item_id', type: 'int' }) itemId!: number; - @ManyToOne(() => DonationItem, (item) => item.allocations) + @ManyToOne(() => DonationItem, (item) => item.allocations, { + onDelete: 'CASCADE', + }) @JoinColumn({ name: 'item_id' }) item!: DonationItem; diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index e4b74b605..563023d2a 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -37,6 +37,7 @@ import { DropDonationTotalColumns1772241115031 } from '../migrations/17722411150 import { FixTrackingLinks1773041840374 } from '../migrations/1773041840374-FixTrackingLinks'; import { CleanupRequestsAndAllocations1771821377918 } from '../migrations/1771821377918-CleanupRequestsAndAllocations'; import { AddDonationItemConfirmation1774140453305 } from '../migrations/1774140453305-AddDonationItemConfirmation'; +import { DonationItemsAllocationsOnDeleteCascade1774214910101 } from '../migrations/1774214910101-DonationItemsOnDeleteCascade'; const schemaMigrations = [ User1725726359198, @@ -78,6 +79,7 @@ const schemaMigrations = [ FixTrackingLinks1773041840374, CleanupRequestsAndAllocations1771821377918, AddDonationItemConfirmation1774140453305, + DonationItemsAllocationsOnDeleteCascade1774214910101, ]; export default schemaMigrations; diff --git a/apps/backend/src/donationItems/donationItems.entity.ts b/apps/backend/src/donationItems/donationItems.entity.ts index ee325ab6e..862ea18b5 100644 --- a/apps/backend/src/donationItems/donationItems.entity.ts +++ b/apps/backend/src/donationItems/donationItems.entity.ts @@ -18,7 +18,7 @@ export class DonationItem { @Column({ name: 'donation_id', type: 'int' }) donationId!: number; - @ManyToOne(() => Donation, { nullable: false }) + @ManyToOne(() => Donation, { nullable: false, onDelete: 'CASCADE' }) @JoinColumn({ name: 'donation_id', referencedColumnName: 'donationId' }) donation!: Donation; diff --git a/apps/backend/src/donationItems/donationItems.module.ts b/apps/backend/src/donationItems/donationItems.module.ts index ef377d2ba..5ecf4f823 100644 --- a/apps/backend/src/donationItems/donationItems.module.ts +++ b/apps/backend/src/donationItems/donationItems.module.ts @@ -10,5 +10,6 @@ import { Donation } from '../donations/donations.entity'; imports: [TypeOrmModule.forFeature([DonationItem, Donation]), AuthModule], controllers: [DonationItemsController], providers: [DonationItemsService], + exports: [DonationItemsService], }) export class DonationItemsModule {} diff --git a/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts b/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts index 0e4b04b39..0a9cf3c15 100644 --- a/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts +++ b/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts @@ -49,3 +49,16 @@ export class CreateMultipleDonationItemsDto { @Type(() => CreateDonationItemDto) items!: CreateDonationItemDto[]; } + +export class ReplaceDonationItemDto extends CreateDonationItemDto { + @IsOptional() + @IsNumber() + id?: number; +} + +export class ReplaceDonationItemsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReplaceDonationItemDto) + items!: ReplaceDonationItemDto[]; +} diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index a13af53c3..4ad76efdf 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -5,6 +5,9 @@ import { mock } from 'jest-mock-extended'; import { Donation } from './donations.entity'; import { CreateDonationDto } from './dtos/create-donation.dto'; import { DonationStatus, RecurrenceEnum } from './types'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; +import { FoodType } from '../donationItems/types'; const mockDonationService = mock(); @@ -120,4 +123,61 @@ describe('DonationsController', () => { expect(mockDonationService.fulfill).toHaveBeenCalledWith(donationId); }); }); + + describe('PUT /:donationId/items', () => { + it('should call donationService.replaceDonationItems and return updated donation', async () => { + const donationId = 1; + + const replaceBody = { + items: [ + { + id: 1, + itemName: 'Apples', + quantity: 10, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + }, + { + itemName: 'Oranges', + quantity: 5, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + }, + ], + }; + + const updatedDonation: Partial = { + donationId, + donationItems: [ + { itemId: 1, itemName: 'Apples', quantity: 10 } as DonationItem, + { itemId: 2, itemName: 'Oranges', quantity: 5 } as DonationItem, + ], + status: DonationStatus.AVAILABLE, + }; + + mockDonationService.replaceDonationItems.mockResolvedValueOnce( + updatedDonation as Donation, + ); + + const result = await controller.replaceDonationItems( + donationId, + replaceBody as ReplaceDonationItemsDto, + ); + + expect(result).toEqual(updatedDonation); + expect(mockDonationService.replaceDonationItems).toHaveBeenCalledWith( + donationId, + replaceBody, + ); + }); + }); + + describe('DELETE /:donationId', () => { + it('should call donationService.delete with the correct id', async () => { + const donationId = 1; + + await controller.deleteDonation(donationId); + + expect(mockDonationService.delete).toHaveBeenCalledWith(donationId); + expect(mockDonationService.delete).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 2862ebb6e..9f930bc35 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -6,12 +6,15 @@ import { Patch, Param, ParseIntPipe, + Put, + Delete, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { RecurrenceEnum } from './types'; import { CreateDonationDto } from './dtos/create-donation.dto'; +import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; @Controller('donations') export class DonationsController { @@ -77,4 +80,19 @@ export class DonationsController { ): Promise { return this.donationService.fulfill(donationId); } + + @Put('/:donationId/items') + async replaceDonationItems( + @Param('donationId', ParseIntPipe) donationId: number, + @Body() body: ReplaceDonationItemsDto, + ): Promise { + return this.donationService.replaceDonationItems(donationId, body); + } + + @Delete('/:donationId') + async deleteDonation( + @Param('donationId', ParseIntPipe) donationId: number, + ): Promise { + return this.donationService.delete(donationId); + } } diff --git a/apps/backend/src/donations/donations.entity.ts b/apps/backend/src/donations/donations.entity.ts index 7f9bf7d05..2cd936c48 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/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index 0b813de8f..827e706f2 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -6,10 +6,25 @@ import { DonationsController } from './donations.controller'; import { AuthModule } from '../auth/auth.module'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationsSchedulerService } from './donations.scheduler'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { DonationItemsModule } from '../donationItems/donationItems.module'; +import { Allocation } from '../allocations/allocations.entity'; +import { AllocationModule } from '../allocations/allocations.module'; @Module({ - imports: [TypeOrmModule.forFeature([Donation, FoodManufacturer]), AuthModule], + imports: [ + TypeOrmModule.forFeature([ + Donation, + FoodManufacturer, + DonationItem, + Allocation, + ]), + AuthModule, + DonationItemsModule, + AllocationModule, + ], controllers: [DonationsController], providers: [DonationService, DonationsSchedulerService], + exports: [DonationService], }) export class DonationModule {} diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 63bde20ca..b00b7d45b 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -7,6 +7,15 @@ import { RecurrenceEnum, DayOfWeek, DonationStatus } from './types'; import { RepeatOnDaysDto } from './dtos/create-donation.dto'; import { testDataSource } from '../config/typeormTestDataSource'; import { NotFoundException } from '@nestjs/common'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { Allocation } from '../allocations/allocations.entity'; +import { DataSource } from 'typeorm'; +import { + ReplaceDonationItemDto, + ReplaceDonationItemsDto, +} from '../donationItems/dtos/create-donation-items.dto'; +import { FoodType } from '../donationItems/types'; jest.setTimeout(60000); @@ -83,6 +92,7 @@ const TODAYOfWeek = (iso: string): DayOfWeek => { describe('DonationService', () => { let service: DonationService; + let donationItemService: DonationItemsService; beforeAll(async () => { if (!testDataSource.isInitialized) { @@ -95,6 +105,7 @@ describe('DonationService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ DonationService, + DonationItemsService, { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), @@ -103,10 +114,24 @@ describe('DonationService', () => { provide: getRepositoryToken(FoodManufacturer), useValue: testDataSource.getRepository(FoodManufacturer), }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, + { + provide: getRepositoryToken(Allocation), + useValue: testDataSource.getRepository(Allocation), + }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile(); service = module.get(DonationService); + donationItemService = + module.get(DonationItemsService); }); beforeEach(async () => { @@ -802,4 +827,122 @@ describe('DonationService', () => { expect(result).toHaveLength(0); }); }); + + describe('replaceDonationItems', () => { + it('should replace donation items for an available donation', async () => { + const donationId = 1; + + // (update item1, remove item2, remove item3, add item 4) + const body = { + items: [ + { + id: 1, + itemName: 'Green Apples', + quantity: 15, + } as Partial, + { + itemName: 'Bananas', + quantity: 20, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + } as Partial, + ], + } as ReplaceDonationItemsDto; + + const updatedDonation = await service.replaceDonationItems( + donationId, + body, + ); + + expect(updatedDonation).toBeDefined(); + expect(updatedDonation.donationItems).toHaveLength(2); + + const updatedItemNames = updatedDonation.donationItems.map( + (i) => i.itemName, + ); + expect(updatedItemNames).toContain('Green Apples'); // updated + expect(updatedItemNames).toContain('Bananas'); // new + expect(updatedItemNames).not.toContain('Canned Green Beans'); // deleted + expect(updatedItemNames).not.toContain('Whole Wheat Bread'); // deleted + }); + + it('should throw NotFoundException if donation does not exist', async () => { + const body = { items: [] }; + await expect(service.replaceDonationItems(9999, body)).rejects.toThrow( + `Donation 9999 not found`, + ); + }); + + it('should throw BadRequestException if donation is not AVAILABLE', async () => { + // Donation with status MATCHED + const donationId = 2; + + const body = { items: [] }; + await expect( + service.replaceDonationItems(donationId, body), + ).rejects.toThrow('Only available donations can be deleted'); + }); + + it('should throw NotFoundException if trying to update an item that does not exist within current donation', async () => { + const donationId = 1; + + const body = { + items: [ + { + id: 9999, + itemName: 'Nonexistent', + quantity: 1, + } as Partial, + ], + } as ReplaceDonationItemsDto; + + await expect( + service.replaceDonationItems(donationId, body), + ).rejects.toThrow( + `Donation item 9999 for Donation ${donationId} not found`, + ); + }); + }); + + describe('delete', () => { + it('should delete an available donation and associated donation items', async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + + const donationId = 3; + + const donationBefore = await service.findOne(donationId); + expect(donationBefore).toBeDefined(); + expect(donationBefore.status).toBe(DonationStatus.AVAILABLE); + + await service.delete(donationId); + + await expect(service.findOne(donationId)).rejects.toThrow( + `Donation ${donationId} not found`, + ); + + const items = await donationItemService.getAllDonationItems(donationId); + expect(items).toHaveLength(0); + + const allocations = await allocationRepo.find({ + where: { + item: { donation: { donationId } }, + }, + }); + expect(allocations).toHaveLength(0); + }); + + it('should throw NotFoundException if donation does not exist', async () => { + await expect(service.delete(9999)).rejects.toThrow( + `Donation 9999 not found`, + ); + }); + + it('should throw BadRequestException if donation is not AVAILABLE', async () => { + // donation with status MATCHED + const donationId = 2; + + await expect(service.delete(donationId)).rejects.toThrow( + 'Only available donations can be deleted', + ); + }); + }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 2f3789867..481e5b25e 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -4,13 +4,15 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { validateId } from '../utils/validation.utils'; import { DayOfWeek, DonationStatus, RecurrenceEnum } from './types'; import { CreateDonationDto, RepeatOnDaysDto } from './dtos/create-donation.dto'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; +import { DonationItem } from '../donationItems/donationItems.entity'; @Injectable() export class DonationService { @@ -18,8 +20,11 @@ export class DonationService { constructor( @InjectRepository(Donation) private repo: Repository, + @InjectRepository(DonationItem) + private donationItemsRepo: Repository, @InjectRepository(FoodManufacturer) private manufacturerRepo: Repository, + @InjectDataSource() private dataSource: DataSource, ) {} async findOne(donationId: number): Promise { @@ -313,4 +318,100 @@ export class DonationService { } return dates; } + + async replaceDonationItems( + donationId: number, + body: ReplaceDonationItemsDto, + ): Promise { + validateId(donationId, 'Donation'); + + const donation = await this.repo.findOne({ + where: { donationId }, + relations: ['donationItems'], + }); + + if (!donation) { + throw new NotFoundException(`Donation ${donationId} not found`); + } + + if (donation.status !== DonationStatus.AVAILABLE) { + throw new BadRequestException(`Only available donations can be deleted`); + } + + const existingItems = donation.donationItems || []; + const incomingItems = body.items || []; + + const existingMap = new Map( + existingItems.map((item) => [item.itemId, item]), + ); + + const incomingIds = new Set( + incomingItems.filter((i) => i.id).map((i) => i.id), + ); + + const itemsToDelete = existingItems.filter( + (item) => !incomingIds.has(item.itemId), + ); + + const itemsToSave = incomingItems.map((donationItem) => { + if (donationItem.id) { + const existing = existingMap.get(donationItem.id); + + if (!existing) { + throw new NotFoundException( + `Donation item ${donationItem.id} for Donation ${donationId} not found`, + ); + } + + return this.donationItemsRepo.merge(existing, donationItem); + } + + return this.donationItemsRepo.create({ + ...donationItem, + donation, + }); + }); + + await this.dataSource.transaction(async (transactionManager) => { + if (itemsToDelete.length > 0) { + await transactionManager.remove(itemsToDelete); + } + + if (itemsToSave.length > 0) { + await transactionManager.save(itemsToSave); + } + }); + + const updatedDonation = await this.repo.findOne({ + where: { donationId }, + relations: ['donationItems'], + }); + + // This should never happen since we already check if the donationId has a donation previously. + if (!updatedDonation) { + throw new NotFoundException( + `Donation ${donationId} not found after update`, + ); + } + + return updatedDonation; + } + + async delete(donationId: number): Promise { + validateId(donationId, 'Donation'); + + const donation = await this.repo.findOne({ + where: { donationId }, + }); + + if (!donation) { + throw new NotFoundException(`Donation ${donationId} not found`); + } + + if (donation.status !== DonationStatus.AVAILABLE) { + throw new BadRequestException(`Only available donations can be deleted`); + } + + await this.repo.delete(donationId); + } } diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 847a7ac8c..ea60d2828 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -24,6 +24,7 @@ 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 { DataSource } from 'typeorm'; jest.setTimeout(60000); @@ -102,6 +103,10 @@ describe('FoodManufacturersService', () => { provide: getRepositoryToken(DonationItem), useValue: testDataSource.getRepository(DonationItem), }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile(); diff --git a/apps/backend/src/migrations/1774214910101-DonationItemsOnDeleteCascade.ts b/apps/backend/src/migrations/1774214910101-DonationItemsOnDeleteCascade.ts new file mode 100644 index 000000000..dda0d95ea --- /dev/null +++ b/apps/backend/src/migrations/1774214910101-DonationItemsOnDeleteCascade.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DonationItemsAllocationsOnDeleteCascade1774214910101 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donation_items + DROP CONSTRAINT IF EXISTS fk_donation_id; + `); + + await queryRunner.query(` + ALTER TABLE donation_items + ADD CONSTRAINT fk_donation_id + FOREIGN KEY(donation_id) + REFERENCES donations(donation_id) + ON DELETE CASCADE; + `); + + await queryRunner.query(` + ALTER TABLE allocations + DROP CONSTRAINT IF EXISTS fk_item_id; + `); + + await queryRunner.query(` + ALTER TABLE allocations + ADD CONSTRAINT fk_item_id + FOREIGN KEY(item_id) + REFERENCES donation_items(item_id) + ON DELETE CASCADE; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donation_items + DROP CONSTRAINT IF EXISTS fk_donation_id; + `); + + await queryRunner.query(` + ALTER TABLE donation_items + ADD CONSTRAINT fk_donation_id + FOREIGN KEY(donation_id) + REFERENCES donations(donation_id); + `); + + await queryRunner.query(` + ALTER TABLE allocations + DROP CONSTRAINT IF EXISTS fk_item_id; + `); + + await queryRunner.query(` + ALTER TABLE allocations + ADD CONSTRAINT fk_item_id + FOREIGN KEY(item_id) + REFERENCES donation_items(item_id); + `); + } +} diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 820e20a19..383237218 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -36,6 +36,7 @@ import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto import { EmailsService } from '../emails/email.service'; import { mock } from 'jest-mock-extended'; import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; +import { DataSource } from 'typeorm'; jest.setTimeout(60000); @@ -155,6 +156,10 @@ describe('PantriesService', () => { provide: getRepositoryToken(FoodManufacturer), useValue: testDataSource.getRepository(FoodManufacturer), }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile(); diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 34f889a9c..1b24bb9c8 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -19,6 +19,7 @@ import { DonationItem } from '../donationItems/donationItems.entity'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { DonationService } from '../donations/donations.service'; import { Donation } from '../donations/donations.entity'; +import { DataSource } from 'typeorm'; jest.setTimeout(60000); @@ -82,6 +83,10 @@ describe('VolunteersService', () => { sendEmails: jest.fn().mockResolvedValue(undefined), }, }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile();