From e1fd3afddacc7ed8f9b29ef78c0d7f73125875cf Mon Sep 17 00:00:00 2001 From: skewalia <145241312+swarkewalia@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:08:56 -0400 Subject: [PATCH 01/18] automated email updates --- .../backend/src/donations/donations.module.ts | 2 + .../src/donations/donations.service.ts | 48 +++++++++---- apps/backend/src/emails/emailTemplates.ts | 70 +++++++++++++++++++ apps/backend/src/orders/order.module.ts | 2 + apps/backend/src/orders/order.service.ts | 42 ++++++++++- 5 files changed, 148 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index c3a4de760..3acfed5ac 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -10,6 +10,7 @@ import { DonationItem } from '../donationItems/donationItems.entity'; import { DonationItemsModule } from '../donationItems/donationItems.module'; import { Allocation } from '../allocations/allocations.entity'; import { AllocationModule } from '../allocations/allocations.module'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { AllocationModule } from '../allocations/allocations.module'; forwardRef(() => AuthModule), DonationItemsModule, AllocationModule, + EmailsModule, ], controllers: [DonationsController], providers: [DonationService, DonationsSchedulerService], diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index ef638e884..5db01d9f3 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable, - Logger, NotFoundException, } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; @@ -15,11 +14,11 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationItem } from '../donationItems/donationItems.entity'; import { Allocation } from '../allocations/allocations.entity'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; @Injectable() export class DonationService { - private readonly logger = new Logger(DonationService.name); - constructor( @InjectRepository(Donation) private repo: Repository, @InjectRepository(Allocation) @@ -30,6 +29,7 @@ export class DonationService { private manufacturerRepo: Repository, private donationItemsService: DonationItemsService, @InjectDataSource() private dataSource: DataSource, + private emailsService: EmailsService, ) {} async findOne(donationId: number): Promise { @@ -203,13 +203,22 @@ export class DonationService { break; } - this.logger.log(`Placeholder for sending automated email`); + const { subject, bodyHTML } = + emailTemplates.fmRecurringDonationReminder({ + fmName: donation.foodManufacturer.foodManufacturerName, + }); + + try { + const fmEmails = [ + donation.foodManufacturer.secondaryContactEmail, + ].filter((e): e is string => e !== null); - /** - * IMPORTANT: future logic below should only proceed if the email is successfully sent - */ - const emailSent = true; - if (!emailSent) continue; + if (fmEmails.length > 0) { + await this.emailsService.sendEmails(fmEmails, subject, bodyHTML); + } + } catch (e) { + continue; + } dates.splice(i, 1); i--; @@ -225,11 +234,22 @@ export class DonationService { // cascading recalculation of next dates when replacement dates are also expired while (nextDate.getTime() <= today.getTime() && occurrences > 0) { - this.logger.log( - `Placeholder for sending automated email for replacement date`, - ); - const cascadeEmailSent = true; - if (!cascadeEmailSent) break; + const { subject: cs, bodyHTML: cb } = + emailTemplates.fmRecurringDonationReminder({ + fmName: donation.foodManufacturer.foodManufacturerName, + }); + + try { + const fmEmails = [ + donation.foodManufacturer.secondaryContactEmail, + ].filter((e): e is string => e !== null); + + if (fmEmails.length > 0) { + await this.emailsService.sendEmails(fmEmails, cs, cb); + } + } catch (e) { + break; + } occurrences -= 1; diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 7d4b8c0f3..41c6350ec 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -98,4 +98,74 @@ export const emailTemplates = {

Best regards,
The Securing Safe Food Team

`, }), + + fmRecurringDonationReminder: (params: { fmName: string }): EmailTemplate => ({ + subject: 'Reminder: Submit Your Scheduled Recurring Donation with SSF', + bodyHTML: ` +

Hi ${params.fmName},

+

+ This is a friendly reminder from Securing Safe Food that your recurring donation + schedule indicates a new donation submission is due. +

+

+ When you have a moment, please log into your account and submit your current + donation availability so we can continue matching your contributions with pantry requests. +

+

+ We greatly appreciate your continued generosity and support of our mission. Your + recurring donations make a meaningful and consistent impact for the communities we serve. +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + trackingLinkAvailable: (params: { + pantryName: string; + fmName: string; + trackingLink: string; + volunteerName: string; + volunteerEmail: string; + }): EmailTemplate => ({ + subject: `Tracking Information for your ${params.fmName} delivery (Securing Safe Food)`, + bodyHTML: ` +

Hi ${params.pantryName},

+

+ Good news! Tracking information is now available for your upcoming SSF delivery + from ${params.fmName}. You can use this tracking information to monitor the + status of your shipment or log into your portal for more information on your + expected donation. +

+

+ Tracking Link: ${params.trackingLink} +

+

+ You can use the tracking link above to monitor your shipment, or log into your portal for full order details and updates. +

+

+ If you experience any issues or have questions, please contact your coordinator, + ${params.volunteerName}, at ${params.volunteerEmail}, and our team will be happy to assist. +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + pantryConfirmsOrderDelivery: (params: { + volunteerName: string; + pantryName: string; + fmName: string; + }): EmailTemplate => ({ + subject: `${params.pantryName} Confirmed for your ${params.fmName} Order`, + bodyHTML: ` +

Hi ${params.volunteerName},

+

+ ${params.pantryName} has confirmed receipt of the most recent ${params.fmName} + order you are assigned to. Please log into the platform to review the completed + request or check for additional information. +

+

+ Thank you for your coordination and support in helping reach this order to completion! +

+

Best regards,
The Securing Safe Food Team

+ `, + }), }; diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index 71003cc7e..07b33f5fa 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -17,6 +17,7 @@ import { DonationItemsModule } from '../donationItems/donationItems.module'; import { Allocation } from '../allocations/allocations.entity'; import { DonationModule } from '../donations/donations.module'; import { Donation } from '../donations/donations.entity'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { Donation } from '../donations/donations.entity'; ManufacturerModule, DonationItemsModule, DonationModule, + EmailsModule, ], controllers: [OrdersController], providers: [OrdersService], diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 378718e0e..0fa0cc21c 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -23,6 +23,8 @@ import { DonationService } from '../donations/donations.service'; import { ApplicationStatus } from '../shared/types'; import { Donation } from '../donations/donations.entity'; import { VolunteerOrder } from '../volunteers/types'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; @Injectable() export class OrdersService { @@ -36,6 +38,7 @@ export class OrdersService { private allocationsService: AllocationsService, private donationService: DonationService, @InjectDataSource() private dataSource: DataSource, + private emailsService: EmailsService, ) {} // TODO: when order is created, set FM @@ -391,6 +394,7 @@ export class OrdersService { .execute(); } + // Updated confirmDelivery() async confirmDelivery( orderId: number, dto: ConfirmDeliveryDto, @@ -403,7 +407,10 @@ export class OrdersService { throw new BadRequestException('Invalid date format for dateReceived'); } - const order = await this.repo.findOneBy({ orderId }); + const order = await this.repo.findOne({ + where: { orderId }, + relations: ['request', 'request.pantry', 'foodManufacturer', 'assignee'], + }); if (!order) { throw new NotFoundException(`Order ${orderId} not found`); @@ -424,6 +431,18 @@ export class OrdersService { await this.requestsService.updateRequestStatus(order.requestId); + const { subject, bodyHTML } = emailTemplates.pantryConfirmsOrderDelivery({ + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + }); + + await this.emailsService.sendEmails( + [order.assignee.email], + subject, + bodyHTML, + ); + return updatedOrder; } @@ -472,7 +491,10 @@ export class OrdersService { dto.trackingLink = sanitized; } - const order = await this.repo.findOneBy({ orderId }); + const order = await this.repo.findOne({ + where: { orderId }, + relations: ['request', 'request.pantry', 'foodManufacturer', 'assignee'], + }); if (!order) { throw new NotFoundException(`Order ${orderId} not found`); } @@ -504,6 +526,22 @@ export class OrdersService { ) { order.status = OrderStatus.SHIPPED; order.shippedAt = new Date(); + + const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({ + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + trackingLink: order.trackingLink, + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + volunteerEmail: order.assignee.email, + }); + + const pantryEmails = [order.request.pantry.secondaryContactEmail].filter( + (e): e is string => e !== null, + ); + + if (pantryEmails.length > 0) { + await this.emailsService.sendEmails(pantryEmails, subject, bodyHTML); + } } await this.repo.save(order); From de35710daa1ce3e4ceda35aad36ec689d566747e Mon Sep 17 00:00:00 2001 From: skewalia <145241312+swarkewalia@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:50:21 -0400 Subject: [PATCH 02/18] fix donation tests --- apps/backend/src/donations/donations.service.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index b37427025..c891ef4e0 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -16,6 +16,7 @@ import { import { FoodType } from '../donationItems/types'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { EmailsService } from '../emails/email.service'; jest.setTimeout(60000); @@ -126,6 +127,10 @@ describe('DonationService', () => { provide: DataSource, useValue: testDataSource, }, + { + provide: EmailsService, + useValue: { sendEmails: jest.fn() }, + }, ], }).compile(); From af27eb644311215edf773191ea44f35706d191d1 Mon Sep 17 00:00:00 2001 From: skewalia <145241312+swarkewalia@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:07:05 -0400 Subject: [PATCH 03/18] automated email user updates --- apps/backend/src/emails/emailTemplates.ts | 21 +++++++++++++++++++ .../src/volunteers/volunteers.module.ts | 2 ++ .../src/volunteers/volunteers.service.ts | 13 +++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 7d4b8c0f3..4046e6435 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -98,4 +98,25 @@ export const emailTemplates = {

Best regards,
The Securing Safe Food Team

`, }), + + volunteerPantryAssignmentChanged: (params: { + volunteerName: string; + }): EmailTemplate => ({ + subject: 'Your SSF Pantry Assignment has been updated', + bodyHTML: ` +

Hi ${params.volunteerName},

+

+ Your pantry assignment with SSF has been updated. Please log into the platform + to review your current assignments and any active requests that may require your attention. +

+

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

+

Best regards,
The Securing Safe Food Team

+

+ To view your pantry assignments, please click the following link: + ${EMAIL_REDIRECT_URL}/volunteer-assigned-pantries +

+ `, + }), }; diff --git a/apps/backend/src/volunteers/volunteers.module.ts b/apps/backend/src/volunteers/volunteers.module.ts index 003910968..01997c832 100644 --- a/apps/backend/src/volunteers/volunteers.module.ts +++ b/apps/backend/src/volunteers/volunteers.module.ts @@ -8,6 +8,7 @@ import { VolunteersService } from './volunteers.service'; import { UsersModule } from '../users/users.module'; import { RequestsModule } from '../foodRequests/request.module'; import { OrdersModule } from '../orders/order.module'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { OrdersModule } from '../orders/order.module'; forwardRef(() => AuthModule), RequestsModule, OrdersModule, + EmailsModule, ], controllers: [VolunteersController], providers: [VolunteersService], diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index c167d98ca..f7bd41848 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -10,6 +10,8 @@ import { UsersService } from '../users/users.service'; import { Assignments } from './types'; import { FoodRequest } from '../foodRequests/request.entity'; import { RequestsService } from '../foodRequests/request.service'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; @Injectable() export class VolunteersService { @@ -19,6 +21,7 @@ export class VolunteersService { private usersService: UsersService, private pantriesService: PantriesService, private requestsService: RequestsService, + private emailsService: EmailsService, ) {} async findOne(id: number): Promise { @@ -74,7 +77,15 @@ export class VolunteersService { ); volunteer.pantries = [...existingPantries, ...newPantries]; - return this.repo.save(volunteer); + const saved = await this.repo.save(volunteer); + + const { subject, bodyHTML } = + emailTemplates.volunteerPantryAssignmentChanged({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + await this.emailsService.sendEmails([volunteer.email], subject, bodyHTML); + + return saved; } async findRequestsByVolunteer(volunteerId: number): Promise { From 98a90b87ac568fee493f6e35bf661f61e20b9e7a Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Mon, 4 May 2026 23:34:29 -0400 Subject: [PATCH 04/18] comments --- .../src/donations/donations.service.spec.ts | 86 +++++++++++- .../src/donations/donations.service.ts | 38 ++--- apps/backend/src/emails/emailTemplates.ts | 2 +- apps/backend/src/orders/order.service.spec.ts | 130 +++++++++++++++++- apps/backend/src/orders/order.service.ts | 46 ++++++- 5 files changed, 272 insertions(+), 30 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index f217fb589..26edd89f7 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -17,6 +17,9 @@ import { ReplaceDonationItemsDto, } from '../donationItems/dtos/create-donation-items.dto'; import { FoodType } from '../donationItems/types'; +import { mock } from 'jest-mock-extended'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; jest.setTimeout(60000); @@ -132,11 +135,15 @@ const TODAYOfWeek = (iso: string): DayOfWeek => { return days[new Date(iso).getDay()]; }; +const mockEmailsService = mock(); + describe('DonationService', () => { let service: DonationService; let donationItemService: DonationItemsService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -170,7 +177,7 @@ describe('DonationService', () => { }, { provide: EmailsService, - useValue: { sendEmails: jest.fn() }, + useValue: mockEmailsService, }, ], }).compile(); @@ -181,6 +188,7 @@ describe('DonationService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); @@ -632,6 +640,82 @@ describe('DonationService', () => { ); expect(donation.occurrencesRemaining).toEqual(3); }); + + it('sends fmRecurringDonationReminder email with correct parameters when expired date is processed', async () => { + const pastDate = daysAgo(5); + await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + const manufacturer = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ + where: { foodManufacturerName: 'FoodCorp Industries' }, + relations: ['foodManufacturerRepresentative'], + }); + + if (!manufacturer) + throw new Error('Missing FoodCorp Industries manufacturer'); + + await service.handleRecurringDonations(); + + const { subject, bodyHTML } = + emailTemplates.fmRecurringDonationReminder({ + fmName: manufacturer.foodManufacturerName, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [manufacturer.foodManufacturerRepresentative.email], + subject, + bodyHTML, + ); + }); + + it('continues processing other donations when one donation email send fails', async () => { + const pastDate1 = daysAgo(5); + const pastDate2 = daysAgo(3); + + const donationId1 = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate1], + occurrencesRemaining: 3, + }); + + const donationId2 = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate2], + occurrencesRemaining: 3, + }); + + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await service.handleRecurringDonations(); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); + + const donation1 = await service.findOne(donationId1); + const donation2 = await service.findOne(donationId2); + + // Exactly one donation should have been updated (occurrences decremented to 2) + // In the case where an email send fails, we do not want to decrement anything + const updatedCount = [donation1, donation2].filter( + (d) => d.occurrencesRemaining === 2, + ).length; + const unchangedCount = [donation1, donation2].filter( + (d) => d.occurrencesRemaining === 3, + ).length; + + expect(updatedCount).toBe(1); + expect(unchangedCount).toBe(1); + }); }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 6b8a89fbc..d3eeea48a 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -50,7 +50,10 @@ export class DonationService { async getAll(): Promise { return this.repo.find({ - relations: ['foodManufacturer'], + relations: [ + 'foodManufacturer', + 'foodManufacturer.foodManufacturerRepresentative', + ], }); } @@ -206,19 +209,18 @@ export class DonationService { break; } + // Successfully send an email first before decrementing the count const { subject, bodyHTML } = emailTemplates.fmRecurringDonationReminder({ fmName: donation.foodManufacturer.foodManufacturerName, }); try { - const fmEmails = [ - donation.foodManufacturer.secondaryContactEmail, - ].filter((e): e is string => e !== null); - - if (fmEmails.length > 0) { - await this.emailsService.sendEmails(fmEmails, subject, bodyHTML); - } + await this.emailsService.sendEmails( + [donation.foodManufacturer.foodManufacturerRepresentative.email], + subject, + bodyHTML, + ); } catch (e) { continue; } @@ -237,21 +239,23 @@ export class DonationService { // cascading recalculation of next dates when replacement dates are also expired while (nextDate.getTime() <= today.getTime() && occurrences > 0) { - const { subject: cs, bodyHTML: cb } = + const { subject, bodyHTML } = emailTemplates.fmRecurringDonationReminder({ fmName: donation.foodManufacturer.foodManufacturerName, }); + // Successfully send an email first before decrementing the count try { - const fmEmails = [ - donation.foodManufacturer.secondaryContactEmail, - ].filter((e): e is string => e !== null); - - if (fmEmails.length > 0) { - await this.emailsService.sendEmails(fmEmails, cs, cb); - } + await this.emailsService.sendEmails( + [ + donation.foodManufacturer.foodManufacturerRepresentative + .email, + ], + subject, + bodyHTML, + ); } catch (e) { - break; + continue; } occurrences -= 1; diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 41c6350ec..010aca5ac 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -136,7 +136,7 @@ export const emailTemplates = { expected donation.

- Tracking Link: ${params.trackingLink} + Tracking Link: ${params.trackingLink}

You can use the tracking link above to monitor your shipment, or log into your portal for full order details and updates. diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 92d37acc3..cd154e6ca 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'; @@ -29,14 +33,20 @@ import { CreateOrderDto } from './dtos/create-order.dto'; import { DataSource } from 'typeorm'; import { EmailsService } from '../emails/email.service'; import { Allocation } from '../allocations/allocations.entity'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates } from '../emails/emailTemplates'; // Set 1 minute timeout for async DB operations jest.setTimeout(60000); +const mockEmailsService = mock(); + describe('OrdersService', () => { let service: OrdersService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + // Initialize DataSource once if (!testDataSource.isInitialized) { await testDataSource.initialize(); @@ -62,9 +72,7 @@ describe('OrdersService', () => { }, { provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, + useValue: mockEmailsService, }, { provide: getRepositoryToken(Order), @@ -109,6 +117,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(); @@ -530,6 +539,64 @@ describe('OrdersService', () => { expect(updatedOrder.status).toEqual(OrderStatus.SHIPPED); expect(updatedOrder.shippedAt).toBeDefined(); }); + + it('sends trackingLinkAvailable email to pantry user when order is shipped', async () => { + const orderId = 4; + const order = await testDataSource.getRepository(Order).findOne({ + where: { orderId }, + relations: [ + 'request', + 'request.pantry', + 'request.pantry.pantryUser', + 'foodManufacturer', + 'assignee', + ], + }); + + if (!order) throw new Error('Missing order test object'); + + const dto: TrackingCostDto = { + trackingLink: 'testtracking.com', + shippingCost: 5.0, + }; + + await service.updateTrackingCostInfo(orderId, dto); + + const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({ + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + trackingLink: 'https://testtracking.com/', + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + volunteerEmail: order.assignee.email, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [order.request.pantry.pantryUser.email], + subject, + bodyHTML, + ); + }); + + it('still updates order to shipped if tracking link email fails to send', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect( + service.updateTrackingCostInfo(4, { + trackingLink: 'testtracking.com', + shippingCost: 5.0, + }), + ).rejects.toThrow( + new InternalServerErrorException( + 'Failed to send new tracking link available email to pantry', + ), + ); + + const order = await service.findOne(4); + expect(order.status).toBe(OrderStatus.SHIPPED); + }); }); describe('checkAndFulfillDonations', () => { @@ -762,6 +829,61 @@ describe('OrdersService', () => { new BadRequestException('Can only confirm delivery for shipped orders'), ); }); + + it('sends pantryConfirmsOrderDelivery email to volunteer when delivery is confirmed', async () => { + const orderId = 3; + const order = await testDataSource.getRepository(Order).findOne({ + where: { orderId }, + relations: [ + 'request', + 'request.pantry', + 'foodManufacturer', + 'assignee', + ], + }); + + if (!order) throw new Error('Missing order test object'); + + await service.confirmDelivery( + orderId, + { dateReceived: new Date().toISOString(), feedback: 'Great!' }, + [], + ); + + const { subject, bodyHTML } = emailTemplates.pantryConfirmsOrderDelivery({ + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [order.assignee.email], + subject, + bodyHTML, + ); + }); + + it('still updates order to delivered if delivery confirmation email fails to send', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect( + service.confirmDelivery( + 3, + { dateReceived: new Date().toISOString(), feedback: 'Great!' }, + [], + ), + ).rejects.toThrow( + new InternalServerErrorException( + 'Failed to send order delivery confirmation email to volunteer', + ), + ); + + const order = await service.findOne(3); + expect(order.status).toBe(OrderStatus.DELIVERED); + }); }); describe('createOrder', () => { diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index c24594694..8865b5ead 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + InternalServerErrorException, NotFoundException, } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; @@ -419,7 +420,6 @@ export class OrdersService { .execute(); } - // Updated confirmDelivery() async confirmDelivery( orderId: number, dto: ConfirmDeliveryDto, @@ -462,11 +462,17 @@ export class OrdersService { fmName: order.foodManufacturer.foodManufacturerName, }); - await this.emailsService.sendEmails( - [order.assignee.email], - subject, - bodyHTML, - ); + try { + await this.emailsService.sendEmails( + [order.assignee.email], + subject, + bodyHTML, + ); + } catch (e) { + throw new InternalServerErrorException( + 'Failed to send order delivery confirmation email to volunteer', + ); + } return updatedOrder; } @@ -514,7 +520,13 @@ export class OrdersService { const order = await this.repo.findOne({ where: { orderId }, - relations: ['request', 'request.pantry', 'foodManufacturer', 'assignee'], + relations: [ + 'request', + 'request.pantry', + 'request.pantry.pantryUser', + 'foodManufacturer', + 'assignee', + ], }); if (!order) { throw new NotFoundException(`Order ${orderId} not found`); @@ -535,6 +547,26 @@ export class OrdersService { await this.repo.save(order); await this.checkAndFulfillDonations(orderId); + + const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({ + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + trackingLink: dto.trackingLink, + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + volunteerEmail: order.assignee.email, + }); + + try { + await this.emailsService.sendEmails( + [order.request.pantry.pantryUser.email], + subject, + bodyHTML, + ); + } catch (e) { + throw new InternalServerErrorException( + 'Failed to send new tracking link available email to pantry', + ); + } } async checkAndFulfillDonations(orderId: number): Promise { From a5d820ac92e4b2ced544f187e865354a802b6f68 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 5 May 2026 00:03:04 -0400 Subject: [PATCH 05/18] Comments --- .../src/donations/donations.service.spec.ts | 3 +- .../src/donations/donations.service.ts | 2 + apps/backend/src/emails/emailTemplates.ts | 8 +- .../components/forms/newDonationFormModal.tsx | 81 ++++++++++++------- .../foodManufacturerDonationManagement.tsx | 42 +++++++++- 5 files changed, 102 insertions(+), 34 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 26edd89f7..9987573cb 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -643,7 +643,7 @@ describe('DonationService', () => { it('sends fmRecurringDonationReminder email with correct parameters when expired date is processed', async () => { const pastDate = daysAgo(5); - await insertDonation({ + const donationId = await insertDonation({ recurrence: RecurrenceEnum.WEEKLY, recurrenceFreq: 1, nextDonationDates: [pastDate], @@ -665,6 +665,7 @@ describe('DonationService', () => { const { subject, bodyHTML } = emailTemplates.fmRecurringDonationReminder({ fmName: manufacturer.foodManufacturerName, + resubmitDonationId: donationId, }); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index d3eeea48a..0187232e8 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -213,6 +213,7 @@ export class DonationService { const { subject, bodyHTML } = emailTemplates.fmRecurringDonationReminder({ fmName: donation.foodManufacturer.foodManufacturerName, + resubmitDonationId: donation.donationId, }); try { @@ -242,6 +243,7 @@ export class DonationService { const { subject, bodyHTML } = emailTemplates.fmRecurringDonationReminder({ fmName: donation.foodManufacturer.foodManufacturerName, + resubmitDonationId: donation.donationId, }); // Successfully send an email first before decrementing the count diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 010aca5ac..67b6ed3b7 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -99,7 +99,10 @@ export const emailTemplates = { `, }), - fmRecurringDonationReminder: (params: { fmName: string }): EmailTemplate => ({ + fmRecurringDonationReminder: (params: { + fmName: string; + resubmitDonationId: number; + }): EmailTemplate => ({ subject: 'Reminder: Submit Your Scheduled Recurring Donation with SSF', bodyHTML: `

Hi ${params.fmName},

@@ -116,6 +119,9 @@ export const emailTemplates = { recurring donations make a meaningful and consistent impact for the communities we serve.

Best regards,
The Securing Safe Food Team

+

+ You can use resubmit this donation by visiting your donation management portal. +

`, }), diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index 4f2e7821d..abee7b77b 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -21,6 +21,7 @@ import ApiClient from '@api/apiClient'; import { CreateDonationDto, DayOfWeek, + DonationItem, FoodType, RecurrenceEnum, RepeatOnState, @@ -35,6 +36,8 @@ interface NewDonationFormModalProps { onDonationSuccess: () => void; isOpen: boolean; onClose: () => void; + prefillItems?: DonationItem[]; + hideRecurring?: boolean; } interface DonationRow { @@ -106,19 +109,35 @@ const NewDonationFormModal: React.FC = ({ onDonationSuccess, isOpen, onClose, + prefillItems, + hideRecurring = false, }) => { useModalBodyCleanup(); - const [rows, setRows] = useState([ - { - id: 1, - foodItem: '', - foodType: '', - numItems: '', - ozPerItem: '', - valuePerItem: '', - foodRescue: false, - }, - ]); + const [rows, setRows] = useState(() => { + if (prefillItems && prefillItems.length > 0) { + return prefillItems.map((item, index) => ({ + id: index + 1, + foodItem: item.itemName, + foodType: item.foodType, + numItems: String(item.quantity), + ozPerItem: item.ozPerItem != null ? String(item.ozPerItem) : '', + valuePerItem: + item.estimatedValue != null ? String(item.estimatedValue) : '', + foodRescue: item.foodRescue, + })); + } + return [ + { + id: 1, + foodItem: '', + foodType: '', + numItems: '', + ozPerItem: '', + valuePerItem: '', + foodRescue: false, + }, + ]; + }); const [isRecurring, setIsRecurring] = useState(false); const [repeatEvery, setRepeatEvery] = useState('1'); @@ -321,25 +340,27 @@ const NewDonationFormModal: React.FC = ({ > Add New Row + - { - setIsRecurring(!!e.checked); - setRepeatInterval( - e.checked - ? RecurrenceEnum.WEEKLY - : RecurrenceEnum.NONE, - ); - }} - > - - - - - - Make Donation Recurring - - + {!hideRecurring && ( + { + setIsRecurring(!!e.checked); + setRepeatInterval( + e.checked + ? RecurrenceEnum.WEEKLY + : RecurrenceEnum.NONE, + ); + }} + > + + + + + + Make Donation Recurring + + + )} diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index f60109c27..c1a9f987a 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -12,12 +12,23 @@ import { import { ChevronRight, ChevronLeft, Mail, CircleCheck } from 'lucide-react'; import { capitalize, formatDate, DONATION_STATUS_COLORS } from '@utils/utils'; import ApiClient from '@api/apiClient'; -import { DonationDetails, DonationStatus } from '../types/types'; +import { DonationDetails, DonationItem, DonationStatus } from '../types/types'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { ROUTES } from '../routes'; const FoodManufacturerDonationManagement: React.FC = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const resubmitDonationId = searchParams.get('resubmitDonationId'); + const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); + const [prefillItems, setPrefillItems] = useState( + undefined, + ); + const [isResubmit, setIsResubmit] = useState(false); + // State to hold donations grouped by status const [statusDonations, setStatusDonations] = useState<{ [key in DonationStatus]: DonationDetails[]; @@ -75,6 +86,22 @@ const FoodManufacturerDonationManagement: React.FC = () => { [DonationStatus.MATCHED]: 1, }; setCurrentPages(initialPages); + + if (resubmitDonationId) { + const id = parseInt(resubmitDonationId, 10); + const allDonations: DonationDetails[] = Object.values(grouped).flat(); + const matchingDetail = allDonations.find( + (d) => d.donation.donationId === id, + ); + if (matchingDetail) { + const items = await ApiClient.getDonationItemsByDonationId(id); + setPrefillItems(items); + setIsResubmit(true); + setIsLogDonationOpen(true); + } else { + navigate(ROUTES.FM_DONATION_MANAGEMENT); + } + } } catch (error) { alert('Error fetching donations: ' + error); } @@ -84,6 +111,15 @@ const FoodManufacturerDonationManagement: React.FC = () => { fetchDonations(); }, []); + const handleModalClose = () => { + setIsLogDonationOpen(false); + setPrefillItems(undefined); + setIsResubmit(false); + if (resubmitDonationId) { + navigate(ROUTES.FM_DONATION_MANAGEMENT); + } + }; + const handlePageChange = (status: DonationStatus, page: number) => { setCurrentPages((prev) => ({ ...prev, @@ -118,7 +154,9 @@ const FoodManufacturerDonationManagement: React.FC = () => { setIsLogDonationOpen(false)} + onClose={handleModalClose} + prefillItems={prefillItems} + hideRecurring={isResubmit} /> )} From a3829d18e0edbdff46d0c04a3cb7f2f4cc2ae6f2 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 5 May 2026 00:03:55 -0400 Subject: [PATCH 06/18] Comments --- apps/backend/src/emails/emailTemplates.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 67b6ed3b7..7d2c5ae16 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -114,14 +114,14 @@ export const emailTemplates = { When you have a moment, please log into your account and submit your current donation availability so we can continue matching your contributions with pantry requests.

+

+ You can use resubmit this donation by visiting your donation management portal. +

We greatly appreciate your continued generosity and support of our mission. Your recurring donations make a meaningful and consistent impact for the communities we serve.

Best regards,
The Securing Safe Food Team

-

- You can use resubmit this donation by visiting your donation management portal. -

`, }), From 23765b8d4282450d4c7c4c605b59ce8254d9fd9e Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 5 May 2026 00:36:09 -0400 Subject: [PATCH 07/18] Fixed formatting and added tests for volunteer assignment email --- .../src/donations/donations.service.spec.ts | 13 +++-- .../src/donations/donations.service.ts | 24 ++++----- .../manufacturers.service.spec.ts | 6 +-- .../src/foodRequests/request.service.spec.ts | 12 ++--- apps/backend/src/orders/order.service.spec.ts | 12 ++--- apps/backend/src/orders/order.service.ts | 36 ++++++------- .../src/pantries/pantries.service.spec.ts | 6 +-- apps/backend/src/users/users.service.spec.ts | 6 +-- .../src/volunteers/volunteers.service.spec.ts | 54 +++++++++++++++++-- .../src/volunteers/volunteers.service.ts | 21 ++++++-- 10 files changed, 123 insertions(+), 67 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 9987573cb..78004769a 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -662,17 +662,16 @@ describe('DonationService', () => { await service.handleRecurringDonations(); - const { subject, bodyHTML } = - emailTemplates.fmRecurringDonationReminder({ - fmName: manufacturer.foodManufacturerName, - resubmitDonationId: donationId, - }); + const message = emailTemplates.fmRecurringDonationReminder({ + fmName: manufacturer.foodManufacturerName, + resubmitDonationId: donationId, + }); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( [manufacturer.foodManufacturerRepresentative.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 0187232e8..e66e24139 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -210,19 +210,18 @@ export class DonationService { } // Successfully send an email first before decrementing the count - const { subject, bodyHTML } = - emailTemplates.fmRecurringDonationReminder({ + try { + const message = emailTemplates.fmRecurringDonationReminder({ fmName: donation.foodManufacturer.foodManufacturerName, resubmitDonationId: donation.donationId, }); - try { await this.emailsService.sendEmails( [donation.foodManufacturer.foodManufacturerRepresentative.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); - } catch (e) { + } catch { continue; } @@ -240,23 +239,22 @@ export class DonationService { // cascading recalculation of next dates when replacement dates are also expired while (nextDate.getTime() <= today.getTime() && occurrences > 0) { - const { subject, bodyHTML } = - emailTemplates.fmRecurringDonationReminder({ + // Successfully send an email first before decrementing the count + try { + const message = emailTemplates.fmRecurringDonationReminder({ fmName: donation.foodManufacturer.foodManufacturerName, resubmitDonationId: donation.donationId, }); - // Successfully send an email first before decrementing the count - try { await this.emailsService.sendEmails( [ donation.foodManufacturer.foodManufacturerRepresentative .email, ], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); - } catch (e) { + } catch { continue; } diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 4d3321e23..cd4c9fda1 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -189,7 +189,7 @@ describe('FoodManufacturersService', () => { const pending = await service.getPendingManufacturers(); const manufacturer = pending[0]; const id = manufacturer.foodManufacturerId; - const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({ + const message = emailTemplates.pantryFmApplicationApproved({ name: manufacturer.foodManufacturerRepresentative.firstName, }); @@ -198,8 +198,8 @@ describe('FoodManufacturersService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( [manufacturer.foodManufacturerRepresentative.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 13be82b49..f98e0e469 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -250,7 +250,7 @@ describe('RequestsService', () => { ]); if (!pantry) throw new Error('Missing pantry test object'); - const { subject, bodyHTML } = emailTemplates.pantrySubmitsFoodRequest({ + const message = emailTemplates.pantrySubmitsFoodRequest({ pantryName: pantry.pantryName, }); const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); @@ -258,8 +258,8 @@ describe('RequestsService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( volunteerEmails, - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); @@ -277,7 +277,7 @@ describe('RequestsService', () => { ]); if (!pantry) throw new Error('Missing pantry test object'); - const { subject, bodyHTML } = emailTemplates.pantrySubmitsFoodRequest({ + const message = emailTemplates.pantrySubmitsFoodRequest({ pantryName: pantry.pantryName, }); const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); @@ -286,8 +286,8 @@ describe('RequestsService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( volunteerEmails, - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index cd154e6ca..b06a98159 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -562,7 +562,7 @@ describe('OrdersService', () => { await service.updateTrackingCostInfo(orderId, dto); - const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({ + const message = emailTemplates.trackingLinkAvailable({ pantryName: order.request.pantry.pantryName, fmName: order.foodManufacturer.foodManufacturerName, trackingLink: 'https://testtracking.com/', @@ -573,8 +573,8 @@ describe('OrdersService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( [order.request.pantry.pantryUser.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); @@ -850,7 +850,7 @@ describe('OrdersService', () => { [], ); - const { subject, bodyHTML } = emailTemplates.pantryConfirmsOrderDelivery({ + const message = emailTemplates.pantryConfirmsOrderDelivery({ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, pantryName: order.request.pantry.pantryName, fmName: order.foodManufacturer.foodManufacturerName, @@ -859,8 +859,8 @@ describe('OrdersService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( [order.assignee.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 8865b5ead..f6391e0d0 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -456,17 +456,17 @@ export class OrdersService { await this.requestsService.updateRequestStatus(order.requestId); - const { subject, bodyHTML } = emailTemplates.pantryConfirmsOrderDelivery({ - volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, - pantryName: order.request.pantry.pantryName, - fmName: order.foodManufacturer.foodManufacturerName, - }); - try { + const message = emailTemplates.pantryConfirmsOrderDelivery({ + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + }); + await this.emailsService.sendEmails( [order.assignee.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); } catch (e) { throw new InternalServerErrorException( @@ -548,19 +548,19 @@ export class OrdersService { await this.checkAndFulfillDonations(orderId); - const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({ - pantryName: order.request.pantry.pantryName, - fmName: order.foodManufacturer.foodManufacturerName, - trackingLink: dto.trackingLink, - volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, - volunteerEmail: order.assignee.email, - }); - try { + const message = emailTemplates.trackingLinkAvailable({ + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + trackingLink: dto.trackingLink, + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + volunteerEmail: order.assignee.email, + }); + await this.emailsService.sendEmails( [order.request.pantry.pantryUser.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); } catch (e) { throw new InternalServerErrorException( diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 83208c3ef..f1b37b282 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -204,7 +204,7 @@ describe('PantriesService', () => { it('sends approval email to pantry user', async () => { const pantry = await service.findOne(5); - const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({ + const message = emailTemplates.pantryFmApplicationApproved({ name: pantry.pantryUser.firstName, }); @@ -213,8 +213,8 @@ describe('PantriesService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( [pantry.pantryUser.email], - subject, - 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 0f7902798..93b260bdc 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -156,12 +156,12 @@ describe('UsersService', () => { const result = await service.create(createUserDto); - const { subject, bodyHTML } = emailTemplates.volunteerAccountCreated(); + const message = emailTemplates.volunteerAccountCreated(); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( [createUserDto.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); expect(mockAuthService.adminCreateUser).toHaveBeenCalledWith({ firstName: createUserDto.firstName, diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index b665751b0..44d36fd03 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -1,4 +1,7 @@ -import { NotFoundException } from '@nestjs/common'; +import { + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; @@ -22,6 +25,10 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { AllocationsService } from '../allocations/allocations.service'; import { DonationService } from '../donations/donations.service'; import { Allocation } from '../allocations/allocations.entity'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates } from '../emails/emailTemplates'; + +const mockEmailsService = mock(); jest.setTimeout(60000); @@ -29,6 +36,8 @@ describe('VolunteersService', () => { let service: VolunteersService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -58,9 +67,7 @@ describe('VolunteersService', () => { }, { provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, + useValue: mockEmailsService, }, { provide: getRepositoryToken(User), @@ -101,6 +108,7 @@ describe('VolunteersService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); @@ -264,6 +272,44 @@ describe('VolunteersService', () => { const pantryIds = result.pantries?.map((p) => p.pantryId); expect(pantryIds).toEqual([2, 3]); }); + + it('sends volunteerPantryAssignmentChanged email to volunteer when pantries are assigned', async () => { + const volunteerId = 7; + const volunteer = await testDataSource + .getRepository(User) + .findOne({ where: { id: volunteerId } }); + + if (!volunteer) throw new Error('Missing volunteer test object'); + + await service.assignPantriesToVolunteer(volunteerId, [1]); + + const message = emailTemplates.volunteerPantryAssignmentChanged({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [volunteer.email], + message.subject, + message.bodyHTML, + ); + }); + + it('still assigns pantries if email fails to send', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect(service.assignPantriesToVolunteer(6, [2])).rejects.toThrow( + new InternalServerErrorException( + 'Failed to send new food request notification email to volunteers', + ), + ); + + const pantries = await service.getVolunteerPantries(6); + const pantryIds = pantries.map((p) => p.pantryId); + expect(pantryIds).toContain(2); + }); }); describe('findRequestsByVolunteer', () => { diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index af4ae0d2b..69e024aa9 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../users/users.entity'; @@ -90,11 +94,20 @@ export class VolunteersService { volunteer.pantries = [...existingPantries, ...newPantries]; const saved = await this.repo.save(volunteer); - const { subject, bodyHTML } = - emailTemplates.volunteerPantryAssignmentChanged({ + try { + const message = emailTemplates.volunteerPantryAssignmentChanged({ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, }); - await this.emailsService.sendEmails([volunteer.email], subject, bodyHTML); + await this.emailsService.sendEmails( + [volunteer.email], + message.subject, + message.bodyHTML, + ); + } catch { + throw new InternalServerErrorException( + 'Failed to send new food request notification email to volunteers', + ); + } return saved; } From 0c09e2acddcfdcf264bf7d599bd5e0b05c075cf4 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 5 May 2026 01:53:14 -0400 Subject: [PATCH 08/18] fixed logit to always process the donation, even if the email send fails --- .../src/donations/donations.service.spec.ts | 16 ++++------------ apps/backend/src/donations/donations.service.ts | 6 ++---- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 78004769a..2407c46f5 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -675,7 +675,7 @@ describe('DonationService', () => { ); }); - it('continues processing other donations when one donation email send fails', async () => { + it('processes all donations when one donation email send fails', async () => { const pastDate1 = daysAgo(5); const pastDate2 = daysAgo(3); @@ -704,17 +704,9 @@ describe('DonationService', () => { const donation1 = await service.findOne(donationId1); const donation2 = await service.findOne(donationId2); - // Exactly one donation should have been updated (occurrences decremented to 2) - // In the case where an email send fails, we do not want to decrement anything - const updatedCount = [donation1, donation2].filter( - (d) => d.occurrencesRemaining === 2, - ).length; - const unchangedCount = [donation1, donation2].filter( - (d) => d.occurrencesRemaining === 3, - ).length; - - expect(updatedCount).toBe(1); - expect(unchangedCount).toBe(1); + // Both donations should be decremented even when an email send fails + expect(donation1.occurrencesRemaining).toBe(2); + expect(donation2.occurrencesRemaining).toBe(2); }); }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index e66e24139..4baab0731 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -209,7 +209,6 @@ export class DonationService { break; } - // Successfully send an email first before decrementing the count try { const message = emailTemplates.fmRecurringDonationReminder({ fmName: donation.foodManufacturer.foodManufacturerName, @@ -222,7 +221,7 @@ export class DonationService { message.bodyHTML, ); } catch { - continue; + // email failed — still count as a recurrence and move on } dates.splice(i, 1); @@ -239,7 +238,6 @@ export class DonationService { // cascading recalculation of next dates when replacement dates are also expired while (nextDate.getTime() <= today.getTime() && occurrences > 0) { - // Successfully send an email first before decrementing the count try { const message = emailTemplates.fmRecurringDonationReminder({ fmName: donation.foodManufacturer.foodManufacturerName, @@ -255,7 +253,7 @@ export class DonationService { message.bodyHTML, ); } catch { - continue; + // email failed — still count as a recurrence and move on } occurrences -= 1; From a6c8e3b9bf14d3e2c22f942d0d131948562fac48 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 5 May 2026 23:47:57 -0400 Subject: [PATCH 09/18] Added Resubmit Previous frontend, as well as used theme.ts colors throughout entire app --- .../components/forms/orderDetailsModal.tsx | 10 +- .../components/forms/requestDetailsModal.tsx | 10 +- .../src/components/forms/requestFormModal.tsx | 4 +- .../forms/resubmitDonationModal.tsx | 386 ++++++++++++++++++ .../src/containers/adminOrderManagement.tsx | 2 +- .../foodManufacturerDonationManagement.tsx | 97 +++-- apps/frontend/src/containers/formRequests.tsx | 14 +- .../src/containers/volunteerManagement.tsx | 11 +- .../containers/volunteerOrderManagement.tsx | 4 +- 9 files changed, 475 insertions(+), 63 deletions(-) create mode 100644 apps/frontend/src/components/forms/resubmitDonationModal.tsx diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index 2d0b85d62..ea921becb 100644 --- a/apps/frontend/src/components/forms/orderDetailsModal.tsx +++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx @@ -118,7 +118,7 @@ const OrderDetailsModal: React.FC = ({ - + Fulfilled by {orderDetails?.foodManufacturerName} @@ -176,8 +176,8 @@ const OrderDetailsModal: React.FC = ({ {foodRequest.status === FoodRequestStatus.CLOSED ? ( Closed @@ -185,7 +185,7 @@ const OrderDetailsModal: React.FC = ({ Active @@ -279,7 +279,7 @@ const OrderDetailsModal: React.FC = ({ {orderDetails?.trackingLink ? ( = ({ - + {pantryName} @@ -196,16 +196,16 @@ const RequestDetailsModal: React.FC = ({ {currentOrder.status === OrderStatus.DELIVERED ? ( Received ) : ( In Progress diff --git a/apps/frontend/src/components/forms/requestFormModal.tsx b/apps/frontend/src/components/forms/requestFormModal.tsx index 4de9e6061..43f3cbfc0 100644 --- a/apps/frontend/src/components/forms/requestFormModal.tsx +++ b/apps/frontend/src/components/forms/requestFormModal.tsx @@ -149,7 +149,9 @@ const FoodRequestFormModal: React.FC = ({ justifyContent="space-between" > {requestedSize || 'Select size'} - + + + diff --git a/apps/frontend/src/components/forms/resubmitDonationModal.tsx b/apps/frontend/src/components/forms/resubmitDonationModal.tsx new file mode 100644 index 000000000..ef0a2d8d4 --- /dev/null +++ b/apps/frontend/src/components/forms/resubmitDonationModal.tsx @@ -0,0 +1,386 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Button, + CloseButton, + Dialog, + Flex, + Portal, + Spinner, + Text, + VStack, + Badge, +} from '@chakra-ui/react'; +import { ChevronDown } from 'lucide-react'; +import { + CreateDonationDto, + DonationDetails, + DonationItem, + RecurrenceEnum, +} from '../../types/types'; +import ApiClient from '@api/apiClient'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useAlert } from '../../hooks/alert'; +import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; +import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; + +interface ResubmitDonationModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + donations: DonationDetails[]; + initialDonationId?: number | null; +} + +const formatDonationDate = (dateString: string) => + new Date(dateString).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); + +const ResubmitDonationModal: React.FC = ({ + isOpen, + onClose, + onSuccess, + donations, + initialDonationId, +}) => { + useModalBodyCleanup(); + const [errorAlertState, setErrorMessage] = useAlert(); + const [selectedDonationId, setSelectedDonationId] = useState( + null, + ); + const [items, setItems] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const groupedItems = useGroupedItemsByFoodType(items); + + const sortedDonations = [...donations].sort( + (a, b) => + new Date(b.donation.dateDonated).getTime() - + new Date(a.donation.dateDonated).getTime(), + ); + + const selectedDonation = donations.find( + (d) => d.donation.donationId === selectedDonationId, + ); + + const fetchItemsForDonation = async (donationId: number) => { + try { + const fetchedItems = await ApiClient.getDonationItemsByDonationId( + donationId, + ); + setItems(fetchedItems); + } catch { + setErrorMessage('Error loading donation details'); + } + }; + + useEffect(() => { + if (isOpen && initialDonationId != null) { + setSelectedDonationId(initialDonationId); + fetchItemsForDonation(initialDonationId); + } + }, [isOpen, initialDonationId]); + + const handleSelect = (donationId: number) => { + setSelectedDonationId(donationId); + fetchItemsForDonation(donationId); + }; + + const handleClose = () => { + setSelectedDonationId(null); + setItems([]); + setIsDropdownOpen(false); + onClose(); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); + const dto: CreateDonationDto = { + foodManufacturerId: fmId, + recurrence: RecurrenceEnum.NONE, + items: items.map((item) => ({ + itemName: item.itemName, + quantity: item.quantity, + ozPerItem: + item.ozPerItem != null ? Number(item.ozPerItem) : undefined, + estimatedValue: + item.estimatedValue != null + ? Number(item.estimatedValue) + : undefined, + foodType: item.foodType, + foodRescue: item.foodRescue, + })), + }; + console.log(dto); + await ApiClient.postDonation(dto); + onSuccess(); + handleClose(); + } catch { + setErrorMessage('Error submitting donation'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + { + if (!e.open) handleClose(); + }} + closeOnInteractOutside + > + {errorAlertState && ( + + )} + + + + + + + + + + + Previous Donations + + + + + + + + Select a Previous Donation + + + setIsDropdownOpen(!isDropdownOpen)} + border="1px solid" + borderColor="neutral.100" + borderRadius="md" + h="40px" + px={3} + align="center" + w="full" + cursor="pointer" + > + {selectedDonation ? ( + + + {formatDonationDate( + selectedDonation.donation.dateDonated, + )} + + {selectedDonation.donation.recurrence !== + RecurrenceEnum.NONE && ( + + Recurring + + )} + + ) : ( + + Select a previous donation + + )} + + + + {isDropdownOpen && ( + <> + setIsDropdownOpen(false)} + zIndex={10} + /> + + {sortedDonations.map((d) => ( + { + handleSelect(d.donation.donationId); + setIsDropdownOpen(false); + }} + > + + {formatDonationDate(d.donation.dateDonated)} + + {d.donation.recurrence !== + RecurrenceEnum.NONE && ( + + Recurring + + )} + + ))} + + + )} + + + + {selectedDonationId !== null && ( + + + Donation Details + + + + {Object.entries(groupedItems).map( + ([foodType, typeItems]) => ( + + + {foodType} + + {typeItems.map((item) => ( + + + {item.itemName} + + + + {item.quantity} + + + ))} + + ), + )} + + + + )} + + + + + + + + + + + ); +}; + +export default ResubmitDonationModal; diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index d4a588e5d..fb65e4e5b 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -689,7 +689,7 @@ const OrderStatusSection: React.FC = ({ ); diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index c1a9f987a..e74e4f172 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Box, Button, + Flex, Table, Heading, Pagination, @@ -12,9 +13,10 @@ import { import { ChevronRight, ChevronLeft, Mail, CircleCheck } from 'lucide-react'; import { capitalize, formatDate, DONATION_STATUS_COLORS } from '@utils/utils'; import ApiClient from '@api/apiClient'; -import { DonationDetails, DonationItem, DonationStatus } from '../types/types'; +import { DonationDetails, DonationStatus } from '../types/types'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; +import ResubmitDonationModal from '@components/forms/resubmitDonationModal'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { ROUTES } from '../routes'; @@ -24,10 +26,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { const resubmitDonationId = searchParams.get('resubmitDonationId'); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); - const [prefillItems, setPrefillItems] = useState( - undefined, - ); - const [isResubmit, setIsResubmit] = useState(false); + const [isResubmitOpen, setIsResubmitOpen] = useState(false); // State to hold donations grouped by status const [statusDonations, setStatusDonations] = useState<{ @@ -55,7 +54,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { const MAX_PER_STATUS = 5; // Fetch all donations on component mount and sorts them into their appropriate status lists - const fetchDonations = async () => { + const fetchDonations = async (checkResubmit = false) => { try { const data = await ApiClient.getAllDonationsByFoodManufacturer(1); // Replace with actual food manufacturer ID @@ -87,17 +86,13 @@ const FoodManufacturerDonationManagement: React.FC = () => { }; setCurrentPages(initialPages); - if (resubmitDonationId) { + // Only run this on mount, not every single time + if (checkResubmit && resubmitDonationId) { const id = parseInt(resubmitDonationId, 10); const allDonations: DonationDetails[] = Object.values(grouped).flat(); - const matchingDetail = allDonations.find( - (d) => d.donation.donationId === id, - ); - if (matchingDetail) { - const items = await ApiClient.getDonationItemsByDonationId(id); - setPrefillItems(items); - setIsResubmit(true); - setIsLogDonationOpen(true); + const exists = allDonations.some((d) => d.donation.donationId === id); + if (exists) { + setIsResubmitOpen(true); } else { navigate(ROUTES.FM_DONATION_MANAGEMENT); } @@ -108,13 +103,11 @@ const FoodManufacturerDonationManagement: React.FC = () => { }; useEffect(() => { - fetchDonations(); + fetchDonations(true); }, []); - const handleModalClose = () => { - setIsLogDonationOpen(false); - setPrefillItems(undefined); - setIsResubmit(false); + const handleResubmitClose = () => { + setIsResubmitOpen(false); if (resubmitDonationId) { navigate(ROUTES.FM_DONATION_MANAGEMENT); } @@ -133,30 +126,56 @@ const FoodManufacturerDonationManagement: React.FC = () => { Donation Management - + + + + {isLogDonationOpen && ( setIsLogDonationOpen(false)} + /> + )} + + {isResubmitOpen && ( + )} diff --git a/apps/frontend/src/containers/formRequests.tsx b/apps/frontend/src/containers/formRequests.tsx index 7adba8da6..486b41eff 100644 --- a/apps/frontend/src/containers/formRequests.tsx +++ b/apps/frontend/src/containers/formRequests.tsx @@ -76,7 +76,7 @@ const FormRequests: React.FC = () => { return ( - + Food Request Management {alertState && ( @@ -93,7 +93,7 @@ const FormRequests: React.FC = () => { fontWeight="semibold" fontSize="14px" color="neutral.50" - bgColor="#2B4E60" + bgColor="blue.core" onClick={newRequestDisclosure.onOpen} px={2} > @@ -164,9 +164,9 @@ const FormRequests: React.FC = () => { {paginatedRequests.map((request) => ( - + setOpenReadOnlyRequest(request)} > @@ -176,8 +176,8 @@ const FormRequests: React.FC = () => { {request.status === FoodRequestStatus.ACTIVE ? ( { ) : ( { const pageSize = 8; - const USER_ICON_COLORS = ['#F89E19', '#CC3538', '#2795A5', '#2B4E60']; + const USER_ICON_COLORS = ['yellow.core', 'red', 'teal.ssf', 'blue.core']; useEffect(() => { const fetchVolunteers = async () => { @@ -67,7 +67,7 @@ const VolunteerManagement: React.FC = () => { return ( - + Volunteer Management {alertState && ( @@ -90,7 +90,12 @@ const VolunteerManagement: React.FC = () => { } + startElement={ + + } maxW={200} > = ({ = ({ {order.assignee?.id === currentUser?.id && From f4317fe22f1ce7b973e33ca984963273eb94266a Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Thu, 7 May 2026 16:19:36 -0400 Subject: [PATCH 10/18] comments --- .../src/donations/donations.service.spec.ts | 94 ++++++++++++++++--- .../src/donations/donations.service.ts | 22 +++-- apps/backend/src/emails/emailTemplates.ts | 13 +-- apps/backend/src/orders/order.service.ts | 4 +- .../forms/resubmitDonationModal.tsx | 40 ++++---- 5 files changed, 126 insertions(+), 47 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 2407c46f5..9a7979d3f 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -675,38 +675,108 @@ describe('DonationService', () => { ); }); - it('processes all donations when one donation email send fails', async () => { - const pastDate1 = daysAgo(5); - const pastDate2 = daysAgo(3); + it('skips recurrence update and logs warning when initial email fails', async () => { + const pastDate = daysAgo(5); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + const warnSpy = jest.spyOn(service['logger'], 'warn'); + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await service.handleRecurringDonations(); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Automated email failed to send. Skipping recurrence update for donation id ${donationId}`, + ), + ); + + // donation state preserved — failed email means we skipped the update + const donation = await service.findOne(donationId); + expect(donation.occurrencesRemaining).toBe(3); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.nextDonationDates?.[0].toDateString()).toEqual( + pastDate.toDateString(), + ); + + warnSpy.mockRestore(); + }); + it("processes other donations when one donation's initial email fails", async () => { + // 3 weekly donations whose replacement dates are all in the future + // (no cascading), each starting at occurrencesRemaining=3. const donationId1 = await insertDonation({ recurrence: RecurrenceEnum.WEEKLY, recurrenceFreq: 1, - nextDonationDates: [pastDate1], + nextDonationDates: [daysAgo(1)], occurrencesRemaining: 3, }); - const donationId2 = await insertDonation({ recurrence: RecurrenceEnum.WEEKLY, recurrenceFreq: 1, - nextDonationDates: [pastDate2], + nextDonationDates: [daysAgo(3)], + occurrencesRemaining: 3, + }); + const donationId3 = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [daysAgo(5)], occurrencesRemaining: 3, }); + // Reject the first sendEmails call. Whichever donation getAll yields + // first will fail; the other two will succeed. mockEmailsService.sendEmails.mockRejectedValueOnce( new Error('Email failed'), ); await service.handleRecurringDonations(); - expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(3); - const donation1 = await service.findOne(donationId1); - const donation2 = await service.findOne(donationId2); + const donations = await Promise.all([ + service.findOne(donationId1), + service.findOne(donationId2), + service.findOne(donationId3), + ]); + + // Exactly one donation should be unchanged (the one whose email failed) + // and the other two should be decremented from 3 → 2. + const remaining = donations.map((d) => d.occurrencesRemaining).sort(); + expect(remaining).toEqual([2, 2, 3]); + }); + + it('breaks out of cascade and logs warning when cascade email fails', async () => { + // 14-day-old weekly date triggers the cascade — its replacement (7daysAgo) is also expired. + const pastDate = daysAgo(14); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 5, + }); + + const warnSpy = jest.spyOn(service['logger'], 'warn'); + mockEmailsService.sendEmails + .mockResolvedValueOnce(undefined) // initial send (pastDate) succeeds + .mockRejectedValueOnce(new Error('Email failed')); // first cascade send fails → break + + await service.handleRecurringDonations(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Cascading recalculation of next dates failed for donation id ${donationId}`, + ), + ); - // Both donations should be decremented even when an email send fails - expect(donation1.occurrencesRemaining).toBe(2); - expect(donation2.occurrencesRemaining).toBe(2); + warnSpy.mockRestore(); }); }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 4baab0731..553ec5c7d 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -1,6 +1,8 @@ import { BadRequestException, Injectable, + InternalServerErrorException, + Logger, NotFoundException, } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; @@ -22,6 +24,7 @@ import { emailTemplates } from '../emails/emailTemplates'; @Injectable() export class DonationService { + private readonly logger = new Logger(DonationService.name); constructor( @InjectRepository(Donation) private repo: Repository, @InjectRepository(Allocation) @@ -209,8 +212,9 @@ export class DonationService { break; } + let message = null; try { - const message = emailTemplates.fmRecurringDonationReminder({ + message = emailTemplates.fmRecurringDonationReminder({ fmName: donation.foodManufacturer.foodManufacturerName, resubmitDonationId: donation.donationId, }); @@ -221,7 +225,10 @@ export class DonationService { message.bodyHTML, ); } catch { - // email failed — still count as a recurrence and move on + this.logger.warn( + `Automated email failed to send. Skipping recurrence update for donation id ${donation.donationId}`, + ); + continue; } dates.splice(i, 1); @@ -239,11 +246,6 @@ export class DonationService { // cascading recalculation of next dates when replacement dates are also expired while (nextDate.getTime() <= today.getTime() && occurrences > 0) { try { - const message = emailTemplates.fmRecurringDonationReminder({ - fmName: donation.foodManufacturer.foodManufacturerName, - resubmitDonationId: donation.donationId, - }); - await this.emailsService.sendEmails( [ donation.foodManufacturer.foodManufacturerRepresentative @@ -253,7 +255,11 @@ export class DonationService { message.bodyHTML, ); } catch { - // email failed — still count as a recurrence and move on + // Early escape to prevent getting stuck in while loop + this.logger.warn( + `Cascading recalculation of next dates failed for donation id ${donation.donationId}, exiting early`, + ); + break; } occurrences -= 1; diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 5023002c3..9c51d2e84 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -15,8 +15,8 @@ export const emailTemplates = {

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 + 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.

@@ -91,7 +91,8 @@ export const emailTemplates = {

Hi,

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. + 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! @@ -115,7 +116,7 @@ export const emailTemplates = { donation availability so we can continue matching your contributions with pantry requests.

- You can use resubmit this donation by visiting your donation management portal. + You can resubmit this donation by visiting your donation management portal.

We greatly appreciate your continued generosity and support of our mission. Your @@ -165,8 +166,8 @@ export const emailTemplates = {

Hi ${params.volunteerName},

${params.pantryName} has confirmed receipt of the most recent ${params.fmName} - order you are assigned to. Please log into the platform to review the completed - request or check for additional information. + order you are assigned to. Please log into the platform + to review the completed request or check for additional information.

Thank you for your coordination and support in helping reach this order to completion! diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index f6391e0d0..b868b8e50 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -468,7 +468,7 @@ export class OrdersService { message.subject, message.bodyHTML, ); - } catch (e) { + } catch { throw new InternalServerErrorException( 'Failed to send order delivery confirmation email to volunteer', ); @@ -562,7 +562,7 @@ export class OrdersService { message.subject, message.bodyHTML, ); - } catch (e) { + } catch { throw new InternalServerErrorException( 'Failed to send new tracking link available email to pantry', ); diff --git a/apps/frontend/src/components/forms/resubmitDonationModal.tsx b/apps/frontend/src/components/forms/resubmitDonationModal.tsx index ef0a2d8d4..1a6d4c238 100644 --- a/apps/frontend/src/components/forms/resubmitDonationModal.tsx +++ b/apps/frontend/src/components/forms/resubmitDonationModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Box, Button, @@ -6,7 +6,6 @@ import { Dialog, Flex, Portal, - Spinner, Text, VStack, Badge, @@ -67,23 +66,26 @@ const ResubmitDonationModal: React.FC = ({ (d) => d.donation.donationId === selectedDonationId, ); - const fetchItemsForDonation = async (donationId: number) => { - try { - const fetchedItems = await ApiClient.getDonationItemsByDonationId( - donationId, - ); - setItems(fetchedItems); - } catch { - setErrorMessage('Error loading donation details'); - } - }; + const fetchItemsForDonation = useCallback( + async (donationId: number) => { + try { + const fetchedItems = await ApiClient.getDonationItemsByDonationId( + donationId, + ); + setItems(fetchedItems); + } catch { + setErrorMessage('Error loading donation details'); + } + }, + [setErrorMessage], + ); useEffect(() => { if (isOpen && initialDonationId != null) { setSelectedDonationId(initialDonationId); fetchItemsForDonation(initialDonationId); } - }, [isOpen, initialDonationId]); + }, [isOpen, initialDonationId, fetchItemsForDonation]); const handleSelect = (donationId: number) => { setSelectedDonationId(donationId); @@ -117,7 +119,6 @@ const ResubmitDonationModal: React.FC = ({ foodRescue: item.foodRescue, })), }; - console.log(dto); await ApiClient.postDonation(dto); onSuccess(); handleClose(); @@ -148,7 +149,7 @@ const ResubmitDonationModal: React.FC = ({ - + @@ -164,8 +165,8 @@ const ResubmitDonationModal: React.FC = ({ - - + + = ({ zIndex={20} mt={1} py={1} - maxH="160px" + maxH="120px" overflowY="auto" + bg="white" > {sortedDonations.map((d) => ( = ({ )} - + - {!hideRecurring && ( - { - setIsRecurring(!!e.checked); - setRepeatInterval( - e.checked - ? RecurrenceEnum.WEEKLY - : RecurrenceEnum.NONE, - ); - }} - > - - - - - - Make Donation Recurring - - - )} + { + setIsRecurring(!!e.checked); + setRepeatInterval( + e.checked + ? RecurrenceEnum.WEEKLY + : RecurrenceEnum.NONE, + ); + }} + > + + + + + + Make Donation Recurring + + diff --git a/apps/frontend/src/containers/donationManagement.tsx b/apps/frontend/src/containers/donationManagement.tsx index 4a717ee10..ca05eca5d 100644 --- a/apps/frontend/src/containers/donationManagement.tsx +++ b/apps/frontend/src/containers/donationManagement.tsx @@ -87,6 +87,7 @@ const DonationManagement: React.FC = () => {

{ const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const resubmitDonationId = searchParams.get('resubmitDonationId'); + const resubmitDonationId: string | null = + searchParams.get('resubmitDonationId'); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); const [isResubmitOpen, setIsResubmitOpen] = useState(false); @@ -54,7 +55,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { const MAX_PER_STATUS = 5; // Fetch all donations on component mount and sorts them into their appropriate status lists - const fetchDonations = async (checkResubmit = false) => { + const fetchDonations = async () => { try { const data = await ApiClient.getAllDonationsByFoodManufacturer(1); // Replace with actual food manufacturer ID @@ -86,24 +87,30 @@ const FoodManufacturerDonationManagement: React.FC = () => { }; setCurrentPages(initialPages); - // Only run this on mount, not every single time - if (checkResubmit && resubmitDonationId) { - const id = parseInt(resubmitDonationId, 10); - const allDonations: DonationDetails[] = Object.values(grouped).flat(); - const exists = allDonations.some((d) => d.donation.donationId === id); - if (exists) { - setIsResubmitOpen(true); - } else { - navigate(ROUTES.FM_DONATION_MANAGEMENT); - } - } + return grouped; } catch (error) { alert('Error fetching donations: ' + error); } }; + const openResubmitFromQueryParam = ( + grouped: Record, + ) => { + if (!resubmitDonationId) return; + const id = parseInt(resubmitDonationId, 10); + const allDonations: DonationDetails[] = Object.values(grouped).flat(); + const exists = allDonations.some((d) => d.donation.donationId === id); + if (exists) { + setIsResubmitOpen(true); + } else { + navigate(ROUTES.FM_DONATION_MANAGEMENT); + } + }; + useEffect(() => { - fetchDonations(true); + fetchDonations().then((grouped) => { + if (grouped) openResubmitFromQueryParam(grouped); + }); }, []); const handleResubmitClose = () => { @@ -161,6 +168,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { {isLogDonationOpen && ( setIsLogDonationOpen(false)} From 763865aa9f91edbbe6eebde4e273444781ea646b Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 12 May 2026 21:30:20 -0400 Subject: [PATCH 12/18] Rest of the comments --- .../src/volunteers/volunteers.service.spec.ts | 10 -------- .../forms/editableFMApplication.tsx | 13 +++++------ .../components/forms/profileAccountInfo.tsx | 13 +++++++---- .../forms/resubmitDonationModal.tsx | 23 ++++++++++++++----- .../foodManufacturerDonationManagement.tsx | 14 ++++++----- apps/frontend/src/containers/profilePage.tsx | 10 +++++--- 6 files changed, 47 insertions(+), 36 deletions(-) diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 86ec4f25a..794790029 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -22,9 +22,6 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { AllocationsService } from '../allocations/allocations.service'; import { DonationService } from '../donations/donations.service'; import { Allocation } from '../allocations/allocations.entity'; -import { mock } from 'jest-mock-extended'; - -const mockEmailsService = mock(); jest.setTimeout(60000); @@ -32,8 +29,6 @@ describe('VolunteersService', () => { let service: VolunteersService; beforeAll(async () => { - mockEmailsService.sendEmails.mockResolvedValue(undefined); - if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -61,10 +56,6 @@ describe('VolunteersService', () => { adminCreateUser: jest.fn().mockResolvedValue('test-sub'), }, }, - { - provide: EmailsService, - useValue: mockEmailsService, - }, { provide: getRepositoryToken(User), useValue: testDataSource.getRepository(User), @@ -104,7 +95,6 @@ describe('VolunteersService', () => { }); beforeEach(async () => { - mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); diff --git a/apps/frontend/src/components/forms/editableFMApplication.tsx b/apps/frontend/src/components/forms/editableFMApplication.tsx index bb9b60cab..a82f61a4c 100644 --- a/apps/frontend/src/components/forms/editableFMApplication.tsx +++ b/apps/frontend/src/components/forms/editableFMApplication.tsx @@ -101,11 +101,13 @@ function validateRequired(form: FormState): boolean { interface EditableFMApplicationProps { isEditing: boolean; onEditingChange: (v: boolean) => void; + foodManufacturerId: number; } const EditableFMApplication: React.FC = ({ isEditing, onEditingChange, + foodManufacturerId, }) => { const [application, setApplication] = useState(null); const [error, setError] = useState(null); @@ -114,16 +116,13 @@ const EditableFMApplication: React.FC = ({ const fetchApplication = useCallback(async () => { try { - const manufacturerId = await ApiClient.getCurrentUserFoodManufacturerId(); - if (manufacturerId) { - const data = await ApiClient.getFoodManufacturer(manufacturerId); - setApplication(data); - setForm(buildFormState(data)); - } + const data = await ApiClient.getFoodManufacturer(foodManufacturerId); + setApplication(data); + setForm(buildFormState(data)); } catch { setError('Could not load application details. Please try again later.'); } - }, []); + }, [foodManufacturerId]); useEffect(() => { fetchApplication(); diff --git a/apps/frontend/src/components/forms/profileAccountInfo.tsx b/apps/frontend/src/components/forms/profileAccountInfo.tsx index 60f45107f..fc1f494ce 100644 --- a/apps/frontend/src/components/forms/profileAccountInfo.tsx +++ b/apps/frontend/src/components/forms/profileAccountInfo.tsx @@ -18,6 +18,7 @@ interface ProfileAccountInfoProps { profile: User; showTabs: boolean; onSave: (fields: UpdateProfileFields) => Promise; + foodManufacturerId?: number | null; } type ProfileFieldProps = @@ -70,6 +71,7 @@ const ProfileAccountInfo: React.FC = ({ profile, showTabs, onSave, + foodManufacturerId, }) => { const { firstName, lastName, email, phone } = profile; const [activeTab, setActiveTab] = useState('Account'); @@ -230,10 +232,13 @@ const ProfileAccountInfo: React.FC = ({ {fields} {profile.role === Role.FOODMANUFACTURER ? ( - + foodManufacturerId != null && ( + + ) ) : ( void; onSuccess: () => void; donations: DonationDetails[]; + foodManufacturerId: number; initialDonationId?: number | null; + onSelect: (donationId: number) => void; } const formatDonationDate = (dateString: string) => @@ -43,7 +45,9 @@ const ResubmitDonationModal: React.FC = ({ onClose, onSuccess, donations, + foodManufacturerId, initialDonationId, + onSelect, }) => { useModalBodyCleanup(); const [errorAlertState, setErrorMessage] = useAlert(); @@ -81,15 +85,19 @@ const ResubmitDonationModal: React.FC = ({ ); useEffect(() => { - if (isOpen && initialDonationId != null) { - setSelectedDonationId(initialDonationId); - fetchItemsForDonation(initialDonationId); + if ( + isOpen && + initialDonationId != null && + initialDonationId !== selectedDonationId + ) { + handleSelect(initialDonationId); } - }, [isOpen, initialDonationId, fetchItemsForDonation]); + }, [isOpen, initialDonationId, selectedDonationId, fetchItemsForDonation]); const handleSelect = (donationId: number) => { setSelectedDonationId(donationId); fetchItemsForDonation(donationId); + onSelect(donationId); }; const handleClose = () => { @@ -102,9 +110,8 @@ const ResubmitDonationModal: React.FC = ({ const handleSubmit = async () => { setIsSubmitting(true); try { - const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); const dto: CreateDonationDto = { - foodManufacturerId: fmId, + foodManufacturerId, recurrence: RecurrenceEnum.NONE, items: items.map((item) => ({ itemName: item.itemName, @@ -226,6 +233,10 @@ const ResubmitDonationModal: React.FC = ({ <> setIsDropdownOpen(false)} zIndex={10} /> diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index a3f91890b..a77dc5307 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -27,7 +27,7 @@ const MAX_PER_STATUS = 5; const FoodManufacturerDonationManagement: React.FC = () => { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const resubmitDonationId: string | null = searchParams.get('resubmitDonationId'); const [isResubmitOpen, setIsResubmitOpen] = useState(false); @@ -198,7 +198,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { - {isLogDonationOpen && manufacturerId !== null && ( + {manufacturerId !== null && ( fetchDonations(manufacturerId)} @@ -207,17 +207,19 @@ const FoodManufacturerDonationManagement: React.FC = () => { /> )} - {isResubmitOpen && ( + {manufacturerId !== null && ( { - if (manufacturerId !== null) fetchDonations(manufacturerId); - }} + onSuccess={() => fetchDonations(manufacturerId)} donations={Object.values(statusDonations).flat()} + foodManufacturerId={manufacturerId} initialDonationId={ resubmitDonationId ? parseInt(resubmitDonationId, 10) : null } + onSelect={(donationId) => + setSearchParams({ resubmitDonationId: String(donationId) }) + } /> )} diff --git a/apps/frontend/src/containers/profilePage.tsx b/apps/frontend/src/containers/profilePage.tsx index 2079f7167..d8d5b6fad 100644 --- a/apps/frontend/src/containers/profilePage.tsx +++ b/apps/frontend/src/containers/profilePage.tsx @@ -19,6 +19,9 @@ const ROLE_CONFIG: Record = { const ProfilePage: React.FC = () => { const [profile, setProfile] = useState(null); const [orgName, setOrgName] = useState(null); + const [foodManufacturerId, setFoodManufacturerId] = useState( + null, + ); const [isLoading, setIsLoading] = useState(true); const [alertState, setAlertMessage] = useAlert(); @@ -37,9 +40,9 @@ const ProfilePage: React.FC = () => { } } else if (user.role === Role.FOODMANUFACTURER) { try { - const foodManufacturerId = - await ApiClient.getCurrentUserFoodManufacturerId(); - const fm = await ApiClient.getFoodManufacturer(foodManufacturerId); + const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); + setFoodManufacturerId(fmId); + const fm = await ApiClient.getFoodManufacturer(fmId); setOrgName(fm.foodManufacturerName); } catch { setAlertMessage('Failed to fetch food manufacturer data.'); @@ -141,6 +144,7 @@ const ProfilePage: React.FC = () => { profile={profile} showTabs={hasTabs} onSave={handleSave} + foodManufacturerId={foodManufacturerId} /> From befe831fcd8f0e728a3e3b0b3bb4970b2c04bc3f Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 12 May 2026 21:54:05 -0400 Subject: [PATCH 13/18] fixed order tracking link email sending logic --- apps/backend/src/orders/order.service.spec.ts | 20 ++++++++++ apps/backend/src/orders/order.service.ts | 40 ++++++++++--------- .../src/volunteers/volunteers.service.spec.ts | 10 +++++ 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 2c4dd6d14..1bd2aa27b 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -1435,6 +1435,26 @@ describe('OrdersService', () => { expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); }); + it('does not send email for orders that already had a tracking link when only shipping cost is updated', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, trackingLink: 'https://tracking.com' }], + }); + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + mockEmailsService.sendEmails.mockClear(); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, shippingCost: 5.0 }], + }); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + it('logs a warning when one email fails but still updates all orders without throwing', async () => { const donationId = await insertMatchedDonation(); const itemId1 = await insertDonationItem(donationId); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index f39c94a92..75d33bcee 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -535,14 +535,14 @@ export class OrdersService { } } - let donation: Donation | null; + const ordersGainedTrackingLink: Order[] = []; await this.dataSource.transaction(async (transactionManager) => { const orderTransactionRepo = transactionManager.getRepository(Order); const donationTransactionRepo = transactionManager.getRepository(Donation); - donation = await donationTransactionRepo.findOneBy({ + const donation = await donationTransactionRepo.findOneBy({ donationId: dto.donationId, }); if (!donation) { @@ -552,8 +552,15 @@ export class OrdersService { const ordersToUpdate: Order[] = []; for (const entry of dto.orders) { - const order = await orderTransactionRepo.findOneBy({ - orderId: entry.orderId, + const order = await orderTransactionRepo.findOne({ + where: { orderId: entry.orderId }, + relations: [ + 'request', + 'request.pantry', + 'request.pantry.pantryUser', + 'foodManufacturer', + 'assignee', + ], }); if (!order) { throw new NotFoundException(`Order ${entry.orderId} not found`); @@ -581,6 +588,9 @@ export class OrdersService { ); } + // Check to see if tracking link existed in the first place + const hadTrackingLink = !!order.trackingLink; + if (entry.trackingLink !== undefined) { order.trackingLink = entry.trackingLink; } @@ -591,6 +601,12 @@ export class OrdersService { order.status = OrderStatus.SHIPPED; order.shippedAt = new Date(); } + + // If tracking link didn't exist previous, but does now, add it to the list to send an email + if (!hadTrackingLink && !!order.trackingLink) { + ordersGainedTrackingLink.push(order); + } + ordersToUpdate.push(order); } @@ -601,24 +617,12 @@ export class OrdersService { ); }); - const updatedOrders = await this.repo.find({ - where: { orderId: In(dto.orders.map((o) => o.orderId)) }, - relations: [ - 'request', - 'request.pantry', - 'request.pantry.pantryUser', - 'foodManufacturer', - 'assignee', - ], - }); - - for (const order of updatedOrders) { - if (!order.trackingLink) continue; + for (const order of ordersGainedTrackingLink) { try { const message = emailTemplates.trackingLinkAvailable({ pantryName: order.request.pantry.pantryName, fmName: order.foodManufacturer.foodManufacturerName, - trackingLink: order.trackingLink, + trackingLink: order.trackingLink!, volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, volunteerEmail: order.assignee.email, }); diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 794790029..86ec4f25a 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -22,6 +22,9 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { AllocationsService } from '../allocations/allocations.service'; import { DonationService } from '../donations/donations.service'; import { Allocation } from '../allocations/allocations.entity'; +import { mock } from 'jest-mock-extended'; + +const mockEmailsService = mock(); jest.setTimeout(60000); @@ -29,6 +32,8 @@ describe('VolunteersService', () => { let service: VolunteersService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -56,6 +61,10 @@ describe('VolunteersService', () => { adminCreateUser: jest.fn().mockResolvedValue('test-sub'), }, }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, { provide: getRepositoryToken(User), useValue: testDataSource.getRepository(User), @@ -95,6 +104,7 @@ describe('VolunteersService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); From 068333439aea9b3be6779a762c94eac6e167f103 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 16 May 2026 00:31:15 -0700 Subject: [PATCH 14/18] Comments --- .../src/donations/donations.service.spec.ts | 2 +- .../src/donations/donations.service.ts | 7 +- apps/backend/src/emails/awsSes.wrapper.ts | 30 +++++-- apps/backend/src/emails/dto/send-email.dto.ts | 19 ++++- apps/backend/src/emails/email.service.ts | 16 ++-- apps/backend/src/emails/emailTemplates.ts | 24 +++++- .../manufacturers.service.spec.ts | 6 +- .../manufacturers.service.ts | 6 +- .../src/foodRequests/request.service.spec.ts | 10 ++- .../src/foodRequests/request.service.ts | 3 +- apps/backend/src/orders/order.service.spec.ts | 4 +- apps/backend/src/orders/order.service.ts | 8 +- .../src/pantries/pantries.service.spec.ts | 81 +++++++++++++++++-- apps/backend/src/pantries/pantries.service.ts | 34 ++++++-- apps/backend/src/users/users.service.spec.ts | 2 +- apps/backend/src/users/users.service.ts | 2 +- .../foodManufacturerDonationManagement.tsx | 3 +- 17 files changed, 201 insertions(+), 56 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 01d8c04e2..fd2478b69 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -670,7 +670,7 @@ describe('DonationService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [manufacturer.foodManufacturerRepresentative.email], + manufacturer.foodManufacturerRepresentative.email, message.subject, message.bodyHTML, ); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 7223a144e..0850bdd65 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -221,7 +221,7 @@ export class DonationService { }); await this.emailsService.sendEmails( - [donation.foodManufacturer.foodManufacturerRepresentative.email], + donation.foodManufacturer.foodManufacturerRepresentative.email, message.subject, message.bodyHTML, ); @@ -248,10 +248,7 @@ export class DonationService { while (nextDate.getTime() <= today.getTime() && occurrences > 0) { try { await this.emailsService.sendEmails( - [ - donation.foodManufacturer.foodManufacturerRepresentative - .email, - ], + donation.foodManufacturer.foodManufacturerRepresentative.email, message.subject, message.bodyHTML, ); diff --git a/apps/backend/src/emails/awsSes.wrapper.ts b/apps/backend/src/emails/awsSes.wrapper.ts index a32180427..ab1f92226 100644 --- a/apps/backend/src/emails/awsSes.wrapper.ts +++ b/apps/backend/src/emails/awsSes.wrapper.ts @@ -11,6 +11,12 @@ export interface EmailAttachment { content: Buffer; } +export interface SendEmailOptions { + ccEmails?: string[]; + bccEmails?: string[]; + attachments?: EmailAttachment[]; +} + @Injectable() export class AmazonSESWrapper { private client: SESv2Client; @@ -26,26 +32,36 @@ export class AmazonSESWrapper { /** * Sends an email via Amazon SES. * - * @param recipientEmails the email addresses of the recipients + * @param recipientEmail the email address of the primary recipient * @param subject the subject of the email * @param bodyHtml the HTML body of the email - * @param attachments any attachments to include in the email + * @param options optional cc/bcc recipients and attachments * @resolves if the email was sent successfully * @rejects if the email was not sent successfully */ async sendEmails( - recipientEmails: string[], + recipientEmail: string, subject: string, bodyHtml: string, - attachments?: EmailAttachment[], + options: SendEmailOptions = {}, ) { + const { ccEmails, bccEmails, attachments } = options; + const mailOptions: Mail.Options = { from: process.env.AWS_SES_SENDER_EMAIL, - to: recipientEmails, + to: recipientEmail, subject: subject, html: bodyHtml, }; + if (ccEmails && ccEmails.length > 0) { + mailOptions.cc = ccEmails; + } + + if (bccEmails && bccEmails.length > 0) { + mailOptions.bcc = bccEmails; + } + if (attachments) { mailOptions.attachments = attachments.map((a) => ({ filename: a.filename, @@ -58,7 +74,9 @@ export class AmazonSESWrapper { const command = new SendEmailCommand({ Destination: { - ToAddresses: recipientEmails, + ToAddresses: [recipientEmail], + CcAddresses: ccEmails, + BccAddresses: bccEmails, }, Content: { Raw: { diff --git a/apps/backend/src/emails/dto/send-email.dto.ts b/apps/backend/src/emails/dto/send-email.dto.ts index 4639790d4..3d27a6dd8 100644 --- a/apps/backend/src/emails/dto/send-email.dto.ts +++ b/apps/backend/src/emails/dto/send-email.dto.ts @@ -9,10 +9,9 @@ import { import { EmailAttachment } from '../awsSes.wrapper'; export class SendEmailDTO { - @IsArray() - @IsEmail({}, { each: true }) - @Length(1, 255, { each: true }) - toEmails!: string[]; + @IsEmail() + @Length(1, 255) + toEmail!: string; @IsString() @IsNotEmpty() @@ -23,6 +22,18 @@ export class SendEmailDTO { @IsNotEmpty() bodyHtml!: string; + @IsArray() + @IsOptional() + @IsEmail({}, { each: true }) + @Length(1, 255, { each: true }) + ccEmails?: string[]; + + @IsArray() + @IsOptional() + @IsEmail({}, { each: true }) + @Length(1, 255, { each: true }) + bccEmails?: string[]; + @IsArray() @IsOptional() attachments?: EmailAttachment[]; diff --git a/apps/backend/src/emails/email.service.ts b/apps/backend/src/emails/email.service.ts index 6792a61ce..0e9a388be 100644 --- a/apps/backend/src/emails/email.service.ts +++ b/apps/backend/src/emails/email.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import Bottleneck from 'bottleneck'; -import { AmazonSESWrapper, EmailAttachment } from './awsSes.wrapper'; +import { AmazonSESWrapper, SendEmailOptions } from './awsSes.wrapper'; @Injectable() export class EmailsService { @@ -18,29 +18,29 @@ export class EmailsService { /** * Sends an email. * - * @param recipientEmail the email address of the recipients + * @param recipientEmail the email address of the primary recipient * @param subject the subject of the email * @param bodyHtml the HTML body of the email - * @param attachments any base64 encoded attachments to include in the email + * @param options optional cc/bcc recipients and attachments * @resolves if the email was sent successfully * @rejects if the email was not sent successfully */ public async sendEmails( - recipientEmails: string[], + recipientEmail: string, subject: string, bodyHTML: string, - attachments?: EmailAttachment[], + options: SendEmailOptions = {}, ): Promise { if ( process.env.SEND_AUTOMATED_EMAILS && process.env.SEND_AUTOMATED_EMAILS === 'true' && - recipientEmails.length > 0 + recipientEmail ) { return this.amazonSESWrapper.sendEmails( - recipientEmails, + recipientEmail, subject, bodyHTML, - attachments, + options, ); } this.logger.warn('Automated emails are disabled. Email not sent.'); diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 04d83c941..6f724057a 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -96,6 +96,7 @@ export const emailTemplates = {

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

Best regards,
The Securing Safe Food Team

`, }), @@ -165,7 +166,7 @@ export const emailTemplates = { bodyHTML: `

Hi ${params.volunteerName},

- ${params.pantryName} has confirmed receipt of one of an order from ${params.fmName} + ${params.pantryName} has confirmed the receipt ofan order from ${params.fmName} which you are assigned to. Please log into the platform to review the completed request or check for additional information.

@@ -196,4 +197,25 @@ export const emailTemplates = {

`, }), + + volunteerRemovedFromPantry: (params: { + volunteerName: string; + }): EmailTemplate => ({ + subject: 'You have been removed from an SSF Pantry Assignment', + bodyHTML: ` +

Hi ${params.volunteerName},

+

+ You have been removed from one of your pantry assignments with SSF. Please log into + the platform to review your current assignments. +

+

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

+

Best regards,
The Securing Safe Food Team

+

+ To view your pantry assignments, please click the following link: + ${EMAIL_REDIRECT_URL}/volunteer-assigned-pantries +

+ `, + }), }; diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 93bf2bf3b..c53596edc 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -197,7 +197,7 @@ describe('FoodManufacturersService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [manufacturer.foodManufacturerRepresentative.email], + manufacturer.foodManufacturerRepresentative.email, message.subject, message.bodyHTML, ); @@ -368,12 +368,12 @@ describe('FoodManufacturersService', () => { const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [dto.contactEmail], + dto.contactEmail, userMessage.subject, userMessage.bodyHTML, ); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [SSF_PARTNER_EMAIL], + SSF_PARTNER_EMAIL, adminMessage.subject, adminMessage.bodyHTML, ); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index bc7a415f6..6d745fc3d 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -292,7 +292,7 @@ export class FoodManufacturersService { }); await this.emailsService.sendEmails( - [foodManufacturerContact.email], + foodManufacturerContact.email, manufacturerMessage.subject, manufacturerMessage.bodyHTML, ); @@ -305,7 +305,7 @@ export class FoodManufacturersService { try { const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); await this.emailsService.sendEmails( - [SSF_PARTNER_EMAIL], + SSF_PARTNER_EMAIL, adminMessage.subject, adminMessage.bodyHTML, ); @@ -381,7 +381,7 @@ export class FoodManufacturersService { }); await this.emailsService.sendEmails( - [newFoodManufacturer.email], + newFoodManufacturer.email, message.subject, message.bodyHTML, ); diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index ecf30e202..761afdcca 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -237,7 +237,7 @@ describe('RequestsService', () => { expect(result.additionalInformation).toBeNull(); }); - it('should send food request email to pantry volunteers', async () => { + it('should send food request email to pantry user with volunteers BCCed', async () => { const pantryId = 1; const pantry = await testDataSource.getRepository(Pantry).findOne({ where: { pantryId }, @@ -257,13 +257,14 @@ describe('RequestsService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - volunteerEmails, + pantry.pantryUser.email, message.subject, message.bodyHTML, + { bccEmails: volunteerEmails }, ); }); - it('should send emails to nobody if request creation succeeds wthout any volunteers', async () => { + it('should send email to pantry user with empty BCC when pantry has no volunteers', async () => { // Harbor Community Center - no volunteers assigned const pantryId = 5; const pantry = await testDataSource.getRepository(Pantry).findOne({ @@ -285,9 +286,10 @@ describe('RequestsService', () => { expect(volunteerEmails).toEqual([]); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - volunteerEmails, + pantry.pantryUser.email, message.subject, message.bodyHTML, + { bccEmails: volunteerEmails }, ); }); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 12a4dd511..17f8e6927 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -253,9 +253,10 @@ export class RequestsService { }); await this.emailsService.sendEmails( - volunteerEmails, + pantry.pantryUser.email, message.subject, message.bodyHTML, + { bccEmails: volunteerEmails }, ); } catch { throw new InternalServerErrorException( diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 1bd2aa27b..b53d1751b 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -657,7 +657,7 @@ describe('OrdersService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [order.assignee.email], + order.assignee.email, message.subject, message.bodyHTML, ); @@ -1415,7 +1415,7 @@ describe('OrdersService', () => { }); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [order.request.pantry.pantryUser.email], + order.request.pantry.pantryUser.email, message.subject, message.bodyHTML, ); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 75d33bcee..6550825e4 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -467,7 +467,7 @@ export class OrdersService { }); await this.emailsService.sendEmails( - [order.assignee.email], + order.assignee.email, message.subject, message.bodyHTML, ); @@ -516,7 +516,7 @@ export class OrdersService { } const orders = new Set(dto.orders.map((o) => o.orderId)); - if (orders.size != dto.orders.length) { + if (orders.size !== dto.orders.length) { throw new BadRequestException( 'Cannot update duplicate entries for orders', ); @@ -597,7 +597,7 @@ export class OrdersService { if (entry.shippingCost !== undefined) { order.shippingCost = entry.shippingCost; } - if (order.trackingLink != null && order.shippingCost != null) { + if (order.trackingLink !== null && order.shippingCost !== null) { order.status = OrderStatus.SHIPPED; order.shippedAt = new Date(); } @@ -628,7 +628,7 @@ export class OrdersService { }); await this.emailsService.sendEmails( - [order.request.pantry.pantryUser.email], + order.request.pantry.pantryUser.email, message.subject, message.bodyHTML, ); diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 8bbb8ee1f..225716362 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -214,7 +214,7 @@ describe('PantriesService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [pantry.pantryUser.email], + pantry.pantryUser.email, message.subject, message.bodyHTML, ); @@ -378,12 +378,12 @@ describe('PantriesService', () => { const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [dto.contactEmail], + dto.contactEmail, userMessage.subject, userMessage.bodyHTML, ); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [SSF_PARTNER_EMAIL], + SSF_PARTNER_EMAIL, adminMessage.subject, adminMessage.bodyHTML, ); @@ -1170,7 +1170,7 @@ describe('PantriesService', () => { volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, }); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [volunteer.email], + volunteer.email, message.subject, message.bodyHTML, ); @@ -1205,7 +1205,7 @@ describe('PantriesService', () => { ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( - `Automated email failed to send. Skipping recurrence update for volunteer id 7`, + `Automated email failed to send. Skipping pantry assignment update for volunteer id 7 and pantryId 1`, ), ); @@ -1219,6 +1219,77 @@ describe('PantriesService', () => { warnSpy.mockRestore(); }); + + it('sends volunteerRemovedFromPantry email to each removed volunteer', async () => { + const removeVolunteerIds = [6, 9]; + const volunteers = await testDataSource + .getRepository(User) + .find({ where: { id: In(removeVolunteerIds) } }); + + expect(volunteers).toHaveLength(removeVolunteerIds.length); + + await service.updatePantryVolunteers(1, { + addVolunteerIds: [], + removeVolunteerIds, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes( + removeVolunteerIds.length, + ); + for (const volunteer of volunteers) { + const message = emailTemplates.volunteerRemovedFromPantry({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + volunteer.email, + message.subject, + message.bodyHTML, + ); + } + }); + + it('does not send email when removing a volunteer not assigned to the pantry', async () => { + // volunteer 8 is not assigned to pantry 1 + await service.updatePantryVolunteers(1, { + addVolunteerIds: [], + removeVolunteerIds: [8], + }); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('logs a warning when one removal email fails but still removes the others without throwing', async () => { + const removeVolunteerIds = [6, 9]; + + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + const warnSpy = jest.spyOn(service['logger'], 'warn'); + + await service.updatePantryVolunteers(1, { + addVolunteerIds: [], + removeVolunteerIds, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes( + removeVolunteerIds.length, + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Automated email failed to send. Skipping pantry removal notification for volunteer id 6 and pantryId 1`, + ), + ); + + const pantry = await testDataSource + .getRepository(Pantry) + .findOne({ where: { pantryId: 1 }, relations: ['volunteers'] }); + const pantryVolunteerIds = pantry?.volunteers?.map((v) => v.id) ?? []; + for (const id of removeVolunteerIds) { + expect(pantryVolunteerIds).not.toContain(id); + } + + warnSpy.mockRestore(); + }); }); describe('getDashboardStats', () => { diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 1684d813b..a3fdabc47 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -350,7 +350,7 @@ export class PantriesService { }); await this.emailsService.sendEmails( - [pantryContact.email], + pantryContact.email, pantryMessage.subject, pantryMessage.bodyHTML, ); @@ -363,7 +363,7 @@ export class PantriesService { try { const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); await this.emailsService.sendEmails( - [SSF_PARTNER_EMAIL], + SSF_PARTNER_EMAIL, adminMessage.subject, adminMessage.bodyHTML, ); @@ -437,7 +437,7 @@ export class PantriesService { }); await this.emailsService.sendEmails( - [newPantryUser.email], + newPantryUser.email, message.subject, message.bodyHTML, ); @@ -545,6 +545,7 @@ export class PantriesService { const volunteersToAdd = users.filter((u) => addSet.has(u.id)); const currentVolunteers = pantry.volunteers ?? []; + const currentVolunteerIds = new Set(currentVolunteers.map((v) => v.id)); const volunteersToKeep = currentVolunteers.filter( (v) => !removeSet.has(v.id), ); @@ -555,6 +556,11 @@ export class PantriesService { (u) => !existingVolunteerIds.has(u.id), ); + // only notify volunteers who were actually assigned before being removed + const removedVolunteers = users.filter( + (u) => removeSet.has(u.id) && currentVolunteerIds.has(u.id), + ); + pantry.volunteers = [...volunteersToKeep, ...newVolunteers]; await this.repo.save(pantry); @@ -564,15 +570,31 @@ export class PantriesService { volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, }); await this.emailsService.sendEmails( - [volunteer.email], + volunteer.email, + message.subject, + message.bodyHTML, + ); + } catch { + this.logger.warn( + `Automated email failed to send. Skipping pantry assignment update for volunteer id ${volunteer.id} and pantryId ${pantryId}`, + ); + } + } + + for (const volunteer of removedVolunteers) { + try { + const message = emailTemplates.volunteerRemovedFromPantry({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + await this.emailsService.sendEmails( + volunteer.email, message.subject, message.bodyHTML, ); } catch { this.logger.warn( - `Automated email failed to send. Skipping recurrence update for volunteer id ${volunteer.id}`, + `Automated email failed to send. Skipping pantry removal notification for volunteer id ${volunteer.id} and pantryId ${pantryId}`, ); - continue; } } } diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 12a1f8072..ae2f047ff 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -159,7 +159,7 @@ describe('UsersService', () => { const message = emailTemplates.volunteerAccountCreated(); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [createUserDto.email], + createUserDto.email, message.subject, message.bodyHTML, ); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 4ae45b9ae..5d920f88b 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -105,7 +105,7 @@ export class UsersService { try { const message = emailTemplates.volunteerAccountCreated(); await this.emailsService.sendEmails( - [email], + email, message.subject, message.bodyHTML, ); diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index a77dc5307..4c34a2010 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -95,7 +95,8 @@ const FoodManufacturerDonationManagement: React.FC = () => { return grouped; } catch (error) { - alert('Error fetching donations: ' + error); + setErrorMessage('Error fetching donations: ' + error); + return; } }; From 7dd6d588164c33c4864a85cbefdad95a384232e6 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 16 May 2026 00:51:16 -0700 Subject: [PATCH 15/18] Adjusted service functions to use actual DTO logic --- .../src/donations/donations.service.spec.ts | 10 ++-- .../src/donations/donations.service.ts | 23 +++++---- apps/backend/src/emails/awsSes.wrapper.ts | 31 +++--------- apps/backend/src/emails/dto/send-email.dto.ts | 6 ++- apps/backend/src/emails/email.service.ts | 24 +++------ .../manufacturers.service.spec.ts | 30 +++++------ .../manufacturers.service.ts | 30 +++++------ .../src/foodRequests/request.service.spec.ts | 24 ++++----- .../src/foodRequests/request.service.ts | 12 ++--- apps/backend/src/orders/order.service.spec.ts | 20 ++++---- apps/backend/src/orders/order.service.ts | 20 ++++---- .../src/pantries/pantries.service.spec.ts | 50 +++++++++---------- apps/backend/src/pantries/pantries.service.ts | 50 +++++++++---------- apps/backend/src/users/users.service.spec.ts | 10 ++-- apps/backend/src/users/users.service.ts | 10 ++-- 15 files changed, 164 insertions(+), 186 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index fd2478b69..c6e556787 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -669,11 +669,11 @@ describe('DonationService', () => { }); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - manufacturer.foodManufacturerRepresentative.email, - message.subject, - message.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: manufacturer.foodManufacturerRepresentative.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); }); it('skips recurrence update and logs warning when initial email fails', async () => { diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 0850bdd65..2e4ee0538 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -220,11 +220,12 @@ export class DonationService { resubmitDonationId: donation.donationId, }); - await this.emailsService.sendEmails( - donation.foodManufacturer.foodManufacturerRepresentative.email, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: + donation.foodManufacturer.foodManufacturerRepresentative.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { this.logger.warn( `Automated email failed to send. Skipping recurrence update for donation id ${donation.donationId}`, @@ -247,11 +248,13 @@ export class DonationService { // cascading recalculation of next dates when replacement dates are also expired while (nextDate.getTime() <= today.getTime() && occurrences > 0) { try { - await this.emailsService.sendEmails( - donation.foodManufacturer.foodManufacturerRepresentative.email, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: + donation.foodManufacturer.foodManufacturerRepresentative + .email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { // Early escape to prevent getting stuck in while loop this.logger.warn( diff --git a/apps/backend/src/emails/awsSes.wrapper.ts b/apps/backend/src/emails/awsSes.wrapper.ts index ab1f92226..b5e2a7a2f 100644 --- a/apps/backend/src/emails/awsSes.wrapper.ts +++ b/apps/backend/src/emails/awsSes.wrapper.ts @@ -4,19 +4,9 @@ import MailComposer from 'nodemailer/lib/mail-composer'; import * as dotenv from 'dotenv'; import Mail from 'nodemailer/lib/mailer'; import { AMAZON_SES_CLIENT } from './awsSesClient.factory'; +import { SendEmailDTO } from './dto/send-email.dto'; dotenv.config(); -export interface EmailAttachment { - filename: string; - content: Buffer; -} - -export interface SendEmailOptions { - ccEmails?: string[]; - bccEmails?: string[]; - attachments?: EmailAttachment[]; -} - @Injectable() export class AmazonSESWrapper { private client: SESv2Client; @@ -32,24 +22,17 @@ export class AmazonSESWrapper { /** * Sends an email via Amazon SES. * - * @param recipientEmail the email address of the primary recipient - * @param subject the subject of the email - * @param bodyHtml the HTML body of the email - * @param options optional cc/bcc recipients and attachments + * @param email the {@link SendEmailDTO} describing the message to send * @resolves if the email was sent successfully * @rejects if the email was not sent successfully */ - async sendEmails( - recipientEmail: string, - subject: string, - bodyHtml: string, - options: SendEmailOptions = {}, - ) { - const { ccEmails, bccEmails, attachments } = options; + async sendEmails(email: SendEmailDTO) { + const { toEmail, subject, bodyHtml, ccEmails, bccEmails, attachments } = + email; const mailOptions: Mail.Options = { from: process.env.AWS_SES_SENDER_EMAIL, - to: recipientEmail, + to: toEmail, subject: subject, html: bodyHtml, }; @@ -74,7 +57,7 @@ export class AmazonSESWrapper { const command = new SendEmailCommand({ Destination: { - ToAddresses: [recipientEmail], + ToAddresses: [toEmail], CcAddresses: ccEmails, BccAddresses: bccEmails, }, diff --git a/apps/backend/src/emails/dto/send-email.dto.ts b/apps/backend/src/emails/dto/send-email.dto.ts index 3d27a6dd8..fcda05d3e 100644 --- a/apps/backend/src/emails/dto/send-email.dto.ts +++ b/apps/backend/src/emails/dto/send-email.dto.ts @@ -6,7 +6,11 @@ import { IsArray, Length, } from 'class-validator'; -import { EmailAttachment } from '../awsSes.wrapper'; + +export interface EmailAttachment { + filename: string; + content: Buffer; +} export class SendEmailDTO { @IsEmail() diff --git a/apps/backend/src/emails/email.service.ts b/apps/backend/src/emails/email.service.ts index 0e9a388be..1eb55bf79 100644 --- a/apps/backend/src/emails/email.service.ts +++ b/apps/backend/src/emails/email.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import Bottleneck from 'bottleneck'; -import { AmazonSESWrapper, SendEmailOptions } from './awsSes.wrapper'; +import { AmazonSESWrapper } from './awsSes.wrapper'; +import { SendEmailDTO } from './dto/send-email.dto'; @Injectable() export class EmailsService { @@ -18,30 +19,17 @@ export class EmailsService { /** * Sends an email. * - * @param recipientEmail the email address of the primary recipient - * @param subject the subject of the email - * @param bodyHtml the HTML body of the email - * @param options optional cc/bcc recipients and attachments + * @param email the {@link SendEmailDTO} describing the message to send * @resolves if the email was sent successfully * @rejects if the email was not sent successfully */ - public async sendEmails( - recipientEmail: string, - subject: string, - bodyHTML: string, - options: SendEmailOptions = {}, - ): Promise { + public async sendEmails(email: SendEmailDTO): Promise { if ( process.env.SEND_AUTOMATED_EMAILS && process.env.SEND_AUTOMATED_EMAILS === 'true' && - recipientEmail + email.toEmail ) { - return this.amazonSESWrapper.sendEmails( - recipientEmail, - subject, - bodyHTML, - options, - ); + return this.amazonSESWrapper.sendEmails(email); } this.logger.warn('Automated emails are disabled. Email not sent.'); return Promise.resolve(); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index c53596edc..aeee345d1 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -196,11 +196,11 @@ describe('FoodManufacturersService', () => { await service.approve(id); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - manufacturer.foodManufacturerRepresentative.email, - message.subject, - message.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: manufacturer.foodManufacturerRepresentative.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); }); it('should still update manufacturer status to approved if email send fails', async () => { @@ -367,16 +367,16 @@ describe('FoodManufacturersService', () => { }); 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).toHaveBeenCalledWith({ + toEmail: dto.contactEmail, + subject: userMessage.subject, + bodyHtml: userMessage.bodyHTML, + }); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: SSF_PARTNER_EMAIL, + subject: adminMessage.subject, + bodyHtml: adminMessage.bodyHTML, + }); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index 6d745fc3d..d2b4b4d84 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -291,11 +291,11 @@ export class FoodManufacturersService { name: foodManufacturerContact.firstName, }); - await this.emailsService.sendEmails( - foodManufacturerContact.email, - manufacturerMessage.subject, - manufacturerMessage.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: foodManufacturerContact.email, + subject: manufacturerMessage.subject, + bodyHtml: manufacturerMessage.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send food manufacturer application submitted confirmation email to representative', @@ -304,11 +304,11 @@ export class FoodManufacturersService { try { const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); - await this.emailsService.sendEmails( - SSF_PARTNER_EMAIL, - adminMessage.subject, - adminMessage.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: SSF_PARTNER_EMAIL, + subject: adminMessage.subject, + bodyHtml: adminMessage.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send new food manufacturer application notification email to SSF', @@ -380,11 +380,11 @@ export class FoodManufacturersService { name: newFoodManufacturer.firstName, }); - await this.emailsService.sendEmails( - newFoodManufacturer.email, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: newFoodManufacturer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send food manufacturer account approved notification email to representative', diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 761afdcca..7c72d2fbc 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -256,12 +256,12 @@ describe('RequestsService', () => { const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - pantry.pantryUser.email, - message.subject, - message.bodyHTML, - { bccEmails: volunteerEmails }, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + bccEmails: volunteerEmails, + }); }); it('should send email to pantry user with empty BCC when pantry has no volunteers', async () => { @@ -285,12 +285,12 @@ describe('RequestsService', () => { expect(volunteerEmails).toEqual([]); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - pantry.pantryUser.email, - message.subject, - message.bodyHTML, - { bccEmails: volunteerEmails }, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + bccEmails: volunteerEmails, + }); }); it('should still save food request to database if email send fails', async () => { diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 17f8e6927..b8ccdaba9 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -252,12 +252,12 @@ export class RequestsService { pantryName: pantry.pantryName, }); - await this.emailsService.sendEmails( - pantry.pantryUser.email, - message.subject, - message.bodyHTML, - { bccEmails: volunteerEmails }, - ); + await this.emailsService.sendEmails({ + toEmail: pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + bccEmails: volunteerEmails, + }); } catch { throw new InternalServerErrorException( 'Failed to send new food request notification email to volunteers', diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index b53d1751b..cba6cf63e 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -656,11 +656,11 @@ describe('OrdersService', () => { }); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - order.assignee.email, - message.subject, - message.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: order.assignee.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); }); it('still updates order to delivered if delivery confirmation email fails to send', async () => { @@ -1414,11 +1414,11 @@ describe('OrdersService', () => { volunteerEmail: order.assignee.email, }); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - order.request.pantry.pantryUser.email, - message.subject, - message.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: order.request.pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } }); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 6550825e4..2096977ed 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -466,11 +466,11 @@ export class OrdersService { fmName: order.foodManufacturer.foodManufacturerName, }); - await this.emailsService.sendEmails( - order.assignee.email, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: order.assignee.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send order delivery confirmation email to volunteer', @@ -627,11 +627,11 @@ export class OrdersService { volunteerEmail: order.assignee.email, }); - await this.emailsService.sendEmails( - order.request.pantry.pantryUser.email, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: order.request.pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { this.logger.warn( `Automated tracking link email failed to send for order ${order.orderId}`, diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 225716362..62e2563da 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -213,11 +213,11 @@ describe('PantriesService', () => { await service.approve(5); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - pantry.pantryUser.email, - message.subject, - message.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); }); it('should still update pantry status to approved if email send fails', async () => { @@ -377,16 +377,16 @@ describe('PantriesService', () => { }); 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).toHaveBeenCalledWith({ + toEmail: dto.contactEmail, + subject: userMessage.subject, + bodyHtml: userMessage.bodyHTML, + }); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: SSF_PARTNER_EMAIL, + subject: adminMessage.subject, + bodyHtml: adminMessage.bodyHTML, + }); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); }); }); @@ -1169,11 +1169,11 @@ describe('PantriesService', () => { const message = emailTemplates.volunteerPantryAssignmentChanged({ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, }); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - volunteer.email, - message.subject, - message.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: volunteer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } }); @@ -1240,11 +1240,11 @@ describe('PantriesService', () => { const message = emailTemplates.volunteerRemovedFromPantry({ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, }); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - volunteer.email, - message.subject, - message.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: volunteer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } }); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index a3fdabc47..89b778842 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -349,11 +349,11 @@ export class PantriesService { name: pantryContact.firstName, }); - await this.emailsService.sendEmails( - pantryContact.email, - pantryMessage.subject, - pantryMessage.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: pantryContact.email, + subject: pantryMessage.subject, + bodyHtml: pantryMessage.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send pantry application submitted confirmation email to representative', @@ -362,11 +362,11 @@ export class PantriesService { try { const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); - await this.emailsService.sendEmails( - SSF_PARTNER_EMAIL, - adminMessage.subject, - adminMessage.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: SSF_PARTNER_EMAIL, + subject: adminMessage.subject, + bodyHtml: adminMessage.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send new pantry application notification email to SSF', @@ -436,11 +436,11 @@ export class PantriesService { name: newPantryUser.firstName, }); - await this.emailsService.sendEmails( - newPantryUser.email, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: newPantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send pantry account approved notification email to representative', @@ -569,11 +569,11 @@ export class PantriesService { const message = emailTemplates.volunteerPantryAssignmentChanged({ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, }); - await this.emailsService.sendEmails( - volunteer.email, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: volunteer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { this.logger.warn( `Automated email failed to send. Skipping pantry assignment update for volunteer id ${volunteer.id} and pantryId ${pantryId}`, @@ -586,11 +586,11 @@ export class PantriesService { const message = emailTemplates.volunteerRemovedFromPantry({ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, }); - await this.emailsService.sendEmails( - volunteer.email, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: volunteer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { this.logger.warn( `Automated email failed to send. Skipping pantry removal notification for volunteer id ${volunteer.id} and pantryId ${pantryId}`, diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index ae2f047ff..c81a6664b 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -158,11 +158,11 @@ describe('UsersService', () => { const message = emailTemplates.volunteerAccountCreated(); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - createUserDto.email, - message.subject, - message.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: createUserDto.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); expect(mockAuthService.adminCreateUser).toHaveBeenCalledWith({ firstName: createUserDto.firstName, lastName: createUserDto.lastName, diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 5d920f88b..a5ea7db2d 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -104,11 +104,11 @@ export class UsersService { if (role === Role.VOLUNTEER) { try { const message = emailTemplates.volunteerAccountCreated(); - await this.emailsService.sendEmails( - email, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send account created notification email to volunteer', From bc1abc176ffcfa7d8871eca900dfbaa14c5e3e51 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 16 May 2026 08:58:42 -0700 Subject: [PATCH 16/18] Comments --- apps/backend/src/emails/emailTemplates.ts | 21 ------------------- .../src/pantries/pantries.service.spec.ts | 6 +++--- apps/backend/src/pantries/pantries.service.ts | 13 +++++------- 3 files changed, 8 insertions(+), 32 deletions(-) diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 6f724057a..0b34c3a63 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -197,25 +197,4 @@ export const emailTemplates = {

`, }), - - volunteerRemovedFromPantry: (params: { - volunteerName: string; - }): EmailTemplate => ({ - subject: 'You have been removed from an SSF Pantry Assignment', - bodyHTML: ` -

Hi ${params.volunteerName},

-

- You have been removed from one of your pantry assignments with SSF. Please log into - the platform to review your current assignments. -

-

- Thank you for your continued support of our partners and mission. -

-

Best regards,
The Securing Safe Food Team

-

- To view your pantry assignments, please click the following link: - ${EMAIL_REDIRECT_URL}/volunteer-assigned-pantries -

- `, - }), }; diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 62e2563da..ebd2d46bf 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -1220,7 +1220,7 @@ describe('PantriesService', () => { warnSpy.mockRestore(); }); - it('sends volunteerRemovedFromPantry email to each removed volunteer', async () => { + it('sends volunteerPantryAssignmentChanged email to each removed volunteer', async () => { const removeVolunteerIds = [6, 9]; const volunteers = await testDataSource .getRepository(User) @@ -1237,7 +1237,7 @@ describe('PantriesService', () => { removeVolunteerIds.length, ); for (const volunteer of volunteers) { - const message = emailTemplates.volunteerRemovedFromPantry({ + const message = emailTemplates.volunteerPantryAssignmentChanged({ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, }); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ @@ -1276,7 +1276,7 @@ describe('PantriesService', () => { ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( - `Automated email failed to send. Skipping pantry removal notification for volunteer id 6 and pantryId 1`, + `Automated email failed to send. Skipping pantry assignment update for volunteer id 6 and pantryId 1`, ), ); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 89b778842..43978fec2 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -542,18 +542,15 @@ export class PantriesService { ); } - const volunteersToAdd = users.filter((u) => addSet.has(u.id)); - const currentVolunteers = pantry.volunteers ?? []; const currentVolunteerIds = new Set(currentVolunteers.map((v) => v.id)); const volunteersToKeep = currentVolunteers.filter( (v) => !removeSet.has(v.id), ); - // avoid re-adding volunteers already associated with the pantry - const existingVolunteerIds = new Set(volunteersToKeep.map((v) => v.id)); - const newVolunteers = volunteersToAdd.filter( - (u) => !existingVolunteerIds.has(u.id), + // only notify volunteers who weren't already assigned to the pantry + const newVolunteers = users.filter( + (u) => addSet.has(u.id) && !currentVolunteerIds.has(u.id), ); // only notify volunteers who were actually assigned before being removed @@ -583,7 +580,7 @@ export class PantriesService { for (const volunteer of removedVolunteers) { try { - const message = emailTemplates.volunteerRemovedFromPantry({ + const message = emailTemplates.volunteerPantryAssignmentChanged({ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, }); await this.emailsService.sendEmails({ @@ -593,7 +590,7 @@ export class PantriesService { }); } catch { this.logger.warn( - `Automated email failed to send. Skipping pantry removal notification for volunteer id ${volunteer.id} and pantryId ${pantryId}`, + `Automated email failed to send. Skipping pantry assignment update for volunteer id ${volunteer.id} and pantryId ${pantryId}`, ); } } From 47b3aafd0e901e72bb40324c6a09fb35fde5d56c Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 19 May 2026 01:13:33 -0700 Subject: [PATCH 17/18] comments --- apps/backend/src/emails/email.service.ts | 16 +++++++------- apps/backend/src/emails/emailTemplates.ts | 2 +- .../src/pantries/pantries.service.spec.ts | 4 ++-- apps/backend/src/pantries/pantries.service.ts | 21 ++----------------- .../foodManufacturerDonationManagement.tsx | 2 +- 5 files changed, 14 insertions(+), 31 deletions(-) diff --git a/apps/backend/src/emails/email.service.ts b/apps/backend/src/emails/email.service.ts index 1eb55bf79..657d0ebbe 100644 --- a/apps/backend/src/emails/email.service.ts +++ b/apps/backend/src/emails/email.service.ts @@ -24,14 +24,14 @@ export class EmailsService { * @rejects if the email was not sent successfully */ public async sendEmails(email: SendEmailDTO): Promise { - if ( - process.env.SEND_AUTOMATED_EMAILS && - process.env.SEND_AUTOMATED_EMAILS === 'true' && - email.toEmail - ) { - return this.amazonSESWrapper.sendEmails(email); + if (!email.toEmail) { + this.logger.warn(`Skipping email, recipient address is empty.`); + return Promise.resolve(); } - this.logger.warn('Automated emails are disabled. Email not sent.'); - return Promise.resolve(); + if (process.env.SEND_AUTOMATED_EMAILS !== 'true') { + this.logger.warn('Automated emails are disabled. Email not sent.'); + return Promise.resolve(); + } + return this.amazonSESWrapper.sendEmails(email); } } diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 0b34c3a63..d3e84592b 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -166,7 +166,7 @@ export const emailTemplates = { bodyHTML: `

Hi ${params.volunteerName},

- ${params.pantryName} has confirmed the receipt ofan order from ${params.fmName} + ${params.pantryName} has confirmed the receipt of an order from ${params.fmName} which you are assigned to. Please log into the platform to review the completed request or check for additional information.

diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index ebd2d46bf..65a267ad6 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -1205,7 +1205,7 @@ describe('PantriesService', () => { ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( - `Automated email failed to send. Skipping pantry assignment update for volunteer id 7 and pantryId 1`, + `Automated email for pantry assignment update for volunteer id 7 and pantryId 1 failed to send.`, ), ); @@ -1276,7 +1276,7 @@ describe('PantriesService', () => { ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( - `Automated email failed to send. Skipping pantry assignment update for volunteer id 6 and pantryId 1`, + `Automated email for pantry assignment update for volunteer id 6 and pantryId 1 failed to send.`, ), ); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 43978fec2..006e14cf5 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -561,7 +561,7 @@ export class PantriesService { pantry.volunteers = [...volunteersToKeep, ...newVolunteers]; await this.repo.save(pantry); - for (const volunteer of newVolunteers) { + for (const volunteer of [...newVolunteers, ...removedVolunteers]) { try { const message = emailTemplates.volunteerPantryAssignmentChanged({ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, @@ -573,24 +573,7 @@ export class PantriesService { }); } catch { this.logger.warn( - `Automated email failed to send. Skipping pantry assignment update for volunteer id ${volunteer.id} and pantryId ${pantryId}`, - ); - } - } - - for (const volunteer of removedVolunteers) { - try { - const message = emailTemplates.volunteerPantryAssignmentChanged({ - volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, - }); - await this.emailsService.sendEmails({ - toEmail: volunteer.email, - subject: message.subject, - bodyHtml: message.bodyHTML, - }); - } catch { - this.logger.warn( - `Automated email failed to send. Skipping pantry assignment update for volunteer id ${volunteer.id} and pantryId ${pantryId}`, + `Automated email for pantry assignment update for volunteer id ${volunteer.id} and pantryId ${pantryId} failed to send.`, ); } } diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 4c34a2010..4e6f390f0 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -95,7 +95,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { return grouped; } catch (error) { - setErrorMessage('Error fetching donations: ' + error); + setErrorMessage('Error fetching donations'); return; } }; From 8f4d8806d2cf80624fcc62f3941f330551885d32 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 19 May 2026 22:41:38 -0700 Subject: [PATCH 18/18] comments --- .../src/donations/donations.service.spec.ts | 40 +++++++++++++++++++ .../src/foodRequests/request.service.spec.ts | 13 +----- .../src/foodRequests/request.service.ts | 10 +++-- apps/backend/src/orders/order.service.ts | 1 - .../src/volunteers/volunteers.service.spec.ts | 3 -- 5 files changed, 49 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index c6e556787..453c5121e 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -754,6 +754,46 @@ describe('DonationService', () => { expect(remaining).toEqual([2, 2, 3]); }); + it('sends multiple cascade emails when several replacement dates are also expired', async () => { + // 21-day-old weekly date cascades 3 times before landing in the future: + // initial send for daysAgo(21), then cascade sends for daysAgo(14), + // daysAgo(7), and daysAgo(0) — the next computed date (daysFromNow(7)) + // exits the while loop. 4 emails total + const pastDate = daysAgo(21); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 5, + }); + + const manufacturer = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ + where: { foodManufacturerName: 'FoodCorp Industries' }, + relations: ['foodManufacturerRepresentative'], + }); + + if (!manufacturer) + throw new Error('Missing FoodCorp Industries manufacturer'); + + await service.handleRecurringDonations(); + + const message = emailTemplates.fmRecurringDonationReminder({ + fmName: manufacturer.foodManufacturerName, + resubmitDonationId: donationId, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(4); + + const donation = await service.findOne(donationId); + expect(donation.occurrencesRemaining).toBe(1); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.nextDonationDates?.[0].toDateString()).toEqual( + daysFromNow(7).toDateString(), + ); + }); + it('breaks out of cascade and logs warning when cascade email fails', async () => { // 14-day-old weekly date triggers the cascade — its replacement (7daysAgo) is also expired. const pastDate = daysAgo(14); diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 7c72d2fbc..0aa75f951 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -264,7 +264,7 @@ describe('RequestsService', () => { }); }); - it('should send email to pantry user with empty BCC when pantry has no volunteers', async () => { + it('should not send email when pantry has no volunteers', async () => { // Harbor Community Center - no volunteers assigned const pantryId = 5; const pantry = await testDataSource.getRepository(Pantry).findOne({ @@ -278,19 +278,10 @@ describe('RequestsService', () => { ]); if (!pantry) throw new Error('Missing pantry test object'); - const message = emailTemplates.pantrySubmitsFoodRequest({ - pantryName: pantry.pantryName, - }); const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); expect(volunteerEmails).toEqual([]); - expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ - toEmail: pantry.pantryUser.email, - subject: message.subject, - bodyHtml: message.bodyHTML, - bccEmails: volunteerEmails, - }); + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); }); it('should still save food request to database if email send fails', async () => { diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index b8ccdaba9..4bf7b8468 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -244,10 +244,14 @@ export class RequestsService { await this.repo.save(foodRequest); - try { - const volunteers = pantry.volunteers || []; - const volunteerEmails = volunteers.map((v) => v.email); + const volunteers = pantry.volunteers || []; + const volunteerEmails = volunteers.map((v) => v.email); + + if (volunteerEmails.length === 0) { + return foodRequest; + } + try { const message = emailTemplates.pantrySubmitsFoodRequest({ pantryName: pantry.pantryName, }); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 2096977ed..76b96128e 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -636,7 +636,6 @@ export class OrdersService { this.logger.warn( `Automated tracking link email failed to send for order ${order.orderId}`, ); - continue; } } } diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 86ec4f25a..d8a3089d5 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -32,8 +32,6 @@ describe('VolunteersService', () => { let service: VolunteersService; beforeAll(async () => { - mockEmailsService.sendEmails.mockResolvedValue(undefined); - if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -104,7 +102,6 @@ describe('VolunteersService', () => { }); beforeEach(async () => { - mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations();