diff --git a/apps/backend/src/allocations/allocations.controller.ts b/apps/backend/src/allocations/allocations.controller.ts index d8d2324d..06cdd4fc 100644 --- a/apps/backend/src/allocations/allocations.controller.ts +++ b/apps/backend/src/allocations/allocations.controller.ts @@ -1,6 +1,5 @@ -import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common'; +import { Controller } from '@nestjs/common'; import { AllocationsService } from './allocations.service'; -import { Allocation } from './allocations.entity'; @Controller('allocations') export class AllocationsController { diff --git a/apps/backend/src/allocations/allocations.module.ts b/apps/backend/src/allocations/allocations.module.ts index 3284e1af..7d62e964 100644 --- a/apps/backend/src/allocations/allocations.module.ts +++ b/apps/backend/src/allocations/allocations.module.ts @@ -4,11 +4,14 @@ import { Allocation } from './allocations.entity'; import { AllocationsController } from './allocations.controller'; import { AllocationsService } from './allocations.service'; import { AuthModule } from '../auth/auth.module'; +import { DonationItemsModule } from '../donationItems/donationItems.module'; +import { DonationItem } from '../donationItems/donationItems.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Allocation]), + TypeOrmModule.forFeature([Allocation, DonationItem]), forwardRef(() => AuthModule), + DonationItemsModule, ], controllers: [AllocationsController], providers: [AllocationsService], diff --git a/apps/backend/src/allocations/allocations.service.ts b/apps/backend/src/allocations/allocations.service.ts index 23634f67..4a7d5084 100644 --- a/apps/backend/src/allocations/allocations.service.ts +++ b/apps/backend/src/allocations/allocations.service.ts @@ -1,12 +1,16 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; import { Allocation } from '../allocations/allocations.entity'; +import { validateId } from '../utils/validation.utils'; +import { DonationItem } from '../donationItems/donationItems.entity'; @Injectable() export class AllocationsService { constructor( @InjectRepository(Allocation) private repo: Repository, + @InjectRepository(DonationItem) + private donationItemRepo: Repository, ) {} async getAllAllocationsByOrder( @@ -21,4 +25,36 @@ export class AllocationsService { }, }); } + + async createMultiple( + orderId: number, + itemAllocations: Record, + manager?: EntityManager, + ): Promise { + const repo = manager ? manager.getRepository(Allocation) : this.repo; + const itemRepo = manager + ? manager.getRepository(DonationItem) + : this.donationItemRepo; + + validateId(orderId, 'Order'); + + const allocations: Allocation[] = []; + + for (const [itemIdStr, quantity] of Object.entries(itemAllocations)) { + const itemId = Number(itemIdStr); + validateId(itemId, 'Donation Item'); + + allocations.push( + repo.create({ + orderId, + itemId, + allocatedQuantity: quantity, + }), + ); + + await itemRepo.increment({ itemId }, 'reservedQuantity', quantity); + } + + return repo.save(allocations); + } } diff --git a/apps/backend/src/donationItems/donationItems.module.ts b/apps/backend/src/donationItems/donationItems.module.ts index ef377d2b..657cf653 100644 --- a/apps/backend/src/donationItems/donationItems.module.ts +++ b/apps/backend/src/donationItems/donationItems.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DonationItemsService } from './donationItems.service'; import { DonationItem } from './donationItems.entity'; @@ -7,8 +7,12 @@ import { AuthModule } from '../auth/auth.module'; import { Donation } from '../donations/donations.entity'; @Module({ - imports: [TypeOrmModule.forFeature([DonationItem, Donation]), AuthModule], + imports: [ + TypeOrmModule.forFeature([DonationItem, Donation]), + forwardRef(() => AuthModule), + ], controllers: [DonationItemsController], providers: [DonationItemsService], + exports: [DonationItemsService], }) export class DonationItemsModule {} diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 2397f545..4e36a8ff 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, In, Repository } from 'typeorm'; import { DonationItem } from './donationItems.entity'; import { validateId } from '../utils/validation.utils'; import { FoodType } from './types'; @@ -28,6 +28,49 @@ export class DonationItemsService { return this.repo.find({ where: { donation: { donationId } } }); } + async getByIds(donationItemIds: number[]): Promise { + donationItemIds.forEach((id) => validateId(id, 'Donation Item')); + + const items = await this.repo.find({ + where: { itemId: In(donationItemIds) }, + }); + + const foundIds = new Set(items.map((item) => item.itemId)); + + const missingIds = donationItemIds.filter((id) => !foundIds.has(id)); + + if (missingIds.length > 0) { + throw new NotFoundException( + `Donation items not found for ID(s): ${missingIds.join(', ')}`, + ); + } + + return items; + } + + async getAssociatedDonationIds( + donationItemIds: number[], + ): Promise> { + donationItemIds.forEach((id) => validateId(id, 'Donation Item')); + + const items = await this.repo.find({ + where: { itemId: In(donationItemIds) }, + select: ['itemId', 'donationId'], + }); + + const foundIds = new Set(items.map((i) => i.itemId)); + + const missingIds = donationItemIds.filter((id) => !foundIds.has(id)); + + if (missingIds.length > 0) { + throw new NotFoundException( + `Donation items not found for ID(s): ${missingIds.join(', ')}`, + ); + } + + return new Set(items.map((i) => i.donationId)); + } + async create( donationId: number, itemName: string, diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index 0b813de8..71ffbc7d 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; @@ -8,8 +8,12 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationsSchedulerService } from './donations.scheduler'; @Module({ - imports: [TypeOrmModule.forFeature([Donation, FoodManufacturer]), AuthModule], + imports: [ + TypeOrmModule.forFeature([Donation, FoodManufacturer]), + forwardRef(() => AuthModule), + ], controllers: [DonationsController], providers: [DonationService, DonationsSchedulerService], + exports: [DonationService], }) export class DonationModule {} diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 84da409c..a4909d71 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -3,7 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { RecurrenceEnum, DayOfWeek } from './types'; +import { RecurrenceEnum, DayOfWeek, DonationStatus } from './types'; import { RepeatOnDaysDto } from './dtos/create-donation.dto'; import { testDataSource } from '../config/typeormTestDataSource'; @@ -136,6 +136,38 @@ describe('DonationService', () => { }); }); + describe('matchAll', () => { + it('updates all given donations to have status MATCHED', async () => { + const donationId1 = 1; + const donationId2 = 2; + const donationIds = [donationId1, donationId2]; + + const donation1 = await service.findOne(donationId1); + const donation2 = await service.findOne(donationId2); + expect(donation1.status).toEqual(DonationStatus.AVAILABLE); + expect(donation2.status).toEqual(DonationStatus.MATCHED); + + await service.matchAll(donationIds); + + const updatedDonation1 = await service.findOne(donationId1); + const updatedDonation2 = await service.findOne(donationId2); + + expect(updatedDonation1.status).toEqual(DonationStatus.MATCHED); + expect(updatedDonation2.status).toEqual(DonationStatus.MATCHED); + }); + + it('throws an error if one or more donationIds do not exist', async () => { + const existingDonationId = 1; + const nonExistingDonationId = 999; + + const donationIds = [existingDonationId, nonExistingDonationId]; + + await expect(service.matchAll(donationIds)).rejects.toThrow( + `Donations not found for ID(s): ${nonExistingDonationId}`, + ); + }); + }); + describe('handleRecurringDonations', () => { describe('no-op cases', () => { it('skips donation with no nextDonationDates', async () => { diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 3e58fcc9..e2b539d4 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, In, Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { validateId } from '../utils/validation.utils'; import { DayOfWeek, DonationStatus, RecurrenceEnum } from './types'; @@ -98,6 +98,35 @@ export class DonationService { return this.repo.save(donation); } + async matchAll( + donationIds: number[], + manager?: EntityManager, + ): Promise { + donationIds.forEach((id) => validateId(id, 'Donation')); + + const repo = manager ? manager.getRepository(Donation) : this.repo; + + const donations = await repo.find({ + where: { donationId: In(donationIds) }, + select: ['donationId'], + }); + + const foundIds = donations.map((d) => d.donationId); + + const missingIds = donationIds.filter((id) => !foundIds.includes(id)); + + if (missingIds.length > 0) { + throw new NotFoundException( + `Donations not found for ID(s): ${missingIds.join(', ')}`, + ); + } + + await repo.update( + { donationId: In(donationIds) }, + { status: DonationStatus.MATCHED }, + ); + } + async handleRecurringDonations(): Promise { const donations = await this.getAll(); const today = new Date(); diff --git a/apps/backend/src/foodManufacturers/manufacturers.module.ts b/apps/backend/src/foodManufacturers/manufacturers.module.ts index e80b08f5..83df4ce7 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.module.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.module.ts @@ -13,5 +13,6 @@ import { Donation } from '../donations/donations.entity'; ], controllers: [FoodManufacturersController], providers: [FoodManufacturersService], + exports: [FoodManufacturersService], }) export class ManufacturerModule {} diff --git a/apps/backend/src/orders/dtos/create-order.dto.ts b/apps/backend/src/orders/dtos/create-order.dto.ts new file mode 100644 index 00000000..c2de5f2b --- /dev/null +++ b/apps/backend/src/orders/dtos/create-order.dto.ts @@ -0,0 +1,16 @@ +import { IsInt, IsNotEmptyObject, IsObject, Min } from 'class-validator'; + +export class CreateOrderDto { + @IsInt() + @Min(1) + foodRequestId!: number; + + @IsInt() + @Min(1) + manufacturerId!: number; + + // This object is not fully validated, the validation is handled in the controller where the DTO is used. + @IsObject() + @IsNotEmptyObject() + itemAllocations!: Record; +} diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index 1c989d88..63e1f458 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -16,6 +16,7 @@ import { BadRequestException } from '@nestjs/common'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; +import { CreateOrderDto } from './dtos/create-order.dto'; const mockOrdersService = mock(); const mockAllocationsService = mock(); @@ -387,4 +388,137 @@ describe('OrdersController', () => { ); }); }); + + describe('createOrder', () => { + it('should call ordersService.create and return the created order', async () => { + const createOrderDto = { + foodRequestId: 1, + manufacturerId: 1, + itemAllocations: { + 5: 10, + 8: 3, + 12: 7, + }, + }; + + const mockCreatedOrder: Partial = { + orderId: 42, + status: OrderStatus.PENDING, + request: { requestId: 1 } as FoodRequest, + foodManufacturer: { foodManufacturerId: 1 } as FoodManufacturer, + }; + + mockOrdersService.create.mockResolvedValueOnce(mockCreatedOrder as Order); + + const result = await controller.createOrder(createOrderDto); + + expect(mockOrdersService.create).toHaveBeenCalledWith( + createOrderDto.foodRequestId, + createOrderDto.manufacturerId, + createOrderDto.itemAllocations, + ); + expect(result).toEqual(mockCreatedOrder); + }); + + it('should throw BadRequestException for invalid item ID', async () => { + const createOrderDto: CreateOrderDto = { + foodRequestId: 1, + manufacturerId: 1, + itemAllocations: { abc: 10 }, + }; + + await expect(controller.createOrder(createOrderDto)).rejects.toThrow( + new BadRequestException('Invalid item ID: abc'), + ); + }); + + it('should throw BadRequestException for quantity invalid quantity', async () => { + const createOrderDto: CreateOrderDto = { + foodRequestId: 1, + manufacturerId: 1, + itemAllocations: { 5: 0 }, + }; + + await expect(controller.createOrder(createOrderDto)).rejects.toThrow( + new BadRequestException('Invalid quantity for item 5'), + ); + }); + + it('should propagate BadRequestException when request is not active', async () => { + const foodRequestId = 1; + + const createOrderDto: CreateOrderDto = { + foodRequestId: foodRequestId, + manufacturerId: 1, + itemAllocations: { 5: 10 }, + }; + + mockOrdersService.create.mockRejectedValueOnce( + new BadRequestException(`Request ${foodRequestId} is not active`), + ); + + const promise = controller.createOrder(createOrderDto); + await expect(promise).rejects.toBeInstanceOf(BadRequestException); + await expect(promise).rejects.toThrow( + `Request ${foodRequestId} is not active`, + ); + expect(mockOrdersService.create).toHaveBeenCalledWith( + createOrderDto.foodRequestId, + createOrderDto.manufacturerId, + createOrderDto.itemAllocations, + ); + }); + + it('should propagate Error when donation item does not belong to FM', async () => { + const createOrderDto: CreateOrderDto = { + foodRequestId: 1, + manufacturerId: 1, + itemAllocations: { 5: 10 }, + }; + + mockOrdersService.create.mockRejectedValueOnce( + new BadRequestException( + `Donation is not associated with the current food manufacturer`, + ), + ); + + const promise = controller.createOrder(createOrderDto); + await expect(promise).rejects.toThrow(BadRequestException); + await expect(promise).rejects.toThrow( + `Donation is not associated with the current food manufacturer`, + ); + expect(mockOrdersService.create).toHaveBeenCalledWith( + createOrderDto.foodRequestId, + createOrderDto.manufacturerId, + createOrderDto.itemAllocations, + ); + }); + + it('should propagate BadRequestException when allocated quantity exceeds remaining', async () => { + const donationItemId = 5; + + const createOrderDto: CreateOrderDto = { + foodRequestId: 1, + manufacturerId: 1, + itemAllocations: { [donationItemId]: 100 }, + }; + + mockOrdersService.create.mockRejectedValueOnce( + new BadRequestException( + `Donation item ${donationItemId} allocated quantity exceeds remaining quantity`, + ), + ); + + const promise = controller.createOrder(createOrderDto); + await expect(promise).rejects.toBeInstanceOf(BadRequestException); + await expect(promise).rejects.toThrow( + `Donation item ${donationItemId} allocated quantity exceeds remaining quantity`, + ); + expect(mockOrdersService.create).toHaveBeenCalledWith( + createOrderDto.foodRequestId, + createOrderDto.manufacturerId, + createOrderDto.itemAllocations, + ); + }); + }); }); diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 85c87ee7..c0c65705 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -10,6 +10,7 @@ import { ValidationPipe, UploadedFiles, UseInterceptors, + Post, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { OrdersService } from './order.service'; @@ -28,6 +29,7 @@ import { FilesInterceptor } from '@nestjs/platform-express'; import * as multer from 'multer'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; import { FoodRequest } from '../foodRequests/request.entity'; +import { CreateOrderDto } from './dtos/create-order.dto'; @Controller('orders') export class OrdersController { @@ -115,6 +117,66 @@ export class OrdersController { return this.allocationsService.getAllAllocationsByOrder(orderId); } + @Post('/create') + @ApiBody({ + description: 'Details for creating a order', + schema: { + type: 'object', + properties: { + foodRequestId: { + type: 'integer', + description: 'ID of the associated request this order is related to', + example: 1, + }, + manufacturerId: { + type: 'integer', + description: 'Food manufacturer ID of the FM fulfilling the order', + example: 1, + }, + itemAllocations: { + type: 'object', + description: + 'Map of donationItemId -> quantity to allocate, donation items and their quantity to allocate for this order', + additionalProperties: { + type: 'integer', + example: 10, + }, + example: { + '5': 10, + '8': 3, + '12': 7, + }, + }, + }, + }, + }) + async createOrder( + @Body(new ValidationPipe()) + orderData: CreateOrderDto, + ): Promise { + const parsedAllocations: Record = {}; + + for (const [key, value] of Object.entries(orderData.itemAllocations)) { + const itemId = Number(key); + + if (!Number.isInteger(itemId) || itemId < 1) { + throw new BadRequestException(`Invalid item ID: ${key}`); + } + + if (!Number.isInteger(value) || value < 1) { + throw new BadRequestException(`Invalid quantity for item ${key}`); + } + + parsedAllocations[itemId] = value; + } + + return this.ordersService.create( + orderData.foodRequestId, + orderData.manufacturerId, + parsedAllocations, + ); + } + @Patch('/update-status/:orderId') async updateStatus( @Param('orderId', ParseIntPipe) orderId: number, diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index c312b0d1..71003cc7 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -10,15 +10,33 @@ import { FoodRequest } from '../foodRequests/request.entity'; import { AWSS3Module } from '../aws/aws-s3.module'; import { MulterModule } from '@nestjs/platform-express'; import { RequestsModule } from '../foodRequests/request.module'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; +import { DonationItemsModule } from '../donationItems/donationItems.module'; +import { Allocation } from '../allocations/allocations.entity'; +import { DonationModule } from '../donations/donations.module'; +import { Donation } from '../donations/donations.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Order, Pantry, FoodRequest]), + TypeOrmModule.forFeature([ + Order, + Pantry, + FoodRequest, + FoodManufacturer, + DonationItem, + Allocation, + Donation, + ]), AllocationModule, forwardRef(() => AuthModule), AWSS3Module, MulterModule.register({ dest: './uploads' }), forwardRef(() => RequestsModule), + ManufacturerModule, + DonationItemsModule, + DonationModule, ], controllers: [OrdersController], providers: [OrdersService], diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index d0e3da93..ffd17219 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -13,8 +13,20 @@ import { FoodRequest } from '../foodRequests/request.entity'; import 'multer'; import { FoodRequestStatus } from '../foodRequests/types'; import { RequestsService } from '../foodRequests/request.service'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { AllocationsService } from '../allocations/allocations.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { UsersService } from '../users/users.service'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { Donation } from '../donations/donations.entity'; +import { Allocation } from '../allocations/allocations.entity'; +import { User } from '../users/users.entity'; +import { AuthService } from '../auth/auth.service'; +import { DonationService } from '../donations/donations.service'; +import { CreateOrderDto } from './dtos/create-order.dto'; +import { DonationStatus } from '../donations/types'; +import { DataSource } from 'typeorm'; // Set 1 minute timeout for async DB operations jest.setTimeout(60000); @@ -36,10 +48,23 @@ describe('OrdersService', () => { providers: [ OrdersService, RequestsService, + FoodManufacturersService, + DonationItemsService, + AllocationsService, + UsersService, + DonationService, + { + provide: DataSource, + useValue: testDataSource, + }, { provide: getRepositoryToken(Order), useValue: testDataSource.getRepository(Order), }, + { + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), + }, { provide: getRepositoryToken(Pantry), useValue: testDataSource.getRepository(Pantry), @@ -48,14 +73,26 @@ describe('OrdersService', () => { provide: getRepositoryToken(FoodRequest), useValue: testDataSource.getRepository(FoodRequest), }, - { - provide: getRepositoryToken(FoodManufacturer), - useValue: testDataSource.getRepository(FoodManufacturer), - }, { provide: getRepositoryToken(DonationItem), useValue: testDataSource.getRepository(DonationItem), }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), + }, + { + provide: getRepositoryToken(Allocation), + useValue: testDataSource.getRepository(Allocation), + }, + { + provide: getRepositoryToken(User), + useValue: testDataSource.getRepository(User), + }, + { + provide: AuthService, + useValue: {}, + }, ], }).compile(); @@ -677,4 +714,151 @@ describe('OrdersService', () => { ); }); }); + + describe('createOrder', () => { + let validCreateOrderDto: CreateOrderDto; + + beforeEach(() => { + validCreateOrderDto = { + foodRequestId: 1, + manufacturerId: 1, + itemAllocations: { + 1: 10, + 2: 3, + }, + }; + }); + + it('should create a new order successfully', async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + const donationItemRepo = testDataSource.getRepository(DonationItem); + const donationRepo = testDataSource.getRepository(Donation); + + // Initial donation items + const donationItem1 = await donationItemRepo.findOne({ + where: { itemId: 1 }, + }); + const donationItem2 = await donationItemRepo.findOne({ + where: { itemId: 2 }, + }); + + if (!donationItem1 || !donationItem2) + throw new Error('Missing dummy donation items'); + + const createdOrder = await service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + validCreateOrderDto.itemAllocations, + ); + + expect(createdOrder).toBeDefined(); + expect(createdOrder.orderId).toBeDefined(); + expect(createdOrder.status).toEqual(OrderStatus.PENDING); + expect(createdOrder.foodManufacturerId).toEqual( + validCreateOrderDto.manufacturerId, + ); + expect(createdOrder.requestId).toEqual(validCreateOrderDto.foodRequestId); + + const allocations = await allocationRepo.find({ + where: { orderId: createdOrder.orderId }, + }); + expect(allocations.length).toBe( + Object.keys(validCreateOrderDto.itemAllocations).length, + ); + expect(allocations.map((a) => a.itemId)).toEqual( + expect.arrayContaining([1, 2]), + ); + + const updatedDonation1 = await donationItemRepo.findOne({ + where: { itemId: 1 }, + }); + const updatedDonation2 = await donationItemRepo.findOne({ + where: { itemId: 2 }, + }); + + expect(updatedDonation1!.reservedQuantity).toBe( + donationItem1.reservedQuantity + 10, + ); + expect(updatedDonation2!.reservedQuantity).toBe( + donationItem2.reservedQuantity + 3, + ); + + const matchedDonation = await donationRepo.findOne({ + where: { donationId: 1 }, + }); + expect(matchedDonation?.status).toBe(DonationStatus.MATCHED); + }); + + it('should throw BadRequestException if request is not active', async () => { + const requestRepo = testDataSource.getRepository(FoodRequest); + + const request = await requestRepo.findOne({ where: { requestId: 2 } }); + + if (!request) throw new Error('Missing dummy request'); + + request.status = FoodRequestStatus.CLOSED; + await requestRepo.save(request); + + validCreateOrderDto.foodRequestId = 2; + await expect( + service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + validCreateOrderDto.itemAllocations, + ), + ).rejects.toThrow(BadRequestException); + await expect( + service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + validCreateOrderDto.itemAllocations, + ), + ).rejects.toThrow( + `Request ${validCreateOrderDto.foodRequestId} is not active`, + ); + }); + + it('should throw BadRequestException if allocated quantity exceeds remaining', async () => { + const donationItemId = 2; + + validCreateOrderDto.itemAllocations = { [donationItemId]: 500 }; + await expect( + service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + validCreateOrderDto.itemAllocations, + ), + ).rejects.toThrow(BadRequestException); + await expect( + service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + validCreateOrderDto.itemAllocations, + ), + ).rejects.toThrow( + `Donation item ${donationItemId} quantity to allocate exceeds remaining quantity`, + ); + }); + + it('should throw Error if donation is not associated with manufacturer', async () => { + const donationItemId = 7; + validCreateOrderDto.itemAllocations = { [donationItemId]: 2 }; + await expect( + service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + validCreateOrderDto.itemAllocations, + ), + ).rejects.toThrow(BadRequestException); + await expect( + service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + validCreateOrderDto.itemAllocations, + ), + ).rejects.toThrow( + `The following donation items are not associated with the current food manufacturer: Donation item ID ${donationItemId} with Donation ID 3`, + ); + }); + }); }); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 74d620fe..cb57cb19 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -3,8 +3,8 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Repository, In, DataSource } from 'typeorm'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @@ -15,6 +15,12 @@ import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; import { RequestsService } from '../foodRequests/request.service'; +import { CreateOrderDto } from './dtos/create-order.dto'; +import { FoodRequestStatus } from '../foodRequests/types'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { AllocationsService } from '../allocations/allocations.service'; +import { DonationService } from '../donations/donations.service'; @Injectable() export class OrdersService { @@ -22,6 +28,11 @@ export class OrdersService { @InjectRepository(Order) private repo: Repository, @InjectRepository(Pantry) private pantryRepo: Repository, private requestsService: RequestsService, + private manufacturerService: FoodManufacturersService, + private donationItemsService: DonationItemsService, + private allocationsService: AllocationsService, + private donationService: DonationService, + @InjectDataSource() private dataSource: DataSource, ) {} // TODO: when order is created, set FM @@ -70,6 +81,101 @@ export class OrdersService { }); } + /* + This create method follows these high level steps: + 1. Validate the request status is active before allowing order creation. + 2. Ensure all donation items belong to the specified manufacturer. + 3. Validate allocated quantities do not exceed the remaining quantity (quantity - reserved_quantity). + 4. Create the order with status pending. + 5. Associate the order with the provided request and manufacturer. + 6. Create allocation records for each donation item included in the order. + 7. Update the reserved quantity for each allocated donation item. + 8. Identify all unique donations associated with the allocated donation items and set their status to matched. + */ + async create( + requestId: number, + manufacturerId: number, + itemAllocations: Record, + ): Promise { + return this.dataSource.transaction(async (manager) => { + validateId(manufacturerId, 'Food Manufacturer'); + validateId(requestId, 'Request'); + + const request = await this.requestsService.findOne(requestId); + + if (request.status !== FoodRequestStatus.ACTIVE) { + throw new BadRequestException(`Request ${requestId} is not active`); + } + + const fmDonations = await this.manufacturerService.getFMDonations( + manufacturerId, + ); + const fmDonationIdSet = new Set(fmDonations.map((d) => d.donationId)); + + const donationItemIds = Object.keys(itemAllocations).map(Number); + const donationItems = await this.donationItemsService.getByIds( + donationItemIds, + ); + + const invalidItems = donationItems.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 current food manufacturer: ${messages.join( + ', ', + )}`, + ); + } + + for (const donationItem of donationItems) { + const id = donationItem.itemId; + const quantityToAllocate = itemAllocations[id]; + + if ( + quantityToAllocate > + donationItem.quantity - donationItem.reservedQuantity + ) { + throw new BadRequestException( + `Donation item ${id} quantity to allocate exceeds remaining quantity`, + ); + } + } + + const order = manager.create(Order, { + requestId: requestId, + foodManufacturerId: manufacturerId, + status: OrderStatus.PENDING, + }); + + const savedOrder = await manager.save(order); + + await this.allocationsService.createMultiple( + savedOrder.orderId, + itemAllocations, + + manager, + ); + + const associatedDonationIdsSet = + await this.donationItemsService.getAssociatedDonationIds( + donationItemIds, + ); + + await this.donationService.matchAll( + Array.from(associatedDonationIdsSet), + manager, + ); + + return savedOrder; + }); + } + async findOne(orderId: number): Promise { validateId(orderId, 'Order'); diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index e6ed91f0..dc896aad 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -27,6 +27,9 @@ import { Donation } from '../donations/donations.entity'; import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { User } from '../users/users.entity'; +import { AllocationsService } from '../allocations/allocations.service'; +import { Allocation } from '../allocations/allocations.entity'; +import { DataSource } from 'typeorm'; jest.setTimeout(60000); @@ -77,6 +80,7 @@ describe('PantriesService', () => { DonationItemsService, DonationService, FoodManufacturersService, + AllocationsService, { provide: AuthService, useValue: { @@ -111,6 +115,14 @@ describe('PantriesService', () => { provide: getRepositoryToken(FoodManufacturer), useValue: testDataSource.getRepository(FoodManufacturer), }, + { + provide: getRepositoryToken(Allocation), + useValue: testDataSource.getRepository(Allocation), + }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile(); diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 35e5a92c..2df2802e 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -18,6 +18,9 @@ import { DonationItem } from '../donationItems/donationItems.entity'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { DonationService } from '../donations/donations.service'; import { Donation } from '../donations/donations.entity'; +import { Allocation } from '../allocations/allocations.entity'; +import { AllocationsService } from '../allocations/allocations.service'; +import { DataSource } from 'typeorm'; jest.setTimeout(60000); @@ -40,6 +43,7 @@ describe('VolunteersService', () => { FoodManufacturersService, DonationItemsService, DonationService, + AllocationsService, { provide: AuthService, useValue: { @@ -74,6 +78,14 @@ describe('VolunteersService', () => { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), }, + { + provide: getRepositoryToken(Allocation), + useValue: testDataSource.getRepository(Allocation), + }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile();