diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts
index e24b716e1..24e770e61 100644
--- a/apps/backend/src/emails/emailTemplates.ts
+++ b/apps/backend/src/emails/emailTemplates.ts
@@ -307,4 +307,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 0e1e01246..f24338fc0 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';
@@ -45,7 +46,7 @@ import { UsersModule } from '../users/users.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 3d32f52a5..4c1e5454c 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,144 @@ ${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 warnSpy = jest.spyOn(service['logger'], 'warn');
+
+ await service.sendConfirmDeliveryReminders();
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'No pantries with unconfirmed deliveries, skipping email sending.',
+ ),
+ );
+ expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
+
+ warnSpy.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');
+
+ const before = (await eligibleOrders()).length;
+
+ // Add a second shipped order to the same request (same pantry), shipped
+ // long enough ago to be eligible.
+ 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();
+
+ expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(before + 1);
+ });
+
+ 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('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 166c9c603..ea9808725 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';
@@ -556,6 +556,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.warn(
+ '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 1d1cf4e02..2bad9d6c0 100644
--- a/apps/frontend/src/containers/pantryOrderManagement.tsx
+++ b/apps/frontend/src/containers/pantryOrderManagement.tsx
@@ -125,13 +125,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) =>
@@ -146,6 +146,16 @@ const PantryOrderManagement: React.FC = () => {
break;
}
}
+
+ if (
+ action === 'confirm-delivery' &&
+ match.status === OrderStatus.SHIPPED
+ ) {
+ setSelectedActionOrder(match);
+ navigate(ROUTES.PANTRY_ORDER_MANAGEMENT, { replace: true });
+ } else {
+ setSelectedOrderId(match.orderId);
+ }
} else {
navigate(ROUTES.PANTRY_ORDER_MANAGEMENT, { replace: true });
}
@@ -237,7 +247,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);