diff --git a/apps/backend/src/donationItems/dtos/confirm-donation-item-details.dto.ts b/apps/backend/src/donationItems/dtos/confirm-donation-item-details.dto.ts new file mode 100644 index 00000000..35c870ed --- /dev/null +++ b/apps/backend/src/donationItems/dtos/confirm-donation-item-details.dto.ts @@ -0,0 +1,23 @@ +import { IsNumber, Min, IsBoolean } from 'class-validator'; + +export class ConfirmDonationItemDetailsDto { + @IsNumber() + itemId!: number; + + @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; + + @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; + + @IsBoolean() + foodRescue!: boolean; +} diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index a13af53c..eecd4f19 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -5,6 +5,7 @@ import { mock } from 'jest-mock-extended'; import { Donation } from './donations.entity'; import { CreateDonationDto } from './dtos/create-donation.dto'; import { DonationStatus, RecurrenceEnum } from './types'; +import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; const mockDonationService = mock(); @@ -120,4 +121,38 @@ describe('DonationsController', () => { expect(mockDonationService.fulfill).toHaveBeenCalledWith(donationId); }); }); + + describe('PATCH /:donationId/item-details', () => { + it('calls confirmDonationItemDetails with the correct donationId and body, returns result', async () => { + const donationId = 1; + const body: ConfirmDonationItemDetailsDto[] = [ + { + itemId: 1, + ozPerItem: 5.0, + estimatedValue: 10.0, + foodRescue: true, + }, + { + itemId: 2, + ozPerItem: 8.0, + estimatedValue: 15.0, + foodRescue: false, + }, + ]; + + mockDonationService.confirmDonationItemDetails.mockResolvedValueOnce( + donation1 as Donation, + ); + + const result = await controller.confirmDonationItemDetails( + donationId, + body, + ); + + expect(result).toEqual(donation1); + expect( + mockDonationService.confirmDonationItemDetails, + ).toHaveBeenCalledWith(donationId, body); + }); + }); }); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 2862ebb6..921ac900 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -6,12 +6,14 @@ import { Patch, Param, ParseIntPipe, + ParseArrayPipe, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { RecurrenceEnum } from './types'; import { CreateDonationDto } from './dtos/create-donation.dto'; +import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; @Controller('donations') export class DonationsController { @@ -77,4 +79,13 @@ export class DonationsController { ): Promise { return this.donationService.fulfill(donationId); } + + @Patch('/:donationId/item-details') + async confirmDonationItemDetails( + @Param('donationId', ParseIntPipe) donationId: number, + @Body(new ParseArrayPipe({ items: ConfirmDonationItemDetailsDto })) + body: ConfirmDonationItemDetailsDto[], + ): Promise { + return this.donationService.confirmDonationItemDetails(donationId, body); + } } diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 63bde20c..74912988 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -1,12 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; 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 { BadRequestException, NotFoundException } from '@nestjs/common'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; jest.setTimeout(60000); @@ -28,6 +32,50 @@ const daysFromNow = (numDays: number): Date => { return date; }; +// Insert a donation with MATCHED status, returns its generated ID +async function insertMatchedDonation(): Promise { + const result = 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 + WHERE food_manufacturer_name = 'FoodCorp Industries' LIMIT 1), + 'matched', 'none', NULL, NULL, NULL + ) + RETURNING donation_id`, + ); + return result[0].donation_id; +} + +// Insert a donation item for a given donation, returns its generated ID +async function insertDonationItem( + donationId: number, + qty: number, + reserved: number, +): Promise { + const result = await testDataSource.query( + `INSERT INTO donation_items + (donation_id, item_name, quantity, reserved_quantity, food_type, details_confirmed) + VALUES ($1, 'Test Item', $2, $3, 'Granola', false) + RETURNING item_id`, + [donationId, qty, reserved], + ); + return result[0].item_id; +} + +// Insert an allocation linking an existing order to a donation item +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], + ); +} + // insert a minimal donation and return its generated ID async function insertDonation(overrides: { recurrence: RecurrenceEnum; @@ -95,6 +143,7 @@ describe('DonationService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ DonationService, + DonationItemsService, { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), @@ -103,6 +152,14 @@ describe('DonationService', () => { provide: getRepositoryToken(FoodManufacturer), useValue: testDataSource.getRepository(FoodManufacturer), }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile(); @@ -802,4 +859,209 @@ describe('DonationService', () => { expect(result).toHaveLength(0); }); }); + + describe('confirmDonationItemDetails', () => { + const makeDto = (itemId: number): ConfirmDonationItemDetailsDto => ({ + itemId, + ozPerItem: 5.0, + estimatedValue: 10.0, + foodRescue: true, + }); + + it('throws NotFoundException when donation does not exist', async () => { + await expect( + service.confirmDonationItemDetails(9999, [makeDto(1)]), + ).rejects.toThrow(new NotFoundException('Donation 9999 not found')); + }); + + it('throws BadRequestException when donation status is not MATCHED', async () => { + // Donation 1 has status 'available' + await expect( + service.confirmDonationItemDetails(1, [makeDto(1)]), + ).rejects.toThrow( + new BadRequestException("Donation status must be 'Matched'"), + ); + }); + + it('throws BadRequestException when an item does not belong to the donation', async () => { + const donationId = await insertMatchedDonation(); + + // Item 1 belongs to donation 1, not the new donation + await expect( + service.confirmDonationItemDetails(donationId, [makeDto(1)]), + ).rejects.toThrow( + new BadRequestException( + `Donation item 1 does not belong to donation ${donationId}`, + ), + ); + }); + + 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 = { + itemId, + ozPerItem: 8.5, + estimatedValue: 12.0, + foodRescue: false, + }; + + await service.confirmDonationItemDetails(donationId, [dto]); + + 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(false); + expect(item?.detailsConfirmed).toBe(true); + }); + + it('updates multiple items in a single call', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId, 10, 5); + const itemId2 = await insertDonationItem(donationId, 20, 10); + + await service.confirmDonationItemDetails(donationId, [ + { + itemId: itemId1, + ozPerItem: 4.0, + estimatedValue: 8.0, + foodRescue: true, + }, + { + itemId: itemId2, + ozPerItem: 6.0, + estimatedValue: 14.0, + foodRescue: false, + }, + ]); + + const item1 = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId: itemId1 }); + const item2 = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId: itemId2 }); + + expect(Number(item1?.ozPerItem)).toBe(4.0); + expect(item1?.foodRescue).toBe(true); + expect(item1?.detailsConfirmed).toBe(true); + + expect(Number(item2?.ozPerItem)).toBe(6.0); + expect(item2?.foodRescue).toBe(false); + expect(item2?.detailsConfirmed).toBe(true); + }); + + it('rolls back all updates when one item does not belong to the donation', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + + // Second dto references item 1 which belongs to donation 1, not ours + await expect( + service.confirmDonationItemDetails(donationId, [ + makeDto(itemId), + makeDto(1), + ]), + ).rejects.toThrow(BadRequestException); + + // The first item should not have been updated due to rollback + const item = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId }); + expect(item?.detailsConfirmed).toBe(false); + expect(item?.ozPerItem).toBeNull(); + }); + + it('does not fulfill donation when only a subset of items are confirmed', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId, 10, 10); + const itemId2 = await insertDonationItem(donationId, 10, 10); + + // Only confirm itemId1 — itemId2 stays detailsConfirmed=false + await service.confirmDonationItemDetails(donationId, [makeDto(itemId1)]); + + const donation = await service.findOne(donationId); + expect(donation.status).toBe(DonationStatus.MATCHED); + + // Verify itemId2 is still unconfirmed + const item2 = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId: itemId2 }); + expect(item2?.detailsConfirmed).toBe(false); + }); + + it('does not fulfill donation when reservedQuantity does not equal quantity', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + + await service.confirmDonationItemDetails(donationId, [makeDto(itemId)]); + + const donation = await service.findOne(donationId); + expect(donation.status).toBe(DonationStatus.MATCHED); + }); + + it('does not fulfill donation when a pending order is associated', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 10); + + // Order 4 is pending in the seed data + await insertAllocation(4, itemId); + + await service.confirmDonationItemDetails(donationId, [makeDto(itemId)]); + + const donation = await service.findOne(donationId); + expect(donation.status).toBe(DonationStatus.MATCHED); + }); + + it('sets donation to FULFILLED when all items confirmed, fully reserved, and no orders', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 10); + + await service.confirmDonationItemDetails(donationId, [makeDto(itemId)]); + + const donation = await service.findOne(donationId); + expect(donation.status).toBe(DonationStatus.FULFILLED); + }); + + it('sets donation to FULFILLED when all orders associated are non-pending', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 10); + + // Order 1 is delivered, so should not block fulfillment + await insertAllocation(1, itemId); + + await service.confirmDonationItemDetails(donationId, [makeDto(itemId)]); + + const donation = await service.findOne(donationId); + expect(donation.status).toBe(DonationStatus.FULFILLED); + }); + + it('sets donation to FULFILLED with multiple items all fully reserved and confirmed', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId, 10, 10); + const itemId2 = await insertDonationItem(donationId, 20, 20); + + await service.confirmDonationItemDetails(donationId, [ + makeDto(itemId1), + makeDto(itemId2), + ]); + + const donation = await service.findOne(donationId); + expect(donation.status).toBe(DonationStatus.FULFILLED); + }); + + it('returns the donation after updating items', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 10); + + const result = await service.confirmDonationItemDetails(donationId, [ + makeDto(itemId), + ]); + + expect(result).toBeDefined(); + expect(result.donationId).toBe(donationId); + }); + }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 2f378986..84ece0d6 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -4,13 +4,17 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { validateId } from '../utils/validation.utils'; import { DayOfWeek, DonationStatus, RecurrenceEnum } from './types'; import { CreateDonationDto, RepeatOnDaysDto } from './dtos/create-donation.dto'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { OrderStatus } from '../orders/types'; @Injectable() export class DonationService { @@ -20,6 +24,10 @@ export class DonationService { @InjectRepository(Donation) private repo: Repository, @InjectRepository(FoodManufacturer) private manufacturerRepo: Repository, + @InjectRepository(DonationItem) + private donationItemsRepo: Repository, + private donationItemsService: DonationItemsService, + @InjectDataSource() private dataSource: DataSource, ) {} async findOne(donationId: number): Promise { @@ -313,4 +321,70 @@ export class DonationService { } return dates; } + + async confirmDonationItemDetails( + donationId: number, + body: ConfirmDonationItemDetailsDto[], + ): Promise { + validateId(donationId, 'Donation'); + + const donation = await this.repo.findOneBy({ donationId }); + if (!donation) { + throw new NotFoundException(`Donation ${donationId} not found`); + } + + if (donation.status !== DonationStatus.MATCHED) { + throw new BadRequestException("Donation status must be 'Matched'"); + } + + const donationItems = await this.donationItemsService.getAllDonationItems( + donationId, + ); + const validItemIds = new Set(donationItems.map((item) => item.itemId)); + + await this.dataSource.transaction(async (transactionManager) => { + const repo = transactionManager.getRepository(DonationItem); + + for (const dto of body) { + if (!validItemIds.has(dto.itemId)) { + throw new BadRequestException( + `Donation item ${dto.itemId} does not belong to donation ${donationId}`, + ); + } + + await repo.update(dto.itemId, { + ozPerItem: dto.ozPerItem, + estimatedValue: dto.estimatedValue, + foodRescue: dto.foodRescue, + detailsConfirmed: true, + }); + } + }); + + await this.checkAndFulfillDonation(donationId); + + return donation; + } + + private async checkAndFulfillDonation(donationId: number): Promise { + const items = await this.donationItemsRepo.find({ + where: { donationId }, + relations: { allocations: { order: true } }, + }); + + const allItemsFulfilled = items.every( + (item) => + item.detailsConfirmed && item.reservedQuantity === item.quantity, + ); + if (!allItemsFulfilled) return; + + const hasPendingOrder = items.some((item) => + item.allocations.some( + (allocation) => allocation.order.status === OrderStatus.PENDING, + ), + ); + if (hasPendingOrder) return; + + await this.repo.update(donationId, { status: DonationStatus.FULFILLED }); + } } diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 847a7ac8..3188f21c 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -24,6 +24,8 @@ import { PantriesService } from '../pantries/pantries.service'; import { mock } from 'jest-mock-extended'; import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { DataSource } from 'typeorm'; jest.setTimeout(60000); @@ -63,7 +65,12 @@ describe('FoodManufacturersService', () => { FoodManufacturersService, UsersService, DonationService, + DonationItemsService, PantriesService, + { + provide: DataSource, + useValue: testDataSource, + }, { provide: AuthService, useValue: { diff --git a/apps/backend/src/orders/dtos/tracking-cost.dto.ts b/apps/backend/src/orders/dtos/tracking-cost.dto.ts index 27327384..94548e83 100644 --- a/apps/backend/src/orders/dtos/tracking-cost.dto.ts +++ b/apps/backend/src/orders/dtos/tracking-cost.dto.ts @@ -1,4 +1,4 @@ -import { IsUrl, IsNumber, Min, IsOptional } from 'class-validator'; +import { IsUrl, IsNumber, Min } from 'class-validator'; export class TrackingCostDto { @IsUrl( @@ -7,14 +7,12 @@ export class TrackingCostDto { }, { message: 'Tracking link must be a valid HTTP/HTTPS URL' }, ) - @IsOptional() - trackingLink?: string; + trackingLink!: string; @IsNumber( { maxDecimalPlaces: 2 }, { message: 'Shipping cost must have at most 2 decimal places' }, ) @Min(0, { message: 'Shipping cost cannot be negative' }) - @IsOptional() - shippingCost?: number; + shippingCost!: number; } diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index c312b0d1..5290de79 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -10,10 +10,18 @@ import { FoodRequest } from '../foodRequests/request.entity'; import { AWSS3Module } from '../aws/aws-s3.module'; import { MulterModule } from '@nestjs/platform-express'; import { RequestsModule } from '../foodRequests/request.module'; +import { Donation } from '../donations/donations.entity'; +import { DonationItem } from '../donationItems/donationItems.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Order, Pantry, FoodRequest]), + TypeOrmModule.forFeature([ + Order, + Pantry, + FoodRequest, + Donation, + DonationItem, + ]), AllocationModule, forwardRef(() => AuthModule), AWSS3Module, diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index b0aa4588..0928e352 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -15,6 +15,8 @@ import { FoodRequestStatus } from '../foodRequests/types'; import { RequestsService } from '../foodRequests/request.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { Donation } from '../donations/donations.entity'; +import { DonationStatus } from '../donations/types'; import { EmailsService } from '../emails/email.service'; // Set 1 minute timeout for async DB operations @@ -64,6 +66,10 @@ describe('OrdersService', () => { provide: getRepositoryToken(DonationItem), useValue: testDataSource.getRepository(DonationItem), }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), + }, ], }).compile(); @@ -400,47 +406,15 @@ describe('OrdersService', () => { ).rejects.toThrow(new NotFoundException('Order 9999 not found')); }); - it('throws when tracking link and shipping cost not given', async () => { - await expect(service.updateTrackingCostInfo(3, {})).rejects.toThrow( - new BadRequestException( - 'At least one of tracking link or shipping cost must be provided', - ), - ); - }); - - it('sanitizes and updates tracking link for shipped order', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'samplelink.com', - }; - - await service.updateTrackingCostInfo(3, trackingCostDto); - - const order = await service.findOne(3); - expect(order.trackingLink).toBeDefined(); - expect(order.trackingLink).toEqual('https://samplelink.com/'); - }); - - it('updates shipping cost for shipped order', async () => { - const trackingCostDto: TrackingCostDto = { - shippingCost: 12.99, - }; - - await service.updateTrackingCostInfo(3, trackingCostDto); - - const order = await service.findOne(3); - expect(order.shippingCost).toBeDefined(); - expect(order.shippingCost).toEqual(12.99); - }); - it('updates both shipping cost and tracking link (sanitized)', async () => { const trackingCostDto: TrackingCostDto = { trackingLink: 'testtracking.com', shippingCost: 7.5, }; - await service.updateTrackingCostInfo(3, trackingCostDto); + await service.updateTrackingCostInfo(4, trackingCostDto); - const order = await service.findOne(3); + const order = await service.findOne(4); expect(order.trackingLink).toEqual('https://testtracking.com/'); expect(order.shippingCost).toEqual(7.5); }); @@ -465,26 +439,6 @@ describe('OrdersService', () => { ); }); - it('throws when both fields are not provided for first time setting', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'testtracking.com', - }; - const orderId = 4; - - const order = await service.findOne(orderId); - - expect(order.shippedAt).toBeNull(); - expect(order.trackingLink).toBeNull(); - - await expect( - service.updateTrackingCostInfo(4, trackingCostDto), - ).rejects.toThrow( - new BadRequestException( - 'Must provide both tracking link and shipping cost on initial assignment', - ), - ); - }); - it('throws when tracking link is invalid', async () => { const trackingCostDto: TrackingCostDto = { trackingLink: `javascript:alert("you've been hacked!")`, @@ -519,6 +473,71 @@ describe('OrdersService', () => { expect(updatedOrder.status).toEqual(OrderStatus.SHIPPED); expect(updatedOrder.shippedAt).toBeDefined(); }); + + 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', () => { diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 74d620fe..6f89ca56 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -15,12 +15,18 @@ import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; import { RequestsService } from '../foodRequests/request.service'; +import { Donation } from '../donations/donations.entity'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { DonationStatus } from '../donations/types'; @Injectable() export class OrdersService { constructor( @InjectRepository(Order) private repo: Repository, @InjectRepository(Pantry) private pantryRepo: Repository, + @InjectRepository(Donation) private donationRepo: Repository, + @InjectRepository(DonationItem) + private donationItemRepo: Repository, private requestsService: RequestsService, ) {} @@ -288,56 +294,67 @@ export class OrdersService { async updateTrackingCostInfo(orderId: number, dto: TrackingCostDto) { validateId(orderId, 'Order'); - if (!dto.trackingLink && !dto.shippingCost) { + + const sanitized = sanitizeUrl(dto.trackingLink); + if (!sanitized) { throw new BadRequestException( - 'At least one of tracking link or shipping cost must be provided', + 'Invalid tracking link. Only valid HTTP/HTTPS URLs are accepted.', ); } - - if (dto.trackingLink) { - const sanitized = sanitizeUrl(dto.trackingLink); - if (!sanitized) { - throw new BadRequestException( - 'Invalid tracking link. Only valid HTTP/HTTPS URLs are accepted.', - ); - } - dto.trackingLink = sanitized; - } + dto.trackingLink = sanitized; const order = await this.repo.findOneBy({ orderId }); if (!order) { throw new NotFoundException(`Order ${orderId} not found`); } - const isFirstTimeSetting = !order.trackingLink && !order.shippingCost; - - if (isFirstTimeSetting && (!dto.trackingLink || !dto.shippingCost)) { - throw new BadRequestException( - 'Must provide both tracking link and shipping cost on initial assignment', - ); - } - - if ( - order.status !== OrderStatus.SHIPPED && - order.status !== OrderStatus.PENDING - ) { + if (order.status !== OrderStatus.PENDING) { throw new BadRequestException( 'Can only update tracking info for pending or shipped orders', ); } - if (dto.trackingLink) order.trackingLink = dto.trackingLink; - if (dto.shippingCost) order.shippingCost = dto.shippingCost; + order.trackingLink = dto.trackingLink; + order.shippingCost = dto.shippingCost; - if ( - order.status === OrderStatus.PENDING && - order.trackingLink && - order.shippingCost - ) { - order.status = OrderStatus.SHIPPED; - order.shippedAt = new Date(); - } + order.status = OrderStatus.SHIPPED; + order.shippedAt = new Date(); await this.repo.save(order); + + await this.checkAndFulfillDonations(orderId); + } + + private 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 items = await this.donationItemRepo.find({ + where: { donationId }, + relations: { allocations: { order: true } }, + }); + + const allItemsFulfilled = items.every( + (item) => + item.detailsConfirmed && item.reservedQuantity === item.quantity, + ); + if (!allItemsFulfilled) continue; + + const hasPendingOrder = items.some((item) => + item.allocations.some( + (allocation) => allocation.order.status === OrderStatus.PENDING, + ), + ); + if (hasPendingOrder) continue; + + await this.donationRepo.update(donationId, { + status: DonationStatus.FULFILLED, + }); + } } } diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 820e20a1..83ff1c4a 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -19,6 +19,7 @@ import { } from './types'; import { ApplicationStatus } from '../shared/types'; import { testDataSource } from '../config/typeormTestDataSource'; +import { DataSource } from 'typeorm'; import { Order } from '../orders/order.entity'; import { FoodRequest } from '../foodRequests/request.entity'; import { RequestsService } from '../foodRequests/request.service'; @@ -155,6 +156,10 @@ describe('PantriesService', () => { provide: getRepositoryToken(FoodManufacturer), useValue: testDataSource.getRepository(FoodManufacturer), }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile(); diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 34f889a9..f4cf6b2e 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -19,6 +19,7 @@ import { DonationItem } from '../donationItems/donationItems.entity'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { DonationService } from '../donations/donations.service'; import { Donation } from '../donations/donations.entity'; +import { DataSource } from 'typeorm'; jest.setTimeout(60000); @@ -42,6 +43,10 @@ describe('VolunteersService', () => { FoodManufacturersService, DonationItemsService, DonationService, + { + provide: DataSource, + useValue: testDataSource, + }, { provide: AuthService, useValue: {