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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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) => ( = ({ )} - +