diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index 7d4b8c0f3..a19024d63 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -4,7 +4,7 @@ export type EmailTemplate = {
additionalContent?: string;
};
-export const EMAIL_REDIRECT_URL = 'localhost:4200';
+export const EMAIL_REDIRECT_URL = 'https://localhost:4200';
// TODO: Change this before production to be the actual ssf email
export const SSF_PARTNER_EMAIL = 'example@gmail.com';
@@ -98,4 +98,113 @@ 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}:
+
+ ${params.items
+ .map((item) => `- ${item.quantity} of ${item.product}
`)
+ .join('')}
+
+
+ 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 order, 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 Items:
+
+ ${params.items
+ .map((item) => `- ${item.quantity} of ${item.product}
`)
+ .join('')}
+
+
+ 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 dfd76b647..ab7c8ae4f 100644
--- a/apps/backend/src/foodRequests/request.controller.spec.ts
+++ b/apps/backend/src/foodRequests/request.controller.spec.ts
@@ -16,6 +16,7 @@ import {
} from './dtos/matching.dto';
import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity';
import { Pantry } from '../pantries/pantries.entity';
+import { AuthenticatedRequest } from '../auth/authenticated-request';
const mockRequestsService = mock();
@@ -309,10 +310,18 @@ describe('RequestsController', () => {
closedRequest as FoodRequest,
);
- const result = await controller.closeRequest(requestId);
+ const req = { user: { id: 1 } };
+
+ const result = await controller.closeRequest(
+ requestId,
+ req as AuthenticatedRequest,
+ );
expect(result).toEqual(closedRequest);
- expect(mockRequestsService.closeRequest).toHaveBeenCalledWith(requestId);
+ 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 9505d0aaf..9bdea232b 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';
@@ -124,7 +126,8 @@ export class RequestsController {
@Patch('/:requestId/close')
async closeRequest(
@Param('requestId', ParseIntPipe) requestId: number,
+ @Req() req: AuthenticatedRequest,
): Promise {
- return this.requestsService.closeRequest(requestId);
+ 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 13be82b49..c88a57e3e 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,
@@ -381,6 +404,64 @@ 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'],
+ });
+ const lastDeliveredOrder = await testDataSource
+ .getRepository(Order)
+ .findOne({
+ where: { requestId, status: OrderStatus.DELIVERED },
+ order: { deliveredAt: 'DESC' },
+ relations: ['assignee'],
+ });
+
+ await service.updateRequestStatus(requestId);
+
+ const assignee = lastDeliveredOrder!.assignee;
+ const expectedMessage = emailTemplates.pantryRequestClosed({
+ pantryName: pantry!.pantryName,
+ volunteerName: `${assignee.firstName} ${assignee.lastName}`,
+ volunteerEmail: assignee.email,
+ });
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
+ [pantry!.pantryUser.email],
+ expectedMessage.subject,
+ expectedMessage.bodyHTML,
+ );
+ });
+
+ it('does not send email when not all orders are delivered (request stays active)', async () => {
+ await service.updateRequestStatus(3);
+
+ expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
+ });
+
+ it('does not send email when request was already closed before updateRequestStatus', async () => {
+ await testDataSource.query(
+ `UPDATE food_requests SET status = 'closed' WHERE request_id = 1`,
+ );
+
+ await service.updateRequestStatus(1);
+
+ expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
+ });
+
+ it('still auto-closes request when email fails', async () => {
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('SMTP error'),
+ );
+
+ await service.updateRequestStatus(1);
+
+ const request = await service.findOne(1);
+ expect(request.status).toBe(FoodRequestStatus.CLOSED);
+ });
});
describe('getMatchingManufacturers', () => {
@@ -746,8 +827,14 @@ describe('RequestsService', () => {
});
describe('closeRequest', () => {
+ let volunteerId: number;
+
+ beforeEach(() => {
+ volunteerId = 6;
+ });
+
it('should close an active request', async () => {
- const result = await service.closeRequest(3);
+ const result = await service.closeRequest(3, volunteerId);
expect(result.status).toBe(FoodRequestStatus.CLOSED);
@@ -756,15 +843,15 @@ describe('RequestsService', () => {
});
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'),
);
});
@@ -774,7 +861,7 @@ describe('RequestsService', () => {
.getRepository(Order)
.find({ where: { requestId: 3 } });
- await service.closeRequest(3);
+ await service.closeRequest(3, volunteerId);
const ordersAfter = await testDataSource
.getRepository(Order)
@@ -786,11 +873,50 @@ describe('RequestsService', () => {
});
it('should not reopen a closed request when updateRequestStatus is called', async () => {
- await service.closeRequest(1);
+ await service.closeRequest(1, volunteerId);
await service.updateRequestStatus(1);
const fromDb = await service.findOne(1);
expect(fromDb.status).toBe(FoodRequestStatus.CLOSED);
});
+
+ 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'],
+ });
+
+ await service.closeRequest(3, volunteerId);
+
+ const expectedMessage = emailTemplates.pantryRequestClosed({
+ pantryName: pantry!.pantryName,
+ volunteerName: `James Thomas`,
+ volunteerEmail: `james.t@volunteer.org`,
+ });
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
+ [pantry!.pantryUser.email],
+ expectedMessage.subject,
+ expectedMessage.bodyHTML,
+ );
+ });
+
+ 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 ee7d771e7..075ac77ec 100644
--- a/apps/backend/src/foodRequests/request.service.ts
+++ b/apps/backend/src/foodRequests/request.service.ts
@@ -2,6 +2,7 @@ import {
BadRequestException,
Injectable,
InternalServerErrorException,
+ Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -24,9 +25,12 @@ 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 {
+ private readonly logger = new Logger(RequestsService.name);
+
constructor(
@InjectRepository(FoodRequest) private repo: Repository,
@InjectRepository(Pantry) private pantryRepo: Repository,
@@ -36,6 +40,7 @@ export class RequestsService {
@InjectRepository(DonationItem)
private donationItemRepo: Repository,
private emailsService: EmailsService,
+ private usersService: UsersService,
) {}
async findOne(requestId: number): Promise {
@@ -279,7 +284,7 @@ export class RequestsService {
const request = await this.repo.findOne({
where: { requestId },
- relations: ['orders'],
+ relations: ['orders', 'pantry', 'pantry.pantryUser'],
});
if (!request) {
@@ -298,13 +303,43 @@ export class RequestsService {
(order) => order.status === OrderStatus.DELIVERED,
);
- if (request.status !== FoodRequestStatus.CLOSED) {
+ const wasAlreadyClosed = request.status === FoodRequestStatus.CLOSED;
+
+ if (!wasAlreadyClosed) {
request.status = allDelivered
? FoodRequestStatus.CLOSED
: FoodRequestStatus.ACTIVE;
}
await this.repo.save(request);
+
+ if (allDelivered && !wasAlreadyClosed) {
+ try {
+ const lastDeliveredOrder = await this.orderRepo.findOne({
+ where: { requestId, status: OrderStatus.DELIVERED },
+ order: { deliveredAt: 'DESC' },
+ relations: ['assignee'],
+ });
+
+ if (lastDeliveredOrder?.assignee) {
+ const { assignee } = lastDeliveredOrder;
+ const message = emailTemplates.pantryRequestClosed({
+ pantryName: request.pantry.pantryName,
+ volunteerName: `${assignee.firstName} ${assignee.lastName}`,
+ volunteerEmail: assignee.email,
+ });
+ await this.emailsService.sendEmails(
+ [request.pantry.pantryUser.email],
+ message.subject,
+ message.bodyHTML,
+ );
+ }
+ } catch {
+ this.logger.warn(
+ `Request ${requestId} auto-closed, but failed to send pantry notification email`,
+ );
+ }
+ }
}
async update(requestId: number, dto: UpdateRequestDto): Promise {
@@ -373,11 +408,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'],
});
if (!request) {
@@ -390,7 +429,27 @@ export class RequestsService {
);
}
+ const assignee = await this.usersService.findOne(actingUserId);
+
request.status = FoodRequestStatus.CLOSED;
- return this.repo.save(request);
+ const saved = await this.repo.save(request);
+ try {
+ const message = emailTemplates.pantryRequestClosed({
+ pantryName: request.pantry.pantryName,
+ volunteerName: `${assignee.firstName} ${assignee.lastName}`,
+ volunteerEmail: assignee.email,
+ });
+ await this.emailsService.sendEmails(
+ [request.pantry.pantryUser.email],
+ message.subject,
+ message.bodyHTML,
+ );
+ } 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 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 92d37acc3..de8d1fd49 100644
--- a/apps/backend/src/orders/order.service.spec.ts
+++ b/apps/backend/src/orders/order.service.spec.ts
@@ -6,7 +6,11 @@ import { testDataSource } from '../config/typeormTestDataSource';
import { OrderStatus, VolunteerAction } from './types';
import { Pantry } from '../pantries/pantries.entity';
import { OrderDetailsDto } from './dtos/order-details.dto';
-import { BadRequestException, NotFoundException } from '@nestjs/common';
+import {
+ BadRequestException,
+ InternalServerErrorException,
+ NotFoundException,
+} from '@nestjs/common';
import { TrackingCostDto } from './dtos/tracking-cost.dto';
import { FoodType } from '../donationItems/types';
import { FoodRequest } from '../foodRequests/request.entity';
@@ -26,17 +30,23 @@ 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 } from 'typeorm';
+import { DataSource, EntityManager } 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();
@@ -102,6 +112,10 @@ describe('OrdersService', () => {
provide: AuthService,
useValue: {},
},
+ {
+ provide: EmailsService,
+ useValue: mockEmailsService,
+ },
],
}).compile();
@@ -109,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();
@@ -785,10 +800,17 @@ 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);
@@ -873,6 +895,71 @@ 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(
+ [request.pantry.pantryUser.email],
+ pantryMessage.subject,
+ pantryMessage.bodyHTML,
+ );
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
+ [manufacturer.foodManufacturerRepresentative.email],
+ fmMessage.subject,
+ fmMessage.bodyHTML,
+ );
});
it('should throw BadRequestException if request is not active', async () => {
@@ -894,17 +981,10 @@ describe('OrdersService', () => {
parsedAllocations,
userId,
),
- ).rejects.toThrow(BadRequestException);
- await expect(
- service.create(
- validCreateOrderDto.foodRequestId,
- validCreateOrderDto.manufacturerId,
- parsedAllocations,
- userId,
- ),
- ).rejects.toThrow(
- `Request ${validCreateOrderDto.foodRequestId} is not active`,
- );
+ ).rejects.toMatchObject({
+ name: BadRequestException.name,
+ message: `Request ${validCreateOrderDto.foodRequestId} is not active`,
+ });
// Asserting that donation item reserved quantity wasn't updated
const donationItem1 = await donationItemRepo.findOne({
@@ -926,17 +1006,10 @@ describe('OrdersService', () => {
parsedAllocations,
userId,
),
- ).rejects.toThrow(BadRequestException);
- await expect(
- service.create(
- validCreateOrderDto.foodRequestId,
- validCreateOrderDto.manufacturerId,
- parsedAllocations,
- userId,
- ),
- ).rejects.toThrow(
- `Manufacturer ${validCreateOrderDto.manufacturerId} is not approved`,
- );
+ ).rejects.toMatchObject({
+ name: BadRequestException.name,
+ message: `Manufacturer ${validCreateOrderDto.manufacturerId} is not approved`,
+ });
// Asserting that donation item reserved quantity wasn't updated
const donationItem1 = await donationItemRepo.findOne({
@@ -957,15 +1030,10 @@ describe('OrdersService', () => {
parsedAllocations,
userId,
),
- ).rejects.toThrow(NotFoundException);
- await expect(
- service.create(
- validCreateOrderDto.foodRequestId,
- validCreateOrderDto.manufacturerId,
- parsedAllocations,
- userId,
- ),
- ).rejects.toThrow(`Donation items not found for ID(s): 999`);
+ ).rejects.toMatchObject({
+ name: NotFoundException.name,
+ message: 'Donation items not found for ID(s): 999',
+ });
// Asserting that donation item reserved quantity wasn't updated
const donationItem1 = await donationItemRepo.findOne({
@@ -990,17 +1058,10 @@ describe('OrdersService', () => {
parsedAllocations,
userId,
),
- ).rejects.toThrow(BadRequestException);
- await expect(
- service.create(
- validCreateOrderDto.foodRequestId,
- validCreateOrderDto.manufacturerId,
- parsedAllocations,
- userId,
- ),
- ).rejects.toThrow(
- `Donation item ${donationItemId} quantity to allocate exceeds remaining quantity`,
- );
+ ).rejects.toMatchObject({
+ name: BadRequestException.name,
+ message: `Donation item ${donationItemId} quantity to allocate exceeds remaining quantity`,
+ });
// Asserting that donation item reserved quantity wasn't updated
const donationItem1 = await donationItemRepo.findOne({
@@ -1024,7 +1085,23 @@ describe('OrdersService', () => {
parsedAllocations,
userId,
),
- ).rejects.toThrow(BadRequestException);
+ ).rejects.toMatchObject({
+ name: BadRequestException.name,
+ message: `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({
+ where: { itemId: 1 },
+ });
+ expect(donationItem1?.reservedQuantity).toBe(10);
+ });
+
+ it('should still create order and send FM email when pantry email fails', async () => {
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('SMTP error'),
+ );
+
await expect(
service.create(
validCreateOrderDto.foodRequestId,
@@ -1033,14 +1110,137 @@ describe('OrdersService', () => {
userId,
),
).rejects.toThrow(
- `The following donation items are not associated with the current food manufacturer: Donation item ID ${donationItemId} with Donation ID 3`,
+ new InternalServerErrorException(
+ 'Failed to send pantry request 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);
+
+ const manufacturerRepo = testDataSource.getRepository(FoodManufacturer);
+ const manufacturer = await manufacturerRepo.findOne({
+ where: { foodManufacturerId: validCreateOrderDto.manufacturerId },
+ relations: ['foodManufacturerRepresentative'],
+ });
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
+ [manufacturer!.foodManufacturerRepresentative.email],
+ expect.any(String),
+ 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,
+ validCreateOrderDto.manufacturerId,
+ parsedAllocations,
+ userId,
+ ),
+ ).rejects.toThrow(
+ new InternalServerErrorException(
+ 'Failed to send pantry request matched order confirmation email; Failed to send food manufacturer donation matched order confirmation email',
+ ),
+ );
+
+ 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.anything(),
+ );
+ });
+
+ 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);
});
});
describe('getAllOrdersForVolunteer', () => {
diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts
index ff41610fa..0860b35c7 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,
+ InternalServerErrorException,
} from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource } from 'typeorm';
@@ -24,6 +25,10 @@ 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 {
@@ -31,13 +36,16 @@ 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,
+ private emailsService: EmailsService,
@InjectDataSource() private dataSource: DataSource,
) {}
@@ -188,97 +196,175 @@ 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 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(
+ [...new Set(donationItems.map((item) => item.donationId))],
+ transactionManager,
);
- await this.donationService.matchAll(
- Array.from(associatedDonationIdsSet),
- 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(
+ [request.pantry.pantryUser.email],
+ pantryMessage.subject,
+ 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(
+ [manufacturer.foodManufacturerRepresentative.email],
+ fmMessage.subject,
+ 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 {