diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index deb7a5a27..b1d2e1f2c 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -328,4 +328,33 @@ export const emailTemplates = {
Best regards,
The Securing Safe Food Team
`,
}),
+
+ pantryConfirmDeliveryReminder: (params: {
+ pantryName: string;
+ fmName: string;
+ confirmDeliveryLink: string;
+ volunteerName: string;
+ volunteerEmail: string;
+ }): EmailTemplate => ({
+ subject: `${params.fmName} Donation Confirmation Reminder`,
+ bodyHTML: `
+ Hi ${params.pantryName},
+
+ This is a friendly reminder to confirm receipt of your recent donation from ${params.fmName}.
+
+
+ To confirm delivery receipt, please scan the QR code included on your donation packing slip
+ (if included in shipment) or click here and complete
+ the brief confirmation process. Confirming receipt helps us verify successful delivery, track the
+ impact of donations, and ensure our food partners receive acknowledgment of their contributions.
+
+ If you have already submitted your confirmation, thank you and please disregard this message.
+
+ If you have any questions or need assistance, please contact your coordinator, ${params.volunteerName},
+ at ${params.volunteerEmail} or email partners@securingsafefood.org.
+
+ Thank you for partnering with Securing Safe Food!
+ 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 486bfc29a..67ab0d41b 100644
--- a/apps/backend/src/orders/order.module.ts
+++ b/apps/backend/src/orders/order.module.ts
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { OrdersController } from './order.controller';
import { Order } from './order.entity';
import { OrdersService } from './order.service';
+import { OrdersSchedulerService } from './orders.scheduler';
import { Pantry } from '../pantries/pantries.entity';
import { AllocationModule } from '../allocations/allocations.module';
import { AuthModule } from '../auth/auth.module';
@@ -47,7 +48,7 @@ import { PantriesModule } from '../pantries/pantries.module';
forwardRef(() => UsersModule),
],
controllers: [OrdersController],
- providers: [OrdersService],
+ providers: [OrdersService, OrdersSchedulerService],
exports: [OrdersService],
})
export class OrdersModule {}
diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts
index f7a864a0c..d5e5a1678 100644
--- a/apps/backend/src/orders/order.service.spec.ts
+++ b/apps/backend/src/orders/order.service.spec.ts
@@ -34,7 +34,8 @@ import { DataSource, EntityManager, In } 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';
+import { emailTemplates, EMAIL_REDIRECT_URL } from '../emails/emailTemplates';
+import { ApplicationStatus } from '../shared/types';
// Set 1 minute timeout for async DB operations
jest.setTimeout(60000);
@@ -1747,4 +1748,175 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${
warnSpy.mockRestore();
});
});
+
+ describe('sendConfirmDeliveryReminders', () => {
+ // Orders eligible for a reminder: shipped, approved pantry, and shipped at
+ // least a week ago (matching the service query).
+ const eligibleOrders = async (): Promise =>
+ testDataSource
+ .getRepository(Order)
+ .createQueryBuilder('order')
+ .leftJoinAndSelect('order.request', 'request')
+ .leftJoinAndSelect('request.pantry', 'pantry')
+ .leftJoinAndSelect('pantry.pantryUser', 'pantryUser')
+ .leftJoinAndSelect('order.assignee', 'assignee')
+ .leftJoinAndSelect('order.foodManufacturer', 'foodManufacturer')
+ .where('order.status = :status', { status: OrderStatus.SHIPPED })
+ .andWhere('pantry.status = :pantryStatus', {
+ pantryStatus: ApplicationStatus.APPROVED,
+ })
+ .andWhere("order.shippedAt <= NOW() - INTERVAL '7 days'")
+ .getMany();
+
+ const expectedMessageFor = (order: Order) =>
+ emailTemplates.pantryConfirmDeliveryReminder({
+ pantryName: order.request.pantry.pantryName,
+ fmName: order.foodManufacturer.foodManufacturerName,
+ confirmDeliveryLink: `${EMAIL_REDIRECT_URL}/pantry-order-management?orderId=${order.orderId}&action=confirm-delivery`,
+ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
+ volunteerEmail: order.assignee.email,
+ });
+
+ it('logs a warning and sends no emails when there are no unconfirmed deliveries', async () => {
+ await testDataSource.query(
+ `UPDATE orders SET status = $1 WHERE status = $2`,
+ [OrderStatus.DELIVERED, OrderStatus.SHIPPED],
+ );
+ const logSpy = jest.spyOn(service['logger'], 'log');
+
+ await service.sendConfirmDeliveryReminders();
+
+ expect(logSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'No pantries with unconfirmed deliveries, skipping email sending.',
+ ),
+ );
+ expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
+
+ logSpy.mockRestore();
+ });
+
+ it('sends one personalized reminder per unconfirmed order', async () => {
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+ const orders = await eligibleOrders();
+ expect(orders.length).toBeGreaterThan(0);
+
+ await service.sendConfirmDeliveryReminders();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(orders.length);
+ for (const order of orders) {
+ const message = expectedMessageFor(order);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({
+ toEmail: order.request.pantry.pantryUser.email,
+ subject: message.subject,
+ bodyHtml: message.bodyHTML,
+ });
+ }
+ expect(warnSpy).not.toHaveBeenCalled();
+
+ warnSpy.mockRestore();
+ });
+
+ it('sends a separate reminder for each unconfirmed order, even within the same pantry', async () => {
+ const orderRepo = testDataSource.getRepository(Order);
+ const existingShippedOrder = await orderRepo.findOne({
+ where: { status: OrderStatus.SHIPPED },
+ });
+ if (!existingShippedOrder)
+ throw new Error('Missing existingShippedOrder test object');
+
+ // Add a second shipped order to the same request
+ const secondOrder = orderRepo.create({
+ requestId: existingShippedOrder.requestId,
+ foodManufacturerId: existingShippedOrder.foodManufacturerId,
+ assigneeId: existingShippedOrder.assigneeId,
+ status: OrderStatus.SHIPPED,
+ shippedAt: new Date('2024-02-03T08:00:00Z'),
+ });
+ await orderRepo.save(secondOrder);
+
+ await service.sendConfirmDeliveryReminders();
+
+ const samePantryOrders = (await eligibleOrders()).filter(
+ (o) => o.requestId === existingShippedOrder.requestId,
+ );
+ expect(samePantryOrders.length).toBe(2);
+
+ const sentForSecondOrder = samePantryOrders.find(
+ (o) => o.orderId === secondOrder.orderId,
+ )!;
+ const message = expectedMessageFor(sentForSecondOrder);
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({
+ toEmail: sentForSecondOrder.request.pantry.pantryUser.email,
+ subject: message.subject,
+ bodyHtml: message.bodyHTML,
+ });
+ });
+
+ it('does not send a reminder for an order shipped less than a week ago', async () => {
+ const orderRepo = testDataSource.getRepository(Order);
+
+ await testDataSource.query(
+ `UPDATE orders SET status = $1 WHERE status = $2`,
+ [OrderStatus.DELIVERED, OrderStatus.SHIPPED],
+ );
+
+ const template = await orderRepo.findOne({
+ where: { status: OrderStatus.DELIVERED },
+ });
+ if (!template) throw new Error('Missing order template');
+
+ const recentOrder = orderRepo.create({
+ requestId: template.requestId,
+ foodManufacturerId: template.foodManufacturerId,
+ assigneeId: template.assigneeId,
+ status: OrderStatus.SHIPPED,
+ shippedAt: new Date(),
+ });
+ await orderRepo.save(recentOrder);
+
+ await service.sendConfirmDeliveryReminders();
+
+ expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
+ });
+
+ it('only sends reminders for orders that are SHIPPED', async () => {
+ const orders = await eligibleOrders();
+ expect(orders.length).toBeGreaterThan(0);
+
+ await service.sendConfirmDeliveryReminders();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(orders.length);
+
+ const orderRepo = testDataSource.getRepository(Order);
+ // Pulling id out of sent email to assert it is shipped.
+ for (const [{ bodyHtml }] of mockEmailsService.sendEmails.mock.calls) {
+ const match = bodyHtml.match(/orderId=(\d+)/);
+ expect(match).not.toBeNull();
+
+ const orderId = Number(match![1]);
+ const order = await orderRepo.findOneBy({ orderId });
+ expect(order).not.toBeNull();
+ expect(order!.status).toEqual(OrderStatus.SHIPPED);
+ }
+ });
+
+ it('logs a warning and continues when sending a reminder fails', async () => {
+ const warnSpy = jest.spyOn(service['logger'], 'warn');
+ mockEmailsService.sendEmails.mockRejectedValueOnce(
+ new Error('SES failure'),
+ );
+
+ await expect(
+ service.sendConfirmDeliveryReminders(),
+ ).resolves.toBeUndefined();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalled();
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to send confirm delivery reminder to'),
+ );
+
+ warnSpy.mockRestore();
+ });
+ });
});
diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts
index 4de85340d..2a726fa1f 100644
--- a/apps/backend/src/orders/order.service.ts
+++ b/apps/backend/src/orders/order.service.ts
@@ -27,7 +27,7 @@ import { ApplicationStatus } from '../shared/types';
import { VolunteerOrder } from '../volunteers/types';
import { EmailsService } from '../emails/email.service';
import { FoodRequest } from '../foodRequests/request.entity';
-import { emailTemplates } from '../emails/emailTemplates';
+import { emailTemplates, EMAIL_REDIRECT_URL } from '../emails/emailTemplates';
import { UsersService } from '../users/users.service';
import { OrderSummary } from '../pantries/types';
import { PantriesService } from '../pantries/pantries.service';
@@ -557,6 +557,55 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${
}
}
+ async sendConfirmDeliveryReminders(): Promise {
+ // One reminder per unconfirmed order (status still SHIPPED). The loop stops
+ // for an order once it becomes DELIVERED. Reminders only start a week after
+ // the order shipped
+ const orders = await this.repo
+ .createQueryBuilder('order')
+ .leftJoinAndSelect('order.request', 'request')
+ .leftJoinAndSelect('request.pantry', 'pantry')
+ .leftJoinAndSelect('pantry.pantryUser', 'pantryUser')
+ .leftJoinAndSelect('order.assignee', 'assignee')
+ .leftJoinAndSelect('order.foodManufacturer', 'foodManufacturer')
+ .where('order.status = :status', { status: OrderStatus.SHIPPED })
+ .andWhere('pantry.status = :pantryStatus', {
+ pantryStatus: ApplicationStatus.APPROVED,
+ })
+ .andWhere("order.shippedAt <= NOW() - INTERVAL '7 days'")
+ .getMany();
+
+ if (orders.length === 0) {
+ this.logger.log(
+ 'No pantries with unconfirmed deliveries, skipping email sending.',
+ );
+ return;
+ }
+
+ for (const order of orders) {
+ const toEmail = order.request.pantry.pantryUser.email;
+ const message = emailTemplates.pantryConfirmDeliveryReminder({
+ pantryName: order.request.pantry.pantryName,
+ fmName: order.foodManufacturer.foodManufacturerName,
+ confirmDeliveryLink: `${EMAIL_REDIRECT_URL}/pantry-order-management?orderId=${order.orderId}&action=confirm-delivery`,
+ volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`,
+ volunteerEmail: order.assignee.email,
+ });
+
+ try {
+ await this.emailsService.sendEmails({
+ toEmail,
+ subject: message.subject,
+ bodyHtml: message.bodyHTML,
+ });
+ } catch {
+ this.logger.warn(
+ `Failed to send confirm delivery reminder to ${toEmail}.`,
+ );
+ }
+ }
+ }
+
async getOrdersByPantry(pantryId: number): Promise {
validateId(pantryId, 'Pantry');
diff --git a/apps/backend/src/orders/orders.scheduler.ts b/apps/backend/src/orders/orders.scheduler.ts
new file mode 100644
index 000000000..2c12f800d
--- /dev/null
+++ b/apps/backend/src/orders/orders.scheduler.ts
@@ -0,0 +1,17 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Cron } from '@nestjs/schedule';
+import { OrdersService } from './order.service';
+
+@Injectable()
+export class OrdersSchedulerService {
+ private readonly logger = new Logger(OrdersSchedulerService.name);
+
+ constructor(private readonly ordersService: OrdersService) {}
+
+ // 12 PM on every Monday
+ @Cron('0 0 12 * * 1', { timeZone: 'America/New_York' })
+ async handleWeeklyConfirmDeliveryReminder() {
+ this.logger.log('Running weekly confirm-delivery reminder cron job');
+ await this.ordersService.sendConfirmDeliveryReminders();
+ }
+}
diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx
index 737b866a1..865222a48 100644
--- a/apps/frontend/src/containers/pantryOrderManagement.tsx
+++ b/apps/frontend/src/containers/pantryOrderManagement.tsx
@@ -124,13 +124,13 @@ const PantryOrderManagement: React.FC = () => {
useEffect(() => {
const orderIdFromUrl = searchParams.get('orderId');
+ const action = searchParams.get('action');
const allOrders = Object.values(statusOrders).flat();
if (!orderIdFromUrl || allOrders.length === 0) return;
const id = Number(orderIdFromUrl);
const match = allOrders.find((o) => o.orderId === id);
if (match) {
- setSelectedOrderId(match.orderId);
// Paginate the containing status to the page that holds this order.
for (const status of Object.values(OrderStatus)) {
const sorted = [...statusOrders[status]].sort((a, b) =>
@@ -145,6 +145,15 @@ const PantryOrderManagement: React.FC = () => {
break;
}
}
+
+ if (
+ action === 'confirm-delivery' &&
+ match.status === OrderStatus.SHIPPED
+ ) {
+ setSelectedActionOrder(match);
+ } else {
+ setSelectedOrderId(match.orderId);
+ }
} else {
navigate(ROUTES.PANTRY_ORDER_MANAGEMENT, { replace: true });
}
@@ -236,7 +245,10 @@ const PantryOrderManagement: React.FC = () => {
orderId={selectedActionOrder.orderId}
orderCreatedAt={selectedActionOrder.createdAt}
isOpen={true}
- onClose={() => setSelectedActionOrder(null)}
+ onClose={() => {
+ setSelectedActionOrder(null);
+ navigate(ROUTES.PANTRY_ORDER_MANAGEMENT, { replace: true });
+ }}
onSuccess={() => {
fetchOrders();
setAlertMessage('Delivery Confirmed', AlertStatus.INFO);