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 e24b716e1..524452b67 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -307,4 +307,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 65a267ad6..6591db0dd 100644
--- a/apps/backend/src/pantries/pantries.service.spec.ts
+++ b/apps/backend/src/pantries/pantries.service.spec.ts
@@ -1332,4 +1332,90 @@ 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.',
+ ),
+ );
+ });
+
+ 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();
+ });
+ });
});
diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts
index cb24b5119..2fc5e345b 100644
--- a/apps/backend/src/pantries/pantries.service.ts
+++ b/apps/backend/src/pantries/pantries.service.ts
@@ -265,6 +265,41 @@ 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();
+
+ try {
+ await this.emailsService.sendEmails({
+ toEmail: senderEmail,
+ bccEmails,
+ subject: message.subject,
+ bodyHtml: message.bodyHTML,
+ });
+ } catch {
+ this.logger.warn('Failed to send food request reminder to pantries.');
+ }
+ }
+
async getApprovedPantryNames(): Promise {
const pantries = await this.repo.find({
select: ['pantryName'],