Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions apps/backend/src/emails/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,89 @@ export const emailTemplates = {
<p>Best regards,<br />The Securing Safe Food Team</p>
`,
}),

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: `
<p>Hi ${params.pantryName},</p>
<p>
Good news! Your recent food request through Securing Safe Food has been successfully matched to an order and is now moving forward toward delivery.
</p>
<p>
<strong>Items you will receive from ${params.brand}:</strong>
<ul>
${params.items
.map((item) => `<li>${item.quantity} of ${item.product}</li>`)
.join('')}
</ul>
</p>
<p>
To view full order details, delivery updates, and any notes from the coordinating volunteer or food manufacturer, please log into the platform.
</p>
<p>
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}.
</p>
<p>
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!
</p>
<p>Best regards,<br />The Securing Safe Food Team</p>
<p>
To log in to your account, please click the following link: <a href="${EMAIL_REDIRECT_URL}/login">${EMAIL_REDIRECT_URL}/login</a>
</p>
`,
}),

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: `
<p>Hi ${params.manufacturerName},</p>
<p>
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.
</p>
<p>
<strong>Matched Items:</strong><br />
${params.items
.map((item) => `• ${item.quantity} of ${item.product}`)
.join('<br />')}
<br /><br />
<strong>Recipient Pantry:</strong> ${params.pantryName}<br />
<strong>Address:</strong><br />
${params.pantryAddress}
</p>
<p>
Please log into the platform to review the full delivery details, timelines, and any special handling instructions associated with this shipment.
</p>
<p>
Your support plays a direct role in expanding access to allergen-safe foods, and we truly appreciate your commitment to this work.
</p>
<p>
If you have any questions or need assistance, please contact your coordinator, ${
params.volunteerName
} at ${params.volunteerEmail}.
</p>
<p>
Thank you so much.
</p>
<p>Best regards,<br />The Securing Safe Food Team</p>
<p>
To log in to your account, please click the following link: <a href="${EMAIL_REDIRECT_URL}/login">${EMAIL_REDIRECT_URL}/login</a>
</p>
`,
}),
};
6 changes: 6 additions & 0 deletions apps/backend/src/orders/order.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -28,6 +31,7 @@ import { Donation } from '../donations/donations.entity';
DonationItem,
Allocation,
Donation,
User,
]),
AllocationModule,
forwardRef(() => AuthModule),
Expand All @@ -37,6 +41,8 @@ import { Donation } from '../donations/donations.entity';
ManufacturerModule,
DonationItemsModule,
DonationModule,
EmailsModule,
forwardRef(() => UsersModule),
],
controllers: [OrdersController],
providers: [OrdersService],
Expand Down
78 changes: 77 additions & 1 deletion apps/backend/src/orders/order.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmailsService>();

describe('OrdersService', () => {
let service: OrdersService;

beforeAll(async () => {
mockEmailsService.sendEmails.mockResolvedValue(undefined);

// Initialize DataSource once
if (!testDataSource.isInitialized) {
await testDataSource.initialize();
Expand Down Expand Up @@ -106,13 +112,18 @@ describe('OrdersService', () => {
provide: AuthService,
useValue: {},
},
{
provide: EmailsService,
useValue: mockEmailsService,
},
],
}).compile();

service = module.get<OrdersService>(OrdersService);
});

beforeEach(async () => {
mockEmailsService.sendEmails.mockClear();
await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`);
await testDataSource.query(`CREATE SCHEMA public`);
await testDataSource.runMigrations();
Expand Down Expand Up @@ -764,10 +775,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);

Expand Down Expand Up @@ -845,6 +859,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 () => {
Expand Down
76 changes: 74 additions & 2 deletions apps/backend/src/orders/order.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
Expand All @@ -24,20 +25,27 @@ 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 {
constructor(
@InjectRepository(Order) private repo: Repository<Order>,
@InjectRepository(Pantry) private pantryRepo: Repository<Pantry>,
@InjectRepository(Donation) private donationRepo: Repository<Donation>,
@InjectRepository(FoodRequest) private requestRepo: Repository<FoodRequest>,
@InjectRepository(DonationItem)
private donationItemRepo: Repository<DonationItem>,
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,
) {}

Expand Down Expand Up @@ -154,7 +162,14 @@ export class OrdersService {
validateId(manufacturerId, 'Food Manufacturer');
validateId(requestId, 'Request');

const request = await this.requestsService.findOne(requestId);
const request = await this.requestRepo.findOne({
where: { requestId },
relations: ['pantry', 'pantry.pantryUser'],
});

if (!request) {
throw new NotFoundException(`Request ${requestId} not found`);
}

if (request.status !== FoodRequestStatus.ACTIVE) {
throw new BadRequestException(`Request ${requestId} is not active`);
Expand Down Expand Up @@ -198,6 +213,8 @@ export class OrdersService {
);
}

const itemDetails: { quantity: string; product: string }[] = [];

for (const donationItem of donationItems) {
const id = donationItem.itemId;
const quantityToAllocate = itemAllocations.get(id)!;
Expand All @@ -210,6 +227,11 @@ export class OrdersService {
`Donation item ${id} quantity to allocate exceeds remaining quantity`,
);
}

itemDetails.push({
quantity: String(quantityToAllocate),
product: donationItem.itemName,
});
}

const orderTransactionRepo = transactionManager.getRepository(Order);
Expand Down Expand Up @@ -239,6 +261,56 @@ export class OrdersService {
transactionManager,
);

const assignee = await this.usersService.findOne(userId);

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 {
throw new InternalServerErrorException(
'Failed to send pantry request matched order confirmation email to representative',
);
}

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 {
throw new InternalServerErrorException(
'Failed to send food manufacturer donation matched to order confirmation email to representative',
);
}

return savedOrder;
});
}
Expand Down
Loading