diff --git a/apps/backend/src/donations/donations.scheduler.ts b/apps/backend/src/donations/donations.scheduler.ts
index 48db415c9..23e2ae3c2 100644
--- a/apps/backend/src/donations/donations.scheduler.ts
+++ b/apps/backend/src/donations/donations.scheduler.ts
@@ -14,7 +14,7 @@ export class DonationsSchedulerService {
// range/# indicates method should be run on the _ unit of time/between the _ and _ unit of time
// step indicates the method should be run every _ unit of time
// fields in order: second, minute, hour, day of month, month, day of week
- @Cron('0 30 10 * * *', { timeZone: 'America/New_York' }) // Runs every day at 10:30 AM
+ @Cron('0 0 12 * * *', { timeZone: 'America/New_York' }) // Runs every day at 12 PM EST
async handleDailyRecurringDonations() {
this.logger.log('Running daily donation reminder cron job');
await this.donationService.handleRecurringDonations();
diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index abacfd73e..deb7a5a27 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -312,4 +312,20 @@ export const emailTemplates = {
Best regards,
The Securing Safe Food Team
`,
}),
+
+ pantryReceiveNewFoodRequest: (): EmailTemplate => ({
+ subject: 'Allergen-Friendly Food Request Form',
+ bodyHTML: `
+ Receive a New Food Delivery Through Securing Safe Food
+
+ Fill out our food request form to be placed on our waiting list at ${EMAIL_REDIRECT_URL}/request-form
+
+
+ If you submitted a request last cycle and did not receive a shipment, thank you for your patience.
+ We match available resources to food pantries based on product type, allergens, size, and shipping restrictions.
+ You are welcome to submit another form to update your request.
+
+ Best regards,
The Securing Safe Food Team
+ `,
+ }),
};
diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts
index 9261d6129..ca187391c 100644
--- a/apps/backend/src/pantries/pantries.module.ts
+++ b/apps/backend/src/pantries/pantries.module.ts
@@ -1,6 +1,7 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PantriesService } from './pantries.service';
+import { PantriesSchedulerService } from './pantries.scheduler';
import { PantriesController } from './pantries.controller';
import { Pantry } from './pantries.entity';
import { AuthModule } from '../auth/auth.module';
@@ -21,7 +22,7 @@ import { RequestsModule } from '../foodRequests/request.module';
forwardRef(() => RequestsModule),
],
controllers: [PantriesController],
- providers: [PantriesService],
+ providers: [PantriesService, PantriesSchedulerService],
exports: [PantriesService],
})
export class PantriesModule {}
diff --git a/apps/backend/src/pantries/pantries.scheduler.ts b/apps/backend/src/pantries/pantries.scheduler.ts
new file mode 100644
index 000000000..39bcf4151
--- /dev/null
+++ b/apps/backend/src/pantries/pantries.scheduler.ts
@@ -0,0 +1,18 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Cron } from '@nestjs/schedule';
+import { PantriesService } from './pantries.service';
+
+@Injectable()
+export class PantriesSchedulerService {
+ private readonly logger = new Logger(PantriesSchedulerService.name);
+
+ constructor(private readonly pantriesService: PantriesService) {}
+
+ // cron pattern fields in order: second, minute, hour, day of month, month, day of week
+ // '0 0 12 1 * *' => 12 PM on the 1st of every month
+ @Cron('0 0 12 1 * *', { timeZone: 'America/New_York' }) // Runs at noon Eastern on the 1st of every month
+ async handleMonthlyFoodRequestReminder() {
+ this.logger.log('Running monthly pantry food request reminder cron job');
+ await this.pantriesService.sendFoodRequestReminderToApprovedPantries();
+ }
+}
diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts
index be9b06b4c..9548b6cf4 100644
--- a/apps/backend/src/pantries/pantries.service.spec.ts
+++ b/apps/backend/src/pantries/pantries.service.spec.ts
@@ -1384,4 +1384,192 @@ describe('PantriesService', () => {
expect(result['Value Received']).toBe('$0');
});
});
+
+ describe('sendFoodRequestReminderToApprovedPantries', () => {
+ const SENDER_EMAIL = 'sender@securingsafefood.org';
+ const originalSenderEmail = process.env.AWS_SES_SENDER_EMAIL;
+
+ afterEach(() => {
+ process.env.AWS_SES_SENDER_EMAIL = originalSenderEmail;
+ jest.restoreAllMocks();
+ });
+
+ it('logs a warning and sends no emails when there are no approved pantries', async () => {
+ process.env.AWS_SES_SENDER_EMAIL = SENDER_EMAIL;
+ await testDataSource
+ .getRepository(Pantry)
+ .update(
+ { status: ApplicationStatus.APPROVED },
+ { status: ApplicationStatus.DENIED },
+ );
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+
+ await service.sendFoodRequestReminderToApprovedPantries();
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'No approved food pantries, skipping email sending.',
+ ),
+ );
+ expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
+ });
+
+ it('logs a warning and sends no emails when the sender email is not set', async () => {
+ delete process.env.AWS_SES_SENDER_EMAIL;
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+
+ await service.sendFoodRequestReminderToApprovedPantries();
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Skipping food request reminder: AWS_SES_SENDER_EMAIL is not set.',
+ ),
+ );
+ expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
+ });
+
+ it('logs a warning when sending the reminder email fails', async () => {
+ process.env.AWS_SES_SENDER_EMAIL = SENDER_EMAIL;
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('SES failure'),
+ );
+
+ await service.sendFoodRequestReminderToApprovedPantries();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Failed to send food request reminder to pantries for batch 1',
+ ),
+ );
+ });
+
+ it('reports the correct batch number when a later batch fails', async () => {
+ process.env.AWS_SES_SENDER_EMAIL = SENDER_EMAIL;
+
+ // Seed enough approved pantries to require more than one batch (> 49).
+ const seededPantryNames: string[] = [];
+ for (let i = 0; i < 60; i++) {
+ const pantryName = `Batch Pantry ${i}`;
+ seededPantryNames.push(pantryName);
+ await service.addPantry({
+ ...dto,
+ contactEmail: `batch-pantry-${i}@example.com`,
+ pantryName,
+ });
+ }
+ await testDataSource
+ .getRepository(Pantry)
+ .update(
+ { pantryName: In(seededPantryNames) },
+ { status: ApplicationStatus.APPROVED },
+ );
+
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+
+ // First batch succeeds, second batch fails.
+ mockEmailsService.sendEmails.mockClear();
+ mockEmailsService.sendEmails
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(new Error('SES failure'));
+
+ await service.sendFoodRequestReminderToApprovedPantries();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Failed to send food request reminder to pantries for batch 2',
+ ),
+ );
+ });
+
+ it('sends a single email to the sender with all approved pantry emails as bcc', async () => {
+ process.env.AWS_SES_SENDER_EMAIL = SENDER_EMAIL;
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+
+ const approvedPantries = await testDataSource.getRepository(Pantry).find({
+ where: { status: ApplicationStatus.APPROVED },
+ relations: ['pantryUser'],
+ });
+ const expectedBccEmails = approvedPantries.map(
+ (pantry) => pantry.pantryUser.email,
+ );
+ const message = emailTemplates.pantryReceiveNewFoodRequest();
+
+ await service.sendFoodRequestReminderToApprovedPantries();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({
+ toEmail: SENDER_EMAIL,
+ bccEmails: expectedBccEmails,
+ subject: message.subject,
+ bodyHtml: message.bodyHTML,
+ });
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('chunks the bcc recipients into groups of at most 49 to stay within the SES recipient limit', async () => {
+ process.env.AWS_SES_SENDER_EMAIL = SENDER_EMAIL;
+ const MAX_BCC_PER_EMAIL = 49;
+
+ // Seed enough approved pantries to require multiple chunks (> 49).
+ const seededPantryNames: string[] = [];
+ for (let i = 0; i < 100; i++) {
+ const pantryName = `Chunk Pantry ${i}`;
+ seededPantryNames.push(pantryName);
+ await service.addPantry({
+ ...dto,
+ contactEmail: `chunk-pantry-${i}@example.com`,
+ pantryName,
+ });
+ }
+ await testDataSource
+ .getRepository(Pantry)
+ .update(
+ { pantryName: In(seededPantryNames) },
+ { status: ApplicationStatus.APPROVED },
+ );
+
+ // Re-fetch exactly as the service does so the ordering matches.
+ const approvedPantries = await testDataSource.getRepository(Pantry).find({
+ where: { status: ApplicationStatus.APPROVED },
+ relations: ['pantryUser'],
+ });
+ const allBccEmails = approvedPantries.map(
+ (pantry) => pantry.pantryUser.email,
+ );
+
+ const expectedChunks: string[][] = [];
+ for (let i = 0; i < allBccEmails.length; i += MAX_BCC_PER_EMAIL) {
+ expectedChunks.push(allBccEmails.slice(i, i + MAX_BCC_PER_EMAIL));
+ }
+ expect(expectedChunks.length).toBeGreaterThan(1);
+
+ const message = emailTemplates.pantryReceiveNewFoodRequest();
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+
+ // Clear the calls made while seeding pantries via addPantry.
+ mockEmailsService.sendEmails.mockClear();
+
+ await service.sendFoodRequestReminderToApprovedPantries();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(
+ expectedChunks.length,
+ );
+ expectedChunks.forEach((chunk, index) => {
+ expect(chunk.length).toBeLessThanOrEqual(MAX_BCC_PER_EMAIL);
+ expect(mockEmailsService.sendEmails).toHaveBeenNthCalledWith(
+ index + 1,
+ {
+ toEmail: SENDER_EMAIL,
+ bccEmails: chunk,
+ subject: message.subject,
+ bodyHtml: message.bodyHTML,
+ },
+ );
+ });
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts
index 4ea494545..e8b42e2ea 100644
--- a/apps/backend/src/pantries/pantries.service.ts
+++ b/apps/backend/src/pantries/pantries.service.ts
@@ -265,6 +265,50 @@ export class PantriesService {
});
}
+ async sendFoodRequestReminderToApprovedPantries(): Promise {
+ const pantries = await this.repo.find({
+ where: { status: ApplicationStatus.APPROVED },
+ relations: ['pantryUser'],
+ });
+
+ if (pantries.length === 0) {
+ this.logger.warn('No approved food pantries, skipping email sending.');
+ return;
+ }
+
+ const senderEmail = process.env.AWS_SES_SENDER_EMAIL;
+ if (!senderEmail) {
+ this.logger.warn(
+ 'Skipping food request reminder: AWS_SES_SENDER_EMAIL is not set.',
+ );
+ return;
+ }
+
+ const bccEmails = pantries.map((pantry) => pantry.pantryUser.email);
+
+ const message = emailTemplates.pantryReceiveNewFoodRequest();
+
+ const MAX_BCC_PER_EMAIL = 49;
+ for (let i = 0; i < bccEmails.length; i += MAX_BCC_PER_EMAIL) {
+ const bccChunk = bccEmails.slice(i, i + MAX_BCC_PER_EMAIL);
+
+ try {
+ await this.emailsService.sendEmails({
+ toEmail: senderEmail,
+ bccEmails: bccChunk,
+ subject: message.subject,
+ bodyHtml: message.bodyHTML,
+ });
+ } catch {
+ this.logger.warn(
+ `Failed to send food request reminder to pantries for batch ${
+ i / MAX_BCC_PER_EMAIL + 1
+ }`,
+ );
+ }
+ }
+ }
+
async getApprovedPantryNames(): Promise {
const pantries = await this.repo.find({
select: ['pantryName'],