From 223bf59a9d21f3332717f73fb057ff726cc551f2 Mon Sep 17 00:00:00 2001 From: Justin Wang Date: Fri, 12 Jun 2026 11:04:10 -0400 Subject: [PATCH] implementation --- apps/backend/src/emails/emailTemplates.ts | 29 ++++ apps/backend/src/orders/order.module.ts | 3 +- apps/backend/src/orders/order.service.spec.ts | 143 +++++++++++++++++- apps/backend/src/orders/order.service.ts | 51 ++++++- apps/backend/src/orders/orders.scheduler.ts | 17 +++ .../src/containers/pantryOrderManagement.tsx | 17 ++- 6 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 apps/backend/src/orders/orders.scheduler.ts 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 2aee388f1..0fe717e45 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); @@ -1708,4 +1709,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 a420c98e1..2407912fd 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'; @@ -547,6 +547,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 5ee5339c2..23b0b82c3 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -126,13 +126,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) => @@ -147,6 +147,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 }); } @@ -246,7 +256,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(); setSuccessMessage('Delivery Confirmed');