diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index e4b74b60..1e8db0af 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -36,6 +36,7 @@ import { RenameDonationMatchingStatus1771260403657 } from '../migrations/1771260 import { DropDonationTotalColumns1772241115031 } from '../migrations/1772241115031-DropDonationTotalColumns'; import { FixTrackingLinks1773041840374 } from '../migrations/1773041840374-FixTrackingLinks'; import { CleanupRequestsAndAllocations1771821377918 } from '../migrations/1771821377918-CleanupRequestsAndAllocations'; +import { MakeFoodRescueRequired1773889925002 } from '../migrations/1773889925002-MakeFoodRescueRequired.ts'; import { AddDonationItemConfirmation1774140453305 } from '../migrations/1774140453305-AddDonationItemConfirmation'; const schemaMigrations = [ @@ -77,6 +78,7 @@ const schemaMigrations = [ DropDonationTotalColumns1772241115031, FixTrackingLinks1773041840374, CleanupRequestsAndAllocations1771821377918, + MakeFoodRescueRequired1773889925002, AddDonationItemConfirmation1774140453305, ]; diff --git a/apps/backend/src/donationItems/donationItems.controller.spec.ts b/apps/backend/src/donationItems/donationItems.controller.spec.ts index 784a3dc5..c5b20c18 100644 --- a/apps/backend/src/donationItems/donationItems.controller.spec.ts +++ b/apps/backend/src/donationItems/donationItems.controller.spec.ts @@ -1,10 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DonationItemsController } from './donationItems.controller'; import { DonationItemsService } from './donationItems.service'; -import { DonationItem } from './donationItems.entity'; import { mock } from 'jest-mock-extended'; -import { FoodType } from './types'; -import { CreateMultipleDonationItemsDto } from './dtos/create-donation-items.dto'; const mockDonationItemsService = mock(); @@ -25,46 +22,4 @@ describe('DonationItemsController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); - - describe('createMultipleDonationItems', () => { - it('should call donationItemsService.createMultipleDonationItems with donationId and items, and return the created donation items', async () => { - const mockBody: CreateMultipleDonationItemsDto = { - donationId: 1, - items: [ - { - itemName: 'Rice Noodles', - quantity: 100, - reservedQuantity: 0, - ozPerItem: 5, - estimatedValue: 100, - foodType: FoodType.DAIRY_FREE_ALTERNATIVES, - }, - { - itemName: 'Beans', - quantity: 50, - reservedQuantity: 0, - ozPerItem: 10, - estimatedValue: 80, - foodType: FoodType.GLUTEN_FREE_BAKING_PANCAKE_MIXES, - }, - ], - }; - - const mockCreatedItems: Partial[] = [ - { itemId: 1, donationId: 1, ...mockBody.items[0] }, - { itemId: 2, donationId: 1, ...mockBody.items[1] }, - ]; - - mockDonationItemsService.createMultipleDonationItems.mockResolvedValue( - mockCreatedItems as DonationItem[], - ); - - const result = await controller.createMultipleDonationItems(mockBody); - - expect( - mockDonationItemsService.createMultipleDonationItems, - ).toHaveBeenCalledWith(mockBody.donationId, mockBody.items); - expect(result).toEqual(mockCreatedItems); - }); - }); }); diff --git a/apps/backend/src/donationItems/donationItems.controller.ts b/apps/backend/src/donationItems/donationItems.controller.ts index 13a47e6d..b4fd4f53 100644 --- a/apps/backend/src/donationItems/donationItems.controller.ts +++ b/apps/backend/src/donationItems/donationItems.controller.ts @@ -1,19 +1,13 @@ import { Controller, - Post, - Body, Param, Get, - Patch, UseGuards, ParseIntPipe, } from '@nestjs/common'; -import { ApiBody } from '@nestjs/swagger'; import { DonationItemsService } from './donationItems.service'; import { DonationItem } from './donationItems.entity'; import { AuthGuard } from '@nestjs/passport'; -import { FoodType } from './types'; -import { CreateMultipleDonationItemsDto } from './dtos/create-donation-items.dto'; @Controller('donation-items') @UseGuards(AuthGuard('jwt')) @@ -26,51 +20,4 @@ export class DonationItemsController { ): Promise { return this.donationItemsService.getAllDonationItems(donationId); } - - @Post('/create-multiple') - @ApiBody({ - description: 'Bulk create donation items for a single donation', - schema: { - type: 'object', - properties: { - donationId: { - type: 'integer', - example: 1, - }, - items: { - type: 'array', - items: { - type: 'object', - properties: { - itemName: { type: 'string', example: 'Rice Noodles' }, - quantity: { type: 'integer', example: 100 }, - reservedQuantity: { type: 'integer', example: 0 }, - ozPerItem: { type: 'integer', example: 5 }, - estimatedValue: { type: 'integer', example: 100 }, - foodType: { - type: 'string', - enum: Object.values(FoodType), - example: FoodType.DAIRY_FREE_ALTERNATIVES, - }, - }, - }, - }, - }, - }, - }) - async createMultipleDonationItems( - @Body() body: CreateMultipleDonationItemsDto, - ): Promise { - return this.donationItemsService.createMultipleDonationItems( - body.donationId, - body.items, - ); - } - - @Patch('/update-quantity/:itemId') - async updateDonationItemQuantity( - @Param('itemId', ParseIntPipe) itemId: number, - ): Promise { - return this.donationItemsService.updateDonationItemQuantity(itemId); - } } diff --git a/apps/backend/src/donationItems/donationItems.module.ts b/apps/backend/src/donationItems/donationItems.module.ts index ef377d2b..5ecf4f82 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/donationItems.service.spec.ts b/apps/backend/src/donationItems/donationItems.service.spec.ts new file mode 100644 index 00000000..734d88d7 --- /dev/null +++ b/apps/backend/src/donationItems/donationItems.service.spec.ts @@ -0,0 +1,293 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DonationItem } from './donationItems.entity'; +import { DonationItemsService } from './donationItems.service'; +import { Donation } from '../donations/donations.entity'; +import { FoodType } from './types'; +import { NotFoundException } from '@nestjs/common'; +import { testDataSource } from '../config/typeormTestDataSource'; +import { CreateDonationItemDto } from '../donations/dtos/create-donation.dto'; + +jest.setTimeout(60000); + +// Get seeded data for tests +async function getSeedDonationId(): Promise { + const result = await testDataSource.query( + `SELECT donation_id FROM donations + WHERE food_manufacturer_id = ( + SELECT food_manufacturer_id FROM food_manufacturers + WHERE food_manufacturer_name = 'FoodCorp Industries' LIMIT 1 + ) + AND status = 'available' + LIMIT 1`, + ); + return result[0].donation_id; +} + +describe('DonationItemsService', () => { + let service: DonationItemsService; + + beforeAll(async () => { + if (!testDataSource.isInitialized) { + await testDataSource.initialize(); + } + + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DonationItemsService, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), + }, + ], + }).compile(); + + service = module.get(DonationItemsService); + }); + + beforeEach(async () => { + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + await testDataSource.runMigrations(); + }); + + afterEach(async () => { + await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + }); + + afterAll(async () => { + if (testDataSource.isInitialized) { + await testDataSource.destroy(); + } + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findOne', () => { + it('returns a donation item by id', async () => { + const result = await testDataSource.query( + `SELECT item_id FROM donation_items WHERE item_name = 'Peanut Butter (16oz)' LIMIT 1`, + ); + const itemId = result[0].item_id; + + const item = await service.findOne(itemId); + expect(item).toBeDefined(); + expect(item.itemId).toEqual(itemId); + }); + + it('throws NotFoundException when item does not exist', async () => { + await expect(service.findOne(99999)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getAllDonationItems', () => { + it('returns all items for a donation', async () => { + const donationId = await getSeedDonationId(); + + const items = await service.getAllDonationItems(donationId); + + // seed data inserts 3 items for the FoodCorp 150-item donation + expect(items).toHaveLength(3); + }); + + it('returns empty array when donation has no items', async () => { + const result = await testDataSource.query( + `INSERT INTO donations (food_manufacturer_id, status, recurrence) + VALUES ( + (SELECT food_manufacturer_id FROM food_manufacturers + WHERE food_manufacturer_name = 'FoodCorp Industries' LIMIT 1), + 'available', + 'none' + ) RETURNING donation_id`, + ); + const emptyDonationId = result[0].donation_id; + + const items = await service.getAllDonationItems(emptyDonationId); + expect(items).toHaveLength(0); + }); + }); + + describe('create', () => { + it('successfully creates a donation item on an existing donation', async () => { + const donationId = await getSeedDonationId(); + + const item = await service.create( + donationId, + 'Canned Beans', + 10, + 15.5, + 2.99, + FoodType.DRIED_BEANS, + ); + + expect(item).toBeDefined(); + expect(item.itemId).toBeDefined(); + expect(item.quantity).toEqual(10); + }); + + it('throws NotFoundException when donation does not exist', async () => { + await expect( + service.create( + 99999, + 'Canned Beans', + 10, + 15.5, + 2.99, + FoodType.DRIED_BEANS, + ), + ).rejects.toThrow(new NotFoundException('Donation not found')); + }); + }); + + describe('createMultiple', () => { + const validItems: CreateDonationItemDto[] = [ + { + itemName: 'Canned Beans', + quantity: 10, + ozPerItem: 15.5, + estimatedValue: 2.99, + foodType: FoodType.DRIED_BEANS, + foodRescue: false, + }, + { + itemName: 'Rice Bag', + quantity: 5, + ozPerItem: 32, + estimatedValue: 4.99, + foodType: FoodType.GRANOLA, + foodRescue: true, + }, + ]; + + async function getSeedDonation(): Promise { + const donationId = await getSeedDonationId(); + return testDataSource + .getRepository(Donation) + .findOneByOrFail({ donationId }); + } + + it('creates all items and returns them with generated ids', async () => { + const donation = await getSeedDonation(); + const transactionManager = testDataSource.createEntityManager(); + + const result = await service.createMultiple( + donation, + validItems, + transactionManager, + ); + + expect(result).toHaveLength(2); + result.forEach((item) => expect(item.itemId).toBeDefined()); + }); + + it('persists all items to the database linked to the correct donation', async () => { + const donation = await getSeedDonation(); + const transactionManager = testDataSource.createEntityManager(); + + await service.createMultiple(donation, validItems, transactionManager); + + const rows = await testDataSource.query( + `SELECT * FROM donation_items WHERE donation_id = $1 AND item_name IN ('Canned Beans', 'Rice Bag')`, + [donation.donationId], + ); + + expect(rows).toHaveLength(2); + rows.forEach((row: any) => + expect(row.donation_id).toEqual(donation.donationId), + ); + }); + + it('sets reservedQuantity to 0 for all items regardless of input', async () => { + const donation = await getSeedDonation(); + const transactionManager = testDataSource.createEntityManager(); + + const result = await service.createMultiple( + donation, + validItems, + transactionManager, + ); + + result.forEach((item) => expect(item.reservedQuantity).toEqual(0)); + }); + + it('creates items with optional fields omitted', async () => { + const donation = await getSeedDonation(); + const transactionManager = testDataSource.createEntityManager(); + + const minimalItems: CreateDonationItemDto[] = [ + { + itemName: 'Plain Item', + quantity: 3, + foodType: FoodType.DRIED_BEANS, + foodRescue: true, + }, + ]; + + const result = await service.createMultiple( + donation, + minimalItems, + transactionManager, + ); + + expect(result).toHaveLength(1); + expect(result[0].itemId).toBeDefined(); + expect(result[0].ozPerItem).toBeNull(); + expect(result[0].estimatedValue).toBeNull(); + }); + + it('rolls back all items when one fails within a transaction', async () => { + const donation = await getSeedDonation(); + + const itemsBefore = await testDataSource.query( + `SELECT * FROM donation_items WHERE donation_id = $1`, + [donation.donationId], + ); + + const badItems: CreateDonationItemDto[] = [ + ...validItems, + { + itemName: 'a'.repeat(1000), + quantity: 5, + foodType: FoodType.DRIED_BEANS, + foodRescue: false, + }, + ]; + + await expect( + testDataSource.transaction(async (transactionManager) => { + await service.createMultiple(donation, badItems, transactionManager); + }), + ).rejects.toThrow(); + + const itemsAfter = await testDataSource.query( + `SELECT * FROM donation_items WHERE donation_id = $1`, + [donation.donationId], + ); + + expect(itemsAfter).toHaveLength(itemsBefore.length); + }); + + it('returns empty array when given empty items list', async () => { + const donation = await getSeedDonation(); + const transactionManager = testDataSource.createEntityManager(); + + const result = await service.createMultiple( + donation, + [], + transactionManager, + ); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 2397f545..df4486c7 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -1,10 +1,11 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; import { DonationItem } from './donationItems.entity'; import { validateId } from '../utils/validation.utils'; import { FoodType } from './types'; import { Donation } from '../donations/donations.entity'; +import { CreateDonationItemDto } from '../donations/dtos/create-donation.dto'; @Injectable() export class DonationItemsService { @@ -32,7 +33,6 @@ export class DonationItemsService { donationId: number, itemName: string, quantity: number, - reservedQuantity: number, ozPerItem: number, estimatedValue: number, foodType: FoodType, @@ -45,7 +45,7 @@ export class DonationItemsService { donation, itemName, quantity, - reservedQuantity, + reservedQuantity: 0, ozPerItem, estimatedValue, foodType, @@ -54,45 +54,25 @@ export class DonationItemsService { return this.repo.save(donationItem); } - async createMultipleDonationItems( - donationId: number, - items: { - itemName: string; - quantity: number; - reservedQuantity: number; - ozPerItem?: number; - estimatedValue?: number; - foodType: FoodType; - }[], + async createMultiple( + savedDonation: Donation, + items: CreateDonationItemDto[], + transactionManager: EntityManager, ): Promise { - validateId(donationId, 'Donation'); - - const donation = await this.donationRepo.findOneBy({ donationId }); - if (!donation) throw new NotFoundException('Donation not found'); + const repo = transactionManager.getRepository(DonationItem); const donationItems = items.map((item) => - this.repo.create({ - donation, + repo.create({ + donation: savedDonation, itemName: item.itemName, quantity: item.quantity, - reservedQuantity: item.reservedQuantity, + reservedQuantity: 0, ozPerItem: item.ozPerItem, estimatedValue: item.estimatedValue, foodType: item.foodType, + foodRescue: item.foodRescue, }), ); - - return this.repo.save(donationItems); - } - - async updateDonationItemQuantity(itemId: number): Promise { - validateId(itemId, 'Donation Item'); - - const donationItem = await this.repo.findOneBy({ itemId }); - if (!donationItem) { - throw new NotFoundException(`Donation item ${itemId} not found`); - } - donationItem.quantity -= 1; - return this.repo.save(donationItem); + return repo.save(donationItems); } } diff --git a/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts b/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts deleted file mode 100644 index 6c14a688..00000000 --- a/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - IsNumber, - IsString, - IsArray, - ValidateNested, - Min, - IsEnum, - IsNotEmpty, - Length, - IsOptional, - IsInt, -} from 'class-validator'; -import { Transform, Type } from 'class-transformer'; -import { FoodType } from '../types'; - -export class CreateDonationItemDto { - @IsString() - @IsNotEmpty() - @Length(1, 255) - itemName!: string; - - @Transform(({ value }) => parseInt(value, 10)) - @IsInt({ message: 'Quantity must be an integer value' }) - @Min(1, { message: 'Quantity must be at least 1' }) - quantity!: number; - - @IsInt() - @Min(0) - reservedQuantity!: number; - - @Transform(({ value }) => parseFloat(value)) - @IsNumber( - { maxDecimalPlaces: 2 }, - { message: 'Oz per item must have at most 2 decimal places' }, - ) - @Min(0.01, { message: 'Oz per item must be at least 0.01' }) - @IsOptional() - ozPerItem?: number; - - @Transform(({ value }) => parseFloat(value)) - @IsNumber( - { maxDecimalPlaces: 2 }, - { message: 'Estimated value must have at most 2 decimal places' }, - ) - @Min(0.01, { message: 'Estimated value must be at least 0.01' }) - @IsOptional() - estimatedValue?: number; - - @IsEnum(FoodType) - foodType!: FoodType; -} - -export class CreateMultipleDonationItemsDto { - @IsNumber() - donationId!: number; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => CreateDonationItemDto) - items!: CreateDonationItemDto[]; -} diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index a13af53c..b418bc38 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -79,7 +79,7 @@ describe('DonationsController', () => { }); }); - describe('POST /create', () => { + describe('POST /', () => { it('should call donationService.create and return the created donation', async () => { const createBody: Partial = { foodManufacturerId: 1, diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 2862ebb6..4046d03b 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -12,6 +12,7 @@ import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { RecurrenceEnum } from './types'; import { CreateDonationDto } from './dtos/create-donation.dto'; +import { FoodType } from '../donationItems/types'; @Controller('donations') export class DonationsController { @@ -34,7 +35,7 @@ export class DonationsController { return this.donationService.findOne(donationId); } - @Post('/create') + @Post() @ApiBody({ description: 'Details for creating a donation', schema: { @@ -61,6 +62,24 @@ export class DonationsController { }, }, occurrencesRemaining: { type: 'integer', example: 2, nullable: true }, + items: { + type: 'array', + items: { + type: 'object', + properties: { + itemName: { type: 'string', example: 'Canned Beans' }, + quantity: { type: 'integer', example: 1 }, + ozPerItem: { type: 'number', example: 0.01, nullable: true }, + estimatedValue: { type: 'number', example: 0.01, nullable: true }, + foodType: { + type: 'enum', + enum: Object.values(FoodType), + example: FoodType.QUINOA, + }, + foodRescue: { type: 'boolean', example: false }, + }, + }, + }, }, }, }) diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index 0b813de8..255b7e6a 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -6,9 +6,15 @@ 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'; @Module({ - imports: [TypeOrmModule.forFeature([Donation, FoodManufacturer]), AuthModule], + imports: [ + TypeOrmModule.forFeature([Donation, FoodManufacturer, DonationItem]), + AuthModule, + DonationItemsModule, + ], controllers: [DonationsController], providers: [DonationService, DonationsSchedulerService], }) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 63bde20c..c3c1cdce 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -6,7 +6,11 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { RecurrenceEnum, DayOfWeek, DonationStatus } from './types'; import { RepeatOnDaysDto } from './dtos/create-donation.dto'; import { testDataSource } from '../config/typeormTestDataSource'; -import { NotFoundException } from '@nestjs/common'; +import { FoodType } from '../donationItems/types'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { DataSource } from 'typeorm'; +import { DonationItemsService } from '../donationItems/donationItems.service'; jest.setTimeout(60000); @@ -95,6 +99,7 @@ describe('DonationService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ DonationService, + DonationItemsService, { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), @@ -103,6 +108,14 @@ describe('DonationService', () => { provide: getRepositoryToken(FoodManufacturer), useValue: testDataSource.getRepository(FoodManufacturer), }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile(); @@ -802,4 +815,105 @@ describe('DonationService', () => { expect(result).toHaveLength(0); }); }); + + describe('create', () => { + const validItems = [ + { + itemName: 'Canned Beans', + quantity: 10, + ozPerItem: 15.5, + estimatedValue: 2.99, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + foodRescue: false, + }, + { + itemName: 'Canned Corn', + quantity: 5, + ozPerItem: 12, + estimatedValue: 1.99, + foodType: FoodType.GRANOLA, + foodRescue: true, + }, + ]; + + it('successfully creates a donation with items', async () => { + const donation = await service.create({ + foodManufacturerId: 1, + recurrence: RecurrenceEnum.NONE, + items: validItems, + }); + + expect(donation).toBeDefined(); + expect(donation.donationId).toBeDefined(); + + const items = await testDataSource.query( + `SELECT * FROM donation_items WHERE donation_id = $1`, + [donation.donationId], + ); + + expect(items).toHaveLength(2); + expect( + items.every((item: any) => item.donation_id === donation.donationId), + ).toBe(true); + }); + + it('throws when foodManufacturerId does not exist', async () => { + expect( + service.create({ + foodManufacturerId: 99999, + recurrence: RecurrenceEnum.NONE, + items: validItems, + }), + ).rejects.toThrow( + new NotFoundException('Food Manufacturer 99999 not found'), + ); + }); + + it('throws when recurrence is not NONE but recurrenceFreq is missing', async () => { + await expect( + service.create({ + foodManufacturerId: 1, + recurrence: RecurrenceEnum.WEEKLY, + repeatOnDays: { + Sunday: false, + Monday: true, + Tuesday: false, + Wednesday: false, + Thursday: false, + Friday: false, + Saturday: false, + }, + items: validItems, + }), + ).rejects.toThrow( + new BadRequestException( + 'recurrenceFreq is required for recurring donations', + ), + ); + + const donations = await testDataSource.query(`SELECT * FROM donations`); + expect(donations).toHaveLength(4); + }); + + it('rolls back donation when a donation item fails to save', async () => { + await expect( + service.create({ + foodManufacturerId: 1, + recurrence: RecurrenceEnum.NONE, + items: [ + ...validItems, + { + itemName: 'a'.repeat(1000), + quantity: 5, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + foodRescue: false, + }, + ], + }), + ).rejects.toThrow(); + + const donations = await testDataSource.query(`SELECT * FROM donations`); + expect(donations).toHaveLength(4); + }); + }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 2f378986..065996d2 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -4,13 +4,14 @@ 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 { DonationItemsService } from '../donationItems/donationItems.service'; @Injectable() export class DonationService { @@ -20,6 +21,8 @@ export class DonationService { @InjectRepository(Donation) private repo: Repository, @InjectRepository(FoodManufacturer) private manufacturerRepo: Repository, + private donationItemsService: DonationItemsService, + @InjectDataSource() private dataSource: DataSource, ) {} async findOne(donationId: number): Promise { @@ -74,17 +77,29 @@ export class DonationService { ); } - const donation = this.repo.create({ - foodManufacturer: manufacturer, - dateDonated: new Date(), - status: DonationStatus.AVAILABLE, - recurrence: donationData.recurrence, - recurrenceFreq: donationData.recurrenceFreq, - nextDonationDates: nextDonationDates, - occurrencesRemaining: donationData.occurrencesRemaining, - }); + return this.dataSource.transaction(async (transactionManager) => { + const repo = transactionManager.getRepository(Donation); + + const donation = repo.create({ + foodManufacturer: manufacturer, + dateDonated: new Date(), + status: DonationStatus.AVAILABLE, + recurrence: donationData.recurrence, + recurrenceFreq: donationData.recurrenceFreq, + nextDonationDates, + occurrencesRemaining: donationData.occurrencesRemaining, + }); + + const savedDonation = await repo.save(donation); + + await this.donationItemsService.createMultiple( + savedDonation, + donationData.items, + transactionManager, + ); - return this.repo.save(donation); + return savedDonation; + }); } async fulfill(donationId: number): Promise { diff --git a/apps/backend/src/donations/dtos/create-donation.dto.ts b/apps/backend/src/donations/dtos/create-donation.dto.ts index fca118c1..564cc1bf 100644 --- a/apps/backend/src/donations/dtos/create-donation.dto.ts +++ b/apps/backend/src/donations/dtos/create-donation.dto.ts @@ -1,10 +1,15 @@ import { + ArrayMinSize, + IsArray, IsBoolean, IsEnum, + IsInt, IsNotEmpty, IsNumber, IsObject, IsOptional, + IsString, + Length, Min, ValidateIf, ValidateNested, @@ -12,6 +17,7 @@ import { } from 'class-validator'; import { RecurrenceEnum } from '../types'; import { Type } from 'class-transformer'; +import { FoodType } from '../../donationItems/types'; function AtLeastOneDaySelected() { return function (object: object, propertyName: string) { @@ -23,6 +29,9 @@ function AtLeastOneDaySelected() { validate(value: Record) { return !!value && Object.values(value).some((v) => v === true); }, + defaultMessage() { + return 'At least one day must be selected for weekly recurrence'; + }, }, }); }; @@ -58,8 +67,35 @@ export class RepeatOnDaysDto { Sunday?: boolean; } -export class CreateDonationDto { +export class CreateDonationItemDto { + @IsString() + @IsNotEmpty() + @Length(1, 255) + itemName!: string; + + @IsInt() + @Min(1) + quantity!: number; + @IsNumber() + @Min(0.01) + @IsOptional() + ozPerItem?: number; + + @IsNumber() + @Min(0.01) + @IsOptional() + estimatedValue?: number; + + @IsEnum(FoodType) + foodType!: FoodType; + + @IsBoolean() + foodRescue!: boolean; +} + +export class CreateDonationDto { + @IsInt() @Min(1) foodManufacturerId!: number; @@ -67,7 +103,7 @@ export class CreateDonationDto { @IsEnum(RecurrenceEnum) recurrence!: RecurrenceEnum; - @IsNumber() + @IsInt() @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) @Min(1) recurrenceFreq?: number; @@ -79,8 +115,14 @@ export class CreateDonationDto { @ValidateIf((o) => o.recurrence === RecurrenceEnum.WEEKLY) repeatOnDays?: RepeatOnDaysDto; - @IsNumber() + @IsInt() @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) @Min(1) occurrencesRemaining?: number; + + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateDonationItemDto) + items!: CreateDonationItemDto[]; } diff --git a/apps/backend/src/foodManufacturers/manufacturers.module.ts b/apps/backend/src/foodManufacturers/manufacturers.module.ts index 8fe5e5c6..7258244c 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.module.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.module.ts @@ -6,12 +6,14 @@ import { FoodManufacturersService } from './manufacturers.service'; import { UsersModule } from '../users/users.module'; import { Donation } from '../donations/donations.entity'; import { EmailsModule } from '../emails/email.module'; +import { DonationItemsModule } from '../donationItems/donationItems.module'; @Module({ imports: [ TypeOrmModule.forFeature([FoodManufacturer, Donation]), forwardRef(() => UsersModule), EmailsModule, + DonationItemsModule, ], controllers: [FoodManufacturersController], providers: [FoodManufacturersService], diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 847a7ac8..179a82b6 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -15,12 +15,6 @@ import { User } from '../users/users.entity'; import { UsersService } from '../users/users.service'; import { AuthService } from '../auth/auth.service'; import { EmailsService } from '../emails/email.service'; -import { Pantry } from '../pantries/pantries.entity'; -import { Order } from '../orders/order.entity'; -import { FoodRequest } from '../foodRequests/request.entity'; -import { DonationItem } from '../donationItems/donationItems.entity'; -import { DonationService } from '../donations/donations.service'; -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'; @@ -62,8 +56,6 @@ describe('FoodManufacturersService', () => { providers: [ FoodManufacturersService, UsersService, - DonationService, - PantriesService, { provide: AuthService, useValue: { @@ -86,22 +78,6 @@ describe('FoodManufacturersService', () => { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), }, - { - provide: getRepositoryToken(Pantry), - useValue: testDataSource.getRepository(Pantry), - }, - { - provide: getRepositoryToken(Order), - useValue: testDataSource.getRepository(Order), - }, - { - provide: getRepositoryToken(FoodRequest), - useValue: testDataSource.getRepository(FoodRequest), - }, - { - provide: getRepositoryToken(DonationItem), - useValue: testDataSource.getRepository(DonationItem), - }, ], }).compile(); diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 153d569f..8eccbd25 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -72,7 +72,7 @@ export class RequestsController { return this.requestsService.getAvailableItems(requestId, manufacturerId); } - @Post('/create') + @Post() @ApiBody({ description: 'Details for creating a food request', schema: { diff --git a/apps/backend/src/migrations/1773889925002-MakeFoodRescueRequired.ts.ts b/apps/backend/src/migrations/1773889925002-MakeFoodRescueRequired.ts.ts new file mode 100644 index 00000000..feca0210 --- /dev/null +++ b/apps/backend/src/migrations/1773889925002-MakeFoodRescueRequired.ts.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MakeFoodRescueRequired1773889925002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE donation_items + SET food_rescue = false + WHERE food_rescue IS NULL + `); + await queryRunner.query(` + ALTER TABLE donation_items + ALTER COLUMN food_rescue SET NOT NULL, + ALTER COLUMN food_rescue SET DEFAULT false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donation_items + ALTER COLUMN food_rescue DROP NOT NULL, + ALTER COLUMN food_rescue DROP DEFAULT + `); + } +} diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index b0aa4588..f5020dee 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -37,7 +37,6 @@ describe('OrdersService', () => { providers: [ OrdersService, RequestsService, - EmailsService, { provide: EmailsService, useValue: { diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 820e20a1..e03fc84d 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -20,17 +20,8 @@ import { import { ApplicationStatus } from '../shared/types'; import { testDataSource } from '../config/typeormTestDataSource'; import { Order } from '../orders/order.entity'; -import { FoodRequest } from '../foodRequests/request.entity'; -import { RequestsService } from '../foodRequests/request.service'; -import { OrdersService } from '../orders/order.service'; import { UsersService } from '../users/users.service'; import { AuthService } from '../auth/auth.service'; -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 { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; -import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { User } from '../users/users.entity'; import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; import { EmailsService } from '../emails/email.service'; @@ -111,12 +102,7 @@ describe('PantriesService', () => { testModule = await Test.createTestingModule({ providers: [ PantriesService, - OrdersService, - RequestsService, UsersService, - DonationItemsService, - DonationService, - FoodManufacturersService, { provide: AuthService, useValue: { @@ -139,22 +125,6 @@ describe('PantriesService', () => { provide: getRepositoryToken(Order), useValue: testDataSource.getRepository(Order), }, - { - provide: getRepositoryToken(FoodRequest), - useValue: testDataSource.getRepository(FoodRequest), - }, - { - provide: getRepositoryToken(DonationItem), - useValue: testDataSource.getRepository(DonationItem), - }, - { - provide: getRepositoryToken(Donation), - useValue: testDataSource.getRepository(Donation), - }, - { - provide: getRepositoryToken(FoodManufacturer), - useValue: testDataSource.getRepository(FoodManufacturer), - }, ], }).compile(); diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 34f889a9..d744baa9 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -7,18 +7,13 @@ import { Pantry } from '../pantries/pantries.entity'; import { testDataSource } from '../config/typeormTestDataSource'; import { UsersService } from '../users/users.service'; import { PantriesService } from '../pantries/pantries.service'; -import { OrdersService } from '../orders/order.service'; import { Order } from '../orders/order.entity'; import { RequestsService } from '../foodRequests/request.service'; import { FoodRequest } from '../foodRequests/request.entity'; import { AuthService } from '../auth/auth.service'; import { EmailsService } from '../emails/email.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { DonationItem } from '../donationItems/donationItems.entity'; -import { DonationItemsService } from '../donationItems/donationItems.service'; -import { DonationService } from '../donations/donations.service'; -import { Donation } from '../donations/donations.entity'; jest.setTimeout(60000); @@ -36,18 +31,19 @@ describe('VolunteersService', () => { VolunteersService, UsersService, PantriesService, - EmailsService, - OrdersService, RequestsService, - FoodManufacturersService, - DonationItemsService, - DonationService, { provide: AuthService, useValue: { adminCreateUser: jest.fn().mockResolvedValue('test-sub'), }, }, + { + provide: EmailsService, + useValue: { + sendEmails: jest.fn().mockResolvedValue(undefined), + }, + }, { provide: getRepositoryToken(User), useValue: testDataSource.getRepository(User), @@ -72,16 +68,6 @@ describe('VolunteersService', () => { provide: getRepositoryToken(DonationItem), useValue: testDataSource.getRepository(DonationItem), }, - { - provide: getRepositoryToken(Donation), - useValue: testDataSource.getRepository(Donation), - }, - { - provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, - }, ], }).compile(); diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index eacf11ad..0172529d 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -17,7 +17,6 @@ import { CreateFoodRequestBody, Pantry, PantryApplicationDto, - CreateMultipleDonationItemsBody, ManufacturerApplicationDto, OrderSummary, UserDto, @@ -28,6 +27,7 @@ import { OrderWithoutFoodManufacturer, PantryWithUser, Assignments, + CreateDonationDto, UpdateProfileFields, } from 'types/types'; @@ -85,22 +85,14 @@ export class ApiClient { .then((response) => response.data); } - public async postDonation(body: unknown): Promise { - return this.post('/api/donations/create', body) as Promise; + public async postDonation(body: CreateDonationDto): Promise { + return this.post('/api/donations/', body) as Promise; } public async createFoodRequest( body: CreateFoodRequestBody, ): Promise { - return this.post('/api/requests/create', body) as Promise; - } - - public async postMultipleDonationItems( - body: CreateMultipleDonationItemsBody, - ): Promise { - return this.post('/api/donation-items/create-multiple', body) as Promise< - DonationItem[] - >; + return this.post('/api/requests/', body) as Promise; } private async patch(path: string, body: unknown): Promise { @@ -133,16 +125,6 @@ export class ApiClient { ) as Promise; } - public async updateDonationItemQuantity( - itemId: number, - body?: unknown, - ): Promise { - return this.patch( - `/api/donation-items/update-quantity/${itemId}`, - body, - ) as Promise; - } - private async delete(path: string): Promise { return this.axiosInstance.delete(path).then((response) => response.data); } diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index f85f76f3..e702b74c 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -15,11 +15,11 @@ import { Menu, NumberInput, Tooltip, - InputGroup, } from '@chakra-ui/react'; import { useState } from 'react'; import ApiClient from '@api/apiClient'; import { + CreateDonationDto, DayOfWeek, FoodType, RecurrenceEnum, @@ -133,7 +133,6 @@ const NewDonationFormModal: React.FC = ({ Sunday: false, }); const [endsAfter, setEndsAfter] = useState('1'); - const [alertState, setAlertMessage] = useAlert(); const handleChange = (id: number, field: string, value: string | boolean) => { @@ -205,54 +204,45 @@ const NewDonationFormModal: React.FC = ({ return; } - const donation_body = { + const donationBody: CreateDonationDto = { foodManufacturerId: 1, - recurrenceFreq: isRecurring ? parseInt(repeatEvery) : null, + recurrenceFreq: isRecurring ? parseInt(repeatEvery) : undefined, recurrence: isRecurring ? repeatInterval : RecurrenceEnum.NONE, repeatOnDays: isRecurring && repeatInterval === RecurrenceEnum.WEEKLY ? repeatOn - : null, - occurrencesRemaining: isRecurring ? parseInt(endsAfter) : null, + : undefined, + occurrencesRemaining: isRecurring ? parseInt(endsAfter) : undefined, + items: rows.map((row) => ({ + itemName: row.foodItem, + quantity: parseInt(row.numItems), + ozPerItem: row.ozPerItem ? parseFloat(row.ozPerItem) : undefined, + estimatedValue: row.valuePerItem + ? parseFloat(row.valuePerItem) + : undefined, + foodType: row.foodType as FoodType, + foodRescue: row.foodRescue, + })), }; try { - const donationResponse = await ApiClient.postDonation(donation_body); - const donationId = donationResponse?.donationId; - - if (donationId) { - const items = rows.map((row) => ({ - itemName: row.foodItem, - quantity: parseInt(row.numItems), - reservedQuantity: 0, - ozPerItem: - row.ozPerItem !== '' ? parseFloat(row.ozPerItem) : undefined, - estimatedValue: - row.valuePerItem !== '' ? parseFloat(row.valuePerItem) : undefined, - foodType: row.foodType as FoodType, - foodRescue: row.foodRescue, - })); - - await ApiClient.postMultipleDonationItems({ donationId, items }); - onDonationSuccess(); - - setRows([ - { - id: 1, - foodItem: '', - foodType: '', - numItems: '', - ozPerItem: '', - valuePerItem: '', - foodRescue: false, - }, - ]); - setIsRecurring(false); - setRepeatInterval(RecurrenceEnum.NONE); - onClose(); - } else { - setAlertMessage('Failed to submit donation'); - } + await ApiClient.postDonation(donationBody); + onDonationSuccess(); + + setRows([ + { + id: 1, + foodItem: '', + foodType: '', + numItems: '', + ozPerItem: '', + valuePerItem: '', + foodRescue: false, + }, + ]); + setIsRecurring(false); + setRepeatInterval(RecurrenceEnum.NONE); + onClose(); } catch { setAlertMessage('Error submitting new donation'); } diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 3f28c59d..677223be 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -145,7 +145,7 @@ export interface DonationItem { ozPerItem?: number; estimatedValue?: number; foodType: FoodType; - foodRescue?: boolean; + foodRescue: boolean; } export enum FoodType { @@ -285,17 +285,22 @@ export interface CreateFoodRequestBody { additionalInformation?: string; } -export interface CreateMultipleDonationItemsBody { - donationId: number; - items: { - itemName: string; - quantity: number; - reservedQuantity: number; - ozPerItem?: number; - estimatedValue?: number; - foodType: FoodType; - foodRescue?: boolean; - }[]; +export interface CreateDonationDto { + foodManufacturerId: number; + recurrenceFreq?: number; + recurrence: RecurrenceEnum; + repeatOnDays?: RepeatOnState; + occurrencesRemaining?: number; + items: CreateDonationItemDto[]; +} + +export interface CreateDonationItemDto { + itemName: string; + quantity: number; + ozPerItem?: number; + estimatedValue?: number; + foodType: FoodType; + foodRescue: boolean; } export interface Allocation {