From e1fd3afddacc7ed8f9b29ef78c0d7f73125875cf Mon Sep 17 00:00:00 2001
From: skewalia <145241312+swarkewalia@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:08:56 -0400
Subject: [PATCH 01/10] automated email updates
---
.../backend/src/donations/donations.module.ts | 2 +
.../src/donations/donations.service.ts | 48 +++++++++----
apps/backend/src/emails/emailTemplates.ts | 70 +++++++++++++++++++
apps/backend/src/orders/order.module.ts | 2 +
apps/backend/src/orders/order.service.ts | 42 ++++++++++-
5 files changed, 148 insertions(+), 16 deletions(-)
diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts
index c3a4de760..3acfed5ac 100644
--- a/apps/backend/src/donations/donations.module.ts
+++ b/apps/backend/src/donations/donations.module.ts
@@ -10,6 +10,7 @@ import { DonationItem } from '../donationItems/donationItems.entity';
import { DonationItemsModule } from '../donationItems/donationItems.module';
import { Allocation } from '../allocations/allocations.entity';
import { AllocationModule } from '../allocations/allocations.module';
+import { EmailsModule } from '../emails/email.module';
@Module({
imports: [
@@ -22,6 +23,7 @@ import { AllocationModule } from '../allocations/allocations.module';
forwardRef(() => AuthModule),
DonationItemsModule,
AllocationModule,
+ EmailsModule,
],
controllers: [DonationsController],
providers: [DonationService, DonationsSchedulerService],
diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts
index ef638e884..5db01d9f3 100644
--- a/apps/backend/src/donations/donations.service.ts
+++ b/apps/backend/src/donations/donations.service.ts
@@ -1,7 +1,6 @@
import {
BadRequestException,
Injectable,
- Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@@ -15,11 +14,11 @@ import { DonationItemsService } from '../donationItems/donationItems.service';
import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto';
import { DonationItem } from '../donationItems/donationItems.entity';
import { Allocation } from '../allocations/allocations.entity';
+import { EmailsService } from '../emails/email.service';
+import { emailTemplates } from '../emails/emailTemplates';
@Injectable()
export class DonationService {
- private readonly logger = new Logger(DonationService.name);
-
constructor(
@InjectRepository(Donation) private repo: Repository,
@InjectRepository(Allocation)
@@ -30,6 +29,7 @@ export class DonationService {
private manufacturerRepo: Repository,
private donationItemsService: DonationItemsService,
@InjectDataSource() private dataSource: DataSource,
+ private emailsService: EmailsService,
) {}
async findOne(donationId: number): Promise {
@@ -203,13 +203,22 @@ export class DonationService {
break;
}
- this.logger.log(`Placeholder for sending automated email`);
+ const { subject, bodyHTML } =
+ emailTemplates.fmRecurringDonationReminder({
+ fmName: donation.foodManufacturer.foodManufacturerName,
+ });
+
+ try {
+ const fmEmails = [
+ donation.foodManufacturer.secondaryContactEmail,
+ ].filter((e): e is string => e !== null);
- /**
- * IMPORTANT: future logic below should only proceed if the email is successfully sent
- */
- const emailSent = true;
- if (!emailSent) continue;
+ if (fmEmails.length > 0) {
+ await this.emailsService.sendEmails(fmEmails, subject, bodyHTML);
+ }
+ } catch (e) {
+ continue;
+ }
dates.splice(i, 1);
i--;
@@ -225,11 +234,22 @@ export class DonationService {
// cascading recalculation of next dates when replacement dates are also expired
while (nextDate.getTime() <= today.getTime() && occurrences > 0) {
- this.logger.log(
- `Placeholder for sending automated email for replacement date`,
- );
- const cascadeEmailSent = true;
- if (!cascadeEmailSent) break;
+ const { subject: cs, bodyHTML: cb } =
+ emailTemplates.fmRecurringDonationReminder({
+ fmName: donation.foodManufacturer.foodManufacturerName,
+ });
+
+ try {
+ const fmEmails = [
+ donation.foodManufacturer.secondaryContactEmail,
+ ].filter((e): e is string => e !== null);
+
+ if (fmEmails.length > 0) {
+ await this.emailsService.sendEmails(fmEmails, cs, cb);
+ }
+ } catch (e) {
+ break;
+ }
occurrences -= 1;
diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index 7d4b8c0f3..41c6350ec 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -98,4 +98,74 @@ export const emailTemplates = {
Best regards, The Securing Safe Food Team
`,
}),
+
+ fmRecurringDonationReminder: (params: { fmName: string }): EmailTemplate => ({
+ subject: 'Reminder: Submit Your Scheduled Recurring Donation with SSF',
+ bodyHTML: `
+ Hi ${params.fmName},
+
+ This is a friendly reminder from Securing Safe Food that your recurring donation
+ schedule indicates a new donation submission is due.
+
+
+ When you have a moment, please log into your account and submit your current
+ donation availability so we can continue matching your contributions with pantry requests.
+
+
+ We greatly appreciate your continued generosity and support of our mission. Your
+ recurring donations make a meaningful and consistent impact for the communities we serve.
+
+ Best regards, The Securing Safe Food Team
+ `,
+ }),
+
+ trackingLinkAvailable: (params: {
+ pantryName: string;
+ fmName: string;
+ trackingLink: string;
+ volunteerName: string;
+ volunteerEmail: string;
+ }): EmailTemplate => ({
+ subject: `Tracking Information for your ${params.fmName} delivery (Securing Safe Food)`,
+ bodyHTML: `
+ Hi ${params.pantryName},
+
+ Good news! Tracking information is now available for your upcoming SSF delivery
+ from ${params.fmName}. You can use this tracking information to monitor the
+ status of your shipment or log into your portal for more information on your
+ expected donation.
+
+
+ Tracking Link: ${params.trackingLink}
+
+
+ You can use the tracking link above to monitor your shipment, or log into your portal for full order details and updates.
+
+
+ If you experience any issues or have questions, please contact your coordinator,
+ ${params.volunteerName}, at ${params.volunteerEmail} , and our team will be happy to assist.
+
+ Best regards, The Securing Safe Food Team
+ `,
+ }),
+
+ pantryConfirmsOrderDelivery: (params: {
+ volunteerName: string;
+ pantryName: string;
+ fmName: string;
+ }): EmailTemplate => ({
+ subject: `${params.pantryName} Confirmed for your ${params.fmName} Order`,
+ bodyHTML: `
+ Hi ${params.volunteerName},
+
+ ${params.pantryName} has confirmed receipt of the most recent ${params.fmName}
+ order you are assigned to. Please log into the platform to review the completed
+ request or check for additional information.
+
+
+ Thank you for your coordination and support in helping reach this order to completion!
+
+ Best regards, The Securing Safe Food Team
+ `,
+ }),
};
diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts
index 71003cc7e..07b33f5fa 100644
--- a/apps/backend/src/orders/order.module.ts
+++ b/apps/backend/src/orders/order.module.ts
@@ -17,6 +17,7 @@ import { DonationItemsModule } from '../donationItems/donationItems.module';
import { Allocation } from '../allocations/allocations.entity';
import { DonationModule } from '../donations/donations.module';
import { Donation } from '../donations/donations.entity';
+import { EmailsModule } from '../emails/email.module';
@Module({
imports: [
@@ -37,6 +38,7 @@ import { Donation } from '../donations/donations.entity';
ManufacturerModule,
DonationItemsModule,
DonationModule,
+ EmailsModule,
],
controllers: [OrdersController],
providers: [OrdersService],
diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts
index 378718e0e..0fa0cc21c 100644
--- a/apps/backend/src/orders/order.service.ts
+++ b/apps/backend/src/orders/order.service.ts
@@ -23,6 +23,8 @@ import { DonationService } from '../donations/donations.service';
import { ApplicationStatus } from '../shared/types';
import { Donation } from '../donations/donations.entity';
import { VolunteerOrder } from '../volunteers/types';
+import { EmailsService } from '../emails/email.service';
+import { emailTemplates } from '../emails/emailTemplates';
@Injectable()
export class OrdersService {
@@ -36,6 +38,7 @@ export class OrdersService {
private allocationsService: AllocationsService,
private donationService: DonationService,
@InjectDataSource() private dataSource: DataSource,
+ private emailsService: EmailsService,
) {}
// TODO: when order is created, set FM
@@ -391,6 +394,7 @@ export class OrdersService {
.execute();
}
+ // Updated confirmDelivery()
async confirmDelivery(
orderId: number,
dto: ConfirmDeliveryDto,
@@ -403,7 +407,10 @@ export class OrdersService {
throw new BadRequestException('Invalid date format for dateReceived');
}
- const order = await this.repo.findOneBy({ orderId });
+ const order = await this.repo.findOne({
+ where: { orderId },
+ relations: ['request', 'request.pantry', 'foodManufacturer', 'assignee'],
+ });
if (!order) {
throw new NotFoundException(`Order ${orderId} not found`);
@@ -424,6 +431,18 @@ export class OrdersService {
await this.requestsService.updateRequestStatus(order.requestId);
+ const { subject, bodyHTML } = emailTemplates.pantryConfirmsOrderDelivery({
+ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
+ pantryName: order.request.pantry.pantryName,
+ fmName: order.foodManufacturer.foodManufacturerName,
+ });
+
+ await this.emailsService.sendEmails(
+ [order.assignee.email],
+ subject,
+ bodyHTML,
+ );
+
return updatedOrder;
}
@@ -472,7 +491,10 @@ export class OrdersService {
dto.trackingLink = sanitized;
}
- const order = await this.repo.findOneBy({ orderId });
+ const order = await this.repo.findOne({
+ where: { orderId },
+ relations: ['request', 'request.pantry', 'foodManufacturer', 'assignee'],
+ });
if (!order) {
throw new NotFoundException(`Order ${orderId} not found`);
}
@@ -504,6 +526,22 @@ export class OrdersService {
) {
order.status = OrderStatus.SHIPPED;
order.shippedAt = new Date();
+
+ const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({
+ pantryName: order.request.pantry.pantryName,
+ fmName: order.foodManufacturer.foodManufacturerName,
+ trackingLink: order.trackingLink,
+ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
+ volunteerEmail: order.assignee.email,
+ });
+
+ const pantryEmails = [order.request.pantry.secondaryContactEmail].filter(
+ (e): e is string => e !== null,
+ );
+
+ if (pantryEmails.length > 0) {
+ await this.emailsService.sendEmails(pantryEmails, subject, bodyHTML);
+ }
}
await this.repo.save(order);
From de35710daa1ce3e4ceda35aad36ec689d566747e Mon Sep 17 00:00:00 2001
From: skewalia <145241312+swarkewalia@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:50:21 -0400
Subject: [PATCH 02/10] fix donation tests
---
apps/backend/src/donations/donations.service.spec.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts
index b37427025..c891ef4e0 100644
--- a/apps/backend/src/donations/donations.service.spec.ts
+++ b/apps/backend/src/donations/donations.service.spec.ts
@@ -16,6 +16,7 @@ import {
import { FoodType } from '../donationItems/types';
import { DonationItemsService } from '../donationItems/donationItems.service';
import { DonationItem } from '../donationItems/donationItems.entity';
+import { EmailsService } from '../emails/email.service';
jest.setTimeout(60000);
@@ -126,6 +127,10 @@ describe('DonationService', () => {
provide: DataSource,
useValue: testDataSource,
},
+ {
+ provide: EmailsService,
+ useValue: { sendEmails: jest.fn() },
+ },
],
}).compile();
From af27eb644311215edf773191ea44f35706d191d1 Mon Sep 17 00:00:00 2001
From: skewalia <145241312+swarkewalia@users.noreply.github.com>
Date: Mon, 27 Apr 2026 22:07:05 -0400
Subject: [PATCH 03/10] automated email user updates
---
apps/backend/src/emails/emailTemplates.ts | 21 +++++++++++++++++++
.../src/volunteers/volunteers.module.ts | 2 ++
.../src/volunteers/volunteers.service.ts | 13 +++++++++++-
3 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index 7d4b8c0f3..4046e6435 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -98,4 +98,25 @@ export const emailTemplates = {
Best regards, The Securing Safe Food Team
`,
}),
+
+ volunteerPantryAssignmentChanged: (params: {
+ volunteerName: string;
+ }): EmailTemplate => ({
+ subject: 'Your SSF Pantry Assignment has been updated',
+ bodyHTML: `
+ Hi ${params.volunteerName},
+
+ Your pantry assignment with SSF has been updated. Please log into the platform
+ to review your current assignments and any active requests that may require your attention.
+
+
+ Thank you for your continued support of our partners and mission.
+
+ Best regards, The Securing Safe Food Team
+
+ To view your pantry assignments, please click the following link:
+ ${EMAIL_REDIRECT_URL}/volunteer-assigned-pantries
+
+ `,
+ }),
};
diff --git a/apps/backend/src/volunteers/volunteers.module.ts b/apps/backend/src/volunteers/volunteers.module.ts
index 003910968..01997c832 100644
--- a/apps/backend/src/volunteers/volunteers.module.ts
+++ b/apps/backend/src/volunteers/volunteers.module.ts
@@ -8,6 +8,7 @@ import { VolunteersService } from './volunteers.service';
import { UsersModule } from '../users/users.module';
import { RequestsModule } from '../foodRequests/request.module';
import { OrdersModule } from '../orders/order.module';
+import { EmailsModule } from '../emails/email.module';
@Module({
imports: [
@@ -17,6 +18,7 @@ import { OrdersModule } from '../orders/order.module';
forwardRef(() => AuthModule),
RequestsModule,
OrdersModule,
+ EmailsModule,
],
controllers: [VolunteersController],
providers: [VolunteersService],
diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts
index c167d98ca..f7bd41848 100644
--- a/apps/backend/src/volunteers/volunteers.service.ts
+++ b/apps/backend/src/volunteers/volunteers.service.ts
@@ -10,6 +10,8 @@ import { UsersService } from '../users/users.service';
import { Assignments } from './types';
import { FoodRequest } from '../foodRequests/request.entity';
import { RequestsService } from '../foodRequests/request.service';
+import { EmailsService } from '../emails/email.service';
+import { emailTemplates } from '../emails/emailTemplates';
@Injectable()
export class VolunteersService {
@@ -19,6 +21,7 @@ export class VolunteersService {
private usersService: UsersService,
private pantriesService: PantriesService,
private requestsService: RequestsService,
+ private emailsService: EmailsService,
) {}
async findOne(id: number): Promise {
@@ -74,7 +77,15 @@ export class VolunteersService {
);
volunteer.pantries = [...existingPantries, ...newPantries];
- return this.repo.save(volunteer);
+ const saved = await this.repo.save(volunteer);
+
+ const { subject, bodyHTML } =
+ emailTemplates.volunteerPantryAssignmentChanged({
+ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`,
+ });
+ await this.emailsService.sendEmails([volunteer.email], subject, bodyHTML);
+
+ return saved;
}
async findRequestsByVolunteer(volunteerId: number): Promise {
From 98a90b87ac568fee493f6e35bf661f61e20b9e7a Mon Sep 17 00:00:00 2001
From: Dalton Burkhart
Date: Mon, 4 May 2026 23:34:29 -0400
Subject: [PATCH 04/10] comments
---
.../src/donations/donations.service.spec.ts | 86 +++++++++++-
.../src/donations/donations.service.ts | 38 ++---
apps/backend/src/emails/emailTemplates.ts | 2 +-
apps/backend/src/orders/order.service.spec.ts | 130 +++++++++++++++++-
apps/backend/src/orders/order.service.ts | 46 ++++++-
5 files changed, 272 insertions(+), 30 deletions(-)
diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts
index f217fb589..26edd89f7 100644
--- a/apps/backend/src/donations/donations.service.spec.ts
+++ b/apps/backend/src/donations/donations.service.spec.ts
@@ -17,6 +17,9 @@ import {
ReplaceDonationItemsDto,
} from '../donationItems/dtos/create-donation-items.dto';
import { FoodType } from '../donationItems/types';
+import { mock } from 'jest-mock-extended';
+import { EmailsService } from '../emails/email.service';
+import { emailTemplates } from '../emails/emailTemplates';
jest.setTimeout(60000);
@@ -132,11 +135,15 @@ const TODAYOfWeek = (iso: string): DayOfWeek => {
return days[new Date(iso).getDay()];
};
+const mockEmailsService = mock();
+
describe('DonationService', () => {
let service: DonationService;
let donationItemService: DonationItemsService;
beforeAll(async () => {
+ mockEmailsService.sendEmails.mockResolvedValue(undefined);
+
if (!testDataSource.isInitialized) {
await testDataSource.initialize();
}
@@ -170,7 +177,7 @@ describe('DonationService', () => {
},
{
provide: EmailsService,
- useValue: { sendEmails: jest.fn() },
+ useValue: mockEmailsService,
},
],
}).compile();
@@ -181,6 +188,7 @@ describe('DonationService', () => {
});
beforeEach(async () => {
+ mockEmailsService.sendEmails.mockClear();
await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`);
await testDataSource.query(`CREATE SCHEMA public`);
await testDataSource.runMigrations();
@@ -632,6 +640,82 @@ describe('DonationService', () => {
);
expect(donation.occurrencesRemaining).toEqual(3);
});
+
+ it('sends fmRecurringDonationReminder email with correct parameters when expired date is processed', async () => {
+ const pastDate = daysAgo(5);
+ await insertDonation({
+ recurrence: RecurrenceEnum.WEEKLY,
+ recurrenceFreq: 1,
+ nextDonationDates: [pastDate],
+ occurrencesRemaining: 3,
+ });
+
+ const manufacturer = await testDataSource
+ .getRepository(FoodManufacturer)
+ .findOne({
+ where: { foodManufacturerName: 'FoodCorp Industries' },
+ relations: ['foodManufacturerRepresentative'],
+ });
+
+ if (!manufacturer)
+ throw new Error('Missing FoodCorp Industries manufacturer');
+
+ await service.handleRecurringDonations();
+
+ const { subject, bodyHTML } =
+ emailTemplates.fmRecurringDonationReminder({
+ fmName: manufacturer.foodManufacturerName,
+ });
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
+ [manufacturer.foodManufacturerRepresentative.email],
+ subject,
+ bodyHTML,
+ );
+ });
+
+ it('continues processing other donations when one donation email send fails', async () => {
+ const pastDate1 = daysAgo(5);
+ const pastDate2 = daysAgo(3);
+
+ const donationId1 = await insertDonation({
+ recurrence: RecurrenceEnum.WEEKLY,
+ recurrenceFreq: 1,
+ nextDonationDates: [pastDate1],
+ occurrencesRemaining: 3,
+ });
+
+ const donationId2 = await insertDonation({
+ recurrence: RecurrenceEnum.WEEKLY,
+ recurrenceFreq: 1,
+ nextDonationDates: [pastDate2],
+ occurrencesRemaining: 3,
+ });
+
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('Email failed'),
+ );
+
+ await service.handleRecurringDonations();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2);
+
+ const donation1 = await service.findOne(donationId1);
+ const donation2 = await service.findOne(donationId2);
+
+ // Exactly one donation should have been updated (occurrences decremented to 2)
+ // In the case where an email send fails, we do not want to decrement anything
+ const updatedCount = [donation1, donation2].filter(
+ (d) => d.occurrencesRemaining === 2,
+ ).length;
+ const unchangedCount = [donation1, donation2].filter(
+ (d) => d.occurrencesRemaining === 3,
+ ).length;
+
+ expect(updatedCount).toBe(1);
+ expect(unchangedCount).toBe(1);
+ });
});
});
diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts
index 6b8a89fbc..d3eeea48a 100644
--- a/apps/backend/src/donations/donations.service.ts
+++ b/apps/backend/src/donations/donations.service.ts
@@ -50,7 +50,10 @@ export class DonationService {
async getAll(): Promise {
return this.repo.find({
- relations: ['foodManufacturer'],
+ relations: [
+ 'foodManufacturer',
+ 'foodManufacturer.foodManufacturerRepresentative',
+ ],
});
}
@@ -206,19 +209,18 @@ export class DonationService {
break;
}
+ // Successfully send an email first before decrementing the count
const { subject, bodyHTML } =
emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
});
try {
- const fmEmails = [
- donation.foodManufacturer.secondaryContactEmail,
- ].filter((e): e is string => e !== null);
-
- if (fmEmails.length > 0) {
- await this.emailsService.sendEmails(fmEmails, subject, bodyHTML);
- }
+ await this.emailsService.sendEmails(
+ [donation.foodManufacturer.foodManufacturerRepresentative.email],
+ subject,
+ bodyHTML,
+ );
} catch (e) {
continue;
}
@@ -237,21 +239,23 @@ export class DonationService {
// cascading recalculation of next dates when replacement dates are also expired
while (nextDate.getTime() <= today.getTime() && occurrences > 0) {
- const { subject: cs, bodyHTML: cb } =
+ const { subject, bodyHTML } =
emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
});
+ // Successfully send an email first before decrementing the count
try {
- const fmEmails = [
- donation.foodManufacturer.secondaryContactEmail,
- ].filter((e): e is string => e !== null);
-
- if (fmEmails.length > 0) {
- await this.emailsService.sendEmails(fmEmails, cs, cb);
- }
+ await this.emailsService.sendEmails(
+ [
+ donation.foodManufacturer.foodManufacturerRepresentative
+ .email,
+ ],
+ subject,
+ bodyHTML,
+ );
} catch (e) {
- break;
+ continue;
}
occurrences -= 1;
diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index 41c6350ec..010aca5ac 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -136,7 +136,7 @@ export const emailTemplates = {
expected donation.
- Tracking Link: ${params.trackingLink}
+ Tracking Link: ${params.trackingLink}
You can use the tracking link above to monitor your shipment, or log into your portal for full order details and updates.
diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts
index 92d37acc3..cd154e6ca 100644
--- a/apps/backend/src/orders/order.service.spec.ts
+++ b/apps/backend/src/orders/order.service.spec.ts
@@ -6,7 +6,11 @@ import { testDataSource } from '../config/typeormTestDataSource';
import { OrderStatus, VolunteerAction } from './types';
import { Pantry } from '../pantries/pantries.entity';
import { OrderDetailsDto } from './dtos/order-details.dto';
-import { BadRequestException, NotFoundException } from '@nestjs/common';
+import {
+ BadRequestException,
+ InternalServerErrorException,
+ NotFoundException,
+} from '@nestjs/common';
import { TrackingCostDto } from './dtos/tracking-cost.dto';
import { FoodType } from '../donationItems/types';
import { FoodRequest } from '../foodRequests/request.entity';
@@ -29,14 +33,20 @@ import { CreateOrderDto } from './dtos/create-order.dto';
import { DataSource } from 'typeorm';
import { EmailsService } from '../emails/email.service';
import { Allocation } from '../allocations/allocations.entity';
+import { mock } from 'jest-mock-extended';
+import { emailTemplates } from '../emails/emailTemplates';
// Set 1 minute timeout for async DB operations
jest.setTimeout(60000);
+const mockEmailsService = mock();
+
describe('OrdersService', () => {
let service: OrdersService;
beforeAll(async () => {
+ mockEmailsService.sendEmails.mockResolvedValue(undefined);
+
// Initialize DataSource once
if (!testDataSource.isInitialized) {
await testDataSource.initialize();
@@ -62,9 +72,7 @@ describe('OrdersService', () => {
},
{
provide: EmailsService,
- useValue: {
- sendEmails: jest.fn().mockResolvedValue(undefined),
- },
+ useValue: mockEmailsService,
},
{
provide: getRepositoryToken(Order),
@@ -109,6 +117,7 @@ describe('OrdersService', () => {
});
beforeEach(async () => {
+ mockEmailsService.sendEmails.mockClear();
await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`);
await testDataSource.query(`CREATE SCHEMA public`);
await testDataSource.runMigrations();
@@ -530,6 +539,64 @@ describe('OrdersService', () => {
expect(updatedOrder.status).toEqual(OrderStatus.SHIPPED);
expect(updatedOrder.shippedAt).toBeDefined();
});
+
+ it('sends trackingLinkAvailable email to pantry user when order is shipped', async () => {
+ const orderId = 4;
+ const order = await testDataSource.getRepository(Order).findOne({
+ where: { orderId },
+ relations: [
+ 'request',
+ 'request.pantry',
+ 'request.pantry.pantryUser',
+ 'foodManufacturer',
+ 'assignee',
+ ],
+ });
+
+ if (!order) throw new Error('Missing order test object');
+
+ const dto: TrackingCostDto = {
+ trackingLink: 'testtracking.com',
+ shippingCost: 5.0,
+ };
+
+ await service.updateTrackingCostInfo(orderId, dto);
+
+ const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({
+ pantryName: order.request.pantry.pantryName,
+ fmName: order.foodManufacturer.foodManufacturerName,
+ trackingLink: 'https://testtracking.com/',
+ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
+ volunteerEmail: order.assignee.email,
+ });
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
+ [order.request.pantry.pantryUser.email],
+ subject,
+ bodyHTML,
+ );
+ });
+
+ it('still updates order to shipped if tracking link email fails to send', async () => {
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('Email failed'),
+ );
+
+ await expect(
+ service.updateTrackingCostInfo(4, {
+ trackingLink: 'testtracking.com',
+ shippingCost: 5.0,
+ }),
+ ).rejects.toThrow(
+ new InternalServerErrorException(
+ 'Failed to send new tracking link available email to pantry',
+ ),
+ );
+
+ const order = await service.findOne(4);
+ expect(order.status).toBe(OrderStatus.SHIPPED);
+ });
});
describe('checkAndFulfillDonations', () => {
@@ -762,6 +829,61 @@ describe('OrdersService', () => {
new BadRequestException('Can only confirm delivery for shipped orders'),
);
});
+
+ it('sends pantryConfirmsOrderDelivery email to volunteer when delivery is confirmed', async () => {
+ const orderId = 3;
+ const order = await testDataSource.getRepository(Order).findOne({
+ where: { orderId },
+ relations: [
+ 'request',
+ 'request.pantry',
+ 'foodManufacturer',
+ 'assignee',
+ ],
+ });
+
+ if (!order) throw new Error('Missing order test object');
+
+ await service.confirmDelivery(
+ orderId,
+ { dateReceived: new Date().toISOString(), feedback: 'Great!' },
+ [],
+ );
+
+ const { subject, bodyHTML } = emailTemplates.pantryConfirmsOrderDelivery({
+ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
+ pantryName: order.request.pantry.pantryName,
+ fmName: order.foodManufacturer.foodManufacturerName,
+ });
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
+ [order.assignee.email],
+ subject,
+ bodyHTML,
+ );
+ });
+
+ it('still updates order to delivered if delivery confirmation email fails to send', async () => {
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('Email failed'),
+ );
+
+ await expect(
+ service.confirmDelivery(
+ 3,
+ { dateReceived: new Date().toISOString(), feedback: 'Great!' },
+ [],
+ ),
+ ).rejects.toThrow(
+ new InternalServerErrorException(
+ 'Failed to send order delivery confirmation email to volunteer',
+ ),
+ );
+
+ const order = await service.findOne(3);
+ expect(order.status).toBe(OrderStatus.DELIVERED);
+ });
});
describe('createOrder', () => {
diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts
index c24594694..8865b5ead 100644
--- a/apps/backend/src/orders/order.service.ts
+++ b/apps/backend/src/orders/order.service.ts
@@ -1,6 +1,7 @@
import {
BadRequestException,
Injectable,
+ InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@@ -419,7 +420,6 @@ export class OrdersService {
.execute();
}
- // Updated confirmDelivery()
async confirmDelivery(
orderId: number,
dto: ConfirmDeliveryDto,
@@ -462,11 +462,17 @@ export class OrdersService {
fmName: order.foodManufacturer.foodManufacturerName,
});
- await this.emailsService.sendEmails(
- [order.assignee.email],
- subject,
- bodyHTML,
- );
+ try {
+ await this.emailsService.sendEmails(
+ [order.assignee.email],
+ subject,
+ bodyHTML,
+ );
+ } catch (e) {
+ throw new InternalServerErrorException(
+ 'Failed to send order delivery confirmation email to volunteer',
+ );
+ }
return updatedOrder;
}
@@ -514,7 +520,13 @@ export class OrdersService {
const order = await this.repo.findOne({
where: { orderId },
- relations: ['request', 'request.pantry', 'foodManufacturer', 'assignee'],
+ relations: [
+ 'request',
+ 'request.pantry',
+ 'request.pantry.pantryUser',
+ 'foodManufacturer',
+ 'assignee',
+ ],
});
if (!order) {
throw new NotFoundException(`Order ${orderId} not found`);
@@ -535,6 +547,26 @@ export class OrdersService {
await this.repo.save(order);
await this.checkAndFulfillDonations(orderId);
+
+ const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({
+ pantryName: order.request.pantry.pantryName,
+ fmName: order.foodManufacturer.foodManufacturerName,
+ trackingLink: dto.trackingLink,
+ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
+ volunteerEmail: order.assignee.email,
+ });
+
+ try {
+ await this.emailsService.sendEmails(
+ [order.request.pantry.pantryUser.email],
+ subject,
+ bodyHTML,
+ );
+ } catch (e) {
+ throw new InternalServerErrorException(
+ 'Failed to send new tracking link available email to pantry',
+ );
+ }
}
async checkAndFulfillDonations(orderId: number): Promise {
From a5d820ac92e4b2ced544f187e865354a802b6f68 Mon Sep 17 00:00:00 2001
From: Dalton Burkhart
Date: Tue, 5 May 2026 00:03:04 -0400
Subject: [PATCH 05/10] Comments
---
.../src/donations/donations.service.spec.ts | 3 +-
.../src/donations/donations.service.ts | 2 +
apps/backend/src/emails/emailTemplates.ts | 8 +-
.../components/forms/newDonationFormModal.tsx | 81 ++++++++++++-------
.../foodManufacturerDonationManagement.tsx | 42 +++++++++-
5 files changed, 102 insertions(+), 34 deletions(-)
diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts
index 26edd89f7..9987573cb 100644
--- a/apps/backend/src/donations/donations.service.spec.ts
+++ b/apps/backend/src/donations/donations.service.spec.ts
@@ -643,7 +643,7 @@ describe('DonationService', () => {
it('sends fmRecurringDonationReminder email with correct parameters when expired date is processed', async () => {
const pastDate = daysAgo(5);
- await insertDonation({
+ const donationId = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
nextDonationDates: [pastDate],
@@ -665,6 +665,7 @@ describe('DonationService', () => {
const { subject, bodyHTML } =
emailTemplates.fmRecurringDonationReminder({
fmName: manufacturer.foodManufacturerName,
+ resubmitDonationId: donationId,
});
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts
index d3eeea48a..0187232e8 100644
--- a/apps/backend/src/donations/donations.service.ts
+++ b/apps/backend/src/donations/donations.service.ts
@@ -213,6 +213,7 @@ export class DonationService {
const { subject, bodyHTML } =
emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
+ resubmitDonationId: donation.donationId,
});
try {
@@ -242,6 +243,7 @@ export class DonationService {
const { subject, bodyHTML } =
emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
+ resubmitDonationId: donation.donationId,
});
// Successfully send an email first before decrementing the count
diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index 010aca5ac..67b6ed3b7 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -99,7 +99,10 @@ export const emailTemplates = {
`,
}),
- fmRecurringDonationReminder: (params: { fmName: string }): EmailTemplate => ({
+ fmRecurringDonationReminder: (params: {
+ fmName: string;
+ resubmitDonationId: number;
+ }): EmailTemplate => ({
subject: 'Reminder: Submit Your Scheduled Recurring Donation with SSF',
bodyHTML: `
Hi ${params.fmName},
@@ -116,6 +119,9 @@ export const emailTemplates = {
recurring donations make a meaningful and consistent impact for the communities we serve.
Best regards, The Securing Safe Food Team
+
+ You can use resubmit this donation by visiting your donation management portal.
+
`,
}),
diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx
index 4f2e7821d..abee7b77b 100644
--- a/apps/frontend/src/components/forms/newDonationFormModal.tsx
+++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx
@@ -21,6 +21,7 @@ import ApiClient from '@api/apiClient';
import {
CreateDonationDto,
DayOfWeek,
+ DonationItem,
FoodType,
RecurrenceEnum,
RepeatOnState,
@@ -35,6 +36,8 @@ interface NewDonationFormModalProps {
onDonationSuccess: () => void;
isOpen: boolean;
onClose: () => void;
+ prefillItems?: DonationItem[];
+ hideRecurring?: boolean;
}
interface DonationRow {
@@ -106,19 +109,35 @@ const NewDonationFormModal: React.FC = ({
onDonationSuccess,
isOpen,
onClose,
+ prefillItems,
+ hideRecurring = false,
}) => {
useModalBodyCleanup();
- const [rows, setRows] = useState([
- {
- id: 1,
- foodItem: '',
- foodType: '',
- numItems: '',
- ozPerItem: '',
- valuePerItem: '',
- foodRescue: false,
- },
- ]);
+ const [rows, setRows] = useState(() => {
+ if (prefillItems && prefillItems.length > 0) {
+ return prefillItems.map((item, index) => ({
+ id: index + 1,
+ foodItem: item.itemName,
+ foodType: item.foodType,
+ numItems: String(item.quantity),
+ ozPerItem: item.ozPerItem != null ? String(item.ozPerItem) : '',
+ valuePerItem:
+ item.estimatedValue != null ? String(item.estimatedValue) : '',
+ foodRescue: item.foodRescue,
+ }));
+ }
+ return [
+ {
+ id: 1,
+ foodItem: '',
+ foodType: '',
+ numItems: '',
+ ozPerItem: '',
+ valuePerItem: '',
+ foodRescue: false,
+ },
+ ];
+ });
const [isRecurring, setIsRecurring] = useState(false);
const [repeatEvery, setRepeatEvery] = useState('1');
@@ -321,25 +340,27 @@ const NewDonationFormModal: React.FC = ({
>
Add New Row +
- {
- setIsRecurring(!!e.checked);
- setRepeatInterval(
- e.checked
- ? RecurrenceEnum.WEEKLY
- : RecurrenceEnum.NONE,
- );
- }}
- >
-
-
-
-
-
- Make Donation Recurring
-
-
+ {!hideRecurring && (
+ {
+ setIsRecurring(!!e.checked);
+ setRepeatInterval(
+ e.checked
+ ? RecurrenceEnum.WEEKLY
+ : RecurrenceEnum.NONE,
+ );
+ }}
+ >
+
+
+
+
+
+ Make Donation Recurring
+
+
+ )}
diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx
index f60109c27..c1a9f987a 100644
--- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx
+++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx
@@ -12,12 +12,23 @@ import {
import { ChevronRight, ChevronLeft, Mail, CircleCheck } from 'lucide-react';
import { capitalize, formatDate, DONATION_STATUS_COLORS } from '@utils/utils';
import ApiClient from '@api/apiClient';
-import { DonationDetails, DonationStatus } from '../types/types';
+import { DonationDetails, DonationItem, DonationStatus } from '../types/types';
import DonationDetailsModal from '@components/forms/donationDetailsModal';
import NewDonationFormModal from '@components/forms/newDonationFormModal';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { ROUTES } from '../routes';
const FoodManufacturerDonationManagement: React.FC = () => {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const resubmitDonationId = searchParams.get('resubmitDonationId');
+
const [isLogDonationOpen, setIsLogDonationOpen] = useState(false);
+ const [prefillItems, setPrefillItems] = useState(
+ undefined,
+ );
+ const [isResubmit, setIsResubmit] = useState(false);
+
// State to hold donations grouped by status
const [statusDonations, setStatusDonations] = useState<{
[key in DonationStatus]: DonationDetails[];
@@ -75,6 +86,22 @@ const FoodManufacturerDonationManagement: React.FC = () => {
[DonationStatus.MATCHED]: 1,
};
setCurrentPages(initialPages);
+
+ if (resubmitDonationId) {
+ const id = parseInt(resubmitDonationId, 10);
+ const allDonations: DonationDetails[] = Object.values(grouped).flat();
+ const matchingDetail = allDonations.find(
+ (d) => d.donation.donationId === id,
+ );
+ if (matchingDetail) {
+ const items = await ApiClient.getDonationItemsByDonationId(id);
+ setPrefillItems(items);
+ setIsResubmit(true);
+ setIsLogDonationOpen(true);
+ } else {
+ navigate(ROUTES.FM_DONATION_MANAGEMENT);
+ }
+ }
} catch (error) {
alert('Error fetching donations: ' + error);
}
@@ -84,6 +111,15 @@ const FoodManufacturerDonationManagement: React.FC = () => {
fetchDonations();
}, []);
+ const handleModalClose = () => {
+ setIsLogDonationOpen(false);
+ setPrefillItems(undefined);
+ setIsResubmit(false);
+ if (resubmitDonationId) {
+ navigate(ROUTES.FM_DONATION_MANAGEMENT);
+ }
+ };
+
const handlePageChange = (status: DonationStatus, page: number) => {
setCurrentPages((prev) => ({
...prev,
@@ -118,7 +154,9 @@ const FoodManufacturerDonationManagement: React.FC = () => {
setIsLogDonationOpen(false)}
+ onClose={handleModalClose}
+ prefillItems={prefillItems}
+ hideRecurring={isResubmit}
/>
)}
From a3829d18e0edbdff46d0c04a3cb7f2f4cc2ae6f2 Mon Sep 17 00:00:00 2001
From: Dalton Burkhart
Date: Tue, 5 May 2026 00:03:55 -0400
Subject: [PATCH 06/10] Comments
---
apps/backend/src/emails/emailTemplates.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index 67b6ed3b7..7d2c5ae16 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -114,14 +114,14 @@ export const emailTemplates = {
When you have a moment, please log into your account and submit your current
donation availability so we can continue matching your contributions with pantry requests.
+
+ You can use resubmit this donation by visiting your donation management portal.
+
We greatly appreciate your continued generosity and support of our mission. Your
recurring donations make a meaningful and consistent impact for the communities we serve.
Best regards, The Securing Safe Food Team
-
- You can use resubmit this donation by visiting your donation management portal.
-
`,
}),
From 23765b8d4282450d4c7c4c605b59ce8254d9fd9e Mon Sep 17 00:00:00 2001
From: Dalton Burkhart
Date: Tue, 5 May 2026 00:36:09 -0400
Subject: [PATCH 07/10] Fixed formatting and added tests for volunteer
assignment email
---
.../src/donations/donations.service.spec.ts | 13 +++--
.../src/donations/donations.service.ts | 24 ++++-----
.../manufacturers.service.spec.ts | 6 +--
.../src/foodRequests/request.service.spec.ts | 12 ++---
apps/backend/src/orders/order.service.spec.ts | 12 ++---
apps/backend/src/orders/order.service.ts | 36 ++++++-------
.../src/pantries/pantries.service.spec.ts | 6 +--
apps/backend/src/users/users.service.spec.ts | 6 +--
.../src/volunteers/volunteers.service.spec.ts | 54 +++++++++++++++++--
.../src/volunteers/volunteers.service.ts | 21 ++++++--
10 files changed, 123 insertions(+), 67 deletions(-)
diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts
index 9987573cb..78004769a 100644
--- a/apps/backend/src/donations/donations.service.spec.ts
+++ b/apps/backend/src/donations/donations.service.spec.ts
@@ -662,17 +662,16 @@ describe('DonationService', () => {
await service.handleRecurringDonations();
- const { subject, bodyHTML } =
- emailTemplates.fmRecurringDonationReminder({
- fmName: manufacturer.foodManufacturerName,
- resubmitDonationId: donationId,
- });
+ const message = emailTemplates.fmRecurringDonationReminder({
+ fmName: manufacturer.foodManufacturerName,
+ resubmitDonationId: donationId,
+ });
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
[manufacturer.foodManufacturerRepresentative.email],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
});
diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts
index 0187232e8..e66e24139 100644
--- a/apps/backend/src/donations/donations.service.ts
+++ b/apps/backend/src/donations/donations.service.ts
@@ -210,19 +210,18 @@ export class DonationService {
}
// Successfully send an email first before decrementing the count
- const { subject, bodyHTML } =
- emailTemplates.fmRecurringDonationReminder({
+ try {
+ const message = emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
resubmitDonationId: donation.donationId,
});
- try {
await this.emailsService.sendEmails(
[donation.foodManufacturer.foodManufacturerRepresentative.email],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
- } catch (e) {
+ } catch {
continue;
}
@@ -240,23 +239,22 @@ export class DonationService {
// cascading recalculation of next dates when replacement dates are also expired
while (nextDate.getTime() <= today.getTime() && occurrences > 0) {
- const { subject, bodyHTML } =
- emailTemplates.fmRecurringDonationReminder({
+ // Successfully send an email first before decrementing the count
+ try {
+ const message = emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
resubmitDonationId: donation.donationId,
});
- // Successfully send an email first before decrementing the count
- try {
await this.emailsService.sendEmails(
[
donation.foodManufacturer.foodManufacturerRepresentative
.email,
],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
- } catch (e) {
+ } catch {
continue;
}
diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts
index 4d3321e23..cd4c9fda1 100644
--- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts
+++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts
@@ -189,7 +189,7 @@ describe('FoodManufacturersService', () => {
const pending = await service.getPendingManufacturers();
const manufacturer = pending[0];
const id = manufacturer.foodManufacturerId;
- const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({
+ const message = emailTemplates.pantryFmApplicationApproved({
name: manufacturer.foodManufacturerRepresentative.firstName,
});
@@ -198,8 +198,8 @@ describe('FoodManufacturersService', () => {
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
[manufacturer.foodManufacturerRepresentative.email],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
});
diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts
index 13be82b49..f98e0e469 100644
--- a/apps/backend/src/foodRequests/request.service.spec.ts
+++ b/apps/backend/src/foodRequests/request.service.spec.ts
@@ -250,7 +250,7 @@ describe('RequestsService', () => {
]);
if (!pantry) throw new Error('Missing pantry test object');
- const { subject, bodyHTML } = emailTemplates.pantrySubmitsFoodRequest({
+ const message = emailTemplates.pantrySubmitsFoodRequest({
pantryName: pantry.pantryName,
});
const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email);
@@ -258,8 +258,8 @@ describe('RequestsService', () => {
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
volunteerEmails,
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
});
@@ -277,7 +277,7 @@ describe('RequestsService', () => {
]);
if (!pantry) throw new Error('Missing pantry test object');
- const { subject, bodyHTML } = emailTemplates.pantrySubmitsFoodRequest({
+ const message = emailTemplates.pantrySubmitsFoodRequest({
pantryName: pantry.pantryName,
});
const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email);
@@ -286,8 +286,8 @@ describe('RequestsService', () => {
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
volunteerEmails,
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
});
diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts
index cd154e6ca..b06a98159 100644
--- a/apps/backend/src/orders/order.service.spec.ts
+++ b/apps/backend/src/orders/order.service.spec.ts
@@ -562,7 +562,7 @@ describe('OrdersService', () => {
await service.updateTrackingCostInfo(orderId, dto);
- const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({
+ const message = emailTemplates.trackingLinkAvailable({
pantryName: order.request.pantry.pantryName,
fmName: order.foodManufacturer.foodManufacturerName,
trackingLink: 'https://testtracking.com/',
@@ -573,8 +573,8 @@ describe('OrdersService', () => {
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
[order.request.pantry.pantryUser.email],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
});
@@ -850,7 +850,7 @@ describe('OrdersService', () => {
[],
);
- const { subject, bodyHTML } = emailTemplates.pantryConfirmsOrderDelivery({
+ const message = emailTemplates.pantryConfirmsOrderDelivery({
volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
pantryName: order.request.pantry.pantryName,
fmName: order.foodManufacturer.foodManufacturerName,
@@ -859,8 +859,8 @@ describe('OrdersService', () => {
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
[order.assignee.email],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
});
diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts
index 8865b5ead..f6391e0d0 100644
--- a/apps/backend/src/orders/order.service.ts
+++ b/apps/backend/src/orders/order.service.ts
@@ -456,17 +456,17 @@ export class OrdersService {
await this.requestsService.updateRequestStatus(order.requestId);
- const { subject, bodyHTML } = emailTemplates.pantryConfirmsOrderDelivery({
- volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
- pantryName: order.request.pantry.pantryName,
- fmName: order.foodManufacturer.foodManufacturerName,
- });
-
try {
+ const message = emailTemplates.pantryConfirmsOrderDelivery({
+ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
+ pantryName: order.request.pantry.pantryName,
+ fmName: order.foodManufacturer.foodManufacturerName,
+ });
+
await this.emailsService.sendEmails(
[order.assignee.email],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
} catch (e) {
throw new InternalServerErrorException(
@@ -548,19 +548,19 @@ export class OrdersService {
await this.checkAndFulfillDonations(orderId);
- const { subject, bodyHTML } = emailTemplates.trackingLinkAvailable({
- pantryName: order.request.pantry.pantryName,
- fmName: order.foodManufacturer.foodManufacturerName,
- trackingLink: dto.trackingLink,
- volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
- volunteerEmail: order.assignee.email,
- });
-
try {
+ const message = emailTemplates.trackingLinkAvailable({
+ pantryName: order.request.pantry.pantryName,
+ fmName: order.foodManufacturer.foodManufacturerName,
+ trackingLink: dto.trackingLink,
+ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
+ volunteerEmail: order.assignee.email,
+ });
+
await this.emailsService.sendEmails(
[order.request.pantry.pantryUser.email],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
} catch (e) {
throw new InternalServerErrorException(
diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts
index 83208c3ef..f1b37b282 100644
--- a/apps/backend/src/pantries/pantries.service.spec.ts
+++ b/apps/backend/src/pantries/pantries.service.spec.ts
@@ -204,7 +204,7 @@ describe('PantriesService', () => {
it('sends approval email to pantry user', async () => {
const pantry = await service.findOne(5);
- const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({
+ const message = emailTemplates.pantryFmApplicationApproved({
name: pantry.pantryUser.firstName,
});
@@ -213,8 +213,8 @@ describe('PantriesService', () => {
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
[pantry.pantryUser.email],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
});
diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts
index 0f7902798..93b260bdc 100644
--- a/apps/backend/src/users/users.service.spec.ts
+++ b/apps/backend/src/users/users.service.spec.ts
@@ -156,12 +156,12 @@ describe('UsersService', () => {
const result = await service.create(createUserDto);
- const { subject, bodyHTML } = emailTemplates.volunteerAccountCreated();
+ const message = emailTemplates.volunteerAccountCreated();
expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
[createUserDto.email],
- subject,
- bodyHTML,
+ message.subject,
+ message.bodyHTML,
);
expect(mockAuthService.adminCreateUser).toHaveBeenCalledWith({
firstName: createUserDto.firstName,
diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts
index b665751b0..44d36fd03 100644
--- a/apps/backend/src/volunteers/volunteers.service.spec.ts
+++ b/apps/backend/src/volunteers/volunteers.service.spec.ts
@@ -1,4 +1,7 @@
-import { NotFoundException } from '@nestjs/common';
+import {
+ InternalServerErrorException,
+ NotFoundException,
+} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@@ -22,6 +25,10 @@ import { DonationItemsService } from '../donationItems/donationItems.service';
import { AllocationsService } from '../allocations/allocations.service';
import { DonationService } from '../donations/donations.service';
import { Allocation } from '../allocations/allocations.entity';
+import { mock } from 'jest-mock-extended';
+import { emailTemplates } from '../emails/emailTemplates';
+
+const mockEmailsService = mock();
jest.setTimeout(60000);
@@ -29,6 +36,8 @@ describe('VolunteersService', () => {
let service: VolunteersService;
beforeAll(async () => {
+ mockEmailsService.sendEmails.mockResolvedValue(undefined);
+
if (!testDataSource.isInitialized) {
await testDataSource.initialize();
}
@@ -58,9 +67,7 @@ describe('VolunteersService', () => {
},
{
provide: EmailsService,
- useValue: {
- sendEmails: jest.fn().mockResolvedValue(undefined),
- },
+ useValue: mockEmailsService,
},
{
provide: getRepositoryToken(User),
@@ -101,6 +108,7 @@ describe('VolunteersService', () => {
});
beforeEach(async () => {
+ mockEmailsService.sendEmails.mockClear();
await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`);
await testDataSource.query(`CREATE SCHEMA public`);
await testDataSource.runMigrations();
@@ -264,6 +272,44 @@ describe('VolunteersService', () => {
const pantryIds = result.pantries?.map((p) => p.pantryId);
expect(pantryIds).toEqual([2, 3]);
});
+
+ it('sends volunteerPantryAssignmentChanged email to volunteer when pantries are assigned', async () => {
+ const volunteerId = 7;
+ const volunteer = await testDataSource
+ .getRepository(User)
+ .findOne({ where: { id: volunteerId } });
+
+ if (!volunteer) throw new Error('Missing volunteer test object');
+
+ await service.assignPantriesToVolunteer(volunteerId, [1]);
+
+ const message = emailTemplates.volunteerPantryAssignmentChanged({
+ volunteerName: `${volunteer.firstName} ${volunteer.lastName}`,
+ });
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith(
+ [volunteer.email],
+ message.subject,
+ message.bodyHTML,
+ );
+ });
+
+ it('still assigns pantries if email fails to send', async () => {
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('Email failed'),
+ );
+
+ await expect(service.assignPantriesToVolunteer(6, [2])).rejects.toThrow(
+ new InternalServerErrorException(
+ 'Failed to send new food request notification email to volunteers',
+ ),
+ );
+
+ const pantries = await service.getVolunteerPantries(6);
+ const pantryIds = pantries.map((p) => p.pantryId);
+ expect(pantryIds).toContain(2);
+ });
});
describe('findRequestsByVolunteer', () => {
diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts
index af4ae0d2b..69e024aa9 100644
--- a/apps/backend/src/volunteers/volunteers.service.ts
+++ b/apps/backend/src/volunteers/volunteers.service.ts
@@ -1,4 +1,8 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import {
+ Injectable,
+ InternalServerErrorException,
+ NotFoundException,
+} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../users/users.entity';
@@ -90,11 +94,20 @@ export class VolunteersService {
volunteer.pantries = [...existingPantries, ...newPantries];
const saved = await this.repo.save(volunteer);
- const { subject, bodyHTML } =
- emailTemplates.volunteerPantryAssignmentChanged({
+ try {
+ const message = emailTemplates.volunteerPantryAssignmentChanged({
volunteerName: `${volunteer.firstName} ${volunteer.lastName}`,
});
- await this.emailsService.sendEmails([volunteer.email], subject, bodyHTML);
+ await this.emailsService.sendEmails(
+ [volunteer.email],
+ message.subject,
+ message.bodyHTML,
+ );
+ } catch {
+ throw new InternalServerErrorException(
+ 'Failed to send new food request notification email to volunteers',
+ );
+ }
return saved;
}
From 0c09e2acddcfdcf264bf7d599bd5e0b05c075cf4 Mon Sep 17 00:00:00 2001
From: Dalton Burkhart
Date: Tue, 5 May 2026 01:53:14 -0400
Subject: [PATCH 08/10] fixed logit to always process the donation, even if the
email send fails
---
.../src/donations/donations.service.spec.ts | 16 ++++------------
apps/backend/src/donations/donations.service.ts | 6 ++----
2 files changed, 6 insertions(+), 16 deletions(-)
diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts
index 78004769a..2407c46f5 100644
--- a/apps/backend/src/donations/donations.service.spec.ts
+++ b/apps/backend/src/donations/donations.service.spec.ts
@@ -675,7 +675,7 @@ describe('DonationService', () => {
);
});
- it('continues processing other donations when one donation email send fails', async () => {
+ it('processes all donations when one donation email send fails', async () => {
const pastDate1 = daysAgo(5);
const pastDate2 = daysAgo(3);
@@ -704,17 +704,9 @@ describe('DonationService', () => {
const donation1 = await service.findOne(donationId1);
const donation2 = await service.findOne(donationId2);
- // Exactly one donation should have been updated (occurrences decremented to 2)
- // In the case where an email send fails, we do not want to decrement anything
- const updatedCount = [donation1, donation2].filter(
- (d) => d.occurrencesRemaining === 2,
- ).length;
- const unchangedCount = [donation1, donation2].filter(
- (d) => d.occurrencesRemaining === 3,
- ).length;
-
- expect(updatedCount).toBe(1);
- expect(unchangedCount).toBe(1);
+ // Both donations should be decremented even when an email send fails
+ expect(donation1.occurrencesRemaining).toBe(2);
+ expect(donation2.occurrencesRemaining).toBe(2);
});
});
});
diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts
index e66e24139..4baab0731 100644
--- a/apps/backend/src/donations/donations.service.ts
+++ b/apps/backend/src/donations/donations.service.ts
@@ -209,7 +209,6 @@ export class DonationService {
break;
}
- // Successfully send an email first before decrementing the count
try {
const message = emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
@@ -222,7 +221,7 @@ export class DonationService {
message.bodyHTML,
);
} catch {
- continue;
+ // email failed — still count as a recurrence and move on
}
dates.splice(i, 1);
@@ -239,7 +238,6 @@ export class DonationService {
// cascading recalculation of next dates when replacement dates are also expired
while (nextDate.getTime() <= today.getTime() && occurrences > 0) {
- // Successfully send an email first before decrementing the count
try {
const message = emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
@@ -255,7 +253,7 @@ export class DonationService {
message.bodyHTML,
);
} catch {
- continue;
+ // email failed — still count as a recurrence and move on
}
occurrences -= 1;
From a6c8e3b9bf14d3e2c22f942d0d131948562fac48 Mon Sep 17 00:00:00 2001
From: Dalton Burkhart
Date: Tue, 5 May 2026 23:47:57 -0400
Subject: [PATCH 09/10] Added Resubmit Previous frontend, as well as used
theme.ts colors throughout entire app
---
.../components/forms/orderDetailsModal.tsx | 10 +-
.../components/forms/requestDetailsModal.tsx | 10 +-
.../src/components/forms/requestFormModal.tsx | 4 +-
.../forms/resubmitDonationModal.tsx | 386 ++++++++++++++++++
.../src/containers/adminOrderManagement.tsx | 2 +-
.../foodManufacturerDonationManagement.tsx | 97 +++--
apps/frontend/src/containers/formRequests.tsx | 14 +-
.../src/containers/volunteerManagement.tsx | 11 +-
.../containers/volunteerOrderManagement.tsx | 4 +-
9 files changed, 475 insertions(+), 63 deletions(-)
create mode 100644 apps/frontend/src/components/forms/resubmitDonationModal.tsx
diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx
index 2d0b85d62..ea921becb 100644
--- a/apps/frontend/src/components/forms/orderDetailsModal.tsx
+++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx
@@ -118,7 +118,7 @@ const OrderDetailsModal: React.FC = ({
-
+
Fulfilled by {orderDetails?.foodManufacturerName}
@@ -176,8 +176,8 @@ const OrderDetailsModal: React.FC = ({
{foodRequest.status === FoodRequestStatus.CLOSED ? (
Closed
@@ -185,7 +185,7 @@ const OrderDetailsModal: React.FC = ({
Active
@@ -279,7 +279,7 @@ const OrderDetailsModal: React.FC = ({
{orderDetails?.trackingLink ? (
= ({
-
+
{pantryName}
@@ -196,16 +196,16 @@ const RequestDetailsModal: React.FC = ({
{currentOrder.status === OrderStatus.DELIVERED ? (
Received
) : (
In Progress
diff --git a/apps/frontend/src/components/forms/requestFormModal.tsx b/apps/frontend/src/components/forms/requestFormModal.tsx
index 4de9e6061..43f3cbfc0 100644
--- a/apps/frontend/src/components/forms/requestFormModal.tsx
+++ b/apps/frontend/src/components/forms/requestFormModal.tsx
@@ -149,7 +149,9 @@ const FoodRequestFormModal: React.FC = ({
justifyContent="space-between"
>
{requestedSize || 'Select size'}
-
+
+
+
diff --git a/apps/frontend/src/components/forms/resubmitDonationModal.tsx b/apps/frontend/src/components/forms/resubmitDonationModal.tsx
new file mode 100644
index 000000000..ef0a2d8d4
--- /dev/null
+++ b/apps/frontend/src/components/forms/resubmitDonationModal.tsx
@@ -0,0 +1,386 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Box,
+ Button,
+ CloseButton,
+ Dialog,
+ Flex,
+ Portal,
+ Spinner,
+ Text,
+ VStack,
+ Badge,
+} from '@chakra-ui/react';
+import { ChevronDown } from 'lucide-react';
+import {
+ CreateDonationDto,
+ DonationDetails,
+ DonationItem,
+ RecurrenceEnum,
+} from '../../types/types';
+import ApiClient from '@api/apiClient';
+import { FloatingAlert } from '@components/floatingAlert';
+import { useAlert } from '../../hooks/alert';
+import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType';
+import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup';
+
+interface ResubmitDonationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ donations: DonationDetails[];
+ initialDonationId?: number | null;
+}
+
+const formatDonationDate = (dateString: string) =>
+ new Date(dateString).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ });
+
+const ResubmitDonationModal: React.FC = ({
+ isOpen,
+ onClose,
+ onSuccess,
+ donations,
+ initialDonationId,
+}) => {
+ useModalBodyCleanup();
+ const [errorAlertState, setErrorMessage] = useAlert();
+ const [selectedDonationId, setSelectedDonationId] = useState(
+ null,
+ );
+ const [items, setItems] = useState([]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+ const groupedItems = useGroupedItemsByFoodType(items);
+
+ const sortedDonations = [...donations].sort(
+ (a, b) =>
+ new Date(b.donation.dateDonated).getTime() -
+ new Date(a.donation.dateDonated).getTime(),
+ );
+
+ const selectedDonation = donations.find(
+ (d) => d.donation.donationId === selectedDonationId,
+ );
+
+ const fetchItemsForDonation = async (donationId: number) => {
+ try {
+ const fetchedItems = await ApiClient.getDonationItemsByDonationId(
+ donationId,
+ );
+ setItems(fetchedItems);
+ } catch {
+ setErrorMessage('Error loading donation details');
+ }
+ };
+
+ useEffect(() => {
+ if (isOpen && initialDonationId != null) {
+ setSelectedDonationId(initialDonationId);
+ fetchItemsForDonation(initialDonationId);
+ }
+ }, [isOpen, initialDonationId]);
+
+ const handleSelect = (donationId: number) => {
+ setSelectedDonationId(donationId);
+ fetchItemsForDonation(donationId);
+ };
+
+ const handleClose = () => {
+ setSelectedDonationId(null);
+ setItems([]);
+ setIsDropdownOpen(false);
+ onClose();
+ };
+
+ const handleSubmit = async () => {
+ setIsSubmitting(true);
+ try {
+ const fmId = await ApiClient.getCurrentUserFoodManufacturerId();
+ const dto: CreateDonationDto = {
+ foodManufacturerId: fmId,
+ recurrence: RecurrenceEnum.NONE,
+ items: items.map((item) => ({
+ itemName: item.itemName,
+ quantity: item.quantity,
+ ozPerItem:
+ item.ozPerItem != null ? Number(item.ozPerItem) : undefined,
+ estimatedValue:
+ item.estimatedValue != null
+ ? Number(item.estimatedValue)
+ : undefined,
+ foodType: item.foodType,
+ foodRescue: item.foodRescue,
+ })),
+ };
+ console.log(dto);
+ await ApiClient.postDonation(dto);
+ onSuccess();
+ handleClose();
+ } catch {
+ setErrorMessage('Error submitting donation');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ {
+ if (!e.open) handleClose();
+ }}
+ closeOnInteractOutside
+ >
+ {errorAlertState && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Previous Donations
+
+
+
+
+
+
+
+ Select a Previous Donation
+
+
+ setIsDropdownOpen(!isDropdownOpen)}
+ border="1px solid"
+ borderColor="neutral.100"
+ borderRadius="md"
+ h="40px"
+ px={3}
+ align="center"
+ w="full"
+ cursor="pointer"
+ >
+ {selectedDonation ? (
+
+
+ {formatDonationDate(
+ selectedDonation.donation.dateDonated,
+ )}
+
+ {selectedDonation.donation.recurrence !==
+ RecurrenceEnum.NONE && (
+
+ Recurring
+
+ )}
+
+ ) : (
+
+ Select a previous donation
+
+ )}
+
+
+
+ {isDropdownOpen && (
+ <>
+ setIsDropdownOpen(false)}
+ zIndex={10}
+ />
+
+ {sortedDonations.map((d) => (
+ {
+ handleSelect(d.donation.donationId);
+ setIsDropdownOpen(false);
+ }}
+ >
+
+ {formatDonationDate(d.donation.dateDonated)}
+
+ {d.donation.recurrence !==
+ RecurrenceEnum.NONE && (
+
+ Recurring
+
+ )}
+
+ ))}
+
+ >
+ )}
+
+
+
+ {selectedDonationId !== null && (
+
+
+ Donation Details
+
+
+
+ {Object.entries(groupedItems).map(
+ ([foodType, typeItems]) => (
+
+
+ {foodType}
+
+ {typeItems.map((item) => (
+
+
+ {item.itemName}
+
+
+
+ {item.quantity}
+
+
+ ))}
+
+ ),
+ )}
+
+
+
+ )}
+
+
+
+ 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/10] comments
---
.../src/donations/donations.service.spec.ts | 94 ++++++++++++++++---
.../src/donations/donations.service.ts | 22 +++--
apps/backend/src/emails/emailTemplates.ts | 13 +--
apps/backend/src/orders/order.service.ts | 4 +-
.../forms/resubmitDonationModal.tsx | 40 ++++----
5 files changed, 126 insertions(+), 47 deletions(-)
diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts
index 2407c46f5..9a7979d3f 100644
--- a/apps/backend/src/donations/donations.service.spec.ts
+++ b/apps/backend/src/donations/donations.service.spec.ts
@@ -675,38 +675,108 @@ describe('DonationService', () => {
);
});
- it('processes all donations when one donation email send fails', async () => {
- const pastDate1 = daysAgo(5);
- const pastDate2 = daysAgo(3);
+ it('skips recurrence update and logs warning when initial email fails', async () => {
+ const pastDate = daysAgo(5);
+ const donationId = await insertDonation({
+ recurrence: RecurrenceEnum.WEEKLY,
+ recurrenceFreq: 1,
+ nextDonationDates: [pastDate],
+ occurrencesRemaining: 3,
+ });
+
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('Email failed'),
+ );
+
+ await service.handleRecurringDonations();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ `Automated email failed to send. Skipping recurrence update for donation id ${donationId}`,
+ ),
+ );
+
+ // donation state preserved — failed email means we skipped the update
+ const donation = await service.findOne(donationId);
+ expect(donation.occurrencesRemaining).toBe(3);
+ expect(donation.nextDonationDates).toHaveLength(1);
+ expect(donation.nextDonationDates?.[0].toDateString()).toEqual(
+ pastDate.toDateString(),
+ );
+
+ warnSpy.mockRestore();
+ });
+ it("processes other donations when one donation's initial email fails", async () => {
+ // 3 weekly donations whose replacement dates are all in the future
+ // (no cascading), each starting at occurrencesRemaining=3.
const donationId1 = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
- nextDonationDates: [pastDate1],
+ nextDonationDates: [daysAgo(1)],
occurrencesRemaining: 3,
});
-
const donationId2 = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
- nextDonationDates: [pastDate2],
+ nextDonationDates: [daysAgo(3)],
+ occurrencesRemaining: 3,
+ });
+ const donationId3 = await insertDonation({
+ recurrence: RecurrenceEnum.WEEKLY,
+ recurrenceFreq: 1,
+ nextDonationDates: [daysAgo(5)],
occurrencesRemaining: 3,
});
+ // Reject the first sendEmails call. Whichever donation getAll yields
+ // first will fail; the other two will succeed.
mockEmailsService.sendEmails.mockRejectedValueOnce(
new Error('Email failed'),
);
await service.handleRecurringDonations();
- expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(3);
- const donation1 = await service.findOne(donationId1);
- const donation2 = await service.findOne(donationId2);
+ const donations = await Promise.all([
+ service.findOne(donationId1),
+ service.findOne(donationId2),
+ service.findOne(donationId3),
+ ]);
+
+ // Exactly one donation should be unchanged (the one whose email failed)
+ // and the other two should be decremented from 3 → 2.
+ const remaining = donations.map((d) => d.occurrencesRemaining).sort();
+ expect(remaining).toEqual([2, 2, 3]);
+ });
+
+ it('breaks out of cascade and logs warning when cascade email fails', async () => {
+ // 14-day-old weekly date triggers the cascade — its replacement (7daysAgo) is also expired.
+ const pastDate = daysAgo(14);
+ const donationId = await insertDonation({
+ recurrence: RecurrenceEnum.WEEKLY,
+ recurrenceFreq: 1,
+ nextDonationDates: [pastDate],
+ occurrencesRemaining: 5,
+ });
+
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+ mockEmailsService.sendEmails
+ .mockResolvedValueOnce(undefined) // initial send (pastDate) succeeds
+ .mockRejectedValueOnce(new Error('Email failed')); // first cascade send fails → break
+
+ await service.handleRecurringDonations();
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ `Cascading recalculation of next dates failed for donation id ${donationId}`,
+ ),
+ );
- // Both donations should be decremented even when an email send fails
- expect(donation1.occurrencesRemaining).toBe(2);
- expect(donation2.occurrencesRemaining).toBe(2);
+ warnSpy.mockRestore();
});
});
});
diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts
index 4baab0731..553ec5c7d 100644
--- a/apps/backend/src/donations/donations.service.ts
+++ b/apps/backend/src/donations/donations.service.ts
@@ -1,6 +1,8 @@
import {
BadRequestException,
Injectable,
+ InternalServerErrorException,
+ Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@@ -22,6 +24,7 @@ import { emailTemplates } from '../emails/emailTemplates';
@Injectable()
export class DonationService {
+ private readonly logger = new Logger(DonationService.name);
constructor(
@InjectRepository(Donation) private repo: Repository,
@InjectRepository(Allocation)
@@ -209,8 +212,9 @@ export class DonationService {
break;
}
+ let message = null;
try {
- const message = emailTemplates.fmRecurringDonationReminder({
+ message = emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
resubmitDonationId: donation.donationId,
});
@@ -221,7 +225,10 @@ export class DonationService {
message.bodyHTML,
);
} catch {
- // email failed — still count as a recurrence and move on
+ this.logger.warn(
+ `Automated email failed to send. Skipping recurrence update for donation id ${donation.donationId}`,
+ );
+ continue;
}
dates.splice(i, 1);
@@ -239,11 +246,6 @@ export class DonationService {
// cascading recalculation of next dates when replacement dates are also expired
while (nextDate.getTime() <= today.getTime() && occurrences > 0) {
try {
- const message = emailTemplates.fmRecurringDonationReminder({
- fmName: donation.foodManufacturer.foodManufacturerName,
- resubmitDonationId: donation.donationId,
- });
-
await this.emailsService.sendEmails(
[
donation.foodManufacturer.foodManufacturerRepresentative
@@ -253,7 +255,11 @@ export class DonationService {
message.bodyHTML,
);
} catch {
- // email failed — still count as a recurrence and move on
+ // Early escape to prevent getting stuck in while loop
+ this.logger.warn(
+ `Cascading recalculation of next dates failed for donation id ${donation.donationId}, exiting early`,
+ );
+ break;
}
occurrences -= 1;
diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index 5023002c3..9c51d2e84 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -15,8 +15,8 @@ export const emailTemplates = {
Hi ${params.name},
We're excited to let you know that your Securing Safe Food account has been
- approved and is now active. You can now log in using the credentials created
- during registration to begin submitting requests, managing donations, and
+ approved and is now active. You can now log in
+ using the credentials created during registration to begin submitting requests, managing donations, and
coordinating with our network.
@@ -91,7 +91,8 @@ export const emailTemplates = {
Hi,
A new food request has been submitted by ${params.pantryName}.
- Please log on to the SSF platform to review these request details and begin coordination when ready.
+ Please log on to the SSF platform
+ to review these request details and begin coordination when ready.
Thank you for your continued support of our network and mission!
@@ -115,7 +116,7 @@ export const emailTemplates = {
donation availability so we can continue matching your contributions with pantry requests.
- You can use resubmit this donation by visiting your donation management portal.
+ You can resubmit this donation by visiting your donation management portal .
We greatly appreciate your continued generosity and support of our mission. Your
@@ -165,8 +166,8 @@ export const emailTemplates = {
Hi ${params.volunteerName},
${params.pantryName} has confirmed receipt of the most recent ${params.fmName}
- order you are assigned to. Please log into the platform to review the completed
- request or check for additional information.
+ order you are assigned to. Please log into the platform
+ to review the completed request or check for additional information.
Thank you for your coordination and support in helping reach this order to completion!
diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts
index f6391e0d0..b868b8e50 100644
--- a/apps/backend/src/orders/order.service.ts
+++ b/apps/backend/src/orders/order.service.ts
@@ -468,7 +468,7 @@ export class OrdersService {
message.subject,
message.bodyHTML,
);
- } catch (e) {
+ } catch {
throw new InternalServerErrorException(
'Failed to send order delivery confirmation email to volunteer',
);
@@ -562,7 +562,7 @@ export class OrdersService {
message.subject,
message.bodyHTML,
);
- } catch (e) {
+ } catch {
throw new InternalServerErrorException(
'Failed to send new tracking link available email to pantry',
);
diff --git a/apps/frontend/src/components/forms/resubmitDonationModal.tsx b/apps/frontend/src/components/forms/resubmitDonationModal.tsx
index ef0a2d8d4..1a6d4c238 100644
--- a/apps/frontend/src/components/forms/resubmitDonationModal.tsx
+++ b/apps/frontend/src/components/forms/resubmitDonationModal.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import {
Box,
Button,
@@ -6,7 +6,6 @@ import {
Dialog,
Flex,
Portal,
- Spinner,
Text,
VStack,
Badge,
@@ -67,23 +66,26 @@ const ResubmitDonationModal: React.FC = ({
(d) => d.donation.donationId === selectedDonationId,
);
- const fetchItemsForDonation = async (donationId: number) => {
- try {
- const fetchedItems = await ApiClient.getDonationItemsByDonationId(
- donationId,
- );
- setItems(fetchedItems);
- } catch {
- setErrorMessage('Error loading donation details');
- }
- };
+ const fetchItemsForDonation = useCallback(
+ async (donationId: number) => {
+ try {
+ const fetchedItems = await ApiClient.getDonationItemsByDonationId(
+ donationId,
+ );
+ setItems(fetchedItems);
+ } catch {
+ setErrorMessage('Error loading donation details');
+ }
+ },
+ [setErrorMessage],
+ );
useEffect(() => {
if (isOpen && initialDonationId != null) {
setSelectedDonationId(initialDonationId);
fetchItemsForDonation(initialDonationId);
}
- }, [isOpen, initialDonationId]);
+ }, [isOpen, initialDonationId, fetchItemsForDonation]);
const handleSelect = (donationId: number) => {
setSelectedDonationId(donationId);
@@ -117,7 +119,6 @@ const ResubmitDonationModal: React.FC = ({
foodRescue: item.foodRescue,
})),
};
- console.log(dto);
await ApiClient.postDonation(dto);
onSuccess();
handleClose();
@@ -148,7 +149,7 @@ const ResubmitDonationModal: React.FC = ({
-
+
@@ -164,8 +165,8 @@ const ResubmitDonationModal: React.FC = ({
-
-
+
+
= ({
zIndex={20}
mt={1}
py={1}
- maxH="160px"
+ maxH="120px"
overflowY="auto"
+ bg="white"
>
{sortedDonations.map((d) => (
= ({
)}
-
+