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'],