diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 62de42177..a5293498d 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'; const schemaMigrations = [ User1725726359198, @@ -76,6 +77,7 @@ const schemaMigrations = [ DropDonationTotalColumns1772241115031, FixTrackingLinks1773041840374, CleanupRequestsAndAllocations1771821377918, + MakeFoodRescueRequired1773889925002, ]; export default schemaMigrations; diff --git a/apps/backend/src/donationItems/donationItems.controller.spec.ts b/apps/backend/src/donationItems/donationItems.controller.spec.ts index 784a3dc5a..c5b20c18e 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 13a47e6df..83e1045e3 100644 --- a/apps/backend/src/donationItems/donationItems.controller.ts +++ b/apps/backend/src/donationItems/donationItems.controller.ts @@ -1,19 +1,14 @@ 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 +21,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 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/donationItems.service.spec.ts b/apps/backend/src/donationItems/donationItems.service.spec.ts new file mode 100644 index 000000000..cae5c70a4 --- /dev/null +++ b/apps/backend/src/donationItems/donationItems.service.spec.ts @@ -0,0 +1,289 @@ +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 manager = testDataSource.createEntityManager(); + + const result = await service.createMultiple( + donation, + validItems, + manager, + ); + + 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 manager = testDataSource.createEntityManager(); + + await service.createMultiple(donation, validItems, manager); + + 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 manager = testDataSource.createEntityManager(); + + const result = await service.createMultiple( + donation, + validItems, + manager, + ); + + result.forEach((item) => expect(item.reservedQuantity).toEqual(0)); + }); + + it('creates items with optional fields omitted', async () => { + const donation = await getSeedDonation(); + const manager = testDataSource.createEntityManager(); + + const minimalItems: CreateDonationItemDto[] = [ + { + itemName: 'Plain Item', + quantity: 3, + foodType: FoodType.DRIED_BEANS, + foodRescue: true, + }, + ]; + + const result = await service.createMultiple( + donation, + minimalItems, + manager, + ); + + 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 (manager) => { + await service.createMultiple(donation, badItems, manager); + }), + ).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 manager = testDataSource.createEntityManager(); + + const result = await service.createMultiple(donation, [], manager); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 2397f545d..e1038e7e7 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 { @@ -15,7 +16,6 @@ export class DonationItemsService { async findOne(itemId: number): Promise { validateId(itemId, 'Donation Item'); - const donationItem = await this.repo.findOneBy({ itemId }); if (!donationItem) { throw new NotFoundException(`Donation item ${itemId} not found`); @@ -32,7 +32,6 @@ export class DonationItemsService { donationId: number, itemName: string, quantity: number, - reservedQuantity: number, ozPerItem: number, estimatedValue: number, foodType: FoodType, @@ -45,7 +44,7 @@ export class DonationItemsService { donation, itemName, quantity, - reservedQuantity, + reservedQuantity: 0, ozPerItem, estimatedValue, foodType, @@ -54,45 +53,23 @@ 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[], + manager: EntityManager, ): Promise { - validateId(donationId, 'Donation'); - - const donation = await this.donationRepo.findOneBy({ donationId }); - if (!donation) throw new NotFoundException('Donation not found'); - const donationItems = items.map((item) => - this.repo.create({ - donation, + manager.create(DonationItem, { + 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 manager.save(DonationItem, 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 0e4b04b39..000000000 --- a/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - IsNumber, - IsString, - IsArray, - ValidateNested, - Min, - IsEnum, - IsNotEmpty, - Length, - IsOptional, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { FoodType } from '../types'; - -export class CreateDonationItemDto { - @IsString() - @IsNotEmpty() - @Length(1, 255) - itemName!: string; - - @IsNumber() - @Min(1) - quantity!: number; - - @IsNumber() - @Min(0) - reservedQuantity!: number; - - @IsNumber() - @Min(0.01) - @IsOptional() - ozPerItem?: number; - - @IsNumber() - @Min(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.ts b/apps/backend/src/donations/donations.controller.ts index 2862ebb6e..6d26dcbb7 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 { @@ -61,6 +62,21 @@ export class DonationsController { }, }, occurrencesRemaining: { type: 'integer', example: 2, nullable: true }, + 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, nullable: true }, + }, + }, }, }, }) diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index 0b813de8f..255b7e6a3 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 63bde20ca..c3c1cdce4 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 2f3789867..25db89a77 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -5,12 +5,13 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from '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, + private dataSource: DataSource, ) {} async findOne(donationId: number): Promise { @@ -74,17 +77,27 @@ 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 (manager) => { + const donation = manager.create(Donation, { + foodManufacturer: manufacturer, + dateDonated: new Date(), + status: DonationStatus.AVAILABLE, + recurrence: donationData.recurrence, + recurrenceFreq: donationData.recurrenceFreq, + nextDonationDates, + occurrencesRemaining: donationData.occurrencesRemaining, + }); + + const savedDonation = await manager.save(Donation, donation); + + await this.donationItemsService.createMultiple( + savedDonation, + donationData.items, + manager, + ); - 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 fca118c16..8e89d21cd 100644 --- a/apps/backend/src/donations/dtos/create-donation.dto.ts +++ b/apps/backend/src/donations/dtos/create-donation.dto.ts @@ -1,10 +1,14 @@ import { + ArrayMinSize, + IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsObject, IsOptional, + IsString, + Length, Min, ValidateIf, ValidateNested, @@ -12,6 +16,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 +28,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,6 +66,33 @@ export class RepeatOnDaysDto { Sunday?: boolean; } +export class CreateDonationItemDto { + @IsString() + @IsNotEmpty() + @Length(1, 255) + itemName!: string; + + @IsNumber() + @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 { @IsNumber() @Min(1) @@ -68,6 +103,7 @@ export class CreateDonationDto { recurrence!: RecurrenceEnum; @IsNumber() + @IsOptional() @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) @Min(1) recurrenceFreq?: number; @@ -80,7 +116,14 @@ export class CreateDonationDto { repeatOnDays?: RepeatOnDaysDto; @IsNumber() + @IsOptional() @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/migrations/1773889925002-MakeFoodRescueRequired.ts.ts b/apps/backend/src/migrations/1773889925002-MakeFoodRescueRequired.ts.ts new file mode 100644 index 000000000..feca02102 --- /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/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index e6ed91f03..358d13129 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -27,6 +27,7 @@ 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 { DataSource } from 'typeorm'; jest.setTimeout(60000); @@ -111,6 +112,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 75e0da925..75a77023b 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -18,6 +18,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); @@ -74,6 +75,10 @@ describe('VolunteersService', () => { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile(); diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 2a4a1a414..320c38990 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, @@ -91,14 +90,6 @@ export class ApiClient { 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[] - >; - } - private async patch(path: string, body: unknown): Promise { return this.axiosInstance .patch(path, body) @@ -129,16 +120,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 f4cf7df7b..161892388 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -18,6 +18,7 @@ import { import { useState } from 'react'; import ApiClient from '@api/apiClient'; import { + CreateDonationDto, DayOfWeek, FoodType, RecurrenceEnum, @@ -166,52 +167,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: parseFloat(row.ozPerItem), - estimatedValue: parseFloat(row.valuePerItem), - 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 40a9ff216..7c8e70353 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -140,7 +140,7 @@ export interface DonationItem { ozPerItem?: number; estimatedValue?: number; foodType: FoodType; - foodRescue?: boolean; + foodRescue: boolean; } export enum FoodType { @@ -267,17 +267,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 {