From 55495f71daec8386965b38d532059f853f890883 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Wed, 11 Mar 2026 01:59:08 -0400 Subject: [PATCH 1/6] Got initial implementation working --- apps/backend/src/emails/email.service.ts | 6 +- apps/backend/src/emails/emailTemplates.ts | 80 +++++++++++++++++++ .../manufacturers.service.ts | 29 ++++++- .../src/foodRequests/request.service.ts | 17 ++++ .../src/pantries/pantries.controller.ts | 15 ---- apps/backend/src/pantries/pantries.service.ts | 25 ++++++ apps/backend/src/users/users.module.ts | 2 + apps/backend/src/users/users.service.ts | 33 +++++++- 8 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 apps/backend/src/emails/emailTemplates.ts diff --git a/apps/backend/src/emails/email.service.ts b/apps/backend/src/emails/email.service.ts index a319e1331..9edfdaa0f 100644 --- a/apps/backend/src/emails/email.service.ts +++ b/apps/backend/src/emails/email.service.ts @@ -21,7 +21,7 @@ export class EmailsService { * @param recipientEmail the email address of the recipients * @param subject the subject of the email * @param bodyHtml the HTML body of the email - * @param attachments any base64 encoded attachments to inlude in the email + * @param attachments any base64 encoded attachments to include in the email * @resolves if the email was sent successfully * @rejects if the email was not sent successfully */ @@ -31,6 +31,10 @@ export class EmailsService { bodyHTML: string, attachments?: EmailAttachment[], ): Promise { + if (process.env.SEND_AUTOMATED_EMAILS === 'false') { + this.logger.warn('Automated emails are disabled. Email not sent.'); + return Promise.resolve(); + } return this.amazonSESWrapper.sendEmails( recipientEmails, subject, diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts new file mode 100644 index 000000000..b572e9825 --- /dev/null +++ b/apps/backend/src/emails/emailTemplates.ts @@ -0,0 +1,80 @@ +export type EmailTemplate = { + subject: string; + bodyHTML: string; + additionalContent?: string; +}; + +export const emailTemplates = { + pantryFmApplicationApproved: (params: { name: string }): EmailTemplate => ({ + subject: 'Your Securing Safe Food Account Has Been Approved', + bodyHTML: ` +

Hi ${params.name},

+

+ We're excited to let you know that your Securing Safe Food account has been + approved and is now active. You can now log in using the credentials created + during registration to begin submitting requests, managing donations, and + coordinating with our network. +

+

+ If you have any questions as you get started or need help navigating the + platform, please do not hesitate to reach out — we are happy to help! +

+

+ We are grateful to have you as part of the SSF community and look forward + to working together to expand access to allergen-safe food. +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + volunteerAccountCreated: (): EmailTemplate => ({ + subject: 'Welcome to Securing Safe Food: Your Volunteer Account Is Ready', + bodyHTML: ` +

Welcome to Securing Safe Food!

+

+ Your volunteer account has been successfully created and you can now log in + to begin supporting pantry coordination, order matching, and delivery logistics. +

+

+ Once logged in, you'll be able to view your assignments, track active requests, + and collaborate with partner organizations. +

+

+ Thank you for being part of our mission. Your time and effort directly help + increase access to safe food for individuals with dietary restrictions. +

+

Best regards,
The Securing Safe Food Team

+ `, + additionalContent: 'localhost:4200/', + }), + + pantryFmApplicationSubmitted: (): EmailTemplate => ({ + subject: 'New Partner Application Submitted', + bodyHTML: ` +

Hi,

+

+ A new partner application has been submitted through the SSF platform. + Please log in to the dashboard to review and take action. +

+

Best regards,
The Securing Safe Food Team

+ `, + additionalContent: 'localhost:4200/', + }), + + pantrySubmitsFoodRequest: (params: { + pantryName: string; + volunteerName: string; + }): EmailTemplate => ({ + subject: `${params.pantryName} Request Requires Your Review`, + bodyHTML: ` +

Hi ${params.volunteerName},

+

+ A new food request has been submitted by ${params.pantryName}. + Please log on to the SSF platform to review these request details and begin coordination when ready. +

+

+ Thank you for your continued support of our network and mission!. +

Best regards,
The Securing Safe Food Team

+ `, + }), +}; diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index ee4b49ac5..1dbb7e892 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -10,6 +10,8 @@ import { ApplicationStatus } from '../shared/types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; import { Donation } from '../donations/donations.entity'; +import { emailTemplates } from '../emails/emailTemplates'; +import { EmailsService } from '../emails/email.service'; @Injectable() export class FoodManufacturersService { @@ -18,6 +20,7 @@ export class FoodManufacturersService { private repo: Repository, private usersService: UsersService, + private emailsService: EmailsService, @InjectRepository(Donation) private donationsRepo: Repository, @@ -114,6 +117,15 @@ export class FoodManufacturersService { foodManufacturerData.newsletterSubscription ?? null; await this.repo.save(foodManufacturer); + + // TODO: Change receiver if deemed that they shouldn't receive an email on submisssion + if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + await this.emailsService.sendEmails( + [foodManufacturer.foodManufacturerRepresentative.email], + emailTemplates.pantryFmApplicationSubmitted().subject, + emailTemplates.pantryFmApplicationSubmitted().bodyHTML, + ); + } } async approve(id: number) { @@ -127,10 +139,7 @@ export class FoodManufacturersService { } const createUserDto: userSchemaDto = { - email: foodManufacturer.foodManufacturerRepresentative.email, - firstName: foodManufacturer.foodManufacturerRepresentative.firstName, - lastName: foodManufacturer.foodManufacturerRepresentative.lastName, - phone: foodManufacturer.foodManufacturerRepresentative.phone, + ...foodManufacturer.foodManufacturerRepresentative, role: Role.FOODMANUFACTURER, }; @@ -140,6 +149,18 @@ export class FoodManufacturersService { status: ApplicationStatus.APPROVED, foodManufacturerRepresentative: newFoodManufacturer, }); + + if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + await this.emailsService.sendEmails( + [newFoodManufacturer.email], + emailTemplates.pantryFmApplicationApproved({ + name: newFoodManufacturer.firstName, + }).subject, + emailTemplates.pantryFmApplicationApproved({ + name: newFoodManufacturer.firstName, + }).bodyHTML, + ); + } } async deny(id: number) { diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 0a3290fd2..8d76e1255 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -15,6 +15,8 @@ import { } from './dtos/matching.dto'; import { FoodType } from '../donationItems/types'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; @Injectable() export class RequestsService { @@ -26,6 +28,7 @@ export class RequestsService { private foodManufacturerRepo: Repository, @InjectRepository(DonationItem) private donationItemRepo: Repository, + private emailsService: EmailsService, ) {} async findOne(requestId: number): Promise { @@ -210,6 +213,20 @@ export class RequestsService { additionalInformation, }); + if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + await this.emailsService.sendEmails( + [foodRequest.pantry.pantryUser.email], + emailTemplates.pantrySubmitsFoodRequest({ + pantryName: foodRequest.pantry.pantryName, + volunteerName: foodRequest.pantry.pantryUser.firstName, + }).subject, + emailTemplates.pantrySubmitsFoodRequest({ + pantryName: foodRequest.pantry.pantryName, + volunteerName: foodRequest.pantry.pantryUser.firstName, + }).bodyHTML, + ); + } + return await this.repo.save(foodRequest); } diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index d5e252a05..031f5dd7f 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -26,8 +26,6 @@ import { import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; -import { EmailsService } from '../emails/email.service'; -import { SendEmailDTO } from '../emails/dto/send-email.dto'; import { Public } from '../auth/public.decorator'; import { AuthenticatedRequest } from '../auth/authenticated-request'; @@ -36,7 +34,6 @@ export class PantriesController { constructor( private pantriesService: PantriesService, private ordersService: OrdersService, - private emailsService: EmailsService, ) {} @Roles(Role.PANTRY) @@ -340,16 +337,4 @@ export class PantriesController { ): Promise { return this.pantriesService.deny(pantryId); } - - @Post('/email') - async sendEmail(@Body() sendEmailDTO: SendEmailDTO): Promise { - const { toEmails, subject, bodyHtml, attachments } = sendEmailDTO; - - await this.emailsService.sendEmails( - toEmails, - subject, - bodyHtml, - attachments, - ); - } } diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index d21cd516e..289d4bfcf 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -14,6 +14,8 @@ import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { Role } from '../users/types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; +import { emailTemplates } from '../emails/emailTemplates'; +import { EmailsService } from '../emails/email.service'; @Injectable() export class PantriesService { @@ -22,6 +24,9 @@ export class PantriesService { @Inject(forwardRef(() => UsersService)) private usersService: UsersService, + + @Inject(forwardRef(() => EmailsService)) + private emailsService: EmailsService, ) {} async findOne(pantryId: number): Promise { @@ -104,6 +109,14 @@ export class PantriesService { // pantry contact is automatically added to User table await this.repo.save(pantry); + + if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + await this.emailsService.sendEmails( + [pantryContact.email], + emailTemplates.pantryFmApplicationSubmitted().subject, + emailTemplates.pantryFmApplicationSubmitted().bodyHTML, + ); + } } async approve(id: number) { @@ -128,6 +141,18 @@ export class PantriesService { status: ApplicationStatus.APPROVED, pantryUser: newPantryUser, }); + + if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + await this.emailsService.sendEmails( + [newPantryUser.email], + emailTemplates.pantryFmApplicationApproved({ + name: newPantryUser.firstName, + }).subject, + emailTemplates.pantryFmApplicationApproved({ + name: newPantryUser.firstName, + }).bodyHTML, + ); + } } async deny(id: number) { diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index f7bf1c194..7408b6c1f 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -5,12 +5,14 @@ import { UsersService } from './users.service'; import { User } from './users.entity'; import { PantriesModule } from '../pantries/pantries.module'; import { AuthModule } from '../auth/auth.module'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ TypeOrmModule.forFeature([User]), forwardRef(() => PantriesModule), forwardRef(() => AuthModule), + EmailsModule, ], controllers: [UsersController], providers: [UsersService], diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 0377a6168..b23b26da5 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { User } from './users.entity'; @@ -6,6 +6,8 @@ import { Role } from './types'; import { validateId } from '../utils/validation.utils'; import { AuthService } from '../auth/auth.service'; import { userSchemaDto } from './dtos/userSchema.dto'; +import { emailTemplates } from '../emails/emailTemplates'; +import { EmailsService } from '../emails/email.service'; @Injectable() export class UsersService { @@ -14,11 +16,29 @@ export class UsersService { private repo: Repository, private authService: AuthService, + + @Inject(EmailsService) + private emailsService: EmailsService, ) {} async create(createUserDto: userSchemaDto): Promise { const { email, firstName, lastName, phone, role } = createUserDto; + const emailsEnabled = process.env.SEND_AUTOMATED_EMAILS === 'true'; + + // Just save to DB if emails are disabled + if (!emailsEnabled) { + const user = this.repo.create({ + role, + firstName, + lastName, + email, + phone, + }); + return this.repo.save(user); + } + // Pantry and food manufacturer users must already exist in the DB + // (created during application) before a Cognito account is made if (role === Role.PANTRY || role === Role.FOODMANUFACTURER) { const existingUser = await this.repo.findOneBy({ email }); if (!existingUser) { @@ -32,6 +52,7 @@ export class UsersService { return this.repo.save(existingUser); } + // All other roles (e.g. VOLUNTEER): create Cognito user and save to DB const userCognitoSub = await this.authService.adminCreateUser({ firstName, lastName, @@ -45,7 +66,15 @@ export class UsersService { phone, userCognitoSub, }); - return this.repo.save(user); + await this.repo.save(user); + + // Send welcome email to new volunteers + if (role === Role.VOLUNTEER) { + const { subject, bodyHTML } = emailTemplates.volunteerAccountCreated(); + await this.emailsService.sendEmails([email], subject, bodyHTML); + } + + return user; } async findOne(id: number): Promise { From e97b10fc704bb6c0bca33b00c016d01202d2a470 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Thu, 12 Mar 2026 23:40:44 -0400 Subject: [PATCH 2/6] Final commit before writing tests --- apps/backend/src/emails/emailTemplates.ts | 8 ++++-- .../foodManufacturers/manufacturers.module.ts | 2 ++ .../manufacturers.service.ts | 22 ++++++++-------- .../src/foodRequests/request.module.ts | 2 ++ .../src/foodRequests/request.service.spec.ts | 8 ++++++ .../src/foodRequests/request.service.ts | 25 +++++++++++-------- apps/backend/src/pantries/pantries.service.ts | 21 ++++++++-------- apps/backend/src/users/users.service.spec.ts | 8 ++++++ apps/backend/src/users/users.service.ts | 10 +++++--- .../src/volunteers/volunteers.service.spec.ts | 8 ++++++ 10 files changed, 78 insertions(+), 36 deletions(-) diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index b572e9825..c3ea19967 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -4,6 +4,10 @@ export type EmailTemplate = { additionalContent?: string; }; +export const EMAIL_REDIRECT_URL = 'localhost:4200'; +// TODO: Change this before production to be the actual ssf email +export const SSF_PARTNER_EMAIL = 'example@gmail.com'; + export const emailTemplates = { pantryFmApplicationApproved: (params: { name: string }): EmailTemplate => ({ subject: 'Your Securing Safe Food Account Has Been Approved', @@ -45,7 +49,7 @@ export const emailTemplates = {

Best regards,
The Securing Safe Food Team

`, - additionalContent: 'localhost:4200/', + additionalContent: EMAIL_REDIRECT_URL + '/login', }), pantryFmApplicationSubmitted: (): EmailTemplate => ({ @@ -58,7 +62,7 @@ export const emailTemplates = {

Best regards,
The Securing Safe Food Team

`, - additionalContent: 'localhost:4200/', + additionalContent: EMAIL_REDIRECT_URL + '/approve-pantries', }), pantrySubmitsFoodRequest: (params: { diff --git a/apps/backend/src/foodManufacturers/manufacturers.module.ts b/apps/backend/src/foodManufacturers/manufacturers.module.ts index e80b08f5e..8fe5e5c6c 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.module.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.module.ts @@ -5,11 +5,13 @@ import { FoodManufacturersController } from './manufacturers.controller'; import { FoodManufacturersService } from './manufacturers.service'; import { UsersModule } from '../users/users.module'; import { Donation } from '../donations/donations.entity'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ TypeOrmModule.forFeature([FoodManufacturer, Donation]), forwardRef(() => UsersModule), + EmailsModule, ], controllers: [FoodManufacturersController], providers: [FoodManufacturersService], diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index 1dbb7e892..58193a523 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -10,7 +10,7 @@ import { ApplicationStatus } from '../shared/types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; import { Donation } from '../donations/donations.entity'; -import { emailTemplates } from '../emails/emailTemplates'; +import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; import { EmailsService } from '../emails/email.service'; @Injectable() @@ -120,10 +120,11 @@ export class FoodManufacturersService { // TODO: Change receiver if deemed that they shouldn't receive an email on submisssion if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + const message = emailTemplates.pantryFmApplicationSubmitted(); await this.emailsService.sendEmails( - [foodManufacturer.foodManufacturerRepresentative.email], - emailTemplates.pantryFmApplicationSubmitted().subject, - emailTemplates.pantryFmApplicationSubmitted().bodyHTML, + [SSF_PARTNER_EMAIL], + message.subject, + message.bodyHTML, ); } } @@ -133,6 +134,7 @@ export class FoodManufacturersService { const foodManufacturer = await this.repo.findOne({ where: { foodManufacturerId: id }, + relations: ['foodManufacturerRepresentative'], }); if (!foodManufacturer) { throw new NotFoundException(`Food Manufacturer ${id} not found`); @@ -151,14 +153,14 @@ export class FoodManufacturersService { }); if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + const message = emailTemplates.pantryFmApplicationApproved({ + name: newFoodManufacturer.firstName, + }); + await this.emailsService.sendEmails( [newFoodManufacturer.email], - emailTemplates.pantryFmApplicationApproved({ - name: newFoodManufacturer.firstName, - }).subject, - emailTemplates.pantryFmApplicationApproved({ - name: newFoodManufacturer.firstName, - }).bodyHTML, + message.subject, + message.bodyHTML, ); } } diff --git a/apps/backend/src/foodRequests/request.module.ts b/apps/backend/src/foodRequests/request.module.ts index d1cadf066..dcec30601 100644 --- a/apps/backend/src/foodRequests/request.module.ts +++ b/apps/backend/src/foodRequests/request.module.ts @@ -8,6 +8,7 @@ import { Order } from '../orders/order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { DonationItem } from '../donationItems/donationItems.entity'; DonationItem, ]), AuthModule, + EmailsModule, ], 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 d404d62a0..c57fb622d 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -11,6 +11,7 @@ import { FoodType } from '../donationItems/types'; import { DonationItem } from '../donationItems/donationItems.entity'; import { testDataSource } from '../config/typeormTestDataSource'; import { NotFoundException } from '@nestjs/common'; +import { EmailsService } from '../emails/email.service'; jest.setTimeout(60000); @@ -25,6 +26,7 @@ describe('RequestsService', () => { const module = await Test.createTestingModule({ providers: [ RequestsService, + EmailsService, { provide: getRepositoryToken(FoodRequest), useValue: testDataSource.getRepository(FoodRequest), @@ -45,6 +47,12 @@ describe('RequestsService', () => { provide: getRepositoryToken(DonationItem), useValue: testDataSource.getRepository(DonationItem), }, + { + provide: EmailsService, + useValue: { + sendEmails: jest.fn().mockResolvedValue(undefined), + }, + }, ], }).compile(); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 8d76e1255..728d827a0 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -201,7 +201,10 @@ export class RequestsService { ): Promise { validateId(pantryId, 'Pantry'); - const pantry = await this.pantryRepo.findOneBy({ pantryId }); + const pantry = await this.pantryRepo.findOne({ + where: { pantryId }, + relations: ['pantryUser', 'volunteers'], + }); if (!pantry) { throw new NotFoundException(`Pantry ${pantryId} not found`); } @@ -214,16 +217,18 @@ export class RequestsService { }); if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + const volunteers = pantry.volunteers || []; + const volunteerEmails = volunteers.map((v) => v.email); + + const message = emailTemplates.pantrySubmitsFoodRequest({ + pantryName: pantry.pantryName, + volunteerName: pantry.pantryUser.firstName, + }); + await this.emailsService.sendEmails( - [foodRequest.pantry.pantryUser.email], - emailTemplates.pantrySubmitsFoodRequest({ - pantryName: foodRequest.pantry.pantryName, - volunteerName: foodRequest.pantry.pantryUser.firstName, - }).subject, - emailTemplates.pantrySubmitsFoodRequest({ - pantryName: foodRequest.pantry.pantryName, - volunteerName: foodRequest.pantry.pantryUser.firstName, - }).bodyHTML, + volunteerEmails, + message.subject, + message.bodyHTML, ); } diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 289d4bfcf..552407f70 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -14,7 +14,7 @@ import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { Role } from '../users/types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; -import { emailTemplates } from '../emails/emailTemplates'; +import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; import { EmailsService } from '../emails/email.service'; @Injectable() @@ -111,10 +111,11 @@ export class PantriesService { await this.repo.save(pantry); if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + const message = emailTemplates.pantryFmApplicationSubmitted(); await this.emailsService.sendEmails( - [pantryContact.email], - emailTemplates.pantryFmApplicationSubmitted().subject, - emailTemplates.pantryFmApplicationSubmitted().bodyHTML, + [SSF_PARTNER_EMAIL], + message.subject, + message.bodyHTML, ); } } @@ -143,14 +144,14 @@ export class PantriesService { }); if (process.env.SEND_AUTOMATED_EMAILS === 'true') { + const message = emailTemplates.pantryFmApplicationApproved({ + name: newPantryUser.firstName, + }); + await this.emailsService.sendEmails( [newPantryUser.email], - emailTemplates.pantryFmApplicationApproved({ - name: newPantryUser.firstName, - }).subject, - emailTemplates.pantryFmApplicationApproved({ - name: newPantryUser.firstName, - }).bodyHTML, + message.subject, + message.bodyHTML, ); } } diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 4207622ef..a488bd573 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -11,6 +11,7 @@ import { BadRequestException } from '@nestjs/common'; import { PantriesService } from '../pantries/pantries.service'; import { userSchemaDto } from './dtos/userSchema.dto'; import { AuthService } from '../auth/auth.service'; +import { EmailsService } from '../emails/email.service'; const mockUserRepository = mock>(); const mockPantriesService = mock(); @@ -40,6 +41,7 @@ describe('UsersService', () => { const module = await Test.createTestingModule({ providers: [ UsersService, + EmailsService, { provide: getRepositoryToken(User), useValue: mockUserRepository, @@ -52,6 +54,12 @@ describe('UsersService', () => { provide: AuthService, useValue: mockAuthService, }, + { + provide: EmailsService, + useValue: { + sendEmails: jest.fn().mockResolvedValue(undefined), + }, + }, ], }).compile(); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index b23b26da5..1e76f054e 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -16,8 +16,6 @@ export class UsersService { private repo: Repository, private authService: AuthService, - - @Inject(EmailsService) private emailsService: EmailsService, ) {} @@ -70,8 +68,12 @@ export class UsersService { // Send welcome email to new volunteers if (role === Role.VOLUNTEER) { - const { subject, bodyHTML } = emailTemplates.volunteerAccountCreated(); - await this.emailsService.sendEmails([email], subject, bodyHTML); + const message = emailTemplates.volunteerAccountCreated(); + await this.emailsService.sendEmails( + [email], + message.subject, + message.bodyHTML, + ); } return user; diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 1bbcc7530..83c40505d 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -8,6 +8,7 @@ import { testDataSource } from '../config/typeormTestDataSource'; import { UsersService } from '../users/users.service'; import { PantriesService } from '../pantries/pantries.service'; import { AuthService } from '../auth/auth.service'; +import { EmailsService } from '../emails/email.service'; jest.setTimeout(60000); @@ -25,6 +26,7 @@ describe('VolunteersService', () => { VolunteersService, UsersService, PantriesService, + EmailsService, { provide: AuthService, useValue: {}, @@ -37,6 +39,12 @@ describe('VolunteersService', () => { provide: getRepositoryToken(Pantry), useValue: testDataSource.getRepository(Pantry), }, + { + provide: EmailsService, + useValue: { + sendEmails: jest.fn().mockResolvedValue(undefined), + }, + }, ], }).compile(); From 9b6d63eda0ba0c6fa3affa3cd5935b3e5c0d3369 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Fri, 13 Mar 2026 00:06:03 -0400 Subject: [PATCH 3/6] Cleaned up check for usage of send email flag --- apps/backend/src/emails/awsSes.wrapper.ts | 3 ++ .../manufacturers.service.ts | 32 ++++++++----------- .../src/foodRequests/request.service.ts | 28 ++++++++-------- apps/backend/src/pantries/pantries.service.ts | 32 ++++++++----------- apps/backend/src/users/users.service.ts | 2 +- 5 files changed, 45 insertions(+), 52 deletions(-) diff --git a/apps/backend/src/emails/awsSes.wrapper.ts b/apps/backend/src/emails/awsSes.wrapper.ts index fd0750d58..4d2849bcb 100644 --- a/apps/backend/src/emails/awsSes.wrapper.ts +++ b/apps/backend/src/emails/awsSes.wrapper.ts @@ -57,6 +57,9 @@ export class AmazonSESWrapper { const messageData = await new MailComposer(mailOptions).compile().build(); const command = new SendEmailCommand({ + Destination: { + ToAddresses: recipientEmails, + }, Content: { Raw: { Data: messageData, diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index 58193a523..4e72399c6 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -119,14 +119,12 @@ export class FoodManufacturersService { await this.repo.save(foodManufacturer); // TODO: Change receiver if deemed that they shouldn't receive an email on submisssion - if (process.env.SEND_AUTOMATED_EMAILS === 'true') { - const message = emailTemplates.pantryFmApplicationSubmitted(); - await this.emailsService.sendEmails( - [SSF_PARTNER_EMAIL], - message.subject, - message.bodyHTML, - ); - } + const message = emailTemplates.pantryFmApplicationSubmitted(); + await this.emailsService.sendEmails( + [SSF_PARTNER_EMAIL], + message.subject, + message.bodyHTML, + ); } async approve(id: number) { @@ -152,17 +150,15 @@ export class FoodManufacturersService { foodManufacturerRepresentative: newFoodManufacturer, }); - if (process.env.SEND_AUTOMATED_EMAILS === 'true') { - const message = emailTemplates.pantryFmApplicationApproved({ - name: newFoodManufacturer.firstName, - }); + const message = emailTemplates.pantryFmApplicationApproved({ + name: newFoodManufacturer.firstName, + }); - await this.emailsService.sendEmails( - [newFoodManufacturer.email], - message.subject, - message.bodyHTML, - ); - } + await this.emailsService.sendEmails( + [newFoodManufacturer.email], + message.subject, + message.bodyHTML, + ); } async deny(id: number) { diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 728d827a0..dc6b71162 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -216,21 +216,19 @@ export class RequestsService { additionalInformation, }); - if (process.env.SEND_AUTOMATED_EMAILS === 'true') { - const volunteers = pantry.volunteers || []; - const volunteerEmails = volunteers.map((v) => v.email); - - const message = emailTemplates.pantrySubmitsFoodRequest({ - pantryName: pantry.pantryName, - volunteerName: pantry.pantryUser.firstName, - }); - - await this.emailsService.sendEmails( - volunteerEmails, - message.subject, - message.bodyHTML, - ); - } + const volunteers = pantry.volunteers || []; + const volunteerEmails = volunteers.map((v) => v.email); + + const message = emailTemplates.pantrySubmitsFoodRequest({ + pantryName: pantry.pantryName, + volunteerName: pantry.pantryUser.firstName, + }); + + await this.emailsService.sendEmails( + volunteerEmails, + message.subject, + message.bodyHTML, + ); return await this.repo.save(foodRequest); } diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 552407f70..716119557 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -110,14 +110,12 @@ export class PantriesService { // pantry contact is automatically added to User table await this.repo.save(pantry); - if (process.env.SEND_AUTOMATED_EMAILS === 'true') { - const message = emailTemplates.pantryFmApplicationSubmitted(); - await this.emailsService.sendEmails( - [SSF_PARTNER_EMAIL], - message.subject, - message.bodyHTML, - ); - } + const message = emailTemplates.pantryFmApplicationSubmitted(); + await this.emailsService.sendEmails( + [SSF_PARTNER_EMAIL], + message.subject, + message.bodyHTML, + ); } async approve(id: number) { @@ -143,17 +141,15 @@ export class PantriesService { pantryUser: newPantryUser, }); - if (process.env.SEND_AUTOMATED_EMAILS === 'true') { - const message = emailTemplates.pantryFmApplicationApproved({ - name: newPantryUser.firstName, - }); + const message = emailTemplates.pantryFmApplicationApproved({ + name: newPantryUser.firstName, + }); - await this.emailsService.sendEmails( - [newPantryUser.email], - message.subject, - message.bodyHTML, - ); - } + await this.emailsService.sendEmails( + [newPantryUser.email], + message.subject, + message.bodyHTML, + ); } async deny(id: number) { diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 1e76f054e..0a6a5c549 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -23,7 +23,7 @@ export class UsersService { const { email, firstName, lastName, phone, role } = createUserDto; const emailsEnabled = process.env.SEND_AUTOMATED_EMAILS === 'true'; - // Just save to DB if emails are disabled + // Just save to DB if emails are disabled (no Cognito creation) if (!emailsEnabled) { const user = this.repo.create({ role, From 578e4a549aaf8dee935dd35cf406b0053956e874 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Fri, 13 Mar 2026 18:28:44 -0400 Subject: [PATCH 4/6] Added confirmed application submission for FMs and pantries --- apps/backend/src/emails/emailTemplates.ts | 24 ++++++++++++++++--- .../manufacturers.service.ts | 18 ++++++++++---- apps/backend/src/pantries/pantries.service.ts | 16 ++++++++++--- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index c3ea19967..ffbdc5996 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -48,11 +48,13 @@ export const emailTemplates = { increase access to safe food for individuals with dietary restrictions.

Best regards,
The Securing Safe Food Team

+

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

`, - additionalContent: EMAIL_REDIRECT_URL + '/login', }), - pantryFmApplicationSubmitted: (): EmailTemplate => ({ + pantryFmApplicationSubmittedToAdmin: (): EmailTemplate => ({ subject: 'New Partner Application Submitted', bodyHTML: `

Hi,

@@ -61,8 +63,24 @@ export const emailTemplates = { Please log in to the dashboard to review and take action.

Best regards,
The Securing Safe Food Team

+

+ To review this application, please enter the admin pantry approval dashboard: ${EMAIL_REDIRECT_URL}/approve-pantries +

+ `, + }), + + pantryFmApplicationSubmittedToUser: (params: { + name: string; + }): EmailTemplate => ({ + subject: 'Your Application Has Been Submitted', + bodyHTML: ` +

Hi ${params.name},

+

+ Thank you for your interest in partnering with Securing Safe Food! + Your application has been successfully submitted and is currently under review. We will notify you via email once a decision has been made. +

+

Best regards,
The Securing Safe Food Team

`, - additionalContent: EMAIL_REDIRECT_URL + '/approve-pantries', }), pantrySubmitsFoodRequest: (params: { diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index 4e72399c6..f5b78268b 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -118,12 +118,22 @@ export class FoodManufacturersService { await this.repo.save(foodManufacturer); - // TODO: Change receiver if deemed that they shouldn't receive an email on submisssion - const message = emailTemplates.pantryFmApplicationSubmitted(); + const manufacturerMessage = + emailTemplates.pantryFmApplicationSubmittedToUser({ + name: foodManufacturerContact.firstName, + }); + + await this.emailsService.sendEmails( + [foodManufacturerContact.email], + manufacturerMessage.subject, + manufacturerMessage.bodyHTML, + ); + + const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); await this.emailsService.sendEmails( [SSF_PARTNER_EMAIL], - message.subject, - message.bodyHTML, + adminMessage.subject, + adminMessage.bodyHTML, ); } diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 716119557..258ad5883 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -110,11 +110,21 @@ export class PantriesService { // pantry contact is automatically added to User table await this.repo.save(pantry); - const message = emailTemplates.pantryFmApplicationSubmitted(); + const pantryMessage = emailTemplates.pantryFmApplicationSubmittedToUser({ + name: pantryContact.firstName, + }); + + await this.emailsService.sendEmails( + [pantryContact.email], + pantryMessage.subject, + pantryMessage.bodyHTML, + ); + + const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); await this.emailsService.sendEmails( [SSF_PARTNER_EMAIL], - message.subject, - message.bodyHTML, + adminMessage.subject, + adminMessage.bodyHTML, ); } From 4ad7344966aed39b94ec2d21a7f9804698b0cc15 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 14 Mar 2026 23:47:00 -0400 Subject: [PATCH 5/6] Final commit --- .../manufaacturers.service.spec.ts | 292 ++++++++++++++++++ .../src/foodRequests/request.service.spec.ts | 37 ++- .../src/pantries/pantries.service.spec.ts | 128 ++++---- apps/backend/src/users/users.service.spec.ts | 34 +- 4 files changed, 431 insertions(+), 60 deletions(-) create mode 100644 apps/backend/src/foodManufacturers/manufaacturers.service.spec.ts diff --git a/apps/backend/src/foodManufacturers/manufaacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufaacturers.service.spec.ts new file mode 100644 index 000000000..14693be6a --- /dev/null +++ b/apps/backend/src/foodManufacturers/manufaacturers.service.spec.ts @@ -0,0 +1,292 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FoodManufacturersService } from './manufacturers.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { FoodManufacturer } from './manufacturers.entity'; +import { NotFoundException } from '@nestjs/common'; +import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; +import { ApplicationStatus } from '../shared/types'; +import { testDataSource } from '../config/typeormTestDataSource'; +import { Donation } from '../donations/donations.entity'; +import { User } from '../users/users.entity'; +import { UsersService } from '../users/users.service'; +import { AuthService } from '../auth/auth.service'; +import { EmailsService } from '../emails/email.service'; +import { Pantry } from '../pantries/pantries.entity'; +import { Order } from '../orders/order.entity'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { DonationService } from '../donations/donations.service'; +import { PantriesService } from '../pantries/pantries.service'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; +import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; + +jest.setTimeout(60000); + +const dto: FoodManufacturerApplicationDto = { + foodManufacturerName: 'Test Manufacturer', + foodManufacturerWebsite: 'https://testmanufacturer.com', + contactFirstName: 'Jane', + contactLastName: 'Doe', + contactEmail: 'jane.doe@example.com', + contactPhone: '555-555-5555', + unlistedProductAllergens: [Allergen.SHELLFISH, Allergen.TREE_NUTS], + facilityFreeAllergens: [Allergen.PEANUT, Allergen.FISH], + productsGlutenFree: false, + productsContainSulfites: false, + productsSustainableExplanation: 'none', + inKindDonations: false, + donateWastedFood: DonateWastedFood.ALWAYS, +}; + +const mockEmailsService = mock(); + +describe('FoodManufacturersService', () => { + let service: FoodManufacturersService; + + beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + + if (!testDataSource.isInitialized) { + await testDataSource.initialize(); + } + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FoodManufacturersService, + UsersService, + DonationService, + PantriesService, + { + provide: AuthService, + useValue: { + adminCreateUser: jest.fn().mockResolvedValue('test-sub'), + }, + }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, + { + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), + }, + { + provide: getRepositoryToken(User), + useValue: testDataSource.getRepository(User), + }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), + }, + { + provide: getRepositoryToken(Pantry), + useValue: testDataSource.getRepository(Pantry), + }, + { + provide: getRepositoryToken(Order), + useValue: testDataSource.getRepository(Order), + }, + { + provide: getRepositoryToken(FoodRequest), + useValue: testDataSource.getRepository(FoodRequest), + }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, + ], + }).compile(); + + service = module.get(FoodManufacturersService); + }); + + beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); + await testDataSource.runMigrations(); + }); + + afterEach(async () => { + await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + }); + + afterAll(async () => { + if (testDataSource.isInitialized) { + await testDataSource.destroy(); + } + }); + + it('service should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findOne', () => { + it('returns manufacturer by existing ID', async () => { + const manufacturer = await service.findOne(1); + expect(manufacturer).toBeDefined(); + expect(manufacturer.foodManufacturerId).toBe(1); + }); + + it('throws NotFoundException for missing ID', async () => { + await expect(service.findOne(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + }); + + describe('getPendingManufacturers', () => { + it('returns manufacturers with pending status', async () => { + const pending = await service.getPendingManufacturers(); + expect(pending.length).toBeGreaterThan(0); + expect(pending.every((m) => m.status === ApplicationStatus.PENDING)).toBe( + true, + ); + }); + }); + + describe('approve', () => { + it('approves a pending manufacturer', async () => { + const pending = await service.getPendingManufacturers(); + const id = pending[0].foodManufacturerId; + + await service.approve(id); + + const approved = await service.findOne(id); + expect(approved.status).toBe(ApplicationStatus.APPROVED); + }); + + it('sends approval email to manufacturer representative', async () => { + const pending = await service.getPendingManufacturers(); + const manufacturer = pending[0]; + const id = manufacturer.foodManufacturerId; + const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({ + name: manufacturer.foodManufacturerRepresentative.firstName, + }); + + await service.approve(id); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [manufacturer.foodManufacturerRepresentative.email], + subject, + bodyHTML, + ); + }); + + it('throws when approving non-existent manufacturer', async () => { + await expect(service.approve(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + }); + + describe('deny', () => { + it('denies a pending manufacturer', async () => { + const pending = await service.getPendingManufacturers(); + const id = pending[0].foodManufacturerId; + + await service.deny(id); + + const denied = await service.findOne(id); + expect(denied.status).toBe(ApplicationStatus.DENIED); + }); + + it('throws when denying non-existent manufacturer', async () => { + await expect(service.deny(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + }); + + describe('addFoodManufacturer', () => { + it('creates manufacturer with minimal required fields', async () => { + await service.addFoodManufacturer(dto); + const saved = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ + where: { foodManufacturerName: 'Test Manufacturer' }, + relations: ['foodManufacturerRepresentative'], + }); + expect(saved).toBeDefined(); + expect(saved?.foodManufacturerRepresentative?.email).toBe( + 'jane.doe@example.com', + ); + expect(saved?.status).toBe(ApplicationStatus.PENDING); + }); + + it('creates manufacturer with all optional fields included', async () => { + const optionalDto: FoodManufacturerApplicationDto = { + ...dto, + foodManufacturerName: 'Test Full Manufacturer', + contactEmail: 'john.smith@example.com', + secondaryContactFirstName: 'Sarah', + secondaryContactLastName: 'Johnson', + secondaryContactEmail: 'sarah.johnson@example.com', + secondaryContactPhone: '555-555-5557', + manufacturerAttribute: ManufacturerAttribute.ORGANIC, + additionalComments: 'We specialize in allergen-free products', + newsletterSubscription: true, + }; + + await service.addFoodManufacturer(optionalDto); + const saved = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ + where: { foodManufacturerName: 'Test Full Manufacturer' }, + relations: ['foodManufacturerRepresentative'], + }); + expect(saved).toBeDefined(); + expect(saved?.foodManufacturerRepresentative?.email).toBe( + 'john.smith@example.com', + ); + expect(saved?.status).toBe(ApplicationStatus.PENDING); + expect(saved?.secondaryContactFirstName).toBe('Sarah'); + expect(saved?.manufacturerAttribute).toBe(ManufacturerAttribute.ORGANIC); + }); + + it('sends confirmation email to applicant and notification email to admin', async () => { + await service.addFoodManufacturer(dto); + + const userMessage = emailTemplates.pantryFmApplicationSubmittedToUser({ + name: dto.contactFirstName, + }); + const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [dto.contactEmail], + userMessage.subject, + userMessage.bodyHTML, + ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [SSF_PARTNER_EMAIL], + adminMessage.subject, + adminMessage.bodyHTML, + ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); + }); + }); + + describe('getFMDonations', () => { + it('returns donations for an existing manufacturer', async () => { + const donations = await service.getFMDonations(1); + expect(Array.isArray(donations)).toBe(true); + }); + + it('returns empty array for manufacturer with no donations', async () => { + await service.addFoodManufacturer(dto); + const saved = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ where: { foodManufacturerName: 'Test Manufacturer' } }); + const donations = await service.getFMDonations(saved!.foodManufacturerId); + expect(donations).toEqual([]); + }); + + it('throws NotFoundException for non-existent manufacturer', async () => { + await expect(service.getFMDonations(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + }); +}); diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index f9675e56e..d4224b006 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -12,13 +12,19 @@ import { DonationItem } from '../donationItems/donationItems.entity'; import { testDataSource } from '../config/typeormTestDataSource'; import { NotFoundException } from '@nestjs/common'; import { EmailsService } from '../emails/email.service'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates } from '../emails/emailTemplates'; jest.setTimeout(60000); +const mockEmailsService = mock(); + describe('RequestsService', () => { let service: RequestsService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -26,7 +32,6 @@ describe('RequestsService', () => { const module = await Test.createTestingModule({ providers: [ RequestsService, - EmailsService, { provide: getRepositoryToken(FoodRequest), useValue: testDataSource.getRepository(FoodRequest), @@ -49,9 +54,7 @@ describe('RequestsService', () => { }, { provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, + useValue: mockEmailsService, }, ], }).compile(); @@ -60,6 +63,7 @@ describe('RequestsService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); @@ -191,6 +195,31 @@ describe('RequestsService', () => { expect(result.additionalInformation).toBeNull(); }); + it('should send food request email to pantry volunteers', async () => { + const pantryId = 1; + const pantry = await testDataSource.getRepository(Pantry).findOne({ + where: { pantryId }, + relations: ['pantryUser', 'volunteers'], + }); + + await service.create(pantryId, RequestSize.MEDIUM, [ + FoodType.DRIED_BEANS, + FoodType.REFRIGERATED_MEALS, + ]); + + const { subject, bodyHTML } = emailTemplates.pantrySubmitsFoodRequest({ + pantryName: pantry!.pantryName, + volunteerName: pantry!.pantryUser.firstName, + }); + const volunteerEmails = (pantry!.volunteers ?? []).map((v) => v.email); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + volunteerEmails, + subject, + bodyHTML, + ); + }); + it('should throw NotFoundException for non-existent pantry', async () => { await expect( service.create( diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 085399198..6ec18e119 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -28,6 +28,8 @@ import { FoodManufacturersService } from '../foodManufacturers/manufacturers.ser import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { User } from '../users/users.entity'; import { EmailsService } from '../emails/email.service'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; jest.setTimeout(60000); @@ -59,10 +61,40 @@ const makePantryDto = (i: number): PantryApplicationDto => needMoreOptions: 'none', } as PantryApplicationDto); +const dto: PantryApplicationDto = { + contactFirstName: 'Jane', + contactLastName: 'Doe', + contactEmail: 'jane.doe@example.com', + contactPhone: '555-555-5555', + hasEmailContact: true, + pantryName: 'Test Pantry', + shipmentAddressLine1: '1 Test St', + shipmentAddressCity: 'Testville', + shipmentAddressState: 'TX', + shipmentAddressZip: '11111', + mailingAddressLine1: '1 Test St', + mailingAddressCity: 'Testville', + mailingAddressState: 'TX', + mailingAddressZip: '11111', + allergenClients: 'none', + restrictions: ['none'], + refrigeratedDonation: RefrigeratedDonation.NO, + acceptFoodDeliveries: false, + reserveFoodForAllergic: ReserveFoodForAllergic.NO, + dedicatedAllergyFriendly: false, + activities: [Activity.CREATE_LABELED_SHELF], + itemsInStock: 'none', + needMoreOptions: 'none', +}; + +const mockEmailsService = mock(); + describe('PantriesService', () => { let service: PantriesService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -78,7 +110,6 @@ describe('PantriesService', () => { DonationItemsService, DonationService, FoodManufacturersService, - EmailsService, { provide: AuthService, useValue: { @@ -87,9 +118,7 @@ describe('PantriesService', () => { }, { provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, + useValue: mockEmailsService, }, { provide: getRepositoryToken(Pantry), @@ -126,6 +155,7 @@ describe('PantriesService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.runMigrations(); }); @@ -177,6 +207,21 @@ describe('PantriesService', () => { expect(pantryAfter.status).toBe(ApplicationStatus.APPROVED); }); + it('sends approval email to pantry user', async () => { + const pantry = await service.findOne(5); + const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({ + name: pantry.pantryUser.firstName, + }); + + await service.approve(5); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [pantry.pantryUser.email], + subject, + bodyHTML, + ); + }); + it('throws when approving non-existent', async () => { await expect(service.approve(9999)).rejects.toThrow( new NotFoundException('Pantry 9999 not found'), @@ -202,35 +247,9 @@ describe('PantriesService', () => { describe('addPantry', () => { it('creates pantry with minimal required fields', async () => { - const dto: PantryApplicationDto = { - contactFirstName: 'Jane', - contactLastName: 'Doe', - contactEmail: 'jane.doe@example.com', - contactPhone: '555-555-5555', - hasEmailContact: true, - pantryName: 'Test Minimal Pantry', - shipmentAddressLine1: '1 Test St', - shipmentAddressCity: 'Testville', - shipmentAddressState: 'TX', - shipmentAddressZip: '11111', - mailingAddressLine1: '1 Test St', - mailingAddressCity: 'Testville', - mailingAddressState: 'TX', - mailingAddressZip: '11111', - allergenClients: 'none', - restrictions: ['none'], - refrigeratedDonation: RefrigeratedDonation.NO, - acceptFoodDeliveries: false, - reserveFoodForAllergic: ReserveFoodForAllergic.NO, - dedicatedAllergyFriendly: false, - activities: [Activity.CREATE_LABELED_SHELF], - itemsInStock: 'none', - needMoreOptions: 'none', - }; - await service.addPantry(dto); const saved = await testDataSource.getRepository(Pantry).findOne({ - where: { pantryName: 'Test Minimal Pantry' }, + where: { pantryName: 'Test Pantry' }, relations: ['pantryUser'], }); expect(saved).toBeDefined(); @@ -239,32 +258,19 @@ describe('PantriesService', () => { }); it('creates pantry with all optional fields included', async () => { - const dto: PantryApplicationDto = { - contactFirstName: 'John', - contactLastName: 'Smith', + const optionalDto: PantryApplicationDto = { + ...dto, + pantryName: 'Test Full Pantry', contactEmail: 'john.smith@example.com', - contactPhone: '555-555-5556', - hasEmailContact: true, emailContactOther: 'Use work phone', secondaryContactFirstName: 'Sarah', secondaryContactLastName: 'Johnson', secondaryContactEmail: 'sarah.johnson@example.com', secondaryContactPhone: '555-555-5557', - pantryName: 'Test Full Pantry', - shipmentAddressLine1: '100 Main St', shipmentAddressLine2: 'Suite 200', - shipmentAddressCity: 'Springfield', - shipmentAddressState: 'IL', - shipmentAddressZip: '62701', shipmentAddressCountry: 'USA', - mailingAddressLine1: '100 Main St', mailingAddressLine2: 'Suite 200', - mailingAddressCity: 'Springfield', - mailingAddressState: 'IL', - mailingAddressZip: '62701', mailingAddressCountry: 'USA', - allergenClients: '10 to 20', - restrictions: ['Peanut allergy', 'Tree nut allergy'], refrigeratedDonation: RefrigeratedDonation.YES, acceptFoodDeliveries: true, deliveryWindowInstructions: 'Weekdays 9am-5pm', @@ -276,12 +282,10 @@ describe('PantriesService', () => { serveAllergicChildren: ServeAllergicChildren.YES_MANY, activities: [Activity.CREATE_LABELED_SHELF, Activity.COLLECT_FEEDBACK], activitiesComments: 'We are committed to allergen management', - itemsInStock: 'Canned goods, pasta', - needMoreOptions: 'Fresh produce', newsletterSubscription: true, - } as PantryApplicationDto; + }; - await service.addPantry(dto); + await service.addPantry(optionalDto); const saved = await testDataSource.getRepository(Pantry).findOne({ where: { pantryName: 'Test Full Pantry' }, relations: ['pantryUser'], @@ -292,6 +296,27 @@ describe('PantriesService', () => { expect(saved?.secondaryContactFirstName).toBe('Sarah'); expect(saved?.shipmentAddressLine2).toBe('Suite 200'); }); + + it('sends confirmation email to applicant and notification email to admin', async () => { + await service.addPantry(dto); + + const userMessage = emailTemplates.pantryFmApplicationSubmittedToUser({ + name: dto.contactFirstName, + }); + const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [dto.contactEmail], + userMessage.subject, + userMessage.bodyHTML, + ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [SSF_PARTNER_EMAIL], + adminMessage.subject, + adminMessage.bodyHTML, + ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); + }); }); describe('getPantryStats (single pantry)', () => { @@ -455,7 +480,6 @@ describe('PantriesService', () => { }); it('validates all names before paginating — throws if any name is invalid regardless of page', async () => { - // Create 12 valid pantries so we have enough to paginate for (let i = 0; i < 12; i++) { await service.addPantry(makePantryDto(i)); } diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index a488bd573..db2035377 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -12,11 +12,12 @@ import { PantriesService } from '../pantries/pantries.service'; import { userSchemaDto } from './dtos/userSchema.dto'; import { AuthService } from '../auth/auth.service'; import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; const mockUserRepository = mock>(); const mockPantriesService = mock(); const mockAuthService = mock(); - +const mockEmailsService = mock(); const mockUser: Partial = { id: 1, email: 'test@example.com', @@ -56,9 +57,7 @@ describe('UsersService', () => { }, { provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, + useValue: mockEmailsService, }, ], }).compile(); @@ -84,6 +83,33 @@ describe('UsersService', () => { }); describe('create', () => { + it('should send a welcome email when creating a volunteer', async () => { + const createUserDto: userSchemaDto = { + email: 'volunteer@example.com', + firstName: 'Jane', + lastName: 'Smith', + phone: '9876543210', + role: Role.VOLUNTEER, + }; + + const createdUser = { + ...createUserDto, + id: 2, + userCognitoSub: 'mock-sub', + } as User; + mockUserRepository.create.mockReturnValue(createdUser); + mockUserRepository.save.mockResolvedValue(createdUser); + + await service.create(createUserDto); + const { subject, bodyHTML } = emailTemplates.volunteerAccountCreated(); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [createUserDto.email], + subject, + bodyHTML, + ); + }); + it('should create a new user with auto-generated ID', async () => { const createUserDto: userSchemaDto = { email: 'newuser@example.com', From f24b9f7c379c0b5d3195b1332007cd7a01fe343a Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 15 Mar 2026 00:09:47 -0400 Subject: [PATCH 6/6] Fixed CI --- .github/workflows/backend-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 26b4e0f44..09c687ecc 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -33,6 +33,7 @@ jobs: DATABASE_NAME_TEST: securing-safe-food-test DATABASE_USERNAME: postgres DATABASE_PASSWORD: postgres + SEND_AUTOMATED_EMAILS: 'true' NX_DAEMON: 'false' CYPRESS_INSTALL_BINARY: '0' steps: