diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 7d4b8c0f3..0afe30eed 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -98,4 +98,90 @@ 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

+

+ To log in to your account, please click the following link: ${EMAIL_REDIRECT_URL}/login +

+ `, + }), + + 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 Items:
+

+ 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

+

+ To log in to your account, please click the following link: ${EMAIL_REDIRECT_URL}/login +

+ `, + }), }; diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index 7e4c638de..0e1e01246 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -17,6 +17,9 @@ import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; 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: [ @@ -28,6 +31,7 @@ import { Donation } from '../donations/donations.entity'; DonationItem, Allocation, Donation, + User, ]), AllocationModule, forwardRef(() => AuthModule), @@ -37,6 +41,8 @@ import { Donation } from '../donations/donations.entity'; ManufacturerModule, 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 753c4f893..01891f0c3 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -28,14 +28,20 @@ import { CreateOrderDto } from './dtos/create-order.dto'; import { DataSource } from 'typeorm'; import { EmailsService } from '../emails/email.service'; import { Allocation } from '../allocations/allocations.entity'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates } from '../emails/emailTemplates'; // Set 1 minute timeout for async DB operations jest.setTimeout(60000); +const mockEmailsService = mock(); + describe('OrdersService', () => { let service: OrdersService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + // Initialize DataSource once if (!testDataSource.isInitialized) { await testDataSource.initialize(); @@ -106,6 +112,10 @@ describe('OrdersService', () => { provide: AuthService, useValue: {}, }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, ], }).compile(); @@ -113,6 +123,7 @@ describe('OrdersService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); @@ -789,10 +800,13 @@ describe('OrdersService', () => { ]); }); - it('should create a new order successfully', async () => { + 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); @@ -877,6 +891,68 @@ describe('OrdersService', () => { where: { donationId: 2 }, }); expect(matchedDonation2?.status).toBe(DonationStatus.MATCHED); + + // Testing emails section + + const assignee = await usersRepo.findOne({ where: { id: userId } }); + const request = await requestRepo.findOne({ + where: { requestId: validCreateOrderDto.foodRequestId }, + relations: ['pantry', 'pantry.pantryUser'], + }); + const manufacturer = await manufacturerRepo.findOne({ + where: { foodManufacturerId: validCreateOrderDto.manufacturerId }, + relations: ['foodManufacturerRepresentative'], + }); + + const pantry = request!.pantry; + const pantryAddress = [ + pantry.mailingAddressLine1, + pantry.mailingAddressCity, + pantry.mailingAddressState, + pantry.mailingAddressZip, + pantry.mailingAddressCountry, + ] + .join(' ') + .replace(/, ,/g, ', '); + + const itemDetails = [ + { quantity: '10', product: updatedDonationItem1!.itemName }, + { quantity: '3', product: updatedDonationItem2!.itemName }, + { quantity: '5', product: updatedDonationItem3!.itemName }, + ]; + + const { subject: fmSubject, bodyHTML: fmBodyHtml } = + emailTemplates.fmDonationMatchedOrder({ + manufacturerName: manufacturer!.foodManufacturerName, + items: itemDetails, + pantryName: pantry.pantryName, + pantryAddress, + volunteerName: assignee!.firstName + ' ' + assignee!.lastName, + volunteerEmail: assignee!.email, + }); + + const { subject: pantrySubject, bodyHTML: pantryBodyHtml } = + 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( + [request!.pantry.pantryUser.email], + pantrySubject, + pantryBodyHtml, + ); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [manufacturer!.foodManufacturerRepresentative.email], + fmSubject, + fmBodyHtml, + ); }); it('should throw BadRequestException if request is not active', async () => { diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index ff41610fa..2a3011129 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException, + Logger, } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Repository, In, DataSource } from 'typeorm'; @@ -24,20 +25,29 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; 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'; @Injectable() export class OrdersService { + private readonly logger = new Logger(OrdersService.name); + constructor( @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, + private emailsService: EmailsService, @InjectDataSource() private dataSource: DataSource, ) {} @@ -188,97 +198,180 @@ 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); - - if (request.status !== FoodRequestStatus.ACTIVE) { - throw new BadRequestException(`Request ${requestId} is not active`); - } + const { savedOrder, request, manufacturer, assignee, itemDetails } = + await this.dataSource.transaction(async (transactionManager) => { + validateId(manufacturerId, 'Food Manufacturer'); + validateId(requestId, 'Request'); + + const request = await this.requestRepo.findOne({ + where: { requestId }, + relations: ['pantry', 'pantry.pantryUser'], + }); + + 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'], - }); - const fmDonationIdSet = new Set(fmDonations.map((d) => d.donationId)); + if (manufacturer.status !== ApplicationStatus.APPROVED) { + throw new BadRequestException( + `Manufacturer ${manufacturerId} is not approved`, + ); + } - const donationItemIds = Array.from(itemAllocations.keys()); - const donationItems = await this.donationItemsService.getByIds( - donationItemIds, - ); + const fmDonations = await this.donationRepo.find({ + where: { foodManufacturer: { foodManufacturerId: manufacturerId } }, + select: ['donationId'], + }); - 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)!; + const invalidItems = donationItems.filter( + (item) => !fmDonationIdSet.has(item.donationId), + ); - if ( - quantityToAllocate > - donationItem.quantity - donationItem.reservedQuantity - ) { + 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 orderTransactionRepo = transactionManager.getRepository(Order); + + const order = orderTransactionRepo.create({ + requestId: requestId, + foodManufacturerId: manufacturerId, + status: OrderStatus.PENDING, + assigneeId: userId, + }); + + const savedOrder = await orderTransactionRepo.save(order); + + await this.allocationsService.createMultiple( + savedOrder.orderId, + itemAllocations, + transactionManager, + ); + + const associatedDonationIdsSet = + await this.donationItemsService.getAssociatedDonationIds( + donationItemIds, + ); + + await this.donationService.matchAll( + Array.from(associatedDonationIdsSet), + transactionManager, + ); + + const assignee = await this.usersService.findOne(userId); - const order = orderTransactionRepo.create({ - requestId: requestId, - foodManufacturerId: manufacturerId, - status: OrderStatus.PENDING, - assigneeId: userId, + return { + savedOrder, + request, + manufacturer, + assignee, + itemDetails, + }; }); - const savedOrder = await orderTransactionRepo.save(order); + const emailErrors: string[] = []; - await this.allocationsService.createMultiple( - savedOrder.orderId, - itemAllocations, - transactionManager, + 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( + [request.pantry.pantryUser.email], + pantryMessage.subject, + pantryMessage.bodyHTML, + ); + } catch { + emailErrors.push( + 'Failed to send pantry request matched order confirmation email', ); + } - const associatedDonationIdsSet = - await this.donationItemsService.getAssociatedDonationIds( - donationItemIds, - ); + try { + const fmMessage = emailTemplates.fmDonationMatchedOrder({ + manufacturerName: manufacturer.foodManufacturerName, + items: itemDetails, + pantryName: request.pantry.pantryName, + pantryAddress: + request.pantry.mailingAddressLine1 + + ' ' + + request.pantry.mailingAddressCity + + ' ' + + request.pantry.mailingAddressState + + ' ' + + request.pantry.mailingAddressZip + + ' ' + + request.pantry.mailingAddressCountry, + volunteerName: assignee.firstName + ' ' + assignee.lastName, + volunteerEmail: assignee.email, + }); + await this.emailsService.sendEmails( + [manufacturer.foodManufacturerRepresentative.email], + fmMessage.subject, + fmMessage.bodyHTML, + ); + } catch { + emailErrors.push( + 'Failed to send food manufacturer donation matched order confirmation email', + ); + } - await this.donationService.matchAll( - Array.from(associatedDonationIdsSet), - transactionManager, + if (emailErrors.length > 0) { + this.logger.warn( + `Order ${ + savedOrder.orderId + } created, but email issues occurred: ${emailErrors.join('; ')}`, ); + } - return savedOrder; - }); + return savedOrder; } async findOne(orderId: number): Promise {