diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 168f46048..916210174 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -54,29 +54,6 @@ export class DonationItemsService { 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/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 878e081e1..e24b716e1 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -196,4 +196,115 @@ export const emailTemplates = {

Best regards,
The Securing Safe Food Team

`, }), + + pantryRequestMatchedOrder: (params: { + pantryName: string; + items: { quantity: string; product: string }[]; + brand: string; + volunteerName: string; + volunteerEmail: string; + }): EmailTemplate => ({ + subject: 'Your Securing Safe Food Request Has Been Matched to a Delivery', + bodyHTML: ` +

Hi ${params.pantryName},

+

+ Good news! Your recent food request through Securing Safe Food has been successfully matched to an order and is now moving forward toward delivery. +

+

Items you will receive from ${params.brand}:

+ +

+ To view full order details, delivery updates, and any notes from the coordinating volunteer or food manufacturer, please log into the platform. +

+

+ If any details change on your end or you have updated availability, please update your request in the system or email your coordinator, ${ + params.volunteerName + } at ${ + params.volunteerEmail + }. +

+

+ We will continue to keep you informed as the order progresses. We’re excited to help support your pantry and looking forward to this donation! +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + pantryRequestClosed: (params: { + pantryName: string; + volunteerName: string; + volunteerEmail: string; + }): EmailTemplate => ({ + subject: 'Your Securing Safe Food Request Has Been Completed', + bodyHTML: ` +

Hi ${params.pantryName},

+

+ Your recent food request through Securing Safe Food has been marked as complete. + We are glad to fulfill your pantry's requests! If you would like to continue receiving + donations, please submit a new food request at any time to ensure there is no interruption + in future deliveries. +

+

+ To submit a new request or view past orders, please log into the platform here: + ${EMAIL_REDIRECT_URL}/login +

+

+ If you have any questions or feedback about this request, please do not hesitate to reach out. + You can contact your pantry coordinator, ${params.volunteerName}, at + ${params.volunteerEmail}. +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + fmDonationMatchedOrder: (params: { + manufacturerName: string; + items: { quantity: string; product: string }[]; + pantryName: string; + pantryAddress: string; + volunteerName: string; + volunteerEmail: string; + }): EmailTemplate => ({ + subject: + 'Your Securing Safe Food Donation Has Been Matched to a Pantry Order', + bodyHTML: ` +

Hi ${params.manufacturerName},

+

+ Thank you for your continued partnership with Securing Safe Food. A donation you submitted has now been successfully matched to a pantry request and is moving forward towards fulfillment. +

+

Matched Item(s):

+ +

+ Recipient Pantry: ${params.pantryName}
+

+

+ Address:
+ ${params.pantryAddress} +

+

+ Please log into the platform to review the full delivery details, timelines, and any special handling instructions associated with this shipment. +

+

+ Your support plays a direct role in expanding access to allergen-safe foods, and we truly appreciate your commitment to this work. +

+

+ If you have any questions or need assistance, please contact your coordinator, ${ + params.volunteerName + } at ${ + params.volunteerEmail + }. +

+

+ Thank you so much. +

+

Best regards,
The Securing Safe Food Team

+ `, + }), }; diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index ae6017ef8..126d2985b 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -19,6 +19,7 @@ import { } from './dtos/matching.dto'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { Pantry } from '../pantries/pantries.entity'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; import { PantriesService } from '../pantries/pantries.service'; import { User } from '../users/users.entity'; import { Role } from '../users/types'; @@ -302,11 +303,22 @@ describe('RequestsController', () => { it('should call requestsService.closeRequest', async () => { const requestId = 1; - mockRequestsService.closeRequest.mockResolvedValueOnce(undefined); + mockRequestsService.closeRequest.mockResolvedValueOnce( + foodRequest1 as FoodRequest, + ); + + const req = { user: { id: 1 } }; - await controller.closeRequest(requestId); + const result = await controller.closeRequest( + requestId, + req as AuthenticatedRequest, + ); - expect(mockRequestsService.closeRequest).toHaveBeenCalledWith(requestId); + expect(result).toEqual(foodRequest1); + expect(mockRequestsService.closeRequest).toHaveBeenCalledWith( + requestId, + 1, + ); }); }); diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 316bdec9f..b42ec69bd 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -8,7 +8,9 @@ import { ValidationPipe, Patch, Delete, + Req, } from '@nestjs/common'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; import { ApiBody } from '@nestjs/swagger'; import { RequestsService } from './request.service'; import { FoodRequest } from './request.entity'; @@ -196,7 +198,8 @@ export class RequestsController { @Patch('/:requestId/close') async closeRequest( @Param('requestId', ParseIntPipe) requestId: number, - ): Promise { - await this.requestsService.closeRequest(requestId); + @Req() req: AuthenticatedRequest, + ): Promise { + return this.requestsService.closeRequest(requestId, req.user.id); } } diff --git a/apps/backend/src/foodRequests/request.module.ts b/apps/backend/src/foodRequests/request.module.ts index dcec30601..3b6535c2e 100644 --- a/apps/backend/src/foodRequests/request.module.ts +++ b/apps/backend/src/foodRequests/request.module.ts @@ -9,6 +9,8 @@ import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationItem } from '../donationItems/donationItems.entity'; import { EmailsModule } from '../emails/email.module'; +import { User } from '../users/users.entity'; +import { UsersModule } from '../users/users.module'; @Module({ imports: [ @@ -18,9 +20,11 @@ import { EmailsModule } from '../emails/email.module'; Pantry, FoodManufacturer, DonationItem, + User, ]), AuthModule, EmailsModule, + UsersModule, ], controllers: [RequestsController], providers: [RequestsService], diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 0aa75f951..ca13422c5 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -20,6 +20,12 @@ import { mock } from 'jest-mock-extended'; import { emailTemplates } from '../emails/emailTemplates'; import { Allocation } from '../allocations/allocations.entity'; import { ApplicationStatus } from '../shared/types'; +import { User } from '../users/users.entity'; +import { UsersService } from '../users/users.service'; +import { Donation } from '../donations/donations.entity'; +import { AuthService } from '../auth/auth.service'; +import { PantriesService } from '../pantries/pantries.service'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; jest.setTimeout(60000); @@ -38,6 +44,9 @@ describe('RequestsService', () => { const module = await Test.createTestingModule({ providers: [ RequestsService, + UsersService, + PantriesService, + FoodManufacturersService, { provide: getRepositoryToken(FoodRequest), useValue: testDataSource.getRepository(FoodRequest), @@ -62,6 +71,20 @@ describe('RequestsService', () => { provide: getRepositoryToken(Allocation), useValue: testDataSource.getRepository(Allocation), }, + { + provide: getRepositoryToken(User), + useValue: testDataSource.getRepository(User), + }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), + }, + { + provide: AuthService, + useValue: { + adminCreateUser: jest.fn().mockResolvedValue('test-sub'), + }, + }, { provide: EmailsService, useValue: mockEmailsService, @@ -353,7 +376,7 @@ describe('RequestsService', () => { expect(request.status).toBe(FoodRequestStatus.ACTIVE); }); - it('should update status to active for request with no orders', async () => { + it('should throw BadRequestException for request with no orders', async () => { const pantryId = 1; const result = await service.create(pantryId, RequestSize.MEDIUM, [ FoodType.DRIED_BEANS, @@ -361,10 +384,11 @@ describe('RequestsService', () => { ]); const requestId = result.requestId; - await service.updateRequestStatus(requestId); - - const request = await service.findOne(requestId); - expect(request.status).toBe(FoodRequestStatus.ACTIVE); + await expect(service.updateRequestStatus(requestId)).rejects.toThrow( + new BadRequestException( + `Cannot update request ${requestId} with no orders`, + ), + ); }); it('should throw NotFoundException for non-existent request', async () => { @@ -374,6 +398,103 @@ describe('RequestsService', () => { new NotFoundException('Request 999 not found'), ); }); + + it('sends pantry closed email with last delivered order assignee on auto-close', async () => { + const requestId = 1; + const pantry = (await testDataSource.getRepository(Pantry).findOne({ + where: { pantryId: 1 }, + relations: ['pantryUser', 'volunteers'], + })) as Pantry; + const lastDeliveredOrder = (await testDataSource + .getRepository(Order) + .findOne({ + where: { requestId, status: OrderStatus.DELIVERED }, + order: { deliveredAt: 'DESC' }, + relations: ['assignee'], + })) as Order; + + const requestBefore = await service.findOne(1); + expect(requestBefore.status).toBe(FoodRequestStatus.ACTIVE); + + await service.updateRequestStatus(requestId); + + const request = await service.findOne(1); + expect(request.status).toBe(FoodRequestStatus.CLOSED); + + const assignee = lastDeliveredOrder.assignee; + const expectedMessage = emailTemplates.pantryRequestClosed({ + pantryName: pantry.pantryName, + volunteerName: `${assignee.firstName} ${assignee.lastName}`, + volunteerEmail: assignee.email, + }); + + const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: pantry.pantryUser.email, + subject: expectedMessage.subject, + bodyHtml: expectedMessage.bodyHTML, + bccEmails: volunteerEmails, + }); + }); + + it('does not send email when not all orders are delivered (request stays active)', async () => { + const request = (await service.findOne(3)) as FoodRequest; + + expect(request.orders).toBeDefined(); + expect( + request.orders?.some((order) => order.status !== OrderStatus.DELIVERED), + ).toBe(true); + + await service.updateRequestStatus(3); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException and does not send email when request is already closed', async () => { + await testDataSource.query( + `UPDATE food_requests SET status = 'closed' WHERE request_id = 1`, + ); + + const request = await service.findOne(1); + expect(request.status).toBe(FoodRequestStatus.CLOSED); + + await expect(service.updateRequestStatus(1)).rejects.toThrow( + new BadRequestException(`Request 1 is already closed`), + ); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('still auto-closes request when email fails', async () => { + const requestBefore = await service.findOne(1); + expect(requestBefore.status).toBe(FoodRequestStatus.ACTIVE); + + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('SMTP error'), + ); + + await expect(service.updateRequestStatus(1)).rejects.toThrow( + new InternalServerErrorException( + 'Request 1 auto-closed, but failed to send pantry notification email', + ), + ); + + const request = await service.findOne(1); + expect(request.status).toBe(FoodRequestStatus.CLOSED); + }); + + it('should not reopen a closed request when updateRequestStatus is called', async () => { + await service.closeRequest(1, 6); + + await expect(service.updateRequestStatus(1)).rejects.toThrow( + new BadRequestException(`Request 1 is already closed`), + ); + + const fromDb = await service.findOne(1); + expect(fromDb.status).toBe(FoodRequestStatus.CLOSED); + }); }); describe('getMatchingManufacturers', () => { @@ -397,13 +518,13 @@ describe('RequestsService', () => { const manufacturerRepo = testDataSource.getRepository(FoodManufacturer); - const manufacturer = await manufacturerRepo.findOne({ + const manufacturer = (await manufacturerRepo.findOne({ where: { foodManufacturerId: 1 }, - }); + })) as FoodManufacturer; - manufacturer!.status = ApplicationStatus.PENDING; + manufacturer.status = ApplicationStatus.PENDING; - await manufacturerRepo.save(manufacturer!); + await manufacturerRepo.save(manufacturer); const resultAfter = await service.getMatchingManufacturers(requestId); @@ -729,23 +850,31 @@ describe('RequestsService', () => { }); describe('closeRequest', () => { + let volunteerId: number; + + beforeEach(() => { + volunteerId = 6; + }); + it('should close an active request', async () => { - await service.closeRequest(3); + const result = await service.closeRequest(3, volunteerId); + + expect(result.status).toBe(FoodRequestStatus.CLOSED); const fromDb = await service.findOne(3); expect(fromDb.status).toBe(FoodRequestStatus.CLOSED); }); it('should throw BadRequestException when request is already closed', async () => { - await service.closeRequest(3); + await service.closeRequest(3, volunteerId); - await expect(service.closeRequest(3)).rejects.toThrow( + await expect(service.closeRequest(3, volunteerId)).rejects.toThrow( new BadRequestException('Cannot close a request with status: closed'), ); }); it('should throw NotFoundException for non-existent request', async () => { - await expect(service.closeRequest(999)).rejects.toThrow( + await expect(service.closeRequest(999, volunteerId)).rejects.toThrow( new NotFoundException('Request 999 not found'), ); }); @@ -755,7 +884,7 @@ describe('RequestsService', () => { .getRepository(Order) .find({ where: { requestId: 3 } }); - await service.closeRequest(3); + await service.closeRequest(3, volunteerId); const ordersAfter = await testDataSource .getRepository(Order) @@ -766,12 +895,46 @@ describe('RequestsService', () => { }); }); - it('should not reopen a closed request when updateRequestStatus is called', async () => { - await service.closeRequest(1); - await service.updateRequestStatus(1); + it('sends pantry closed email with acting volunteer info on successful close', async () => { + const pantry = (await testDataSource.getRepository(Pantry).findOne({ + where: { pantryId: 3 }, + relations: ['pantryUser', 'volunteers'], + })) as Pantry; - const fromDb = await service.findOne(1); - expect(fromDb.status).toBe(FoodRequestStatus.CLOSED); + await service.closeRequest(3, volunteerId); + + const expectedMessage = emailTemplates.pantryRequestClosed({ + pantryName: pantry.pantryName, + volunteerName: `James Thomas`, + volunteerEmail: `james.t@volunteer.org`, + }); + + const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: pantry.pantryUser.email, + subject: expectedMessage.subject, + bodyHtml: expectedMessage.bodyHTML, + bccEmails: expect.arrayContaining(volunteerEmails), + }); + }); + + it('still closes request when email fails (manual close)', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('SMTP error'), + ); + + await expect(service.closeRequest(3, volunteerId)).rejects.toThrow( + new InternalServerErrorException( + 'Failed to send food request closed email to pantry', + ), + ); + + const request = await service.findOne(3); + + expect(request.status).toBe(FoodRequestStatus.CLOSED); + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); }); }); }); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 4bf7b8468..c9f17b52e 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -24,6 +24,7 @@ import { DonationItem } from '../donationItems/donationItems.entity'; import { EmailsService } from '../emails/email.service'; import { emailTemplates } from '../emails/emailTemplates'; import { UpdateRequestDto } from './dtos/update-request.dto'; +import { UsersService } from '../users/users.service'; @Injectable() export class RequestsService { @@ -36,6 +37,7 @@ export class RequestsService { @InjectRepository(DonationItem) private donationItemRepo: Repository, private emailsService: EmailsService, + private usersService: UsersService, ) {} async findOne(requestId: number): Promise { @@ -285,7 +287,7 @@ export class RequestsService { const request = await this.repo.findOne({ where: { requestId }, - relations: ['orders'], + relations: ['orders', 'pantry', 'pantry.pantryUser', 'pantry.volunteers'], }); if (!request) { @@ -295,22 +297,60 @@ export class RequestsService { const orders = request.orders || []; if (!orders.length) { - request.status = FoodRequestStatus.ACTIVE; - await this.repo.save(request); - return; + throw new BadRequestException( + `Cannot update request ${requestId} with no orders`, + ); + } + + if (request.status === FoodRequestStatus.CLOSED) { + throw new BadRequestException(`Request ${requestId} is already closed`); } const allDelivered = orders.every( (order) => order.status === OrderStatus.DELIVERED, ); - if (request.status !== FoodRequestStatus.CLOSED) { - request.status = allDelivered - ? FoodRequestStatus.CLOSED - : FoodRequestStatus.ACTIVE; - } + request.status = allDelivered + ? FoodRequestStatus.CLOSED + : FoodRequestStatus.ACTIVE; await this.repo.save(request); + + if (allDelivered) { + try { + const lastDeliveredOrder = await this.orderRepo.findOne({ + where: { requestId, status: OrderStatus.DELIVERED }, + order: { deliveredAt: 'DESC' }, + relations: ['assignee'], + }); + + if (lastDeliveredOrder) { + const volunteers = request.pantry.volunteers || []; + const volunteerEmails = volunteers.map((v) => v.email); + + const { assignee } = lastDeliveredOrder; + const message = emailTemplates.pantryRequestClosed({ + pantryName: request.pantry.pantryName, + volunteerName: `${assignee.firstName} ${assignee.lastName}`, + volunteerEmail: assignee.email, + }); + await this.emailsService.sendEmails({ + toEmail: request.pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + bccEmails: volunteerEmails, + }); + } else { + throw new InternalServerErrorException( + `Request ${requestId} auto-closed, but failed to send pantry notification email`, + ); + } + } catch { + throw new InternalServerErrorException( + `Request ${requestId} auto-closed, but failed to send pantry notification email`, + ); + } + } } async update(requestId: number, dto: UpdateRequestDto): Promise { @@ -379,11 +419,15 @@ export class RequestsService { await this.repo.remove(request); } - async closeRequest(requestId: number): Promise { + async closeRequest( + requestId: number, + actingUserId: number, + ): Promise { validateId(requestId, 'Request'); const request = await this.repo.findOne({ where: { requestId }, + relations: ['pantry', 'pantry.pantryUser', 'pantry.volunteers'], }); if (!request) { @@ -396,7 +440,30 @@ export class RequestsService { ); } + const assignee = await this.usersService.findOne(actingUserId); + request.status = FoodRequestStatus.CLOSED; - await this.repo.save(request); + const saved = await this.repo.save(request); + try { + const volunteers = request.pantry.volunteers || []; + const volunteerEmails = volunteers.map((v) => v.email); + const message = emailTemplates.pantryRequestClosed({ + pantryName: request.pantry.pantryName, + volunteerName: `${assignee.firstName} ${assignee.lastName}`, + volunteerEmail: assignee.email, + }); + await this.emailsService.sendEmails({ + toEmail: request.pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + bccEmails: volunteerEmails, + }); + } catch { + throw new InternalServerErrorException( + 'Failed to send food request closed email to pantry', + ); + } + + return saved; } } diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index 0e3367fb0..0e1e01246 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -18,6 +18,8 @@ import { DonationItemsModule } from '../donationItems/donationItems.module'; import { Allocation } from '../allocations/allocations.entity'; import { Donation } from '../donations/donations.entity'; import { EmailsModule } from '../emails/email.module'; +import { User } from '../users/users.entity'; +import { UsersModule } from '../users/users.module'; @Module({ imports: [ @@ -29,6 +31,7 @@ import { EmailsModule } from '../emails/email.module'; DonationItem, Allocation, Donation, + User, ]), AllocationModule, forwardRef(() => AuthModule), @@ -39,6 +42,7 @@ import { EmailsModule } from '../emails/email.module'; DonationItemsModule, DonationModule, EmailsModule, + forwardRef(() => UsersModule), ], 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 4eec28849..2aee388f1 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -30,7 +30,7 @@ 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, In } from 'typeorm'; +import { DataSource, EntityManager, In } from 'typeorm'; import { EmailsService } from '../emails/email.service'; import { Allocation } from '../allocations/allocations.entity'; import { mock } from 'jest-mock-extended'; @@ -111,6 +111,10 @@ describe('OrdersService', () => { provide: AuthService, useValue: {}, }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, ], }).compile(); @@ -591,7 +595,7 @@ describe('OrdersService', () => { fmName: order.foodManufacturer.foodManufacturerName, }); - expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ toEmail: order.assignee.email, subject: message.subject, @@ -612,7 +616,7 @@ describe('OrdersService', () => { ), ).rejects.toThrow( new InternalServerErrorException( - 'Failed to send order delivery confirmation email to volunteer', + 'Request 3 auto-closed, but failed to send pantry notification email', ), ); @@ -621,7 +625,7 @@ describe('OrdersService', () => { }); }); - describe('createOrder', () => { + describe('create', () => { let validCreateOrderDto: CreateOrderDto; let parsedAllocations: Map; const userId = 3; @@ -642,26 +646,30 @@ describe('OrdersService', () => { ]); }); - it('should create a new order successfully', async () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create a new order successfully and send appropriate emails', async () => { const allocationRepo = testDataSource.getRepository(Allocation); const donationItemRepo = testDataSource.getRepository(DonationItem); const donationRepo = testDataSource.getRepository(Donation); + const usersRepo = testDataSource.getRepository(User); + const requestRepo = testDataSource.getRepository(FoodRequest); + const manufacturerRepo = testDataSource.getRepository(FoodManufacturer); parsedAllocations.set(9, 5); // Initial donation items - const donationItem1 = await donationItemRepo.findOne({ + const donationItem1 = (await donationItemRepo.findOne({ where: { itemId: 1 }, - }); - const donationItem2 = await donationItemRepo.findOne({ + })) as DonationItem; + const donationItem2 = (await donationItemRepo.findOne({ where: { itemId: 2 }, - }); - const donationItem3 = await donationItemRepo.findOne({ + })) as DonationItem; + const donationItem3 = (await donationItemRepo.findOne({ where: { itemId: 9 }, - }); - - if (!donationItem1 || !donationItem2 || !donationItem3) - throw new Error('Missing dummy donation items'); + })) as DonationItem; donationItem3.quantity = 100; @@ -694,23 +702,16 @@ describe('OrdersService', () => { expect.arrayContaining([10, 3, 5]), ); - const updatedDonationItem1 = await donationItemRepo.findOne({ + const updatedDonationItem1 = (await donationItemRepo.findOne({ where: { itemId: 1 }, - }); - const updatedDonationItem2 = await donationItemRepo.findOne({ + })) as DonationItem; + const updatedDonationItem2 = (await donationItemRepo.findOne({ where: { itemId: 2 }, - }); - const updatedDonationItem3 = await donationItemRepo.findOne({ + })) as DonationItem; + const updatedDonationItem3 = (await donationItemRepo.findOne({ where: { itemId: 9 }, - }); + })) as DonationItem; - if ( - !updatedDonationItem1 || - !updatedDonationItem2 || - !updatedDonationItem3 - ) { - throw new Error('Missing donation item test object'); - } expect(updatedDonationItem1.reservedQuantity).toBe( donationItem1.reservedQuantity + 10, ); @@ -730,28 +731,85 @@ describe('OrdersService', () => { where: { donationId: 2 }, }); expect(matchedDonation2?.status).toBe(DonationStatus.MATCHED); + + // Testing emails section + + const assignee = (await usersRepo.findOne({ + where: { id: userId }, + })) as User; + const request = (await requestRepo.findOne({ + where: { requestId: validCreateOrderDto.foodRequestId }, + relations: ['pantry', 'pantry.pantryUser'], + })) as FoodRequest; + const manufacturer = (await manufacturerRepo.findOne({ + where: { foodManufacturerId: validCreateOrderDto.manufacturerId }, + relations: ['foodManufacturerRepresentative'], + })) as FoodManufacturer; + + const pantry = request.pantry; + const pantryAddress = `${request.pantry.shipmentAddressLine1}${ + request.pantry.shipmentAddressLine2 + ? `
${request.pantry.shipmentAddressLine2}` + : '' + }
+${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ + request.pantry.shipmentAddressZip + }${ + request.pantry.shipmentAddressCountry + ? `
${request.pantry.shipmentAddressCountry}` + : '' + }`; + + const itemDetails = [ + { quantity: '10', product: updatedDonationItem1.itemName }, + { quantity: '3', product: updatedDonationItem2.itemName }, + { quantity: '5', product: updatedDonationItem3.itemName }, + ]; + + const fmMessage = emailTemplates.fmDonationMatchedOrder({ + manufacturerName: manufacturer.foodManufacturerName, + items: itemDetails, + pantryName: pantry.pantryName, + pantryAddress, + volunteerName: assignee.firstName + ' ' + assignee.lastName, + volunteerEmail: assignee.email, + }); + + const pantryMessage = emailTemplates.pantryRequestMatchedOrder({ + pantryName: request.pantry.pantryName, + items: itemDetails, + brand: manufacturer.foodManufacturerName, + volunteerName: assignee.firstName + ' ' + assignee.lastName, + volunteerEmail: assignee.email, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: request.pantry.pantryUser.email, + subject: pantryMessage.subject, + bodyHtml: pantryMessage.bodyHTML, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: manufacturer.foodManufacturerRepresentative.email, + subject: fmMessage.subject, + bodyHtml: fmMessage.bodyHTML, + }); }); it('should throw BadRequestException if request is not active', async () => { const requestRepo = testDataSource.getRepository(FoodRequest); const donationItemRepo = testDataSource.getRepository(DonationItem); - const request = await requestRepo.findOne({ where: { requestId: 2 } }); - - if (!request) throw new Error('Missing dummy request'); + const request = (await requestRepo.findOne({ + where: { requestId: 2 }, + })) as FoodRequest; request.status = FoodRequestStatus.CLOSED; await requestRepo.save(request); validCreateOrderDto.foodRequestId = 2; - await expect( - service.create( - validCreateOrderDto.foodRequestId, - validCreateOrderDto.manufacturerId, - parsedAllocations, - userId, - ), - ).rejects.toThrow(BadRequestException); await expect( service.create( validCreateOrderDto.foodRequestId, @@ -760,7 +818,9 @@ describe('OrdersService', () => { userId, ), ).rejects.toThrow( - `Request ${validCreateOrderDto.foodRequestId} is not active`, + new BadRequestException( + `Request ${validCreateOrderDto.foodRequestId} is not active`, + ), ); // Asserting that donation item reserved quantity wasn't updated @@ -783,7 +843,27 @@ describe('OrdersService', () => { parsedAllocations, userId, ), - ).rejects.toThrow(BadRequestException); + ).rejects.toThrow( + new BadRequestException( + `Manufacturer ${validCreateOrderDto.manufacturerId} is not approved`, + ), + ); + + // Asserting that donation item reserved quantity wasn't updated + const donationItem1 = await donationItemRepo.findOne({ + where: { itemId: 1 }, + }); + expect(donationItem1?.reservedQuantity).toBe(10); + }); + + it('should throw BadRequestException if manufacturer has no donations', async () => { + const donationItemRepo = testDataSource.getRepository(DonationItem); + + await testDataSource.query( + `UPDATE donations SET food_manufacturer_id = 2 WHERE food_manufacturer_id = $1`, + [validCreateOrderDto.manufacturerId], + ); + await expect( service.create( validCreateOrderDto.foodRequestId, @@ -792,7 +872,9 @@ describe('OrdersService', () => { userId, ), ).rejects.toThrow( - `Manufacturer ${validCreateOrderDto.manufacturerId} is not approved`, + new BadRequestException( + `Manufacturer ${validCreateOrderDto.manufacturerId} has no donations`, + ), ); // Asserting that donation item reserved quantity wasn't updated @@ -802,11 +884,15 @@ describe('OrdersService', () => { expect(donationItem1?.reservedQuantity).toBe(10); }); - it('should throw NotFoundException if donation item does not exist', async () => { + it('should throw BadRequestException if allocated quantity exceeds remaining', async () => { const donationItemRepo = testDataSource.getRepository(DonationItem); - parsedAllocations.set(999, 1); + const donationItemId = 2; + parsedAllocations = new Map([ + [donationItemId, 500], + [1, 10], + ]); await expect( service.create( validCreateOrderDto.foodRequestId, @@ -814,7 +900,27 @@ describe('OrdersService', () => { parsedAllocations, userId, ), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow( + new BadRequestException( + `Donation item ${donationItemId} quantity to allocate exceeds remaining quantity`, + ), + ); + + // Asserting that donation item reserved quantity wasn't updated + const donationItem1 = await donationItemRepo.findOne({ + where: { itemId: 1 }, + }); + expect(donationItem1?.reservedQuantity).toBe(10); + }); + + it('should throw BadRequestException if donation is not associated with manufacturer', async () => { + const donationItemRepo = testDataSource.getRepository(DonationItem); + + const donationItemId = 7; + parsedAllocations = new Map([ + [donationItemId, 2], + [1, 10], + ]); await expect( service.create( validCreateOrderDto.foodRequestId, @@ -822,7 +928,11 @@ describe('OrdersService', () => { parsedAllocations, userId, ), - ).rejects.toThrow(`Donation items not found for ID(s): 999`); + ).rejects.toThrow( + new BadRequestException( + `The following donation items are not associated with the current food manufacturer: Donation item ID ${donationItemId} with Donation ID 3`, + ), + ); // Asserting that donation item reserved quantity wasn't updated const donationItem1 = await donationItemRepo.findOne({ @@ -831,15 +941,11 @@ describe('OrdersService', () => { expect(donationItem1?.reservedQuantity).toBe(10); }); - it('should throw BadRequestException if allocated quantity exceeds remaining', async () => { - const donationItemRepo = testDataSource.getRepository(DonationItem); - - const donationItemId = 2; + it('should still create order and send FM email when pantry email fails', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('SMTP error'), + ); - parsedAllocations = new Map([ - [donationItemId, 500], - [1, 10], - ]); await expect( service.create( validCreateOrderDto.foodRequestId, @@ -847,7 +953,38 @@ describe('OrdersService', () => { parsedAllocations, userId, ), - ).rejects.toThrow(BadRequestException); + ).rejects.toThrow( + new InternalServerErrorException( + 'Failed to send pantry request matched order confirmation email', + ), + ); + + const createdOrder = await service.findOne(5); + + expect(createdOrder.status).toEqual(OrderStatus.PENDING); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); + + const manufacturerRepo = testDataSource.getRepository(FoodManufacturer); + const manufacturer = (await manufacturerRepo.findOne({ + where: { foodManufacturerId: validCreateOrderDto.manufacturerId }, + relations: ['foodManufacturerRepresentative'], + })) as FoodManufacturer; + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: manufacturer.foodManufacturerRepresentative.email, + subject: expect.any(String), + bodyHtml: expect.any(String), + }); + }); + + it('should still create order when both emails fail', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('SMTP error'), + ); + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('SMTP error'), + ); + await expect( service.create( validCreateOrderDto.foodRequestId, @@ -856,24 +993,116 @@ describe('OrdersService', () => { userId, ), ).rejects.toThrow( - `Donation item ${donationItemId} quantity to allocate exceeds remaining quantity`, + new InternalServerErrorException( + 'Failed to send pantry request matched order confirmation email; Failed to send food manufacturer donation matched order confirmation email', + ), ); - // Asserting that donation item reserved quantity wasn't updated - const donationItem1 = await donationItemRepo.findOne({ + const createdOrder = await service.findOne(5); + expect(createdOrder.status).toEqual(OrderStatus.PENDING); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); + }); + + it('should call allocationsService.createMultiple once with correct parameters', async () => { + const spy = jest.spyOn( + (service as any).allocationsService as AllocationsService, + 'createMultiple', + ); + + const createdOrder = await service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + parsedAllocations, + userId, + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + createdOrder.orderId, + parsedAllocations, + expect.any(EntityManager), + ); + }); + + it('should call donationService.matchAll once with correct parameters', async () => { + const spy = jest.spyOn( + (service as any).donationService as DonationService, + 'matchAll', + ); + + await service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + parsedAllocations, + userId, + ); + + // Items 1 and 2 both belong to donation_id 1 + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + expect.arrayContaining([1]), + expect.any(EntityManager), + ); + }); + + it('should rollback transaction and not create order if allocation creation fails', async () => { + const orderRepo = testDataSource.getRepository(Order); + const donationItemRepo = testDataSource.getRepository(DonationItem); + + const orderCountBefore = await orderRepo.count(); + const item1Before = await donationItemRepo.findOne({ where: { itemId: 1 }, }); - expect(donationItem1?.reservedQuantity).toBe(10); + const item2Before = await donationItemRepo.findOne({ + where: { itemId: 2 }, + }); + + jest + .spyOn( + (service as any).allocationsService as AllocationsService, + 'createMultiple', + ) + .mockRejectedValueOnce(new Error('DB error')); + + await expect( + service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + parsedAllocations, + userId, + ), + ).rejects.toThrow('DB error'); + + const orderCountAfter = await orderRepo.count(); + expect(orderCountAfter).toBe(orderCountBefore); + + const item1After = await donationItemRepo.findOne({ + where: { itemId: 1 }, + }); + const item2After = await donationItemRepo.findOne({ + where: { itemId: 2 }, + }); + expect(item1After?.reservedQuantity).toBe(item1Before?.reservedQuantity); + expect(item2After?.reservedQuantity).toBe(item2Before?.reservedQuantity); }); - it('should throw Error if donation is not associated with manufacturer', async () => { + it('should rollback transaction and not create order if donation matching fails', async () => { + const orderRepo = testDataSource.getRepository(Order); const donationItemRepo = testDataSource.getRepository(DonationItem); - const donationItemId = 7; - parsedAllocations = new Map([ - [donationItemId, 2], - [1, 10], - ]); + const orderCountBefore = await orderRepo.count(); + const item1Before = await donationItemRepo.findOne({ + where: { itemId: 1 }, + }); + const item2Before = await donationItemRepo.findOne({ + where: { itemId: 2 }, + }); + + jest + .spyOn((service as any).donationService as DonationService, 'matchAll') + .mockRejectedValueOnce(new Error('DB error')); + await expect( service.create( validCreateOrderDto.foodRequestId, @@ -881,16 +1110,56 @@ describe('OrdersService', () => { parsedAllocations, userId, ), - ).rejects.toThrow(BadRequestException); + ).rejects.toThrow('DB error'); + + const orderCountAfter = await orderRepo.count(); + expect(orderCountAfter).toBe(orderCountBefore); + + const item1After = await donationItemRepo.findOne({ + where: { itemId: 1 }, + }); + const item2After = await donationItemRepo.findOne({ + where: { itemId: 2 }, + }); + expect(item1After?.reservedQuantity).toBe(item1Before?.reservedQuantity); + expect(item2After?.reservedQuantity).toBe(item2Before?.reservedQuantity); + }); + + it('should throw BadRequestException if itemAllocations is empty', async () => { + const donationItemRepo = testDataSource.getRepository(DonationItem); + const emptyAllocations = new Map(); + await expect( service.create( validCreateOrderDto.foodRequestId, validCreateOrderDto.manufacturerId, + emptyAllocations, + userId, + ), + ).rejects.toThrow( + new BadRequestException('Cannot create order with no donation items'), + ); + + // Asserting that donation item reserved quantity wasn't updated + const donationItem1 = await donationItemRepo.findOne({ + where: { itemId: 1 }, + }); + expect(donationItem1?.reservedQuantity).toBe(10); + }); + + it('should throw NotFoundException if request is not found', async () => { + const nonExistentRequestId = 999; + const donationItemRepo = testDataSource.getRepository(DonationItem); + + await expect( + service.create( + nonExistentRequestId, + validCreateOrderDto.manufacturerId, parsedAllocations, userId, ), ).rejects.toThrow( - `The following donation items are not associated with the current food manufacturer: Donation item ID ${donationItemId} with Donation ID 3`, + new NotFoundException(`Request ${nonExistentRequestId} not found`), ); // Asserting that donation item reserved quantity wasn't updated diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index ccc9ebe5c..1018c940c 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -26,7 +26,9 @@ import { AllocationsService } from '../allocations/allocations.service'; import { ApplicationStatus } from '../shared/types'; import { VolunteerOrder } from '../volunteers/types'; import { EmailsService } from '../emails/email.service'; +import { FoodRequest } from '../foodRequests/request.entity'; import { emailTemplates } from '../emails/emailTemplates'; +import { UsersService } from '../users/users.service'; import { OrderSummary } from '../pantries/types'; @Injectable() @@ -37,11 +39,15 @@ export class OrdersService { @InjectRepository(Order) private repo: Repository, @InjectRepository(Pantry) private pantryRepo: Repository, @InjectRepository(Donation) private donationRepo: Repository, + @InjectRepository(FoodRequest) private requestRepo: Repository, + @InjectRepository(DonationItem) + private donationItemRepo: Repository, private requestsService: RequestsService, - private donationService: DonationService, + private usersService: UsersService, private manufacturerService: FoodManufacturersService, private donationItemsService: DonationItemsService, private allocationsService: AllocationsService, + private donationService: DonationService, @InjectDataSource() private dataSource: DataSource, private emailsService: EmailsService, ) {} @@ -179,97 +185,188 @@ export class OrdersService { itemAllocations: Map, userId: number, ): Promise { - return this.dataSource.transaction(async (transactionManager) => { - validateId(manufacturerId, 'Food Manufacturer'); - validateId(requestId, 'Request'); - - const request = await this.requestsService.findOne(requestId); + const { savedOrder, request, manufacturer, assignee, itemDetails } = + await this.dataSource.transaction(async (transactionManager) => { + validateId(manufacturerId, 'Food Manufacturer'); + validateId(requestId, 'Request'); + validateId(userId, 'User'); + + const request = await this.requestRepo.findOne({ + where: { requestId }, + relations: ['pantry', 'pantry.pantryUser'], + }); - if (request.status !== FoodRequestStatus.ACTIVE) { - throw new BadRequestException(`Request ${requestId} is not active`); - } + if (!request) { + throw new NotFoundException(`Request ${requestId} not found`); + } - const manufacturer = await this.manufacturerService.findOne( - manufacturerId, - ); + if (request.status !== FoodRequestStatus.ACTIVE) { + throw new BadRequestException(`Request ${requestId} is not active`); + } - if (manufacturer.status !== ApplicationStatus.APPROVED) { - throw new BadRequestException( - `Manufacturer ${manufacturerId} is not approved`, + const manufacturer = await this.manufacturerService.findOne( + manufacturerId, ); - } - const fmDonations = await this.donationRepo.find({ - where: { foodManufacturer: { foodManufacturerId: manufacturerId } }, - select: ['donationId'], - }); + if (manufacturer.status !== ApplicationStatus.APPROVED) { + throw new BadRequestException( + `Manufacturer ${manufacturerId} is not approved`, + ); + } - const fmDonationIdSet = new Set(fmDonations.map((d) => d.donationId)); + const fmDonations = await this.donationRepo.find({ + where: { foodManufacturer: { foodManufacturerId: manufacturerId } }, + select: ['donationId'], + }); - const donationItemIds = Array.from(itemAllocations.keys()); - const donationItems = await this.donationItemsService.getByIds( - donationItemIds, - ); + if (fmDonations.length === 0) { + throw new BadRequestException( + `Manufacturer ${manufacturerId} has no donations`, + ); + } - const invalidItems = donationItems.filter( - (item) => !fmDonationIdSet.has(item.donationId), - ); + const fmDonationIdSet = new Set(fmDonations.map((d) => d.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( - ', ', - )}`, + const donationItemIds = Array.from(itemAllocations.keys()); + const donationItems = await this.donationItemsService.getByIds( + donationItemIds, ); - } - for (const donationItem of donationItems) { - const id = donationItem.itemId; - const quantityToAllocate = itemAllocations.get(id)!; + if (donationItems.length === 0) { + throw new BadRequestException( + 'Cannot create order with no donation items', + ); + } - if ( - quantityToAllocate > - donationItem.quantity - donationItem.reservedQuantity - ) { + 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( - `Donation item ${id} quantity to allocate exceeds remaining quantity`, + `The following donation items are not associated with the current food manufacturer: ${messages.join( + ', ', + )}`, ); } - } - const orderTransactionRepo = transactionManager.getRepository(Order); + const itemDetails: { quantity: string; product: string }[] = []; + + for (const donationItem of donationItems) { + const id = donationItem.itemId; + const quantityToAllocate = itemAllocations.get(id)!; + + if ( + quantityToAllocate > + donationItem.quantity - donationItem.reservedQuantity + ) { + throw new BadRequestException( + `Donation item ${id} quantity to allocate exceeds remaining quantity`, + ); + } + + itemDetails.push({ + quantity: String(quantityToAllocate), + product: donationItem.itemName, + }); + } - const order = orderTransactionRepo.create({ - requestId: requestId, - foodManufacturerId: manufacturerId, - status: OrderStatus.PENDING, - assigneeId: userId, - }); + const orderTransactionRepo = transactionManager.getRepository(Order); - const savedOrder = await orderTransactionRepo.save(order); + const order = orderTransactionRepo.create({ + requestId: requestId, + foodManufacturerId: manufacturerId, + status: OrderStatus.PENDING, + assigneeId: userId, + }); - await this.allocationsService.createMultiple( - savedOrder.orderId, - itemAllocations, - transactionManager, - ); + const savedOrder = await orderTransactionRepo.save(order); - const associatedDonationIdsSet = - await this.donationItemsService.getAssociatedDonationIds( - donationItemIds, + await this.allocationsService.createMultiple( + savedOrder.orderId, + itemAllocations, + transactionManager, ); - await this.donationService.matchAll( - Array.from(associatedDonationIdsSet), - transactionManager, + await this.donationService.matchAll( + [...new Set(donationItems.map((item) => item.donationId))], + transactionManager, + ); + + const assignee = await this.usersService.findOne(userId); + + return { + savedOrder, + request, + manufacturer, + assignee, + itemDetails, + }; + }); + + const emailErrors: string[] = []; + + try { + const pantryMessage = emailTemplates.pantryRequestMatchedOrder({ + pantryName: request.pantry.pantryName, + items: itemDetails, + brand: manufacturer.foodManufacturerName, + volunteerName: `${assignee.firstName} ${assignee.lastName}`, + volunteerEmail: assignee.email, + }); + await this.emailsService.sendEmails({ + toEmail: request.pantry.pantryUser.email, + subject: pantryMessage.subject, + bodyHtml: pantryMessage.bodyHTML, + }); + } catch { + emailErrors.push( + 'Failed to send pantry request matched order confirmation email', ); + } - return savedOrder; - }); + try { + const pantryAddress = `${request.pantry.shipmentAddressLine1}${ + request.pantry.shipmentAddressLine2 + ? `
${request.pantry.shipmentAddressLine2}` + : '' + }
+${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ + request.pantry.shipmentAddressZip + }${ + request.pantry.shipmentAddressCountry + ? `
${request.pantry.shipmentAddressCountry}` + : '' + }`; + + const fmMessage = emailTemplates.fmDonationMatchedOrder({ + manufacturerName: manufacturer.foodManufacturerName, + items: itemDetails, + pantryName: request.pantry.pantryName, + pantryAddress: pantryAddress, + volunteerName: `${assignee.firstName} ${assignee.lastName}`, + volunteerEmail: assignee.email, + }); + await this.emailsService.sendEmails({ + toEmail: manufacturer.foodManufacturerRepresentative.email, + subject: fmMessage.subject, + bodyHtml: fmMessage.bodyHTML, + }); + } catch { + emailErrors.push( + 'Failed to send food manufacturer donation matched order confirmation email', + ); + } + + if (emailErrors.length > 0) { + throw new InternalServerErrorException(emailErrors.join('; ')); + } + + return savedOrder; } async findOne(orderId: number): Promise {