diff --git a/apps/backend/src/donationItems/donationItems.service.spec.ts b/apps/backend/src/donationItems/donationItems.service.spec.ts index e054d81f7..b77a40c36 100644 --- a/apps/backend/src/donationItems/donationItems.service.spec.ts +++ b/apps/backend/src/donationItems/donationItems.service.spec.ts @@ -8,7 +8,7 @@ import { FoodType } from './types'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { testDataSource } from '../config/typeormTestDataSource'; import { CreateDonationItemDto } from './dtos/create-donation-items.dto'; -import { ConfirmDonationItemDetailsDto } from './dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto'; jest.setTimeout(60000); @@ -297,8 +297,8 @@ describe('DonationItemsService', () => { }); }); - describe('confirmItemDetails', () => { - const makeDto = (itemId: number): ConfirmDonationItemDetailsDto => ({ + describe('updateItemDetails', () => { + const makeDto = (itemId: number): UpdateDonationItemDetailsDto => ({ itemId, ozPerItem: 5.0, estimatedValue: 10.0, @@ -339,7 +339,7 @@ describe('DonationItemsService', () => { const donationId = await insertMatchedDonation(); await expect( testDataSource.transaction((tm) => - service.confirmItemDetails(donationId, [makeDto(99999)], tm), + service.updateItemDetails(donationId, [makeDto(99999)], tm), ), ).rejects.toThrow(new NotFoundException('Donation item 99999 not found')); }); @@ -349,7 +349,7 @@ describe('DonationItemsService', () => { // Item 1 belongs to donation 1, not the new donation await expect( testDataSource.transaction((tm) => - service.confirmItemDetails(donationId, [makeDto(1)], tm), + service.updateItemDetails(donationId, [makeDto(1)], tm), ), ).rejects.toThrow( new BadRequestException( @@ -358,30 +358,11 @@ describe('DonationItemsService', () => { ); }); - it('throws BadRequestException when an item in the body is already confirmed', async () => { - const donationId = await insertMatchedDonation(); - const itemId = await insertDonationItem(donationId, 10, 10); - await testDataSource.query( - `UPDATE donation_items SET details_confirmed = true WHERE item_id = $1`, - [itemId], - ); - - await expect( - testDataSource.transaction((tm) => - service.confirmItemDetails(donationId, [makeDto(itemId)], tm), - ), - ).rejects.toThrow( - new BadRequestException( - `Donation item ${itemId} has already been confirmed`, - ), - ); - }); - it('updates fields and sets detailsConfirmed to true for a single item', async () => { const donationId = await insertMatchedDonation(); const itemId = await insertDonationItem(donationId, 10, 5); - const dto: ConfirmDonationItemDetailsDto = { + const dto: UpdateDonationItemDetailsDto = { itemId, ozPerItem: 8.5, estimatedValue: 12.0, @@ -389,7 +370,7 @@ describe('DonationItemsService', () => { }; await testDataSource.transaction((tm) => - service.confirmItemDetails(donationId, [dto], tm), + service.updateItemDetails(donationId, [dto], tm), ); const item = await testDataSource @@ -407,7 +388,7 @@ describe('DonationItemsService', () => { const itemId2 = await insertDonationItem(donationId, 20, 10); await testDataSource.transaction((tm) => - service.confirmItemDetails( + service.updateItemDetails( donationId, [ { @@ -452,7 +433,7 @@ describe('DonationItemsService', () => { // Second dto references item 1 which belongs to donation 1, not ours await expect( testDataSource.transaction((tm) => - service.confirmItemDetails( + service.updateItemDetails( donationId, [makeDto(itemId), makeDto(1)], tm, @@ -470,5 +451,71 @@ describe('DonationItemsService', () => { expect(item?.detailsConfirmed).toBe(false); expect(item?.ozPerItem).toBeNull(); }); + + it('returns false and does not confirm when only some fields are provided', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + + const result = await testDataSource.transaction((tm) => + service.updateItemDetails(donationId, [{ itemId, ozPerItem: 8.5 }], tm), + ); + + expect(result).toBe(false); + const item = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId }); + expect(Number(item?.ozPerItem)).toBe(8.5); + expect(item?.estimatedValue).toBeNull(); + expect(item?.detailsConfirmed).toBe(false); + }); + + it('confirms item on a second call that supplies the remaining fields', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + + const firstResult = await testDataSource.transaction((tm) => + service.updateItemDetails(donationId, [{ itemId, ozPerItem: 8.5 }], tm), + ); + expect(firstResult).toBe(false); + + const secondResult = await testDataSource.transaction((tm) => + service.updateItemDetails( + donationId, + [{ itemId, estimatedValue: 12.0, foodRescue: true }], + tm, + ), + ); + expect(secondResult).toBe(true); + + const item = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId }); + expect(Number(item?.ozPerItem)).toBe(8.5); + expect(Number(item?.estimatedValue)).toBe(12.0); + expect(item?.foodRescue).toBe(true); + expect(item?.detailsConfirmed).toBe(true); + }); + + it('allows updating an already-confirmed item without throwing', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + await testDataSource.query( + `UPDATE donation_items + SET details_confirmed = true, oz_per_item = 5.0, estimated_value = 10.0 + WHERE item_id = $1`, + [itemId], + ); + + const result = await testDataSource.transaction((tm) => + service.updateItemDetails(donationId, [{ itemId, ozPerItem: 9.0 }], tm), + ); + + expect(result).toBe(true); + const item = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId }); + expect(Number(item?.ozPerItem)).toBe(9.0); + expect(item?.detailsConfirmed).toBe(true); + }); }); }); diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 30ac3d522..13619e95d 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -9,9 +9,8 @@ import { DonationItem } from './donationItems.entity'; import { validateId } from '../utils/validation.utils'; import { FoodType } from './types'; import { Donation } from '../donations/donations.entity'; -import { DonationStatus } from '../donations/types'; import { CreateDonationItemDto } from './dtos/create-donation-items.dto'; -import { ConfirmDonationItemDetailsDto } from './dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto'; @Injectable() export class DonationItemsService { @@ -103,14 +102,16 @@ export class DonationItemsService { return this.repo.save(donationItem); } - async confirmItemDetails( + async updateItemDetails( donationId: number, - body: ConfirmDonationItemDetailsDto[], + body: UpdateDonationItemDetailsDto[], transactionManager: EntityManager, - ): Promise { + ): Promise { const donationItemTransactionRepo = transactionManager.getRepository(DonationItem); + let confirmedDetailsForAnItem = false; + for (const dto of body) { const item = await donationItemTransactionRepo.findOneBy({ itemId: dto.itemId, @@ -126,19 +127,39 @@ export class DonationItemsService { ); } - if (item.detailsConfirmed) { - throw new BadRequestException( - `Donation item ${dto.itemId} has already been confirmed`, - ); + const updateData: Partial = {}; + if (dto.ozPerItem !== undefined) updateData.ozPerItem = dto.ozPerItem; + if (dto.estimatedValue !== undefined) + updateData.estimatedValue = dto.estimatedValue; + if (dto.foodRescue !== undefined) updateData.foodRescue = dto.foodRescue; + + // If included in DTO, keep it, otherwise use whatever is in the DB (could be null) + const resultingOzPerItem = + updateData.ozPerItem !== undefined + ? updateData.ozPerItem + : item.ozPerItem; + const resultingEstimatedValue = + updateData.estimatedValue !== undefined + ? updateData.estimatedValue + : item.estimatedValue; + const resultingFoodRescue = + updateData.foodRescue !== undefined + ? updateData.foodRescue + : item.foodRescue; + + if ( + resultingOzPerItem != null && + resultingEstimatedValue != null && + resultingFoodRescue != null + ) { + updateData.detailsConfirmed = true; + confirmedDetailsForAnItem = true; } - await donationItemTransactionRepo.update(dto.itemId, { - ozPerItem: dto.ozPerItem, - estimatedValue: dto.estimatedValue, - foodRescue: dto.foodRescue, - detailsConfirmed: true, - }); + await donationItemTransactionRepo.update(dto.itemId, updateData); } + + return confirmedDetailsForAnItem; } async createMultiple( diff --git a/apps/backend/src/donationItems/dtos/confirm-donation-item-details.dto.ts b/apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts similarity index 62% rename from apps/backend/src/donationItems/dtos/confirm-donation-item-details.dto.ts rename to apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts index 68bf556a4..182ace64d 100644 --- a/apps/backend/src/donationItems/dtos/confirm-donation-item-details.dto.ts +++ b/apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts @@ -1,23 +1,26 @@ -import { IsNumber, Min, IsBoolean, IsInt } from 'class-validator'; +import { IsNumber, Min, IsBoolean, IsInt, IsOptional } from 'class-validator'; -export class ConfirmDonationItemDetailsDto { +export class UpdateDonationItemDetailsDto { @IsInt() itemId!: number; + @IsOptional() @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' }) - ozPerItem!: number; + ozPerItem?: number; + @IsOptional() @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' }) - estimatedValue!: number; + estimatedValue?: number; + @IsOptional() @IsBoolean() - foodRescue!: boolean; + foodRescue?: boolean; } diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index d14b7f6ad..31c1fba1a 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -6,7 +6,7 @@ import { Donation } from './donations.entity'; import { CreateDonationDto } from './dtos/create-donation.dto'; import { CreateDonationItemDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationStatus, RecurrenceEnum } from './types'; -import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { DonationItem } from '../donationItems/donationItems.entity'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { FoodType } from '../donationItems/types'; @@ -135,9 +135,9 @@ describe('DonationsController', () => { }); describe('PATCH /:donationId/item-details', () => { - it('calls confirmDonationItemDetails with the correct donationId and body, returns result', async () => { + it('calls updateDonationItemDetails with the correct donationId and body, returns result', async () => { const donationId = 1; - const body: ConfirmDonationItemDetailsDto[] = [ + const body: UpdateDonationItemDetailsDto[] = [ { itemId: 1, ozPerItem: 5.0, @@ -152,18 +152,18 @@ describe('DonationsController', () => { }, ]; - mockDonationService.confirmDonationItemDetails.mockResolvedValueOnce( + mockDonationService.updateDonationItemDetails.mockResolvedValueOnce( donation1 as Donation, ); - const result = await controller.confirmDonationItemDetails( + const result = await controller.updateDonationItemDetails( donationId, body, ); expect(result).toEqual(donation1); expect( - mockDonationService.confirmDonationItemDetails, + mockDonationService.updateDonationItemDetails, ).toHaveBeenCalledWith(donationId, body); }); }); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 1376139de..8452ea33b 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -15,7 +15,7 @@ import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { RecurrenceEnum } from './types'; import { CreateDonationDto } from './dtos/create-donation.dto'; -import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { FoodType } from '../donationItems/types'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; @@ -103,12 +103,12 @@ export class DonationsController { } @Patch('/:donationId/item-details') - async confirmDonationItemDetails( + async updateDonationItemDetails( @Param('donationId', ParseIntPipe) donationId: number, - @Body(new ParseArrayPipe({ items: ConfirmDonationItemDetailsDto })) - body: ConfirmDonationItemDetailsDto[], + @Body(new ParseArrayPipe({ items: UpdateDonationItemDetailsDto })) + body: UpdateDonationItemDetailsDto[], ): Promise { - return this.donationService.confirmDonationItemDetails(donationId, body); + return this.donationService.updateDonationItemDetails(donationId, body); } @Put('/:donationId/items') diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 619fd7d34..1111bd6c3 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -8,7 +8,7 @@ import { RepeatOnDaysDto } from './dtos/create-donation.dto'; import { testDataSource } from '../config/typeormTestDataSource'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { DonationItem } from '../donationItems/donationItems.entity'; -import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { Allocation } from '../allocations/allocations.entity'; import { DataSource, In } from 'typeorm'; @@ -183,6 +183,7 @@ describe('DonationService', () => { }); afterEach(async () => { + jest.restoreAllMocks(); await testDataSource.query(`DROP SCHEMA public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); }); @@ -1254,8 +1255,8 @@ describe('DonationService', () => { }); }); - describe('confirmDonationItemDetails', () => { - const makeDto = (itemId: number): ConfirmDonationItemDetailsDto => ({ + describe('updateDonationItemDetails', () => { + const makeDto = (itemId: number): UpdateDonationItemDetailsDto => ({ itemId, ozPerItem: 5.0, estimatedValue: 10.0, @@ -1264,14 +1265,14 @@ describe('DonationService', () => { it('throws NotFoundException when donation does not exist', async () => { await expect( - service.confirmDonationItemDetails(9999, [makeDto(1)]), + service.updateDonationItemDetails(9999, [makeDto(1)]), ).rejects.toThrow(new NotFoundException('Donation 9999 not found')); }); it('throws BadRequestException when donation status is not MATCHED', async () => { // seed donation 1 has status 'available' — status check fires before item lookup await expect( - service.confirmDonationItemDetails(1, [makeDto(1)]), + service.updateDonationItemDetails(1, [makeDto(1)]), ).rejects.toThrow( new BadRequestException( `Donation status must be ${DonationStatus.MATCHED}`, @@ -1285,7 +1286,7 @@ describe('DonationService', () => { const spy = jest.spyOn(service, 'checkAndFulfillDonation'); - const result = await service.confirmDonationItemDetails(donationId, [ + const result = await service.updateDonationItemDetails(donationId, [ makeDto(itemId), ]); @@ -1299,6 +1300,22 @@ describe('DonationService', () => { expect(dbDonation.status).toBe(DonationStatus.FULFILLED); expect(spy).toHaveBeenCalled(); }); + + it('does not call checkAndFulfillDonation when no items are fully confirmed', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + + const spy = jest.spyOn(service, 'checkAndFulfillDonation'); + + const result = await service.updateDonationItemDetails(donationId, [ + { itemId, ozPerItem: 5.0 }, + ]); + + expect(result).toBeDefined(); + expect(result.donationId).toBe(donationId); + expect(result.status).toBe(DonationStatus.MATCHED); + expect(spy).not.toHaveBeenCalled(); + }); }); describe('checkAndFulfillDonation', () => { diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 68a15abb7..4e5b33e8b 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -12,7 +12,7 @@ import { DayOfWeek, DonationStatus, RecurrenceEnum } from './types'; import { OrderStatus } from '../orders/types'; import { CreateDonationDto, RepeatOnDaysDto } from './dtos/create-donation.dto'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationItem } from '../donationItems/donationItems.entity'; @@ -373,9 +373,9 @@ export class DonationService { return dates; } - async confirmDonationItemDetails( + async updateDonationItemDetails( donationId: number, - body: ConfirmDonationItemDetailsDto[], + body: UpdateDonationItemDetailsDto[], ): Promise { validateId(donationId, 'Donation'); @@ -395,11 +395,14 @@ export class DonationService { ); } - await this.donationItemsService.confirmItemDetails( - donationId, - body, - transactionManager, - ); + const confirmedDetailsForAnItem = + await this.donationItemsService.updateItemDetails( + donationId, + body, + transactionManager, + ); + + if (!confirmedDetailsForAnItem) return donation; const updated = await donationTransactionRepo.findOne({ where: { donationId }, diff --git a/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts b/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts index c1f220e87..e38d29346 100644 --- a/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts +++ b/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts @@ -6,12 +6,25 @@ export class DonationItemWithAllocatedQuantityDto { itemName!: string; foodType!: FoodType; allocatedQuantity!: number; + ozPerItem?: number; + estimatedValue?: number; + foodRescue!: boolean; +} + +export class PendingOrderItemDto { + id!: number; + name!: string; + quantity!: number; + foodType!: FoodType; } export class DonationOrderDetailsDto { orderId!: number; pantryId!: number; pantryName!: string; + trackingLink!: string | null; + shippingCost!: number | null; + items!: PendingOrderItemDto[]; } export class DonationDetailsDto { diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index bc86024a5..3932cdda6 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -108,6 +108,9 @@ describe('FoodManufacturersController', () => { orderId: 1, pantryId: 2, pantryName: 'Community Food Pantry', + trackingLink: null, + shippingCost: null, + items: [], }, ], relevantDonationItems: [ @@ -116,6 +119,7 @@ describe('FoodManufacturersController', () => { itemName: 'Almond Breeze Almond Milk', foodType: FoodType.DAIRY_FREE_ALTERNATIVES, allocatedQuantity: 10, + foodRescue: false, }, ], }, diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 4cd5c221a..08e8fef97 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -464,22 +464,6 @@ describe('FoodManufacturersService', () => { expect(item.foodType).toBe(FoodType.GLUTEN_FREE_BREAD); }); - it('excludes donation items where detailsConfirmed is true', async () => { - await testDataSource.query( - `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, - [fulfilledDonationId], - ); - - await testDataSource.query( - `UPDATE public.donation_items SET details_confirmed = true - WHERE item_name = 'Cereal Boxes'`, - ); - - const result = await service.getFMDonations(fmId1, fmRepId1); - - expect(result[0].relevantDonationItems).toEqual([]); - }); - it('excludes donation items not used in any pending order', async () => { await testDataSource.query( `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index cd04726a0..de49daf91 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -121,17 +121,18 @@ export class FoodManufacturersService { if (pendingAllocations.length === 0) return; - if (!item.detailsConfirmed) { - relevantDonationItems.push({ - itemId: item.itemId, - itemName: item.itemName, - foodType: item.foodType, - allocatedQuantity: pendingAllocations.reduce( - (sum, a) => sum + a.allocatedQuantity, - 0, - ), - }); - } + relevantDonationItems.push({ + itemId: item.itemId, + itemName: item.itemName, + foodType: item.foodType, + allocatedQuantity: pendingAllocations.reduce( + (sum, a) => sum + a.allocatedQuantity, + 0, + ), + ozPerItem: item.ozPerItem ?? undefined, + estimatedValue: item.estimatedValue ?? undefined, + foodRescue: item.foodRescue, + }); pendingAllocations.forEach((a) => { const order = a.order; @@ -140,8 +141,18 @@ export class FoodManufacturersService { orderId: order.orderId, pantryId: order.request.pantry.pantryId, pantryName: order.request.pantry.pantryName, + trackingLink: order.trackingLink, + shippingCost: order.shippingCost, + items: [], }); } + // Populate the items afterwards + orderMap.get(order.orderId)!.items.push({ + id: item.itemId, + name: item.itemName, + quantity: a.allocatedQuantity, + foodType: item.foodType, + }); }); }); } diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 135609d50..27dc4281b 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -124,6 +124,7 @@ describe('RequestsController', () => { status: OrderStatus.DELIVERED, foodManufacturerName: 'Test Manufacturer', trackingLink: 'examplelink.com', + shippingCost: 8.0, items: [ { id: 1, @@ -144,6 +145,7 @@ describe('RequestsController', () => { status: OrderStatus.PENDING, foodManufacturerName: 'Another Manufacturer', trackingLink: 'examplelink.com', + shippingCost: 8.0, items: [ { id: 1, diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 615a18f55..11ba4d880 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -95,6 +95,7 @@ export class RequestsService { status: order.status, foodManufacturerName: order.foodManufacturer.foodManufacturerName, trackingLink: order.trackingLink, + shippingCost: order.shippingCost, items: order.allocations.map((allocation) => ({ id: allocation.item.itemId, name: allocation.item.itemName, diff --git a/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts b/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts new file mode 100644 index 000000000..8f9cea09f --- /dev/null +++ b/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts @@ -0,0 +1,45 @@ +import { + IsArray, + IsInt, + IsNumber, + IsOptional, + IsUrl, + Min, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class OrderTrackingCostEntryDto { + @IsInt() + @Min(1) + orderId!: number; + + @IsOptional() + @IsUrl( + { + protocols: ['http', 'https'], + require_protocol: true, + }, + { message: 'Tracking link must be a valid HTTP/HTTPS URL' }, + ) + trackingLink?: string; + + @IsOptional() + @IsNumber( + { maxDecimalPlaces: 2 }, + { message: 'Shipping cost must have at most 2 decimal places' }, + ) + @Min(0, { message: 'Shipping cost cannot be negative' }) + shippingCost?: number; +} + +export class BulkUpdateTrackingCostDto { + @IsInt() + @Min(1) + donationId!: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OrderTrackingCostEntryDto) + orders!: OrderTrackingCostEntryDto[]; +} diff --git a/apps/backend/src/orders/dtos/order-details.dto.ts b/apps/backend/src/orders/dtos/order-details.dto.ts index f18ef3f83..fc5333677 100644 --- a/apps/backend/src/orders/dtos/order-details.dto.ts +++ b/apps/backend/src/orders/dtos/order-details.dto.ts @@ -13,5 +13,6 @@ export class OrderDetailsDto { status!: OrderStatus; foodManufacturerName!: string; trackingLink!: string | null; + shippingCost!: number | null; items!: OrderItemDetailsDto[]; } diff --git a/apps/backend/src/orders/dtos/tracking-cost.dto.ts b/apps/backend/src/orders/dtos/tracking-cost.dto.ts deleted file mode 100644 index 94548e838..000000000 --- a/apps/backend/src/orders/dtos/tracking-cost.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsUrl, IsNumber, Min } from 'class-validator'; - -export class TrackingCostDto { - @IsUrl( - { - protocols: ['http', 'https'], - }, - { message: 'Tracking link must be a valid HTTP/HTTPS URL' }, - ) - trackingLink!: string; - - @IsNumber( - { maxDecimalPlaces: 2 }, - { message: 'Shipping cost must have at most 2 decimal places' }, - ) - @Min(0, { message: 'Shipping cost cannot be negative' }) - shippingCost!: number; -} diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index d3ebf17d9..18179ff46 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -9,7 +9,7 @@ import { OrderStatus, VolunteerAction } from './types'; import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; import { AWSS3Service } from '../aws/aws-s3.service'; -import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodType } from '../donationItems/types'; import { BadRequestException } from '@nestjs/common'; @@ -359,17 +359,22 @@ describe('OrdersController', () => { }); }); - describe('updateTrackingCostInfo', () => { - it('should call ordersService.updateTrackingCostInfo with correct parameters', async () => { - const orderId = 1; - const trackingLink = 'www.samplelink/samplelink'; - const shippingCost = 15.99; - const dto: TrackingCostDto = { trackingLink, shippingCost }; + describe('bulkUpdateTrackingCostInfo', () => { + it('should call ordersService.bulkUpdateTrackingCostInfo with correct parameters', async () => { + const dto: BulkUpdateTrackingCostDto = { + donationId: 1, + orders: [ + { + orderId: 4, + trackingLink: 'https://tracking.example.com', + shippingCost: 15.99, + }, + ], + }; - await controller.updateTrackingCostInfo(orderId, dto); + await controller.bulkUpdateTrackingCostInfo(dto); - expect(mockOrdersService.updateTrackingCostInfo).toHaveBeenCalledWith( - orderId, + expect(mockOrdersService.bulkUpdateTrackingCostInfo).toHaveBeenCalledWith( dto, ); }); diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index a43649640..fafde4b01 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -23,7 +23,7 @@ import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; import { PantriesService } from '../pantries/pantries.service'; -import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { AWSS3Service } from '../aws/aws-s3.service'; @@ -203,13 +203,11 @@ export class OrdersController { return this.ordersService.updateStatus(orderId, newStatus as OrderStatus); } - @Patch('/:orderId/update-tracking-cost-info') - async updateTrackingCostInfo( - @Param('orderId', ParseIntPipe) orderId: number, - @Body(new ValidationPipe()) - dto: TrackingCostDto, + @Patch('/bulk-update-tracking-cost-info') + async bulkUpdateTrackingCostInfo( + @Body(new ValidationPipe()) dto: BulkUpdateTrackingCostDto, ): Promise { - return this.ordersService.updateTrackingCostInfo(orderId, dto); + return this.ordersService.bulkUpdateTrackingCostInfo(dto); } @Patch('/:orderId/confirm-delivery') diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index d32a328ce..83527fda5 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -7,7 +7,7 @@ import { OrderStatus, VolunteerAction } from './types'; import { Pantry } from '../pantries/pantries.entity'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { FoodType } from '../donationItems/types'; import { FoodRequest } from '../foodRequests/request.entity'; import 'multer'; @@ -34,6 +34,7 @@ jest.setTimeout(60000); describe('OrdersService', () => { let service: OrdersService; + let donationService: DonationService; beforeAll(async () => { // Initialize DataSource once @@ -110,6 +111,7 @@ describe('OrdersService', () => { }).compile(); service = module.get(OrdersService); + donationService = module.get(DonationService); }); beforeEach(async () => { @@ -206,6 +208,7 @@ describe('OrdersService', () => { orderId: 1, status: OrderStatus.DELIVERED, foodManufacturerName: 'FoodCorp Industries', + shippingCost: 8.0, trackingLink: 'https://www.samplelink.com/samplelink', items: [ { @@ -414,154 +417,6 @@ describe('OrdersService', () => { }); }); - describe('updateTrackingCostInfo', () => { - it('throws when order is non-existent', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'www.test.com', - shippingCost: 5.99, - }; - - await expect( - service.updateTrackingCostInfo(9999, trackingCostDto), - ).rejects.toThrow(new NotFoundException('Order 9999 not found')); - }); - - it('updates both shipping cost and tracking link (sanitized)', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'testtracking.com', - shippingCost: 7.5, - }; - - await service.updateTrackingCostInfo(4, trackingCostDto); - - const order = await service.findOne(4); - expect(order.trackingLink).toEqual('https://testtracking.com/'); - expect(order.shippingCost).toEqual(7.5); - }); - - it('throws BadRequestException for delivered order', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'testtracking.com', - shippingCost: 7.5, - }; - const orderId = 2; - - const order = await service.findOne(orderId); - - expect(order.status).toEqual(OrderStatus.DELIVERED); - - await expect( - service.updateTrackingCostInfo(orderId, trackingCostDto), - ).rejects.toThrow( - new BadRequestException( - 'Can only update tracking info for pending orders', - ), - ); - }); - - it('throws when tracking link is invalid', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: `javascript:alert("you've been hacked!")`, - shippingCost: 7.5, - }; - - await expect( - service.updateTrackingCostInfo(3, trackingCostDto), - ).rejects.toThrow( - new BadRequestException( - 'Invalid tracking link. Only valid HTTP/HTTPS URLs are accepted.', - ), - ); - }); - - it('sets status to shipped when both fields provided and previous status pending', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'testtracking.com', - shippingCost: 5.75, - }; - const orderId = 4; - - const order = await service.findOne(orderId); - - expect(order.status).toEqual(OrderStatus.PENDING); - expect(order.shippedAt).toBeNull(); - - await service.updateTrackingCostInfo(orderId, trackingCostDto); - - const updatedOrder = await service.findOne(orderId); - - expect(updatedOrder.status).toEqual(OrderStatus.SHIPPED); - expect(updatedOrder.shippedAt).toBeDefined(); - }); - }); - - describe('checkAndFulfillDonations', () => { - it('does not fulfill associated donation when items are not fully reserved or confirmed', async () => { - // Create a matched donation with an item that is not fully reserved - const [{ donation_id }] = await testDataSource.query(` - INSERT INTO donations (food_manufacturer_id, status, recurrence, recurrence_freq, next_donation_dates, occurrences_remaining) - VALUES ( - (SELECT food_manufacturer_id FROM food_manufacturers LIMIT 1), - 'matched', 'none', NULL, NULL, NULL - ) - RETURNING donation_id - `); - const [{ item_id }] = await testDataSource.query( - `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, details_confirmed) - VALUES ($1, 'Test Item', 10, 5, 'Granola', false) - RETURNING item_id`, - [donation_id], - ); - await testDataSource.query( - `INSERT INTO allocations (order_id, item_id, allocated_quantity) VALUES (4, $1, 1)`, - [item_id], - ); - - await service.updateTrackingCostInfo(4, { - trackingLink: 'testtracking.com', - shippingCost: 5.0, - }); - - const donation = await testDataSource - .getRepository(Donation) - .findOneBy({ donationId: donation_id }); - expect(donation?.status).toBe(DonationStatus.MATCHED); - }); - - it('fulfills associated donation when all items are confirmed, fully reserved, and no pending orders remain', async () => { - // Create a matched donation with a fully-reserved confirmed item allocated to order 4 - const [{ donation_id }] = await testDataSource.query(` - INSERT INTO donations (food_manufacturer_id, status, recurrence, recurrence_freq, next_donation_dates, occurrences_remaining) - VALUES ( - (SELECT food_manufacturer_id FROM food_manufacturers LIMIT 1), - 'matched', 'none', NULL, NULL, NULL - ) - RETURNING donation_id - `); - const [{ item_id }] = await testDataSource.query( - `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, details_confirmed) - VALUES ($1, 'Test Item', 10, 10, 'Granola', true) - RETURNING item_id`, - [donation_id], - ); - // Allocate to order 4 (pending); after updateTrackingCostInfo it becomes shipped → no more pending orders - await testDataSource.query( - `INSERT INTO allocations (order_id, item_id, allocated_quantity) VALUES (4, $1, 1)`, - [item_id], - ); - - await service.updateTrackingCostInfo(4, { - trackingLink: 'testtracking.com', - shippingCost: 5.0, - }); - - const donation = await testDataSource - .getRepository(Donation) - .findOneBy({ donationId: donation_id }); - expect(donation?.status).toBe(DonationStatus.FULFILLED); - }); - }); - describe('confirmDelivery', () => { it('should throw BadRequestException for invalid date format', async () => { await expect( @@ -1106,4 +961,250 @@ describe('OrdersService', () => { ); }); }); + + describe('bulkUpdateTrackingCostInfo', () => { + async function insertMatchedDonation(): Promise { + const [{ donation_id }] = await testDataSource.query(` + INSERT INTO donations (food_manufacturer_id, status, recurrence, recurrence_freq, next_donation_dates, occurrences_remaining) + VALUES ( + (SELECT food_manufacturer_id FROM food_manufacturers LIMIT 1), + 'matched', 'none', NULL, NULL, NULL + ) + RETURNING donation_id + `); + return donation_id; + } + + async function insertDonationItem(donationId: number): Promise { + const [{ item_id }] = await testDataSource.query( + `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, food_rescue, details_confirmed) + VALUES ($1, 'Test Item', 10, 10, 'Granola', false, false) + RETURNING item_id`, + [donationId], + ); + return item_id; + } + + async function insertAllocation( + orderId: number, + itemId: number, + ): Promise { + await testDataSource.query( + `INSERT INTO allocations (order_id, item_id, allocated_quantity) VALUES ($1, $2, 1)`, + [orderId, itemId], + ); + } + + async function createPendingOrder(): Promise { + const [{ order_id }] = await testDataSource.query(` + INSERT INTO orders (request_id, food_manufacturer_id, status, assignee_id) + VALUES ( + (SELECT request_id FROM food_requests LIMIT 1), + (SELECT food_manufacturer_id FROM food_manufacturers LIMIT 1), + 'pending', + (SELECT user_id FROM users LIMIT 1) + ) + RETURNING order_id + `); + return order_id; + } + + it('throws BadRequestException when neither tracking link nor shipping cost is provided', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await expect( + service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4 }], + }), + ).rejects.toThrow( + new BadRequestException( + 'Order 4 must include at least a tracking link or shipping cost.', + ), + ); + }); + + it('throws NotFoundException when donation does not exist', async () => { + const dto: BulkUpdateTrackingCostDto = { + donationId: 9999, + orders: [{ orderId: 4, shippingCost: 5.0 }], + }; + + await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( + new NotFoundException('Donation 9999 not found'), + ); + }); + + it('throws NotFoundException when one order does not exist', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + const dto: BulkUpdateTrackingCostDto = { + donationId, + orders: [ + { orderId: 4, shippingCost: 5.0 }, + { orderId: 9999, trackingLink: 'https://tracking2.com' }, + ], + }; + + await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( + new NotFoundException('Order 9999 not found'), + ); + }); + + it('throws BadRequestException when one order is not pending', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId); + const itemId2 = await insertDonationItem(donationId); + await insertAllocation(4, itemId1); + await insertAllocation(2, itemId2); + + const dto: BulkUpdateTrackingCostDto = { + donationId, + orders: [ + { orderId: 4, shippingCost: 5.0 }, + { orderId: 2, trackingLink: 'https://tracking2.com' }, + ], + }; + + await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( + new BadRequestException( + `Can only update tracking info for pending orders. Order 2 is ${OrderStatus.DELIVERED}`, + ), + ); + }); + + it('throws BadRequestException when one order does not belong to the donation', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + const orderId2 = await createPendingOrder(); + // orderId2 has no allocation to donationId + + const dto: BulkUpdateTrackingCostDto = { + donationId, + orders: [ + { orderId: 4, shippingCost: 5.0 }, + { orderId: orderId2, trackingLink: 'https://tracking2.com' }, + ], + }; + + await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( + new BadRequestException( + `Order ${orderId2} does not belong to donation ${donationId}`, + ), + ); + }); + + it('updates both fields when tracking link and shipping cost are provided', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId); + const itemId2 = await insertDonationItem(donationId); + const orderId2 = await createPendingOrder(); + await insertAllocation(4, itemId1); + await insertAllocation(orderId2, itemId2); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [ + { + orderId: 4, + trackingLink: 'https://tracking1.com', + shippingCost: 5.0, + }, + { + orderId: orderId2, + trackingLink: 'https://tracking2.com', + shippingCost: 7.5, + }, + ], + }); + + const after1 = await service.findOne(4); + const after2 = await service.findOne(orderId2); + expect(after1.trackingLink).toEqual('https://tracking1.com'); + expect(after1.shippingCost).toEqual(5.0); + expect(after1.status).toEqual(OrderStatus.SHIPPED); + expect(after1.shippedAt).toBeDefined(); + expect(after2.trackingLink).toEqual('https://tracking2.com'); + expect(after2.shippingCost).toEqual(7.5); + expect(after2.status).toEqual(OrderStatus.SHIPPED); + expect(after2.shippedAt).toBeDefined(); + }); + + it('updates only tracking link when no shipping cost is provided, order stays PENDING', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, trackingLink: 'https://tracking.com' }], + }); + + const after = await service.findOne(4); + expect(after.trackingLink).toEqual('https://tracking.com'); + expect(after.shippingCost).toBeNull(); + expect(after.status).toEqual(OrderStatus.PENDING); + expect(after.shippedAt).toBeNull(); + }); + + it('updates only shipping cost when no tracking link is provided, order stays PENDING', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, shippingCost: 12.5 }], + }); + + const after = await service.findOne(4); + expect(after.trackingLink).toBeNull(); + expect(after.shippingCost).toEqual(12.5); + expect(after.status).toEqual(OrderStatus.PENDING); + expect(after.shippedAt).toBeNull(); + }); + + it('sets order to SHIPPED when a second partial call completes both fields', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, trackingLink: 'https://tracking.com' }], + }); + expect((await service.findOne(4)).status).toEqual(OrderStatus.PENDING); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, shippingCost: 10.0 }], + }); + + const after = await service.findOne(4); + expect(after.trackingLink).toEqual('https://tracking.com'); + expect(after.shippingCost).toEqual(10.0); + expect(after.status).toEqual(OrderStatus.SHIPPED); + expect(after.shippedAt).toBeDefined(); + }); + + it('calls donationService.checkAndFulfillDonation after updating orders', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + const spy = jest.spyOn(donationService, 'checkAndFulfillDonation'); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, shippingCost: 5.0 }], + }); + + expect(spy).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index ab73160a1..88ba3f787 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -8,10 +8,10 @@ import { Repository, In, DataSource } from 'typeorm'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { sanitizeUrl, validateId } from '../utils/validation.utils'; +import { validateId } from '../utils/validation.utils'; import { DonationService } from '../donations/donations.service'; import { OrderStatus, VolunteerAction } from './types'; -import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; @@ -276,6 +276,7 @@ export class OrdersService { status: order.status, foodManufacturerName: order.foodManufacturer.foodManufacturerName, trackingLink: order.trackingLink, + shippingCost: order.shippingCost, items: order.allocations.map((allocation) => ({ id: allocation.item.itemId, name: allocation.item.itemName, @@ -441,52 +442,87 @@ export class OrdersService { return qb.getMany(); } - async updateTrackingCostInfo(orderId: number, dto: TrackingCostDto) { - validateId(orderId, 'Order'); + async bulkUpdateTrackingCostInfo( + dto: BulkUpdateTrackingCostDto, + ): Promise { + for (const order of dto.orders) { + validateId(order.orderId, 'Order'); - const sanitized = sanitizeUrl(dto.trackingLink); - if (!sanitized) { - throw new BadRequestException( - 'Invalid tracking link. Only valid HTTP/HTTPS URLs are accepted.', - ); + if ( + order.trackingLink === undefined && + order.shippingCost === undefined + ) { + throw new BadRequestException( + `Order ${order.orderId} must include at least a tracking link or shipping cost.`, + ); + } } - dto.trackingLink = sanitized; - const order = await this.repo.findOneBy({ orderId }); - if (!order) { - throw new NotFoundException(`Order ${orderId} not found`); - } + await this.dataSource.transaction(async (transactionManager) => { + const orderTransactionRepo = transactionManager.getRepository(Order); + const donationTransactionRepo = + transactionManager.getRepository(Donation); - if (order.status !== OrderStatus.PENDING) { - throw new BadRequestException( - 'Can only update tracking info for pending orders', - ); - } + const donation = await donationTransactionRepo.findOneBy({ + donationId: dto.donationId, + }); + if (!donation) { + throw new NotFoundException(`Donation ${dto.donationId} not found`); + } - order.trackingLink = dto.trackingLink; - order.shippingCost = dto.shippingCost; + const ordersToUpdate: Order[] = []; - order.status = OrderStatus.SHIPPED; - order.shippedAt = new Date(); + for (const entry of dto.orders) { + const order = await orderTransactionRepo.findOneBy({ + orderId: entry.orderId, + }); + if (!order) { + throw new NotFoundException(`Order ${entry.orderId} not found`); + } - await this.repo.save(order); + if (order.status !== OrderStatus.PENDING) { + throw new BadRequestException( + `Can only update tracking info for pending orders. Order ${entry.orderId} is ${order.status}`, + ); + } - await this.checkAndFulfillDonations(orderId); - } + // Can only update orders belonging to the provided donation + const relatedCount = await transactionManager + .createQueryBuilder(DonationItem, 'item') + .innerJoin('item.allocations', 'allocation') + .where('allocation.orderId = :orderId', { orderId: entry.orderId }) + .andWhere('item.donationId = :donationId', { + donationId: dto.donationId, + }) + .getCount(); + + if (relatedCount === 0) { + throw new BadRequestException( + `Order ${entry.orderId} does not belong to donation ${dto.donationId}`, + ); + } - async checkAndFulfillDonations(orderId: number): Promise { - const affectedDonations = await this.donationItemRepo - .createQueryBuilder('item') - .innerJoin('item.allocations', 'allocation') - .where('allocation.orderId = :orderId', { orderId }) - .select('DISTINCT item.donationId', 'donationId') - .getRawMany<{ donationId: number }>(); - - for (const { donationId } of affectedDonations) { - const donation = await this.donationRepo.findOneBy({ donationId }); - if (donation) { - await this.donationService.checkAndFulfillDonation(donation); + if (entry.trackingLink !== undefined) { + order.trackingLink = entry.trackingLink; + } + if (entry.shippingCost !== undefined) { + order.shippingCost = entry.shippingCost; + } + if (order.trackingLink != null && order.shippingCost != null) { + order.status = OrderStatus.SHIPPED; + order.shippedAt = new Date(); + } + ordersToUpdate.push(order); } + + await orderTransactionRepo.save(ordersToUpdate); + }); + + const donation = await this.donationRepo.findOneBy({ + donationId: dto.donationId, + }); + if (donation) { + await this.donationService.checkAndFulfillDonation(donation); } } diff --git a/apps/backend/src/utils/validation.utils.spec.ts b/apps/backend/src/utils/validation.utils.spec.ts index 3815ec8ae..422b2a7fc 100644 --- a/apps/backend/src/utils/validation.utils.spec.ts +++ b/apps/backend/src/utils/validation.utils.spec.ts @@ -2,12 +2,7 @@ import { BadRequestException, InternalServerErrorException, } from '@nestjs/common'; -import { - hasDuplicates, - sanitizeUrl, - validateEnv, - validateId, -} from './validation.utils'; +import { hasDuplicates, validateEnv, validateId } from './validation.utils'; describe('validateId', () => { it('should not throw an error for a valid ID', () => { @@ -45,41 +40,6 @@ describe('validateEnv', () => { }); }); -describe('sanitizeUrl', () => { - it('should return null for malicious protocols', () => { - const maliciousProtocols = ['javascript:', 'data:', 'file:', 'vbscript:']; - - for (const protocol of maliciousProtocols) { - expect(sanitizeUrl(protocol + 'test')).toBeNull(); - } - }); - - it('should return null for empty or invalid URLs', () => { - expect(sanitizeUrl('')).toBeNull(); - expect(sanitizeUrl('https://')).toBeNull(); - expect(sanitizeUrl('https://foo')).toBeNull(); - }); - - it('should accept valid http/https URLs', () => { - const validHttpUrl = 'http://www.tracking.com/test'; - const validHttpsUrl = 'https://www.tracking.com/test'; - expect(sanitizeUrl(validHttpUrl)).toBe(validHttpUrl); - expect(sanitizeUrl(validHttpsUrl)).toBe(validHttpsUrl); - }); - - it('adds https:// to URL without protocol', () => { - expect(sanitizeUrl('www.tracking.com/test')).toBe( - 'https://www.tracking.com/test', - ); - }); - - it('trims whitespace from URL', () => { - expect(sanitizeUrl(' https://www.tracking.com/test ')).toBe( - 'https://www.tracking.com/test', - ); - }); -}); - describe('hasDuplicates', () => { it('returns true for int array with duplicates', () => { expect(hasDuplicates([1, 1, 2])).toBeTruthy(); diff --git a/apps/backend/src/utils/validation.utils.ts b/apps/backend/src/utils/validation.utils.ts index 9852173b4..d55ecd97f 100644 --- a/apps/backend/src/utils/validation.utils.ts +++ b/apps/backend/src/utils/validation.utils.ts @@ -19,27 +19,6 @@ export function validateEnv(name: string): string { return v; } -export function sanitizeUrl(url: string): string | null { - try { - const trimmed = url.trim(); - if (!trimmed) return null; - - let fullUrl = trimmed; - if (!/^https?:\/\//i.test(trimmed)) { - fullUrl = 'https://' + trimmed; - } - - const urlObj = new URL(fullUrl); - - if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') - return null; - if (!urlObj.hostname || !urlObj.hostname.includes('.')) return null; - return urlObj.href; - } catch { - return null; - } -} - export function hasDuplicates(arr: T[]): boolean { return new Set(arr).size !== arr.length; } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index ffed2ae0d..d69207bfb 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -40,6 +40,8 @@ import { VolunteerOrder, VolunteerAction, FoodRequestWithoutRelations, + BulkUpdateTrackingCostDto, + UpdateDonationItemDetailsDto, } from 'types/types'; const defaultBaseUrl = @@ -453,6 +455,25 @@ export class ApiClient { .then((response) => response.data); } + public async bulkUpdateTrackingCostInfo( + data: BulkUpdateTrackingCostDto, + ): Promise { + await this.axiosInstance.patch( + '/api/orders/bulk-update-tracking-cost-info', + data, + ); + } + + public async updateDonationItemDetails( + donationId: number, + items: UpdateDonationItemDetailsDto[], + ): Promise { + await this.axiosInstance.patch( + `/api/donations/${donationId}/item-details`, + items, + ); + } + public async updateFoodManufacturerApplicationData( manufacturerId: number, data: UpdateFoodManufacturerApplicationDto, diff --git a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx new file mode 100644 index 000000000..70fefac7e --- /dev/null +++ b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx @@ -0,0 +1,626 @@ +import React, { useState, useMemo } from 'react'; +import { + Box, + Button, + Text, + Flex, + Input, + Dialog, + CloseButton, + Pagination, + ButtonGroup, + IconButton, + Table, + Checkbox, +} from '@chakra-ui/react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import axios from 'axios'; +import ApiClient from '@api/apiClient'; +import { + DonationDetails, + OrderItemDetails, + UpdateDonationItemDetailsDto, +} from '../../types/types'; +import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useAlert } from '../../hooks/alert'; + +type Stage = 'shipping' | 'itemDetails'; + +interface FmCompleteRequiredActionsModalProps { + donation: DonationDetails; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +interface OrderFormData { + shippingCost: string; + trackingLink: string; +} + +interface ItemFormData { + ozPerItem: string; + estimatedValue: string; + foodRescue: boolean; +} + +// Order items section +const OrderItemsSection: React.FC<{ + items: OrderItemDetails[] | undefined; +}> = ({ items }) => { + const groupedItems = useGroupedItemsByFoodType(items); + + if (!items) { + return ( + + Loading order details... + + ); + } + + return ( + + {Object.entries(groupedItems).map(([foodType, items]) => ( + + + {foodType} + + {items.map((item) => ( + + + {item.name} + + + + {item.quantity} + + + ))} + + ))} + + ); +}; + +const FmCompleteRequiredActionsModal: React.FC< + FmCompleteRequiredActionsModalProps +> = ({ donation, isOpen, onClose, onSuccess }) => { + const orders = donation.associatedPendingOrders; + + // Which stage of the two-step modal the user is currently on + const [stage, setStage] = useState('shipping'); + const [currentPage, setCurrentPage] = useState(1); + + // Shipping cost and tracking link inputs keyed by orderId, pre-filled from prop and persisted across pagination + const [orderFormData, setOrderFormData] = useState< + Record + >(() => + Object.fromEntries( + orders.map((o) => [ + o.orderId, + { + shippingCost: o.shippingCost?.toString() ?? '', + trackingLink: o.trackingLink ?? '', + }, + ]), + ), + ); + + // ozPerItem, estimatedValue, and foodRescue inputs keyed by itemId, pre-filled from prop + const [itemFormData, setItemFormData] = useState< + Record + >(() => + Object.fromEntries( + donation.relevantDonationItems.map((item) => [ + item.itemId, + { + ozPerItem: item.ozPerItem?.toString() ?? '', + estimatedValue: item.estimatedValue?.toString() ?? '', + foodRescue: item.foodRescue, + }, + ]), + ), + ); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [alertState, setAlertMessage] = useAlert(); + + // True once every relevant item has both ozPerItem and estimatedValue filled in + const isSubmitEnabled = useMemo( + () => + donation.relevantDonationItems.length > 0 && + donation.relevantDonationItems.every( + (item) => + (itemFormData[item.itemId]?.ozPerItem ?? '') !== '' && + (itemFormData[item.itemId]?.estimatedValue ?? '') !== '', + ), + [itemFormData], + ); + + // The order currently shown in the shipping stage based on the current page + const currentOrder = orders[currentPage - 1]; + + const updateOrderField = ( + orderId: number, + field: keyof OrderFormData, + value: string, + ) => { + setOrderFormData((prev) => ({ + ...prev, + [orderId]: { ...prev[orderId], [field]: value }, + })); + }; + + const updateItemField = ( + itemId: number, + field: keyof ItemFormData, + value: string | boolean, + ) => { + setItemFormData((prev) => ({ + ...prev, + [itemId]: { ...prev[itemId], [field]: value }, + })); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + // Only include items where the user actually changed a value from the original prop values + const confirmItems: UpdateDonationItemDetailsDto[] = + donation.relevantDonationItems + .filter((item) => { + const formData = itemFormData[item.itemId]; + return ( + formData.ozPerItem !== (item.ozPerItem?.toString() ?? '') || + formData.estimatedValue !== + (item.estimatedValue?.toString() ?? '') || + formData.foodRescue !== item.foodRescue + ); + }) + .map((item) => { + const formData = itemFormData[item.itemId]; + const dto: UpdateDonationItemDetailsDto = { + itemId: item.itemId, + foodRescue: formData.foodRescue, + }; + if (formData.ozPerItem !== '') + dto.ozPerItem = parseFloat(formData.ozPerItem); + if (formData.estimatedValue !== '') + dto.estimatedValue = parseFloat(formData.estimatedValue); + return dto; + }); + + // Donation items must be updated before tracking/shipping so detailsConfirmed is set first + if (confirmItems.length > 0) { + await ApiClient.updateDonationItemDetails( + donation.donation.donationId, + confirmItems, + ); + } + + // Only include orders where the user actually changed a value from the original prop values + const ordersToUpdate = orders + .filter((order) => { + const { trackingLink, shippingCost } = orderFormData[order.orderId]; + const originalTracking = order.trackingLink ?? ''; + const originalCost = order.shippingCost?.toString() ?? ''; + return ( + trackingLink !== originalTracking || shippingCost !== originalCost + ); + }) + .map( + ( + order, + ): { + orderId: number; + trackingLink?: string; + shippingCost?: number; + } => { + const { trackingLink, shippingCost } = orderFormData[order.orderId]; + return { + orderId: order.orderId, + ...(trackingLink.trim() !== '' && { trackingLink }), + ...(shippingCost !== '' && { + shippingCost: parseFloat(shippingCost), + }), + }; + }, + ); + + if (ordersToUpdate.length > 0) { + await ApiClient.bulkUpdateTrackingCostInfo({ + donationId: donation.donation.donationId, + orders: ordersToUpdate, + }); + } + + onSuccess(); + } catch (error) { + const rawMsg = axios.isAxiosError(error) && error.response?.data?.message; + const msg = Array.isArray(rawMsg) ? rawMsg[0] : rawMsg; + setAlertMessage( + msg + ? msg.replace(/^orders\.\d+\./, '') + : 'Error completing required actions. Please try again.', + ); + } finally { + setIsSubmitting(false); + } + }; + + if (!currentOrder) return null; + + // Shared style props applied to every column header in the item details table + const tableHeaderStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'neutral.800', + fontFamily: 'ibm', + fontWeight: '600', + fontSize: 'sm', + py: 2, + }; + + return ( + { + if (!e.open) onClose(); + }} + closeOnInteractOutside + > + {alertState && ( + + )} + + + + + + + + + + Complete Required Actions + + + + + {stage === 'shipping' && ( + <> + + Your donation has been partially matched to a pantry's food + request. Please check your inbox for details on where to ship + the following quantities of products, then provide the + shipping cost and tracking links for the following delivery. + + + + + Shipping Cost + + + updateOrderField( + currentOrder.orderId, + 'shippingCost', + e.target.value, + ) + } + /> + + + + + Delivery Tracking Link + + + updateOrderField( + currentOrder.orderId, + 'trackingLink', + e.target.value, + ) + } + /> + + + + + Order {currentOrder.orderId} -{' '} + + Requested by {currentOrder.pantryName} + + + + + + {orders.length > 1 && ( + + + setCurrentPage(e.page) + } + > + + + + + + ( + + {page.value} + + )} + /> + + + + + + + + )} + + + + + + + )} + + {stage === 'itemDetails' && ( + <> + + Please fill out the missing fields information to record + donation details. + + + + + + + + Food Item + + + Oz. per item + + + Donation Value + + + Food Rescue + + + + + {donation.relevantDonationItems.map((item) => ( + + + {item.itemName} + + + + updateItemField( + item.itemId, + 'ozPerItem', + e.target.value, + ) + } + /> + + + + updateItemField( + item.itemId, + 'estimatedValue', + e.target.value, + ) + } + /> + + + + updateItemField( + item.itemId, + 'foodRescue', + !!e.checked, + ) + } + > + + + + + + + + ))} + + + + + + + + + + )} + + + + + ); +}; + +export default FmCompleteRequiredActionsModal; diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index 8386b850f..8fb4d4a43 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -31,6 +31,7 @@ import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../../hooks/alert'; interface NewDonationFormModalProps { + foodManufacturerId: number; onDonationSuccess: () => void; isOpen: boolean; onClose: () => void; @@ -102,6 +103,7 @@ const getFirstValidationError = ( }; const NewDonationFormModal: React.FC = ({ + foodManufacturerId, onDonationSuccess, isOpen, onClose, @@ -205,7 +207,7 @@ const NewDonationFormModal: React.FC = ({ } const donationBody: CreateDonationDto = { - foodManufacturerId: 1, + foodManufacturerId, recurrenceFreq: isRecurring ? parseInt(repeatEvery) : undefined, recurrence: isRecurring ? repeatInterval : RecurrenceEnum.NONE, repeatOnDays: diff --git a/apps/frontend/src/components/signOutButton.tsx b/apps/frontend/src/components/signOutButton.tsx index 2de170fe2..cff94f15c 100644 --- a/apps/frontend/src/components/signOutButton.tsx +++ b/apps/frontend/src/components/signOutButton.tsx @@ -1,4 +1,3 @@ -import apiClient from '@api/apiClient'; import { Button, ButtonProps } from '@chakra-ui/react'; import { signOut } from 'aws-amplify/auth'; import { useNavigate } from 'react-router-dom'; diff --git a/apps/frontend/src/containers/donationManagement.tsx b/apps/frontend/src/containers/donationManagement.tsx index 4a717ee10..c0c1b39f9 100644 --- a/apps/frontend/src/containers/donationManagement.tsx +++ b/apps/frontend/src/containers/donationManagement.tsx @@ -22,6 +22,13 @@ const DonationManagement: React.FC = () => { const [donationItemStock, setDonationItemStock] = useState<{ [key: number]: number; }>({}); + const [manufacturerId, setManufacturerId] = useState(null); + + useEffect(() => { + ApiClient.getCurrentUserFoodManufacturerId() + .then(setManufacturerId) + .catch(() => setManufacturerId(null)); + }, []); const fetchDonations = async () => { try { @@ -86,11 +93,14 @@ const DonationManagement: React.FC = () => { return (
- + {manufacturerId !== null && ( + + )} diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index f60109c27..0f2b93cd4 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -15,9 +15,13 @@ import ApiClient from '@api/apiClient'; import { DonationDetails, DonationStatus } from '../types/types'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; +import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequiredActionsModal'; const FoodManufacturerDonationManagement: React.FC = () => { const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); + const [manufacturerId, setManufacturerId] = useState(null); + const [selectedActionDonation, setSelectedActionDonation] = + useState(null); // State to hold donations grouped by status const [statusDonations, setStatusDonations] = useState<{ [key in DonationStatus]: DonationDetails[]; @@ -44,9 +48,9 @@ const FoodManufacturerDonationManagement: React.FC = () => { const MAX_PER_STATUS = 5; // Fetch all donations on component mount and sorts them into their appropriate status lists - const fetchDonations = async () => { + const fetchDonations = async (fmId: number) => { try { - const data = await ApiClient.getAllDonationsByFoodManufacturer(1); // Replace with actual food manufacturer ID + const data = await ApiClient.getAllDonationsByFoodManufacturer(fmId); const grouped: Record = { [DonationStatus.AVAILABLE]: [], @@ -80,8 +84,18 @@ const FoodManufacturerDonationManagement: React.FC = () => { } }; + // On page load, get the food manufacturer id and all appropriate donations useEffect(() => { - fetchDonations(); + const init = async () => { + try { + const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); + setManufacturerId(fmId); + await fetchDonations(fmId); + } catch (error) { + alert('Error initializing donation management: ' + error); + } + }; + init(); }, []); const handlePageChange = (status: DonationStatus, page: number) => { @@ -114,14 +128,27 @@ const FoodManufacturerDonationManagement: React.FC = () => { Log New Donation - {isLogDonationOpen && ( + {isLogDonationOpen && manufacturerId !== null && ( fetchDonations(manufacturerId)} isOpen={isLogDonationOpen} onClose={() => setIsLogDonationOpen(false)} /> )} + {selectedActionDonation && ( + setSelectedActionDonation(null)} + onSuccess={() => { + setSelectedActionDonation(null); + if (manufacturerId !== null) fetchDonations(manufacturerId); + }} + /> + )} + {Object.values(DonationStatus).map((status) => { const allDonationsByStatus = statusDonations[status] || []; @@ -142,6 +169,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { totalDonations={allDonationsByStatus.length} currentPage={currentPage} onPageChange={(page) => handlePageChange(status, page)} + onActionSelect={setSelectedActionDonation} /> ); @@ -159,6 +187,7 @@ interface DonationStatusSectionProps { totalDonations: number; currentPage: number; onPageChange: (page: number) => void; + onActionSelect: (donation: DonationDetails | null) => void; } const DonationStatusSection: React.FC = ({ @@ -170,6 +199,7 @@ const DonationStatusSection: React.FC = ({ totalDonations, currentPage, onPageChange, + onActionSelect, }) => { const MAX_PER_STATUS = 5; const totalPages = Math.ceil(totalDonations / MAX_PER_STATUS); @@ -336,7 +366,17 @@ const DonationStatusSection: React.FC = ({ textAlign="right" color="neutral.700" > - No Action Required + {donationDetail.associatedPendingOrders.length > 0 ? ( + onActionSelect(donationDetail)} + > + Complete Required Actions + + ) : ( + 'No Action Required' + )} ); diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 20318879c..d5dd8a835 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -198,12 +198,19 @@ export interface DonationItemWithAllocatedQuantity { itemName: string; foodType: FoodType; allocatedQuantity: number; + detailsConfirmed: boolean; + ozPerItem?: number; + estimatedValue?: number; + foodRescue: boolean; } export interface DonationOrderDetails { orderId: number; pantryId: number; pantryName: string; + trackingLink: string | null; + shippingCost: number | null; + items: OrderItemDetails[]; } export interface DonationItem { @@ -323,6 +330,7 @@ export interface OrderDetails { status: OrderStatus; foodManufacturerName: string; trackingLink: string | null; + shippingCost: number | null; items: OrderItemDetails[]; } @@ -552,3 +560,19 @@ export type OrderItemDetailsGroupedByFoodType = Partial< export type DonationItemsGroupedByFoodType = Partial< Record >; + +export interface BulkUpdateTrackingCostDto { + donationId: number; + orders: { + orderId: number; + trackingLink?: string; + shippingCost?: number; + }[]; +} + +export interface UpdateDonationItemDetailsDto { + itemId: number; + ozPerItem?: number; + estimatedValue?: number; + foodRescue?: boolean; +}