Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/backend/src/donations/donations.scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions apps/backend/src/emails/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,4 +307,20 @@ export const emailTemplates = {
<p>Best regards,<br />The Securing Safe Food Team</p>
`,
}),

pantryReceiveNewFoodRequest: (): EmailTemplate => ({
subject: 'Allergen-Friendly Food Request Form',
bodyHTML: `
<p><strong>Receive a New Food Delivery Through Securing Safe Food</strong></p>
<p>
Fill out our food request form to be placed on our waiting list at <a href="${EMAIL_REDIRECT_URL}/request-form">${EMAIL_REDIRECT_URL}/request-form</a>
</p>
<p>
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.
</p>
<p>Best regards,<br />The Securing Safe Food Team</p>
`,
}),
};
3 changes: 2 additions & 1 deletion apps/backend/src/pantries/pantries.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,7 +22,7 @@ import { RequestsModule } from '../foodRequests/request.module';
forwardRef(() => RequestsModule),
],
controllers: [PantriesController],
providers: [PantriesService],
providers: [PantriesService, PantriesSchedulerService],
exports: [PantriesService],
})
export class PantriesModule {}
18 changes: 18 additions & 0 deletions apps/backend/src/pantries/pantries.scheduler.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
86 changes: 86 additions & 0 deletions apps/backend/src/pantries/pantries.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
35 changes: 35 additions & 0 deletions apps/backend/src/pantries/pantries.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,41 @@ export class PantriesService {
});
}

async sendFoodRequestReminderToApprovedPantries(): Promise<void> {
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<string[]> {
const pantries = await this.repo.find({
select: ['pantryName'],
Expand Down
Loading