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}
+
+
+ ))}
+
+ ),
+ )}
+
+
+
+ )}
+
+
+
+ Submit Donation
+
+
+
+
+
+
+
+
+ );
+};
+
+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
- setIsLogDonationOpen(true)}
- >
- Log New Donation
-
+
+ setIsLogDonationOpen(true)}
+ >
+ Log New Donation
+
+ setIsResubmitOpen(true)}
+ >
+ Resubmit Previous
+
+
{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) => (
= ({
)}
-
+
Date: Tue, 12 May 2026 20:40:47 -0400
Subject: [PATCH 11/18] some comments
---
.../src/donations/donations.service.spec.ts | 2 +-
.../src/donations/donations.service.ts | 2 +-
apps/backend/src/emails/emailTemplates.ts | 6 +-
.../src/pantries/pantries.service.spec.ts | 73 ++++++++++++++++
apps/backend/src/pantries/pantries.service.ts | 20 +++++
.../volunteers/volunteers.controller.spec.ts | 29 -------
.../src/volunteers/volunteers.controller.ts | 18 +---
.../src/volunteers/volunteers.service.spec.ts | 84 +-----------------
.../src/volunteers/volunteers.service.ts | 47 +---------
.../components/forms/newDonationFormModal.tsx | 85 +++++++------------
.../src/containers/donationManagement.tsx | 1 +
.../foodManufacturerDonationManagement.tsx | 36 +++++---
12 files changed, 157 insertions(+), 246 deletions(-)
diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts
index 9a7979d3f..a9205187c 100644
--- a/apps/backend/src/donations/donations.service.spec.ts
+++ b/apps/backend/src/donations/donations.service.spec.ts
@@ -1145,7 +1145,7 @@ describe('DonationService', () => {
});
it('throws when foodManufacturerId does not exist', async () => {
- expect(
+ await expect(
service.create({
foodManufacturerId: 99999,
recurrence: RecurrenceEnum.NONE,
diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts
index 553ec5c7d..8c38f9e29 100644
--- a/apps/backend/src/donations/donations.service.ts
+++ b/apps/backend/src/donations/donations.service.ts
@@ -257,7 +257,7 @@ export class DonationService {
} catch {
// 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`,
+ `Cascading recalculation of next dates failed for donation id ${donation.donationId} due to an email sending failure, exiting early`,
);
break;
}
diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index 9c51d2e84..04d83c941 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -91,7 +91,7 @@ export const emailTemplates = {
Hi,
A new food request has been submitted by ${params.pantryName}.
- Please log on to the SSF platform
+ Please log on to the SSF platform
to review these request details and begin coordination when ready.
@@ -165,8 +165,8 @@ export const emailTemplates = {
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
+ ${params.pantryName} has confirmed receipt of one 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 f1b37b282..7e0c72614 100644
--- a/apps/backend/src/pantries/pantries.service.spec.ts
+++ b/apps/backend/src/pantries/pantries.service.spec.ts
@@ -1,12 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PantriesService } from './pantries.service';
import { getRepositoryToken } from '@nestjs/typeorm';
+import { In } from 'typeorm';
import { Pantry } from './pantries.entity';
import {
BadRequestException,
ConflictException,
ForbiddenException,
InternalServerErrorException,
+ Logger,
NotFoundException,
} from '@nestjs/common';
import { PantryApplicationDto } from './dtos/pantry-application.dto';
@@ -1148,6 +1150,77 @@ describe('PantriesService', () => {
.findOne({ where: { pantryId: 1 }, relations: ['volunteers'] });
expect(pantryBefore?.volunteers).toEqual(pantryAfter?.volunteers);
});
+
+ it('sends volunteerPantryAssignmentChanged email to each newly added volunteer', async () => {
+ const addVolunteerIds = [7, 8];
+ const volunteers = await testDataSource
+ .getRepository(User)
+ .find({ where: { id: In(addVolunteerIds) } });
+
+ expect(volunteers).toHaveLength(addVolunteerIds.length);
+
+ await service.updatePantryVolunteers(1, {
+ addVolunteerIds,
+ removeVolunteerIds: [],
+ });
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(
+ addVolunteerIds.length,
+ );
+ for (const volunteer of volunteers) {
+ const message = emailTemplates.volunteerPantryAssignmentChanged({
+ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`,
+ });
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
+ [volunteer.email],
+ message.subject,
+ message.bodyHTML,
+ );
+ }
+ });
+
+ it('does not send email when no new volunteers are added', async () => {
+ // volunteer 6 is already assigned to pantry 1
+ await service.updatePantryVolunteers(1, {
+ addVolunteerIds: [6],
+ removeVolunteerIds: [],
+ });
+
+ expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
+ });
+
+ it('logs a warning when one email fails but still sends the others without throwing', async () => {
+ const addVolunteerIds = [7, 8];
+
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('Email failed'),
+ );
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+
+ await service.updatePantryVolunteers(1, {
+ addVolunteerIds,
+ removeVolunteerIds: [],
+ });
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(
+ addVolunteerIds.length,
+ );
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ `Automated email failed to send. Skipping recurrence update for volunteer id 7`,
+ ),
+ );
+
+ const pantry = await testDataSource
+ .getRepository(Pantry)
+ .findOne({ where: { pantryId: 1 }, relations: ['volunteers'] });
+ const pantryVolunteerIds = pantry?.volunteers?.map((v) => v.id) ?? [];
+ for (const id of addVolunteerIds) {
+ expect(pantryVolunteerIds).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 22f048bd9..53bd5dd3c 100644
--- a/apps/backend/src/pantries/pantries.service.ts
+++ b/apps/backend/src/pantries/pantries.service.ts
@@ -7,6 +7,7 @@ import {
ConflictException,
InternalServerErrorException,
ForbiddenException,
+ Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
@@ -32,6 +33,7 @@ import { UpdatePantryVolunteersDto } from './dtos/update-pantry-volunteers-dto';
@Injectable()
export class PantriesService {
+ private readonly logger = new Logger(PantriesService.name);
constructor(
@InjectRepository(Pantry) private repo: Repository,
@InjectRepository(Order) private orderRepo: Repository,
@@ -555,6 +557,24 @@ export class PantriesService {
pantry.volunteers = [...volunteersToKeep, ...newVolunteers];
await this.repo.save(pantry);
+
+ for (const volunteer of newVolunteers) {
+ try {
+ const message = emailTemplates.volunteerPantryAssignmentChanged({
+ 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}`,
+ );
+ continue;
+ }
+ }
}
// given pantryIds should not have duplicates
diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts
index b2868620f..b48684f4c 100644
--- a/apps/backend/src/volunteers/volunteers.controller.spec.ts
+++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts
@@ -31,11 +31,6 @@ const mockVolunteer2: Partial = {
role: Role.VOLUNTEER,
};
-const mockVolunteer3: Partial = {
- id: 3,
- role: Role.VOLUNTEER,
-};
-
const mockPantries: Partial[] = [
{
pantryId: 1,
@@ -146,30 +141,6 @@ describe('VolunteersController', () => {
});
});
- describe('POST /:id/pantries', () => {
- it('should assign pantries to a volunteer and return result', async () => {
- const pantryIds = [1, 3];
- const updatedUser = {
- ...mockVolunteer3,
- pantries: [mockPantries[0] as Pantry, mockPantries[2] as Pantry],
- } as User;
-
- mockVolunteersService.assignPantriesToVolunteer.mockResolvedValue(
- updatedUser,
- );
-
- const result = await controller.assignPantries(3, pantryIds);
-
- expect(result).toEqual(updatedUser);
- expect(result.pantries).toHaveLength(2);
- expect(result.pantries?.[0].pantryId).toBe(1);
- expect(result.pantries?.[1].pantryId).toBe(3);
- expect(
- mockVolunteersService.assignPantriesToVolunteer,
- ).toHaveBeenCalledWith(3, pantryIds);
- });
- });
-
describe('GET /me/assigned-requests', () => {
it('returns assigned requests when req.currentUser is present', async () => {
const req: AuthenticatedRequest = {
diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts
index ee330b44e..fb9018540 100644
--- a/apps/backend/src/volunteers/volunteers.controller.ts
+++ b/apps/backend/src/volunteers/volunteers.controller.ts
@@ -1,12 +1,4 @@
-import {
- Controller,
- Get,
- Param,
- ParseIntPipe,
- Post,
- Body,
- Req,
-} from '@nestjs/common';
+import { Controller, Get, Param, ParseIntPipe, Req } from '@nestjs/common';
import { User } from '../users/users.entity';
import { Pantry } from '../pantries/pantries.entity';
import { VolunteersService } from './volunteers.service';
@@ -57,14 +49,6 @@ export class VolunteersController {
return this.volunteersService.getRecentOrders(id);
}
- @Post('/:id/pantries')
- async assignPantries(
- @Param('id', ParseIntPipe) id: number,
- @Body('pantryIds') pantryIds: number[],
- ): Promise {
- return this.volunteersService.assignPantriesToVolunteer(id, pantryIds);
- }
-
@Roles(Role.VOLUNTEER)
@Get('/me/assigned-requests')
async getAssignedRequests(
diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts
index 44d36fd03..86ec4f25a 100644
--- a/apps/backend/src/volunteers/volunteers.service.spec.ts
+++ b/apps/backend/src/volunteers/volunteers.service.spec.ts
@@ -1,7 +1,4 @@
-import {
- InternalServerErrorException,
- NotFoundException,
-} from '@nestjs/common';
+import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@@ -26,7 +23,6 @@ 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();
@@ -234,84 +230,6 @@ describe('VolunteersService', () => {
});
});
- describe('assignPantriesToVolunteer', () => {
- it('assigns new pantries to a volunteer with existing assignments', async () => {
- const beforeAssignment = await service.getVolunteerPantries(7);
- expect(beforeAssignment).toHaveLength(2);
- const beforePantryIds = beforeAssignment.map((p) => p.pantryId);
- expect(beforePantryIds).toEqual([2, 3]);
-
- const result = await service.assignPantriesToVolunteer(7, [1, 4]);
- expect(result.pantries).toHaveLength(4);
- const afterPantryIds = result.pantries?.map((p) => p.pantryId);
- expect(afterPantryIds).toEqual([2, 3, 1, 4]);
- });
-
- it('assigns pantries to a volunteer with no existing assignments', async () => {
- await testDataSource.query(
- `DELETE FROM "volunteer_assignments" WHERE volunteer_id = 6`,
- );
-
- const beforeAssignment = await service.getVolunteerPantries(6);
- expect(beforeAssignment).toEqual([]);
-
- const result = await service.assignPantriesToVolunteer(6, [2, 3]);
- expect(result.pantries).toHaveLength(2);
- const pantryIds = result.pantries?.map((p) => p.pantryId);
- expect(pantryIds).toEqual([2, 3]);
- });
-
- it('does not contain duplicate pantry assignments when called with ones that already exist', async () => {
- const beforeAssignment = await service.getVolunteerPantries(7);
- expect(beforeAssignment).toHaveLength(2);
- const beforePantryIds = beforeAssignment.map((p) => p.pantryId);
- expect(beforePantryIds).toEqual([2, 3]);
-
- const result = await service.assignPantriesToVolunteer(7, [2, 3]);
- expect(result.pantries).toHaveLength(2);
- 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', () => {
it('returned requests include pantry info', async () => {
const requests = await service.findRequestsByVolunteer(7);
diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts
index 69e024aa9..376b27c6d 100644
--- a/apps/backend/src/volunteers/volunteers.service.ts
+++ b/apps/backend/src/volunteers/volunteers.service.ts
@@ -1,20 +1,13 @@
-import {
- Injectable,
- InternalServerErrorException,
- NotFoundException,
-} from '@nestjs/common';
+import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../users/users.entity';
import { Role } from '../users/types';
import { validateId } from '../utils/validation.utils';
import { Pantry } from '../pantries/pantries.entity';
-import { PantriesService } from '../pantries/pantries.service';
import { UsersService } from '../users/users.service';
import { Assignments, VolunteerOrder } from './types';
import { RequestsService } from '../foodRequests/request.service';
-import { EmailsService } from '../emails/email.service';
-import { emailTemplates } from '../emails/emailTemplates';
import { OrdersService } from '../orders/order.service';
import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto';
@@ -24,9 +17,7 @@ export class VolunteersService {
@InjectRepository(User)
private repo: Repository,
private usersService: UsersService,
- private pantriesService: PantriesService,
private requestsService: RequestsService,
- private emailsService: EmailsService,
private ordersService: OrdersService,
) {}
@@ -76,42 +67,6 @@ export class VolunteersService {
return this.ordersService.getRecentOrdersByAssignee(volunteerId);
}
- async assignPantriesToVolunteer(
- volunteerId: number,
- pantryIds: number[],
- ): Promise {
- const volunteer = await this.findOne(volunteerId);
-
- const uniquePantryIds = new Set(pantryIds);
-
- const pantries = await this.pantriesService.findByIds([...uniquePantryIds]);
- const existingPantries = volunteer.pantries || [];
- const existingPantryIds = new Set(existingPantries.map((p) => p.pantryId));
- const newPantries = pantries.filter(
- (p) => !existingPantryIds.has(p.pantryId),
- );
-
- volunteer.pantries = [...existingPantries, ...newPantries];
- const saved = await this.repo.save(volunteer);
-
- try {
- const message = emailTemplates.volunteerPantryAssignmentChanged({
- volunteerName: `${volunteer.firstName} ${volunteer.lastName}`,
- });
- 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;
- }
-
async findRequestsByVolunteer(
volunteerId: number,
): Promise {
diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx
index abee7b77b..d9bf495a8 100644
--- a/apps/frontend/src/components/forms/newDonationFormModal.tsx
+++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx
@@ -21,7 +21,6 @@ import ApiClient from '@api/apiClient';
import {
CreateDonationDto,
DayOfWeek,
- DonationItem,
FoodType,
RecurrenceEnum,
RepeatOnState,
@@ -33,11 +32,10 @@ import { useAlert } from '../../hooks/alert';
import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup';
interface NewDonationFormModalProps {
+ foodManufacturerId: number;
onDonationSuccess: () => void;
isOpen: boolean;
onClose: () => void;
- prefillItems?: DonationItem[];
- hideRecurring?: boolean;
}
interface DonationRow {
@@ -106,38 +104,23 @@ const getFirstValidationError = (
};
const NewDonationFormModal: React.FC = ({
+ foodManufacturerId,
onDonationSuccess,
isOpen,
onClose,
- prefillItems,
- hideRecurring = false,
}) => {
useModalBodyCleanup();
- 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 [rows, setRows] = useState([
+ {
+ id: 1,
+ foodItem: '',
+ foodType: '',
+ numItems: '',
+ ozPerItem: '',
+ valuePerItem: '',
+ foodRescue: false,
+ },
+ ]);
const [isRecurring, setIsRecurring] = useState(false);
const [repeatEvery, setRepeatEvery] = useState('1');
@@ -226,7 +209,7 @@ const NewDonationFormModal: React.FC = ({
}
const donationBody: CreateDonationDto = {
- foodManufacturerId: 1,
+ foodManufacturerId,
recurrenceFreq: isRecurring ? parseInt(repeatEvery) : undefined,
recurrence: isRecurring ? repeatInterval : RecurrenceEnum.NONE,
repeatOnDays:
@@ -340,27 +323,25 @@ const NewDonationFormModal: React.FC = ({
>
Add New Row +
- {!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 = () => {
Submit new donation
{
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();