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 395c88ecb..e381d1d2b 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, food_type, details_confirmed) + VALUES ($1, 'Test Item', $2, $3, '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`); }); @@ -222,4 +301,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, food_type, details_confirmed) + VALUES (1, 'Item A', 20, 5, 'Granola', false) RETURNING item_id`, + ); + const [{ item_id: itemBId }] = await testDataSource.query( + `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, details_confirmed) + VALUES (1, 'Item B', 20, 8, '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 bd951892c..ffd5155a1 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, ) {} async getAllAllocationsByOrder( @@ -65,4 +73,224 @@ 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 || newQuantity === 0) { + // Not referenced, or explicitly set to 0, so delete + 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) { + if (quantity > 0) { + 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 65a88e352..a7fe305a7 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'; const schemaMigrations = [ User1725726359198, @@ -86,6 +87,7 @@ const schemaMigrations = [ AddDonationItemConfirmation1774140453305, DonationItemsOnDeleteCascade1774214910101, OrdersVolunteerActions1774883880543, + AddOrderStatusClosed1780562894014, ]; export default schemaMigrations; 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 6fa0bcda3..88874d043 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -206,6 +206,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 7fe8664b3..753669a71 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -431,6 +431,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/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.ts b/apps/backend/src/orders/order.controller.ts index f53f25a5d..f5179f781 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -19,7 +19,11 @@ import { OrdersService } from './order.service'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { AllocationsService } from '../allocations/allocations.service'; -import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { + CheckOwnership, + OwnerIdResolver, + pipeNullable, +} from '../auth/ownership.decorator'; import { PantriesService } from '../pantries/pantries.service'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; @@ -30,11 +34,31 @@ 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'; import { OrderStatus } from './types'; +const resolveOrderAuthorizedUserIds: OwnerIdResolver = ({ + entityId, + services, + user, +}) => { + if (user?.role === Role.VOLUNTEER) { + return pipeNullable( + () => services.get(OrdersService).findOne(entityId), + (order: Order) => [order.assigneeId], + ); + } + return pipeNullable( + () => services.get(OrdersService).findOrderFoodRequest(entityId), + (request: FoodRequestSummaryDto) => + services.get(PantriesService).findOne(request.pantry.pantryId), + (pantry: Pantry) => [pantry.pantryUser.id], + ); +}; + @Controller('orders') export class OrdersController { constructor( @@ -271,4 +295,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 2aee388f1..d22676167 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'; @@ -44,6 +51,7 @@ const mockEmailsService = mock(); describe('OrdersService', () => { let service: OrdersService; let donationService: DonationService; + let allocationsService: AllocationsService; beforeAll(async () => { mockEmailsService.sendEmails.mockResolvedValue(undefined); @@ -120,6 +128,7 @@ describe('OrdersService', () => { service = module.get(OrdersService); donationService = module.get(DonationService); + allocationsService = module.get(AllocationsService); }); beforeEach(async () => { @@ -1169,6 +1178,240 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ expect(donationItem1?.reservedQuantity).toBe(10); }); }); + + 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('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 matchAll fails', async () => { + const { + orderId, + allocationRepo, + donationItemRepo, + allocationsBefore, + item1Before, + item2Before, + } = await createPendingOrder(); + + jest + .spyOn((service as any).donationService as DonationService, 'matchAll') + .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; @@ -1191,15 +1434,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 1018c940c..c0de5a200 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'; @@ -749,4 +751,66 @@ ${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); + + // Donation should always have items matched to it + await this.donationService.matchAll(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 23c4e9e80..7f4547a99 100644 --- a/apps/frontend/src/components/forms/requestDetailsModal.tsx +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -4,8 +4,7 @@ import { OrderDetails, FoodRequestSummaryDto, } from 'types/types'; -import { OrderStatus } 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, @@ -194,23 +193,13 @@ const RequestDetailsModal: React.FC = ({ Fulfilled by {currentOrder.foodManufacturerName} - {currentOrder.status === OrderStatus.DELIVERED ? ( - - {ORDER_STATUS_LABELS[currentOrder.status]} - - ) : ( - - {ORDER_STATUS_LABELS[currentOrder.status]} - - )} + + {ORDER_STATUS_LABELS[currentOrder.status]} + {Object.entries(groupedOrderItemsByType).map( ([foodType, items]) => ( diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index 26f56e931..ec2c30fea 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 { diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index 5ee5339c2..b003e4c2c 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, }, ); @@ -85,6 +87,9 @@ const PantryOrderManagement: React.FC = () => { [OrderStatus.DELIVERED]: { sortAsc: false, }, + [OrderStatus.CLOSED]: { + sortAsc: false, + }, }); const fetchOrders = useCallback(async () => { @@ -96,6 +101,7 @@ const PantryOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: [], [OrderStatus.PENDING]: [], [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], }; for (const order of data) { @@ -113,6 +119,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 d23677005..11f5357f0 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -63,6 +63,7 @@ const VolunteerOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: [], [OrderStatus.PENDING]: [], [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], }); const [selectedOrderId, setSelectedOrderId] = useState(null); @@ -74,6 +75,7 @@ const VolunteerOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: 1, [OrderStatus.PENDING]: 1, [OrderStatus.DELIVERED]: 1, + [OrderStatus.CLOSED]: 1, }, ); @@ -106,6 +108,11 @@ const VolunteerOrderManagement: React.FC = () => { searchPantry: '', sortAsc: false, }, + [OrderStatus.CLOSED]: { + selectedPantries: [], + searchPantry: '', + sortAsc: false, + }, }); const MAX_PER_STATUS = 5; @@ -131,6 +138,7 @@ const VolunteerOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: [], [OrderStatus.PENDING]: [], [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], }; for (const order of data) { @@ -153,6 +161,7 @@ const VolunteerOrderManagement: React.FC = () => { [OrderStatus.SHIPPED]: 1, [OrderStatus.PENDING]: 1, [OrderStatus.DELIVERED]: 1, + [OrderStatus.CLOSED]: 1, }; setCurrentPages(initialPages); } catch { diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 8addbea64..d58c475e3 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -506,6 +506,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 =