diff --git a/apps/backend/src/allocations/allocations.module.ts b/apps/backend/src/allocations/allocations.module.ts index 4fccf7065..7716c4d20 100644 --- a/apps/backend/src/allocations/allocations.module.ts +++ b/apps/backend/src/allocations/allocations.module.ts @@ -5,12 +5,15 @@ import { AllocationsService } from './allocations.service'; import { AuthModule } from '../auth/auth.module'; import { DonationItemsModule } from '../donationItems/donationItems.module'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { Donation } from '../donations/donations.entity'; +import { DonationModule } from '../donations/donations.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Allocation, DonationItem]), + TypeOrmModule.forFeature([Allocation, DonationItem, Donation]), forwardRef(() => AuthModule), DonationItemsModule, + DonationModule, ], providers: [AllocationsService], exports: [AllocationsService], diff --git a/apps/backend/src/allocations/allocations.service.spec.ts b/apps/backend/src/allocations/allocations.service.spec.ts index 1cfee9da9..d85394bae 100644 --- a/apps/backend/src/allocations/allocations.service.spec.ts +++ b/apps/backend/src/allocations/allocations.service.spec.ts @@ -1,12 +1,80 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { mock } from 'jest-mock-extended'; import { testDataSource } from '../config/typeormTestDataSource'; import { AllocationsService } from './allocations.service'; import { Allocation } from './allocations.entity'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { Donation } from '../donations/donations.entity'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { DonationService } from '../donations/donations.service'; +import { BadRequestException } from '@nestjs/common'; +import { EntityManager, Repository } from 'typeorm'; +import { Order } from '../orders/order.entity'; +import { UpdateAllocationsDto } from '../orders/dtos/update-allocations.dto'; jest.setTimeout(60000); +const FOODCORP = 'FoodCorp Industries'; +const OTHER_FM = 'Healthy Foods Co'; + +const mockDonationService = mock(); + +async function getFmId(name: string): Promise { + const [{ food_manufacturer_id }] = await testDataSource.query( + `SELECT food_manufacturer_id FROM food_manufacturers WHERE food_manufacturer_name = $1 LIMIT 1`, + [name], + ); + return food_manufacturer_id; +} + +async function insertDonationForFm(fmId: number): Promise { + const [{ donation_id }] = await testDataSource.query( + `INSERT INTO donations (food_manufacturer_id, status, recurrence) + VALUES ($1, 'matched', 'none') RETURNING donation_id`, + [fmId], + ); + return donation_id; +} + +async function insertItem( + donationId: number, + quantity: number, + reserved: number, +): Promise { + const [{ item_id }] = await testDataSource.query( + `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, oz_per_item, estimated_value, food_type, details_confirmed) + VALUES ($1, 'Test Item', $2, $3, 0, 0, 'Granola', false) RETURNING item_id`, + [donationId, quantity, reserved], + ); + return item_id; +} + +async function insertOrder(fmId: number): 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), $1, 'pending', (SELECT user_id FROM users LIMIT 1)) + RETURNING order_id`, + [fmId], + ); + return testDataSource.getRepository(Order).findOneByOrFail({ + orderId: order_id, + }); +} + +async function insertAllocation( + orderId: number, + itemId: number, + quantity: number, +): Promise { + const [{ allocation_id }] = await testDataSource.query( + `INSERT INTO allocations (order_id, item_id, allocated_quantity) + VALUES ($1, $2, $3) RETURNING allocation_id`, + [orderId, itemId, quantity], + ); + return allocation_id; +} + describe('AllocationsService', () => { let service: AllocationsService; @@ -28,6 +96,15 @@ describe('AllocationsService', () => { provide: getRepositoryToken(DonationItem), useValue: testDataSource.getRepository(DonationItem), }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), + }, + DonationItemsService, + { + provide: DonationService, + useValue: mockDonationService, + }, ], }).compile(); @@ -41,6 +118,8 @@ describe('AllocationsService', () => { }); afterEach(async () => { + jest.restoreAllMocks(); + mockDonationService.recheckDonationAllocationStatus.mockReset(); await testDataSource.query(`DROP SCHEMA public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); }); @@ -198,4 +277,434 @@ describe('AllocationsService', () => { expect(Number(allocationCountAfter)).toBe(Number(allocationCountBefore)); }); }); + + describe('deleteMultiple', () => { + it('never calls remove when given an empty array', async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + const removeSpy = jest.spyOn(allocationRepo, 'remove'); + + await service.deleteMultiple([]); + + expect(removeSpy).not.toHaveBeenCalled(); + + removeSpy.mockRestore(); + }); + + it('removes the allocations and decrements each donation item reservedQuantity', async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + const donationItemRepo = testDataSource.getRepository(DonationItem); + + // Two donation items with known reserved quantities (donation 1 is seeded). + const [{ item_id: itemAId }] = await testDataSource.query( + `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, oz_per_item, estimated_value, food_type, details_confirmed) + VALUES (1, 'Item A', 20, 5, 0, 0, 'Granola', false) RETURNING item_id`, + ); + const [{ item_id: itemBId }] = await testDataSource.query( + `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, oz_per_item, estimated_value, food_type, details_confirmed) + VALUES (1, 'Item B', 20, 8, 0, 0, 'Granola', false) RETURNING item_id`, + ); + + // Two allocations against those items (order 1 is seeded). + const [{ allocation_id: allocAId }] = await testDataSource.query( + `INSERT INTO allocations (order_id, item_id, allocated_quantity) + VALUES (1, $1, 3) RETURNING allocation_id`, + [itemAId], + ); + const [{ allocation_id: allocBId }] = await testDataSource.query( + `INSERT INTO allocations (order_id, item_id, allocated_quantity) + VALUES (1, $1, 8) RETURNING allocation_id`, + [itemBId], + ); + + const itemABefore = (await donationItemRepo.findOneBy({ + itemId: itemAId, + })) as DonationItem; + const itemBBefore = (await donationItemRepo.findOneBy({ + itemId: itemBId, + })) as DonationItem; + expect(itemABefore.reservedQuantity).toBe(5); + expect(itemBBefore.reservedQuantity).toBe(8); + + const allocA = (await allocationRepo.findOneBy({ + allocationId: allocAId, + })) as Allocation; + const allocB = (await allocationRepo.findOneBy({ + allocationId: allocBId, + })) as Allocation; + + await service.deleteMultiple([allocA, allocB]); + + expect( + await allocationRepo.findOneBy({ allocationId: allocAId }), + ).toBeNull(); + expect( + await allocationRepo.findOneBy({ allocationId: allocBId }), + ).toBeNull(); + + const itemAAfter = (await donationItemRepo.findOneBy({ + itemId: itemAId, + })) as DonationItem; + const itemBAfter = (await donationItemRepo.findOneBy({ + itemId: itemBId, + })) as DonationItem; + expect(itemAAfter.reservedQuantity).toBe(2); // 5 - 3 + expect(itemBAfter.reservedQuantity).toBe(0); // 8 - 8 + }); + }); + + describe('freeAllByOrder', () => { + const orderId = 2; + + it("calls deleteMultiple with the order's allocations", async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + const expectedAllocations = await allocationRepo.find({ + where: { orderId }, + }); + expect(expectedAllocations.length).toBeGreaterThan(0); + + const deleteMultipleSpy = jest + .spyOn(service, 'deleteMultiple') + .mockResolvedValue(undefined); + + await service.freeAllByOrder(orderId); + + expect(deleteMultipleSpy).toHaveBeenCalledWith( + expectedAllocations, + undefined, + ); + + deleteMultipleSpy.mockRestore(); + }); + }); + + describe('updateOrderAllocations', () => { + const runInTransaction = (order: Order, dto: UpdateAllocationsDto) => + testDataSource.transaction((manager) => + service.updateOrderAllocations(order, dto, manager), + ); + + it('throws when an entry has both an allocation id and a donation item id', async () => { + const order = await insertOrder(await getFmId(FOODCORP)); + const dto: UpdateAllocationsDto = { + allocations: [ + { allocationId: 1, donationItemId: 1, allocatedQuantity: 5 }, + ], + }; + + await expect(runInTransaction(order, dto)).rejects.toThrow( + new BadRequestException( + 'Each allocation may only contain one of: allocation id OR donation item id', + ), + ); + }); + + it('throws on duplicate allocation ids', async () => { + const order = await insertOrder(await getFmId(FOODCORP)); + const dto: UpdateAllocationsDto = { + allocations: [ + { allocationId: 1, allocatedQuantity: 5 }, + { allocationId: 1, allocatedQuantity: 3 }, + ], + }; + + await expect(runInTransaction(order, dto)).rejects.toThrow( + new BadRequestException('Duplicate allocation ID 1 in request'), + ); + }); + + it('throws on duplicate donation item ids', async () => { + const order = await insertOrder(await getFmId(FOODCORP)); + const dto: UpdateAllocationsDto = { + allocations: [ + { donationItemId: 1, allocatedQuantity: 5 }, + { donationItemId: 1, allocatedQuantity: 3 }, + ], + }; + + await expect(runInTransaction(order, dto)).rejects.toThrow( + new BadRequestException('Duplicate donation item ID 1 in request'), + ); + }); + + it('throws when an entry has neither an allocation id nor a donation item id', async () => { + const order = await insertOrder(await getFmId(FOODCORP)); + const dto: UpdateAllocationsDto = { + allocations: [{ allocatedQuantity: 5 }], + }; + + await expect(runInTransaction(order, dto)).rejects.toThrow( + new BadRequestException( + 'Each allocation must include either an allocationId or a donationItemId', + ), + ); + }); + + it('throws when an edited allocation does not belong to the order', async () => { + const fmId = await getFmId(FOODCORP); + const order = await insertOrder(fmId); + const donationId = await insertDonationForFm(fmId); + const itemId = await insertItem(donationId, 20, 10); + const allocA = await insertAllocation(order.orderId, itemId, 5); + const allocB = await insertAllocation(order.orderId, itemId, 5); + + const dto: UpdateAllocationsDto = { + allocations: [ + { allocationId: allocA, allocatedQuantity: 5 }, + { allocationId: allocB, allocatedQuantity: 5 }, + { allocationId: 999999, allocatedQuantity: 5 }, + ], + }; + + await expect(runInTransaction(order, dto)).rejects.toThrow( + new BadRequestException( + `Allocation 999999 does not belong to order ${order.orderId}`, + ), + ); + }); + + it("throws when a donation item does not belong to the order's manufacturer", async () => { + const fmId = await getFmId(FOODCORP); + const order = await insertOrder(fmId); + const otherDonationId = await insertDonationForFm( + await getFmId(OTHER_FM), + ); + const otherItemId = await insertItem(otherDonationId, 20, 0); + + const dto: UpdateAllocationsDto = { + allocations: [{ donationItemId: otherItemId, allocatedQuantity: 5 }], + }; + + await expect(runInTransaction(order, dto)).rejects.toThrow( + new BadRequestException( + `The following donation items are not associated with the order's food manufacturer: Donation item ID ${otherItemId} with Donation ID ${otherDonationId}`, + ), + ); + }); + + it('throws when an allocated quantity exceeds the remaining quantity', async () => { + const fmId = await getFmId(FOODCORP); + const order = await insertOrder(fmId); + const donationId = await insertDonationForFm(fmId); + const itemId = await insertItem(donationId, 10, 10); + const allocId = await insertAllocation(order.orderId, itemId, 10); + + const dto: UpdateAllocationsDto = { + allocations: [{ allocationId: allocId, allocatedQuantity: 11 }], + }; + + await expect(runInTransaction(order, dto)).rejects.toThrow( + new BadRequestException( + `Donation item ${itemId} allocated quantity exceeds remaining quantity`, + ), + ); + }); + + it('throws when a donation item would have a negative reserved quantity', async () => { + const fmId = await getFmId(FOODCORP); + const order = await insertOrder(fmId); + const donationId = await insertDonationForFm(fmId); + const itemId = await insertItem(donationId, 20, 3); + const allocId = await insertAllocation(order.orderId, itemId, 10); + + const dto: UpdateAllocationsDto = { + allocations: [{ allocationId: allocId, allocatedQuantity: 0 }], + }; + + await expect(runInTransaction(order, dto)).rejects.toThrow( + new BadRequestException( + `Donation item ${itemId} would have a negative reserved quantity`, + ), + ); + }); + + it('rolls back every change when recheckDonationAllocationStatus fails', async () => { + const fmId = await getFmId(FOODCORP); + const order = await insertOrder(fmId); + const donationId = await insertDonationForFm(fmId); + const itemA = await insertItem(donationId, 20, 5); + const itemB = await insertItem(donationId, 20, 4); + const itemC = await insertItem(donationId, 20, 0); + const allocA = await insertAllocation(order.orderId, itemA, 5); + const allocB = await insertAllocation(order.orderId, itemB, 4); + + const allocationRepo = testDataSource.getRepository(Allocation); + const itemRepo = testDataSource.getRepository(DonationItem); + + const updateSpy = jest.spyOn(Repository.prototype, 'update'); + const deleteSpy = jest.spyOn(service, 'deleteMultiple'); + const createSpy = jest.spyOn(service, 'createMultiple'); + mockDonationService.recheckDonationAllocationStatus.mockRejectedValueOnce( + new Error('DB error'), + ); + + const dto: UpdateAllocationsDto = { + allocations: [ + { allocationId: allocA, allocatedQuantity: 8 }, + // allocB omitted -> delete + { donationItemId: itemC, allocatedQuantity: 6 }, + ], + }; + + await expect(runInTransaction(order, dto)).rejects.toThrow('DB error'); + + // Make sure everything was still called + expect(updateSpy).toHaveBeenCalled(); + expect(deleteSpy).toHaveBeenCalled(); + expect(createSpy).toHaveBeenCalled(); + expect( + mockDonationService.recheckDonationAllocationStatus, + ).toHaveBeenCalled(); + + // Make sure it all rolled back + expect( + (await allocationRepo.findOneBy({ allocationId: allocA })) + ?.allocatedQuantity, + ).toBe(5); + expect( + await allocationRepo.findOneBy({ allocationId: allocB }), + ).not.toBeNull(); + expect( + await allocationRepo.findBy({ orderId: order.orderId, itemId: itemC }), + ).toHaveLength(0); + expect( + (await itemRepo.findOneBy({ itemId: itemA }))?.reservedQuantity, + ).toBe(5); + expect( + (await itemRepo.findOneBy({ itemId: itemB }))?.reservedQuantity, + ).toBe(4); + expect( + (await itemRepo.findOneBy({ itemId: itemC }))?.reservedQuantity, + ).toBe(0); + }); + + it('updates, deletes, and creates allocations and rechecks the affected donation', async () => { + const fmId = await getFmId(FOODCORP); + const order = await insertOrder(fmId); + const donationId = await insertDonationForFm(fmId); + const itemA = await insertItem(donationId, 20, 5); + const itemB = await insertItem(donationId, 20, 4); + const itemC = await insertItem(donationId, 20, 0); + const allocA = await insertAllocation(order.orderId, itemA, 5); + const allocB = await insertAllocation(order.orderId, itemB, 4); + + const allocationRepo = testDataSource.getRepository(Allocation); + const itemRepo = testDataSource.getRepository(DonationItem); + + const updateSpy = jest.spyOn(Repository.prototype, 'update'); + const deleteSpy = jest.spyOn(service, 'deleteMultiple'); + const createSpy = jest.spyOn(service, 'createMultiple'); + + const dto: UpdateAllocationsDto = { + allocations: [ + { allocationId: allocA, allocatedQuantity: 8 }, + // allocB omitted -> delete + { donationItemId: itemC, allocatedQuantity: 6 }, + ], + }; + + await runInTransaction(order, dto); + + expect(updateSpy).toHaveBeenCalledWith(allocA, { allocatedQuantity: 8 }); + expect( + (await allocationRepo.findOneBy({ allocationId: allocA })) + ?.allocatedQuantity, + ).toBe(8); + + // deleteMultiple is called as (allocations, transactionManager), so the + // trailing manager arg must be matched too. + expect(deleteSpy).toHaveBeenCalledWith( + [expect.objectContaining({ itemId: itemB, allocatedQuantity: 4 })], + expect.any(EntityManager), + ); + expect( + await allocationRepo.findOneBy({ allocationId: allocB }), + ).toBeNull(); + + expect(createSpy).toHaveBeenCalledWith( + order.orderId, + new Map([[itemC, 6]]), + expect.any(EntityManager), + ); + const createdC = await allocationRepo.findBy({ + orderId: order.orderId, + itemId: itemC, + }); + expect(createdC).toHaveLength(1); + expect(createdC[0].allocatedQuantity).toBe(6); + + expect( + (await itemRepo.findOneBy({ itemId: itemA }))?.reservedQuantity, + ).toBe( + 8, // 5 + 3 + ); + expect( + (await itemRepo.findOneBy({ itemId: itemB }))?.reservedQuantity, + ).toBe( + 0, // 4 - 4 + ); + expect( + (await itemRepo.findOneBy({ itemId: itemC }))?.reservedQuantity, + ).toBe( + 6, // 0 + 6 + ); + + // affected donation rechecked (called with the donation ids and the manager). + expect( + mockDonationService.recheckDonationAllocationStatus, + ).toHaveBeenCalledWith([donationId], expect.any(EntityManager)); + }); + + it('never calls the update repo when there are no allocations to update', async () => { + const fmId = await getFmId(FOODCORP); + const order = await insertOrder(fmId); + const donationId = await insertDonationForFm(fmId); + const itemC = await insertItem(donationId, 20, 0); + + const updateSpy = jest.spyOn(Repository.prototype, 'update'); + + const dto: UpdateAllocationsDto = { + allocations: [{ donationItemId: itemC, allocatedQuantity: 6 }], + }; + + await runInTransaction(order, dto); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('never calls the delete repo when there are no allocations to delete', async () => { + const fmId = await getFmId(FOODCORP); + const order = await insertOrder(fmId); + const donationId = await insertDonationForFm(fmId); + const itemA = await insertItem(donationId, 20, 5); + const allocA = await insertAllocation(order.orderId, itemA, 5); + + const removeSpy = jest.spyOn(Repository.prototype, 'remove'); + + const dto: UpdateAllocationsDto = { + allocations: [{ allocationId: allocA, allocatedQuantity: 8 }], + }; + + await runInTransaction(order, dto); + + expect(removeSpy).not.toHaveBeenCalled(); + }); + + it('never calls createMultiple when there are no allocations to create', async () => { + const fmId = await getFmId(FOODCORP); + const order = await insertOrder(fmId); + const donationId = await insertDonationForFm(fmId); + const itemA = await insertItem(donationId, 20, 5); + const allocA = await insertAllocation(order.orderId, itemA, 5); + + const createSpy = jest.spyOn(service, 'createMultiple'); + + const dto: UpdateAllocationsDto = { + allocations: [{ allocationId: allocA, allocatedQuantity: 8 }], + }; + + await runInTransaction(order, dto); + + expect(createSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/allocations/allocations.service.ts b/apps/backend/src/allocations/allocations.service.ts index cba5e6923..2b145bb83 100644 --- a/apps/backend/src/allocations/allocations.service.ts +++ b/apps/backend/src/allocations/allocations.service.ts @@ -1,9 +1,14 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EntityManager, Repository } from 'typeorm'; import { Allocation } from '../allocations/allocations.entity'; import { validateId } from '../utils/validation.utils'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { Donation } from '../donations/donations.entity'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { DonationService } from '../donations/donations.service'; +import { Order } from '../orders/order.entity'; +import { UpdateAllocationsDto } from '../orders/dtos/update-allocations.dto'; @Injectable() export class AllocationsService { @@ -11,6 +16,9 @@ export class AllocationsService { @InjectRepository(Allocation) private repo: Repository, @InjectRepository(DonationItem) private donationItemRepo: Repository, + @InjectRepository(Donation) private donationRepo: Repository, + private donationItemsService: DonationItemsService, + private donationService: DonationService, ) {} // This function assumes that orderId and itemAllocations were already correctly validated (see call in create method of OrdersService) @@ -52,4 +60,222 @@ export class AllocationsService { return targetAllocationRepo.save(allocations); } + + async deleteMultiple( + allocations: Allocation[], + transactionManager?: EntityManager, + ): Promise { + if (allocations.length === 0) return; + + const targetAllocationRepo = transactionManager + ? transactionManager.getRepository(Allocation) + : this.repo; + const targetItemRepo = transactionManager + ? transactionManager.getRepository(DonationItem) + : this.donationItemRepo; + + for (const allocation of allocations) { + await targetItemRepo.decrement( + { itemId: allocation.itemId }, + 'reservedQuantity', + allocation.allocatedQuantity, + ); + } + + await targetAllocationRepo.remove(allocations); + } + + async freeAllByOrder( + orderId: number, + transactionManager?: EntityManager, + ): Promise { + const targetAllocationRepo = transactionManager + ? transactionManager.getRepository(Allocation) + : this.repo; + + validateId(orderId, 'Order'); + + // All orders have allocations so this will have something. + const allocations = await targetAllocationRepo.find({ where: { orderId } }); + + await this.deleteMultiple(allocations, transactionManager); + } + + async updateOrderAllocations( + order: Order, + dto: UpdateAllocationsDto, + transactionManager: EntityManager, + ): Promise { + const allocationRepo = transactionManager.getRepository(Allocation); + const itemRepo = transactionManager.getRepository(DonationItem); + + // Parse the body into edits (existing allocations) and creates (new items). + const editQuantities = new Map(); + const createQuantities = new Map(); + + // Validate DTO IDs + for (const entry of dto.allocations) { + if (entry.allocationId != null && entry.donationItemId != null) { + throw new BadRequestException( + 'Each allocation may only contain one of: allocation id OR donation item id', + ); + } else if (entry.allocationId != null) { + if (editQuantities.has(entry.allocationId)) { + throw new BadRequestException( + `Duplicate allocation ID ${entry.allocationId} in request`, + ); + } + editQuantities.set(entry.allocationId, entry.allocatedQuantity); + } else if (entry.donationItemId != null) { + if (createQuantities.has(entry.donationItemId)) { + throw new BadRequestException( + `Duplicate donation item ID ${entry.donationItemId} in request`, + ); + } + createQuantities.set(entry.donationItemId, entry.allocatedQuantity); + } else { + throw new BadRequestException( + 'Each allocation must include either an allocationId or a donationItemId', + ); + } + } + + // Get all current allocations + const existingAllocations = await allocationRepo.find({ + where: { orderId: order.orderId }, + }); + const existingById = new Map( + existingAllocations.map((a) => [a.allocationId, a]), + ); + + // Verify all edited allocations belong to the order + for (const allocationId of editQuantities.keys()) { + if (!existingById.has(allocationId)) { + throw new BadRequestException( + `Allocation ${allocationId} does not belong to order ${order.orderId}`, + ); + } + } + + // Sort all current allocations by update or delete + const allocationsToUpdate: { allocationId: number; quantity: number }[] = + []; + const allocationsToDelete: Allocation[] = []; + const itemDeltas = new Map(); + const addDelta = (itemId: number, delta: number) => + itemDeltas.set(itemId, (itemDeltas.get(itemId) ?? 0) + delta); + + for (const existing of existingAllocations) { + const newQuantity = editQuantities.get(existing.allocationId); + if (newQuantity === undefined) { + // Was set to 0 on clientside, so thus not referenced and to be deleted + allocationsToDelete.push(existing); + addDelta(existing.itemId, -existing.allocatedQuantity); + } else { + // Calculate how much quantity needs to go back to the DI + allocationsToUpdate.push({ + allocationId: existing.allocationId, + quantity: newQuantity, + }); + addDelta(existing.itemId, newQuantity - existing.allocatedQuantity); + } + } + + // Populate create map and all quantities needed to be taken + const createMap = new Map(); + for (const [itemId, quantity] of createQuantities) { + createMap.set(itemId, quantity); + addDelta(itemId, quantity); + } + + // Validate that every single donation item being affected exists + const involvedItemIds = [ + ...new Set([ + ...existingAllocations.map((a) => a.itemId), + ...createMap.keys(), + ]), + ]; + const items = await this.donationItemsService.getByIds(involvedItemIds); + const itemsById = new Map(items.map((item) => [item.itemId, item])); + + // Verify all involved donation items are part of the FM donations + const fmDonations = await this.donationRepo.find({ + where: { + foodManufacturer: { foodManufacturerId: order.foodManufacturerId }, + }, + select: ['donationId'], + }); + const fmDonationIdSet = new Set(fmDonations.map((d) => d.donationId)); + const invalidItems = items.filter( + (item) => !fmDonationIdSet.has(item.donationId), + ); + if (invalidItems.length > 0) { + const messages = invalidItems.map( + (item) => + `Donation item ID ${item.itemId} with Donation ID ${item.donationId}`, + ); + throw new BadRequestException( + `The following donation items are not associated with the order's food manufacturer: ${messages.join( + ', ', + )}`, + ); + } + + // Make sure no item ends up over-allocated or below zero. + for (const [itemId, delta] of itemDeltas) { + const item = itemsById.get(itemId)!; + const resultingReserved = item.reservedQuantity + delta; + if (resultingReserved > item.quantity) { + throw new BadRequestException( + `Donation item ${itemId} allocated quantity exceeds remaining quantity`, + ); + } + if (resultingReserved < 0) { + throw new BadRequestException( + `Donation item ${itemId} would have a negative reserved quantity`, + ); + } + } + + // Update edited allocations in place, adjusting reservedQuantity + for (const { allocationId, quantity } of allocationsToUpdate) { + const existing = existingById.get(allocationId)!; + const delta = quantity - existing.allocatedQuantity; + await allocationRepo.update(allocationId, { + allocatedQuantity: quantity, + }); + if (delta > 0) { + await itemRepo.increment( + { itemId: existing.itemId }, + 'reservedQuantity', + delta, + ); + } else if (delta < 0) { + await itemRepo.decrement( + { itemId: existing.itemId }, + 'reservedQuantity', + -delta, + ); + } + } + + // Delete all allocations marked for deletion (handles freeing allocations) + if (allocationsToDelete.length > 0) { + await this.deleteMultiple(allocationsToDelete, transactionManager); + } + + // Create all new allocations + if (createMap.size > 0) { + await this.createMultiple(order.orderId, createMap, transactionManager); + } + + // Recheck affected donations' status to see which have become available or matched + const affectedDonationIds = [ + ...new Set(items.map((item) => item.donationId)), + ]; + await this.donationService.recheckDonationAllocationStatus( + affectedDonationIds, + transactionManager, + ); + } } diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 1c9a6ad53..22819c1f7 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -41,6 +41,7 @@ import { MakeFoodRescueRequired1773889925002 } from '../migrations/1773889925002 import { AddDonationItemConfirmation1774140453305 } from '../migrations/1774140453305-AddDonationItemConfirmation'; import { DonationItemsOnDeleteCascade1774214910101 } from '../migrations/1774214910101-DonationItemsOnDeleteCascade'; import { OrdersVolunteerActions1774883880543 } from '../migrations/1774883880543-OrdersVolunteerActions'; +import { AddOrderStatusClosed1780562894014 } from '../migrations/1780562894014-AddOrderStatusClosed'; import { UpdateFoodRequestTypesAndOrder1781476891610 } from '../migrations/1781476891610-UpdateFoodTypesAndOrder'; import { UpdatePantryFMApplicationInfo1780913024514 } from '../migrations/1780913024514-UpdatePantryFMApplicationInfo'; import { AddUserActiveField1780531200000 } from '../migrations/1780531200000-AddUserActiveField'; @@ -90,6 +91,7 @@ const schemaMigrations = [ AddDonationItemConfirmation1774140453305, DonationItemsOnDeleteCascade1774214910101, OrdersVolunteerActions1774883880543, + AddOrderStatusClosed1780562894014, UpdateFoodRequestTypesAndOrder1781476891610, UpdatePantryFMApplicationInfo1780913024514, AddUserActiveField1780531200000, diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index c57f3b7ac..493b4911e 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -9,7 +9,6 @@ import { DonationsSchedulerService } from './donations.scheduler'; import { DonationItem } from '../donationItems/donationItems.entity'; import { DonationItemsModule } from '../donationItems/donationItems.module'; import { Allocation } from '../allocations/allocations.entity'; -import { AllocationModule } from '../allocations/allocations.module'; import { EmailsModule } from '../emails/email.module'; import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; @@ -23,7 +22,6 @@ import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; ]), forwardRef(() => AuthModule), DonationItemsModule, - AllocationModule, EmailsModule, ManufacturerModule, ], diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index e4e2bad24..a27dd958e 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -225,6 +225,49 @@ describe('DonationService', () => { expect(service).toBeDefined(); }); + describe('recheckDonationAllocationStatus', () => { + it('does not update any donations for an empty list', async () => { + const donationRepo = testDataSource.getRepository(Donation); + const updateSpy = jest.spyOn(donationRepo, 'update'); + + await service.recheckDonationAllocationStatus([]); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('sets a donation with allocations to MATCHED and one without to AVAILABLE', async () => { + const donationRepo = testDataSource.getRepository(Donation); + + const toMatchId = await insertDonation({ + recurrence: RecurrenceEnum.NONE, + recurrenceFreq: null, + nextDonationDates: null, + occurrencesRemaining: null, + }); + const matchedItemId = await insertDonationItem(toMatchId, 10, 1); + await insertAllocation(4, matchedItemId); + + const toFreeId = await insertMatchedDonation(); + await insertDonationItem(toFreeId, 10, 0); + + const before = await donationRepo.findBy({ + donationId: In([toMatchId, toFreeId]), + }); + const beforeById = new Map(before.map((d) => [d.donationId, d.status])); + expect(beforeById.get(toMatchId)).toBe(DonationStatus.AVAILABLE); + expect(beforeById.get(toFreeId)).toBe(DonationStatus.MATCHED); + + await service.recheckDonationAllocationStatus([toMatchId, toFreeId]); + + const after = await donationRepo.findBy({ + donationId: In([toMatchId, toFreeId]), + }); + const afterById = new Map(after.map((d) => [d.donationId, d.status])); + expect(afterById.get(toMatchId)).toBe(DonationStatus.MATCHED); + expect(afterById.get(toFreeId)).toBe(DonationStatus.AVAILABLE); + }); + }); + describe('findOne', () => { it('should return a donation with the corresponding id', async () => { const donationId = 1; diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 48fb899ea..d8308880a 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -483,6 +483,32 @@ export class DonationService { return donation; } + async recheckDonationAllocationStatus( + donationIds: number[], + transactionManager?: EntityManager, + ): Promise { + const donationRepo = transactionManager + ? transactionManager.getRepository(Donation) + : this.repo; + const allocationRepo = transactionManager + ? transactionManager.getRepository(Allocation) + : this.allocationRepo; + + for (const donationId of donationIds) { + validateId(donationId, 'Donation'); + + const hasAllocations = await allocationRepo.exists({ + where: { item: { donation: { donationId } } }, + }); + + await donationRepo.update(donationId, { + status: hasAllocations + ? DonationStatus.MATCHED + : DonationStatus.AVAILABLE, + }); + } + } + async delete(donationId: number): Promise { validateId(donationId, 'Donation'); diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index afba14e6c..b0199ec7a 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -414,6 +414,36 @@ describe('RequestsService', () => { expect(request.status).toBe(FoodRequestStatus.CLOSED); }); + it('should close the request when its orders are a mix of delivered and closed', async () => { + const requestId = 1; + const orderRepo = testDataSource.getRepository(Order); + + // Seed request 1 has a single delivered order. Add a second, closed order + // so the request has one of each terminal status to exercise the + // delivered-or-closed "all complete" check. + const [deliveredOrder] = await orderRepo.find({ where: { requestId } }); + expect(deliveredOrder.status).toBe(OrderStatus.DELIVERED); + + await orderRepo.save( + orderRepo.create({ + requestId, + foodManufacturerId: deliveredOrder.foodManufacturerId, + assigneeId: deliveredOrder.assigneeId, + status: OrderStatus.CLOSED, + }), + ); + + const orders = await orderRepo.find({ where: { requestId } }); + expect(orders.map((o) => o.status).sort()).toEqual( + [OrderStatus.CLOSED, OrderStatus.DELIVERED].sort(), + ); + + await service.updateRequestStatus(requestId); + + const request = await service.findOne(requestId); + expect(request.status).toBe(FoodRequestStatus.CLOSED); + }); + it('should update request status to active since all orders are not delivered', async () => { const requestId = 3; diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index f9c77a31f..f6d93495f 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -326,17 +326,19 @@ export class RequestsService { throw new BadRequestException(`Request ${requestId} is already closed`); } - const allDelivered = orders.every( - (order) => order.status === OrderStatus.DELIVERED, + const allComplete = orders.every( + (order) => + order.status === OrderStatus.DELIVERED || + order.status === OrderStatus.CLOSED, ); - request.status = allDelivered + request.status = allComplete ? FoodRequestStatus.CLOSED : FoodRequestStatus.ACTIVE; await this.repo.save(request); - if (allDelivered) { + if (allComplete) { try { const lastDeliveredOrder = await this.orderRepo.findOne({ where: { requestId, status: OrderStatus.DELIVERED }, diff --git a/apps/backend/src/migrations/1780562894014-AddOrderStatusClosed.ts b/apps/backend/src/migrations/1780562894014-AddOrderStatusClosed.ts new file mode 100644 index 000000000..2621779b5 --- /dev/null +++ b/apps/backend/src/migrations/1780562894014-AddOrderStatusClosed.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOrderStatusClosed1780562894014 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE orders + ALTER COLUMN status DROP DEFAULT; + + CREATE TYPE orders_status_enum_new AS ENUM ( + 'delivered', + 'pending', + 'shipped', + 'closed' + ); + + ALTER TABLE orders + ALTER COLUMN status + TYPE orders_status_enum_new + USING status::text::orders_status_enum_new; + + DROP TYPE orders_status_enum; + + ALTER TYPE orders_status_enum_new + RENAME TO orders_status_enum; + + ALTER TABLE orders + ALTER COLUMN status + SET DEFAULT 'pending'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE orders + ALTER COLUMN status DROP DEFAULT; + + CREATE TYPE orders_status_enum_old AS ENUM ( + 'delivered', + 'pending', + 'shipped' + ); + + ALTER TABLE orders + ALTER COLUMN status + TYPE orders_status_enum_old + USING ( + CASE + WHEN status = 'closed' + THEN 'pending' + ELSE status::text + END + )::orders_status_enum_old; + + DROP TYPE orders_status_enum; + + ALTER TYPE orders_status_enum_old + RENAME TO orders_status_enum; + + ALTER TABLE orders + ALTER COLUMN status + SET DEFAULT 'pending'; + `); + } +} diff --git a/apps/backend/src/orders/dtos/update-allocations.dto.ts b/apps/backend/src/orders/dtos/update-allocations.dto.ts new file mode 100644 index 000000000..ef9f2c921 --- /dev/null +++ b/apps/backend/src/orders/dtos/update-allocations.dto.ts @@ -0,0 +1,31 @@ +import { + IsArray, + IsInt, + IsOptional, + Min, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class AllocationUpdateDto { + @IsOptional() + @IsInt() + @Min(1) + allocationId?: number; + + @IsOptional() + @IsInt() + @Min(1) + donationItemId?: number; + + @IsInt() + @Min(1) + allocatedQuantity!: number; +} + +export class UpdateAllocationsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AllocationUpdateDto) + allocations!: AllocationUpdateDto[]; +} diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index e4ce974bc..2bcc0d9ec 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -234,29 +234,6 @@ describe('OrdersController', () => { }); }); - describe('updateStatus', () => { - it('should call ordersService.updateStatus', async () => { - const status = OrderStatus.DELIVERED; - const orderId = 1; - - await controller.updateStatus(orderId, status); - - expect(mockOrdersService.updateStatus).toHaveBeenCalledWith( - orderId, - status, - ); - }); - - it('should throw with invalid status', async () => { - const invalidStatus = 'invalid status'; - const orderId = 1; - - await expect( - controller.updateStatus(orderId, invalidStatus), - ).rejects.toThrow(new BadRequestException('Invalid status')); - }); - }); - describe('bulkUpdateTrackingCostInfo', () => { it('should call ordersService.bulkUpdateTrackingCostInfo with correct parameters', async () => { const dto: BulkUpdateTrackingCostDto = { diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 09f12a65a..eec2205e7 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -18,6 +18,7 @@ import { ApiBody } from '@nestjs/swagger'; import { OrdersService } from './order.service'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; +import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; import { CheckOwnership, @@ -38,6 +39,7 @@ import * as multer from 'multer'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; import { CompleteVolunteerActionDto } from './dtos/complete-volunteer-action.dto'; import { CreateOrderDto } from './dtos/create-order.dto'; +import { UpdateAllocationsDto } from './dtos/update-allocations.dto'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; @@ -211,22 +213,6 @@ export class OrdersController { ); } - @Roles(Role.VOLUNTEER) - @CheckOwnership({ - idParam: 'orderId', - resolver: resolveOrderAuthorizedUserIds, - }) - @Patch('/update-status/:orderId') - async updateStatus( - @Param('orderId', ParseIntPipe) orderId: number, - @Body('newStatus') newStatus: string, - ): Promise { - if (!Object.values(OrderStatus).includes(newStatus as OrderStatus)) { - throw new BadRequestException('Invalid status'); - } - return this.ordersService.updateStatus(orderId, newStatus as OrderStatus); - } - @Roles(Role.FOODMANUFACTURER) @CheckOwnership({ idParam: 'donationId', @@ -320,4 +306,29 @@ export class OrdersController { ): Promise { await this.ordersService.completeVolunteerAction(orderId, dto.action); } + + @CheckOwnership({ + idParam: 'orderId', + resolver: resolveOrderAuthorizedUserIds, + }) + @Roles(Role.VOLUNTEER) + @Patch('/:orderId/close') + async closeOrder( + @Param('orderId', ParseIntPipe) orderId: number, + ): Promise { + await this.ordersService.closeOrder(orderId); + } + + @CheckOwnership({ + idParam: 'orderId', + resolver: resolveOrderAuthorizedUserIds, + }) + @Roles(Role.VOLUNTEER) + @Patch('/:orderId/allocations') + async editAllocations( + @Param('orderId', ParseIntPipe) orderId: number, + @Body(new ValidationPipe()) dto: UpdateAllocationsDto, + ): Promise { + await this.ordersService.updateAllocations(orderId, dto); + } } diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 2ebe78a32..e47dab219 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -30,7 +30,14 @@ import { AuthService } from '../auth/auth.service'; import { DonationService } from '../donations/donations.service'; import { PantriesService } from '../pantries/pantries.service'; import { CreateOrderDto } from './dtos/create-order.dto'; -import { DataSource, EntityManager, In } from 'typeorm'; +import { UpdateAllocationsDto } from './dtos/update-allocations.dto'; +import { + DataSource, + EntityManager, + In, + ObjectLiteral, + Repository, +} from 'typeorm'; import { EmailsService } from '../emails/email.service'; import { Allocation } from '../allocations/allocations.entity'; import { mock } from 'jest-mock-extended'; @@ -45,6 +52,7 @@ const mockEmailsService = mock(); describe('OrdersService', () => { let service: OrdersService; let donationService: DonationService; + let allocationsService: AllocationsService; beforeAll(async () => { mockEmailsService.sendEmails.mockResolvedValue(undefined); @@ -121,6 +129,7 @@ describe('OrdersService', () => { service = module.get(OrdersService); donationService = module.get(DonationService); + allocationsService = module.get(AllocationsService); }); beforeEach(async () => { @@ -345,36 +354,6 @@ describe('OrdersService', () => { }); }); - describe('updateStatus', () => { - it('updates order status to delivered', async () => { - const orderId = 3; - const order = await service.findOne(orderId); - - expect(order.status).toEqual(OrderStatus.SHIPPED); - expect(order.shippedAt).toBeDefined(); - - await service.updateStatus(orderId, OrderStatus.DELIVERED); - const updatedOrder = await service.findOne(orderId); - - expect(updatedOrder.status).toEqual(OrderStatus.DELIVERED); - expect(updatedOrder.deliveredAt).toBeDefined(); - }); - - it('updates order status to shipped', async () => { - const orderId = 4; - const order = await service.findOne(orderId); - - expect(order.status).toEqual(OrderStatus.PENDING); - - await service.updateStatus(orderId, OrderStatus.SHIPPED); - const updatedOrder = await service.findOne(orderId); - - expect(updatedOrder.status).toEqual(OrderStatus.SHIPPED); - expect(updatedOrder.shippedAt).toBeDefined(); - expect(updatedOrder.deliveredAt).toBeNull(); - }); - }); - describe('getOrdersByPantry', () => { it('returns order from pantry ID', async () => { const pantryId = 1; @@ -1209,6 +1188,310 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ }); }); + describe('closeOrder', () => { + const userId = 3; + let validCreateOrderDto: CreateOrderDto; + let parsedAllocations: Map; + + beforeEach(() => { + validCreateOrderDto = { + foodRequestId: 1, + manufacturerId: 1, + itemAllocations: { + 1: 10, + 2: 3, + }, + }; + + parsedAllocations = new Map([ + [1, 10], + [2, 3], + ]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Creates a pending order (reserving items 1 and 2) and returns the + // post-create state so rollback tests can assert nothing changed. + const createPendingOrder = async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + const donationItemRepo = testDataSource.getRepository(DonationItem); + + const createdOrder = await service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + parsedAllocations, + userId, + ); + + const allocationsBefore = await allocationRepo.find({ + where: { orderId: createdOrder.orderId }, + }); + const item1Before = (await donationItemRepo.findOne({ + where: { itemId: 1 }, + })) as DonationItem; + const item2Before = (await donationItemRepo.findOne({ + where: { itemId: 2 }, + })) as DonationItem; + + return { + orderId: createdOrder.orderId, + allocationRepo, + donationItemRepo, + allocationsBefore, + item1Before, + item2Before, + }; + }; + + it('sets the order status to CLOSED when everything succeeds', async () => { + const { orderId } = await createPendingOrder(); + + await service.closeOrder(orderId); + + expect((await service.findOne(orderId)).status).toBe(OrderStatus.CLOSED); + }); + + it('frees reserved quantity and recomputes donation status on success', async () => { + const donationItemRepo = testDataSource.getRepository(DonationItem); + const donationRepo = testDataSource.getRepository(Donation); + + // Seed order 4 is the pending order. It allocates: + // - Cereal Boxes: the only allocation on its donation, which should + // therefore transition to AVAILABLE once the order is closed. + // - Almond Milk: its donation also has allocations from other orders, + // so it should remain MATCHED. + const orderId = 4; + + const cerealBefore = (await donationItemRepo.findOne({ + where: { itemName: 'Cereal Boxes' }, + })) as DonationItem; + const almondBefore = (await donationItemRepo.findOne({ + where: { itemName: 'Almond Milk' }, + })) as DonationItem; + + // Both donations are allocated by the pending order 4, so the + // domain-correct starting status for each is MATCHED (a donation with a + // pending order is never FULFILLED). Set that explicitly so the before + // state is accurate and the post-close transitions are meaningful. + await donationRepo.update( + { donationId: In([cerealBefore.donationId, almondBefore.donationId]) }, + { status: DonationStatus.MATCHED }, + ); + const cerealDonationBefore = (await donationRepo.findOne({ + where: { donationId: cerealBefore.donationId }, + })) as Donation; + const almondDonationBefore = (await donationRepo.findOne({ + where: { donationId: almondBefore.donationId }, + })) as Donation; + + // Confirm the order is not already closed and the starting state we rely on. + const orderBefore = await service.findOne(orderId); + expect(orderBefore.status).toBe(OrderStatus.PENDING); + expect(cerealBefore.reservedQuantity).toBe(75); + expect(almondBefore.reservedQuantity).toBe(20); + expect(cerealDonationBefore.status).toBe(DonationStatus.MATCHED); + expect(almondDonationBefore.status).toBe(DonationStatus.MATCHED); + + await service.closeOrder(orderId); + + expect((await service.findOne(orderId)).status).toBe(OrderStatus.CLOSED); + + const cerealAfter = (await donationItemRepo.findOne({ + where: { itemId: cerealBefore.itemId }, + })) as DonationItem; + const almondAfter = (await donationItemRepo.findOne({ + where: { itemId: almondBefore.itemId }, + })) as DonationItem; + const cerealDonationAfter = (await donationRepo.findOne({ + where: { donationId: cerealBefore.donationId }, + })) as Donation; + const almondDonationAfter = (await donationRepo.findOne({ + where: { donationId: almondBefore.donationId }, + })) as Donation; + + // Order 4 reserved 75 cereal boxes and 10 almond milk; both are released. + expect(cerealAfter.reservedQuantity).toBe(0); + expect(almondAfter.reservedQuantity).toBe(10); + + // Cereal's donation has no remaining allocations -> AVAILABLE. + expect(cerealDonationAfter.status).toBe(DonationStatus.AVAILABLE); + // Almond milk's donation still has allocations from other orders -> MATCHED. + expect(almondDonationAfter.status).toBe(DonationStatus.MATCHED); + }); + + it('throws NotFoundException if the order does not exist', async () => { + const nonExistentOrderId = 999; + await expect(service.closeOrder(nonExistentOrderId)).rejects.toThrow( + new NotFoundException(`Order ${nonExistentOrderId} not found`), + ); + }); + + it('throws BadRequestException if the order is not pending', async () => { + const orderRepo = testDataSource.getRepository(Order); + const { orderId } = await createPendingOrder(); + + await orderRepo.update({ orderId }, { status: OrderStatus.SHIPPED }); + + await expect(service.closeOrder(orderId)).rejects.toThrow( + new BadRequestException(`Order ${orderId} must be pending`), + ); + + expect((await service.findOne(orderId)).status).not.toBe( + OrderStatus.CLOSED, + ); + }); + + it('rolls back all changes when recheckDonationAllocationStatus fails', async () => { + const { + orderId, + allocationRepo, + donationItemRepo, + allocationsBefore, + item1Before, + item2Before, + } = await createPendingOrder(); + + jest + .spyOn( + (service as any).donationService as DonationService, + 'recheckDonationAllocationStatus', + ) + .mockRejectedValueOnce(new Error('DB error')); + + await expect(service.closeOrder(orderId)).rejects.toThrow('DB error'); + + const orderAfter = await service.findOne(orderId); + expect(orderAfter.status).not.toBe(OrderStatus.CLOSED); + expect(orderAfter.status).toBe(OrderStatus.PENDING); + expect(await allocationRepo.find({ where: { orderId } })).toHaveLength( + allocationsBefore.length, + ); + + const item1After = (await donationItemRepo.findOne({ + where: { itemId: 1 }, + })) as DonationItem; + const item2After = (await donationItemRepo.findOne({ + where: { itemId: 2 }, + })) as DonationItem; + expect(item1After.reservedQuantity).toBe(item1Before.reservedQuantity); + expect(item2After.reservedQuantity).toBe(item2Before.reservedQuantity); + }); + + it('rolls back all changes when the order status update fails', async () => { + const { + orderId, + allocationRepo, + donationItemRepo, + allocationsBefore, + item1Before, + item2Before, + } = await createPendingOrder(); + + const originalUpdate = Repository.prototype.update; + jest + .spyOn(Repository.prototype, 'update') + .mockImplementation(function ( + this: Repository, + ...args: Parameters + ) { + if (this.metadata.target === Order) { + return Promise.reject(new Error('DB error')); + } + return originalUpdate.apply(this, args); + }); + + await expect(service.closeOrder(orderId)).rejects.toThrow('DB error'); + + jest.restoreAllMocks(); + + const orderAfter = await service.findOne(orderId); + expect(orderAfter.status).not.toBe(OrderStatus.CLOSED); + expect(orderAfter.status).toBe(OrderStatus.PENDING); + expect(await allocationRepo.find({ where: { orderId } })).toHaveLength( + allocationsBefore.length, + ); + + const item1After = (await donationItemRepo.findOne({ + where: { itemId: 1 }, + })) as DonationItem; + const item2After = (await donationItemRepo.findOne({ + where: { itemId: 2 }, + })) as DonationItem; + expect(item1After.reservedQuantity).toBe(item1Before.reservedQuantity); + expect(item2After.reservedQuantity).toBe(item2Before.reservedQuantity); + }); + }); + + describe('updateAllocations', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const sampleDto: UpdateAllocationsDto = { + allocations: [{ donationItemId: 1, allocatedQuantity: 1 }], + }; + + const insertPendingOrder = async (): 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 no allocations are provided', async () => { + await expect( + service.updateAllocations(1, { allocations: [] }), + ).rejects.toThrow( + new BadRequestException('Must add or edit at least one allocation'), + ); + }); + + it('throws NotFoundException when the order does not exist', async () => { + const missingOrderId = 999999; + await expect( + service.updateAllocations(missingOrderId, sampleDto), + ).rejects.toThrow( + new NotFoundException(`Order ${missingOrderId} not found`), + ); + }); + + it('throws BadRequestException when the order is not pending', async () => { + const orderId = await insertPendingOrder(); + await testDataSource + .getRepository(Order) + .update({ orderId }, { status: OrderStatus.SHIPPED }); + + await expect( + service.updateAllocations(orderId, sampleDto), + ).rejects.toThrow( + new BadRequestException(`Order ${orderId} must be pending`), + ); + }); + + it('delegates to allocationsService.updateOrderAllocations with the order, dto, and transaction manager', async () => { + const orderId = await insertPendingOrder(); + const updateOrderAllocationsSpy = jest + .spyOn(allocationsService, 'updateOrderAllocations') + .mockResolvedValue(undefined); + + await service.updateAllocations(orderId, sampleDto); + + expect(updateOrderAllocationsSpy).toHaveBeenCalledWith( + expect.objectContaining({ orderId, status: OrderStatus.PENDING }), + sampleDto, + expect.anything(), + ); + }); + }); + describe('getAllOrdersForVolunteer', () => { it('should return all orders across all pantries and assignees, with required actions for assigned orders', async () => { const volunteerId = 6; @@ -1231,15 +1514,15 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ it('should map the rest of the data correctly', async () => { const volunteerId = 6; const result = await service.getAllOrdersForVolunteer(volunteerId); - const firstOrder = result[0]; - - expect(firstOrder.orderId).toBe(4); - expect(firstOrder.status).toBe(OrderStatus.PENDING); - expect(firstOrder).toHaveProperty('createdAt'); - expect(firstOrder).toHaveProperty('shippedAt'); - expect(firstOrder).toHaveProperty('deliveredAt'); - expect(firstOrder.pantryName).toBe('Community Food Pantry Downtown'); - expect(firstOrder.assignee.id).toBe(volunteerId); + const order = result.find((o) => o.orderId === 4); + + expect(order).toBeDefined(); + expect(order?.status).toBe(OrderStatus.PENDING); + expect(order).toHaveProperty('createdAt'); + expect(order).toHaveProperty('shippedAt'); + expect(order).toHaveProperty('deliveredAt'); + expect(order?.pantryName).toBe('Community Food Pantry Downtown'); + expect(order?.assignee.id).toBe(volunteerId); }); }); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 03d658fe4..f1c246b35 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -14,6 +14,7 @@ import { DonationService } from '../donations/donations.service'; import { OrderStatus, VolunteerAction } from './types'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; +import { UpdateAllocationsDto } from './dtos/update-allocations.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; import { RequestsService } from '../foodRequests/request.service'; @@ -23,6 +24,7 @@ import { FoodRequestStatus } from '../foodRequests/types'; import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { AllocationsService } from '../allocations/allocations.service'; +import { Allocation } from '../allocations/allocations.entity'; import { ApplicationStatus } from '../shared/types'; import { VolunteerOrder } from '../volunteers/types'; import { EmailsService } from '../emails/email.service'; @@ -491,22 +493,6 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ }; } - async updateStatus(orderId: number, newStatus: OrderStatus) { - validateId(orderId, 'Order'); - - await this.repo - .createQueryBuilder() - .update(Order) - .set({ - status: newStatus as OrderStatus, - shippedAt: newStatus === OrderStatus.SHIPPED ? new Date() : undefined, - deliveredAt: - newStatus === OrderStatus.DELIVERED ? new Date() : undefined, - }) - .where('order_id = :orderId', { orderId }) - .execute(); - } - async confirmDelivery( orderId: number, dto: ConfirmDeliveryDto, @@ -814,4 +800,68 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ await this.repo.save(order); } + + async closeOrder(orderId: number): Promise { + validateId(orderId, 'Order'); + + const order = await this.repo.findOneBy({ orderId }); + + if (!order) { + throw new NotFoundException(`Order ${orderId} not found`); + } + + if (order.status !== OrderStatus.PENDING) { + throw new BadRequestException(`Order ${orderId} must be pending`); + } + + await this.dataSource.transaction(async (transactionManager) => { + // Capture which donations are affected before allocations are removed + const allocations = await transactionManager + .getRepository(Allocation) + .find({ where: { orderId }, relations: ['item'] }); + const donationIds = [ + ...new Set(allocations.map((allocation) => allocation.item.donationId)), + ]; + + await this.allocationsService.freeAllByOrder(orderId, transactionManager); + + await this.donationService.recheckDonationAllocationStatus( + donationIds, + transactionManager, + ); + + await transactionManager + .getRepository(Order) + .update({ orderId }, { status: OrderStatus.CLOSED }); + }); + } + + async updateAllocations( + orderId: number, + dto: UpdateAllocationsDto, + ): Promise { + validateId(orderId, 'Order'); + + if (dto.allocations.length == 0) { + throw new BadRequestException('Must add or edit at least one allocation'); + } + + await this.dataSource.transaction(async (transactionManager) => { + const order = await transactionManager + .getRepository(Order) + .findOneBy({ orderId }); + if (!order) { + throw new NotFoundException(`Order ${orderId} not found`); + } + if (order.status !== OrderStatus.PENDING) { + throw new BadRequestException(`Order ${orderId} must be pending`); + } + + await this.allocationsService.updateOrderAllocations( + order, + dto, + transactionManager, + ); + }); + } } diff --git a/apps/backend/src/orders/types.ts b/apps/backend/src/orders/types.ts index 2966034d7..7eb899ca3 100644 --- a/apps/backend/src/orders/types.ts +++ b/apps/backend/src/orders/types.ts @@ -2,6 +2,7 @@ export enum OrderStatus { DELIVERED = 'delivered', PENDING = 'pending', SHIPPED = 'shipped', + CLOSED = 'closed', } export enum VolunteerAction { diff --git a/apps/frontend/src/components/dashboardCard.tsx b/apps/frontend/src/components/dashboardCard.tsx index ad8208d6d..406933496 100644 --- a/apps/frontend/src/components/dashboardCard.tsx +++ b/apps/frontend/src/components/dashboardCard.tsx @@ -57,6 +57,11 @@ export const ORDER_STATUS_BADGE: Record = { bg: ORDER_STATUS_COLORS[OrderStatus.DELIVERED][0], color: ORDER_STATUS_COLORS[OrderStatus.DELIVERED][1], }, + [OrderStatus.CLOSED]: { + label: ORDER_STATUS_LABELS[OrderStatus.CLOSED], + bg: ORDER_STATUS_COLORS[OrderStatus.CLOSED][0], + color: ORDER_STATUS_COLORS[OrderStatus.CLOSED][1], + }, }; export const DONATION_STATUS_BADGE: Record = diff --git a/apps/frontend/src/components/forms/requestDetailsModal.tsx b/apps/frontend/src/components/forms/requestDetailsModal.tsx index 1bafe78c4..c745ffddd 100644 --- a/apps/frontend/src/components/forms/requestDetailsModal.tsx +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -5,15 +5,13 @@ import { FoodRequestSummaryDto, FoodRequestStatus, AlertStatus, -} from '../../types/types'; -import { OrderStatus, RequestSize, FoodType, User, Role, } from '../../types/types'; -import { ORDER_STATUS_LABELS } from '@utils/utils'; +import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '@utils/utils'; import React, { useState, useEffect } from 'react'; import { Flex, diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index 79ab23b39..dc293f146 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -47,6 +47,7 @@ const AdminOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: [], [OrderStatus.PENDING]: [], [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], }); // State to hold selected order for details modal @@ -58,6 +59,7 @@ const AdminOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: 1, [OrderStatus.PENDING]: 1, [OrderStatus.DELIVERED]: 1, + [OrderStatus.CLOSED]: 1, }, ); @@ -95,6 +97,11 @@ const AdminOrderManagement: React.FC = () => { searchPantry: '', sortAsc: false, }, + [OrderStatus.CLOSED]: { + selectedPantries: [], + searchPantry: '', + sortAsc: false, + }, }); const MAX_PER_STATUS = 5; @@ -109,6 +116,7 @@ const AdminOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: [], [OrderStatus.PENDING]: [], [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], }; for (const order of data) { @@ -130,6 +138,7 @@ const AdminOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: 1, [OrderStatus.PENDING]: 1, [OrderStatus.DELIVERED]: 1, + [OrderStatus.CLOSED]: 1, }; setCurrentPages(initialPages); } catch { @@ -209,6 +218,10 @@ const AdminOrderManagement: React.FC = () => { ...prev[OrderStatus.DELIVERED], selectedPantries: [pantryName], }, + [OrderStatus.CLOSED]: { + ...prev[OrderStatus.CLOSED], + selectedPantries: [pantryName], + }, })); } else { setAlertMessage('Selected pantry has no orders', AlertStatus.ERROR); diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index 865222a48..d4b66c447 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -43,6 +43,7 @@ const PantryOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: [], [OrderStatus.PENDING]: [], [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], }); // State to hold selected order for details modal @@ -57,6 +58,7 @@ const PantryOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: 1, [OrderStatus.PENDING]: 1, [OrderStatus.DELIVERED]: 1, + [OrderStatus.CLOSED]: 1, }, ); @@ -84,6 +86,9 @@ const PantryOrderManagement: React.FC = () => { [OrderStatus.DELIVERED]: { sortAsc: false, }, + [OrderStatus.CLOSED]: { + sortAsc: false, + }, }); const fetchOrders = useCallback(async () => { @@ -94,6 +99,7 @@ const PantryOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: [], [OrderStatus.PENDING]: [], [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], }; for (const order of data) { @@ -111,6 +117,7 @@ const PantryOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: 1, [OrderStatus.PENDING]: 1, [OrderStatus.DELIVERED]: 1, + [OrderStatus.CLOSED]: 1, }; setCurrentPages(initialPages); } catch { diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index bc4787d63..bcc1acbbe 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -64,6 +64,7 @@ const VolunteerOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: [], [OrderStatus.PENDING]: [], [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], }); const [selectedOrderId, setSelectedOrderId] = useState(null); @@ -75,6 +76,7 @@ const VolunteerOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: 1, [OrderStatus.PENDING]: 1, [OrderStatus.DELIVERED]: 1, + [OrderStatus.CLOSED]: 1, }, ); @@ -107,6 +109,11 @@ const VolunteerOrderManagement: React.FC = () => { searchPantry: '', sortAsc: false, }, + [OrderStatus.CLOSED]: { + selectedPantries: [], + searchPantry: '', + sortAsc: false, + }, }); const MAX_PER_STATUS = 5; @@ -135,6 +142,7 @@ const VolunteerOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: [], [OrderStatus.PENDING]: [], [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], }; for (const order of data) { @@ -157,6 +165,7 @@ const VolunteerOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: 1, [OrderStatus.PENDING]: 1, [OrderStatus.DELIVERED]: 1, + [OrderStatus.CLOSED]: 1, }; setCurrentPages(initialPages); } catch { @@ -226,6 +235,10 @@ const VolunteerOrderManagement: React.FC = () => { ...prev[OrderStatus.DELIVERED], selectedPantries: [pantryName], }, + [OrderStatus.CLOSED]: { + ...prev[OrderStatus.CLOSED], + selectedPantries: [pantryName], + }, })); } else { setAlertMessage('Selected pantry has no orders', AlertStatus.ERROR); diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 8f7195de6..011955136 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -508,6 +508,7 @@ export enum OrderStatus { SHIPPED = 'shipped', PENDING = 'pending', DELIVERED = 'delivered', + CLOSED = 'closed', } export enum RequestSize { diff --git a/apps/frontend/src/utils/utils.ts b/apps/frontend/src/utils/utils.ts index f56049957..06ee9ca9f 100644 --- a/apps/frontend/src/utils/utils.ts +++ b/apps/frontend/src/utils/utils.ts @@ -9,18 +9,21 @@ import { export const YELLOW_STATUS: [string, string] = ['yellow.200', 'yellow.hover']; export const BLUE_STATUS: [string, string] = ['blue.100', 'blue.core']; export const TEAL_STATUS: [string, string] = ['teal.200', 'teal.hover']; +export const GRAY_STATUS: [string, string] = ['neutral.100', 'neutral.700']; // color mapping for order/donation statuses, the first color is background, the second is color for status text export const ORDER_STATUS_COLORS: Record = { [OrderStatus.SHIPPED]: YELLOW_STATUS, [OrderStatus.PENDING]: BLUE_STATUS, [OrderStatus.DELIVERED]: TEAL_STATUS, + [OrderStatus.CLOSED]: GRAY_STATUS, }; export const ORDER_STATUS_LABELS: Record = { [OrderStatus.PENDING]: 'Received', [OrderStatus.SHIPPED]: 'In Progress', [OrderStatus.DELIVERED]: 'Completed', + [OrderStatus.CLOSED]: 'Closed', }; export const DONATION_STATUS_COLORS: Record =