diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index c3a4de760..3acfed5ac 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -10,6 +10,7 @@ import { DonationItem } from '../donationItems/donationItems.entity'; import { DonationItemsModule } from '../donationItems/donationItems.module'; import { Allocation } from '../allocations/allocations.entity'; import { AllocationModule } from '../allocations/allocations.module'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { AllocationModule } from '../allocations/allocations.module'; forwardRef(() => AuthModule), DonationItemsModule, AllocationModule, + EmailsModule, ], controllers: [DonationsController], providers: [DonationService, DonationsSchedulerService], diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 619fd7d34..9a7979d3f 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -17,6 +17,9 @@ import { ReplaceDonationItemsDto, } from '../donationItems/dtos/create-donation-items.dto'; import { FoodType } from '../donationItems/types'; +import { mock } from 'jest-mock-extended'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; jest.setTimeout(60000); @@ -132,11 +135,15 @@ const TODAYOfWeek = (iso: string): DayOfWeek => { return days[new Date(iso).getDay()]; }; +const mockEmailsService = mock(); + describe('DonationService', () => { let service: DonationService; let donationItemService: DonationItemsService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -168,6 +175,10 @@ describe('DonationService', () => { provide: DataSource, useValue: testDataSource, }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, ], }).compile(); @@ -177,6 +188,7 @@ describe('DonationService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); @@ -628,6 +640,144 @@ describe('DonationService', () => { ); expect(donation.occurrencesRemaining).toEqual(3); }); + + it('sends fmRecurringDonationReminder email with correct parameters when expired date is processed', async () => { + const pastDate = daysAgo(5); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + const manufacturer = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ + where: { foodManufacturerName: 'FoodCorp Industries' }, + relations: ['foodManufacturerRepresentative'], + }); + + if (!manufacturer) + throw new Error('Missing FoodCorp Industries manufacturer'); + + await service.handleRecurringDonations(); + + const message = emailTemplates.fmRecurringDonationReminder({ + fmName: manufacturer.foodManufacturerName, + resubmitDonationId: donationId, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [manufacturer.foodManufacturerRepresentative.email], + message.subject, + message.bodyHTML, + ); + }); + + it('skips recurrence update and logs warning when initial email fails', async () => { + const pastDate = daysAgo(5); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + const warnSpy = jest.spyOn(service['logger'], 'warn'); + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await service.handleRecurringDonations(); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Automated email failed to send. Skipping recurrence update for donation id ${donationId}`, + ), + ); + + // donation state preserved — failed email means we skipped the update + const donation = await service.findOne(donationId); + expect(donation.occurrencesRemaining).toBe(3); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.nextDonationDates?.[0].toDateString()).toEqual( + pastDate.toDateString(), + ); + + warnSpy.mockRestore(); + }); + + it("processes other donations when one donation's initial email fails", async () => { + // 3 weekly donations whose replacement dates are all in the future + // (no cascading), each starting at occurrencesRemaining=3. + const donationId1 = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [daysAgo(1)], + occurrencesRemaining: 3, + }); + const donationId2 = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [daysAgo(3)], + occurrencesRemaining: 3, + }); + const donationId3 = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [daysAgo(5)], + occurrencesRemaining: 3, + }); + + // Reject the first sendEmails call. Whichever donation getAll yields + // first will fail; the other two will succeed. + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await service.handleRecurringDonations(); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(3); + + const donations = await Promise.all([ + service.findOne(donationId1), + service.findOne(donationId2), + service.findOne(donationId3), + ]); + + // Exactly one donation should be unchanged (the one whose email failed) + // and the other two should be decremented from 3 → 2. + const remaining = donations.map((d) => d.occurrencesRemaining).sort(); + expect(remaining).toEqual([2, 2, 3]); + }); + + it('breaks out of cascade and logs warning when cascade email fails', async () => { + // 14-day-old weekly date triggers the cascade — its replacement (7daysAgo) is also expired. + const pastDate = daysAgo(14); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 5, + }); + + const warnSpy = jest.spyOn(service['logger'], 'warn'); + mockEmailsService.sendEmails + .mockResolvedValueOnce(undefined) // initial send (pastDate) succeeds + .mockRejectedValueOnce(new Error('Email failed')); // first cascade send fails → break + + await service.handleRecurringDonations(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Cascading recalculation of next dates failed for donation id ${donationId}`, + ), + ); + + warnSpy.mockRestore(); + }); }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 289b47e17..553ec5c7d 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + InternalServerErrorException, Logger, NotFoundException, } from '@nestjs/common'; @@ -18,11 +19,12 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationItem } from '../donationItems/donationItems.entity'; import { Allocation } from '../allocations/allocations.entity'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; @Injectable() export class DonationService { private readonly logger = new Logger(DonationService.name); - constructor( @InjectRepository(Donation) private repo: Repository, @InjectRepository(Allocation) @@ -33,6 +35,7 @@ export class DonationService { private manufacturerRepo: Repository, private donationItemsService: DonationItemsService, @InjectDataSource() private dataSource: DataSource, + private emailsService: EmailsService, ) {} async findOne(donationId: number): Promise { @@ -50,7 +53,10 @@ export class DonationService { async getAll(): Promise { return this.repo.find({ - relations: ['foodManufacturer'], + relations: [ + 'foodManufacturer', + 'foodManufacturer.foodManufacturerRepresentative', + ], }); } @@ -206,13 +212,24 @@ export class DonationService { break; } - this.logger.log(`Placeholder for sending automated email`); - - /** - * IMPORTANT: future logic below should only proceed if the email is successfully sent - */ - const emailSent = true; - if (!emailSent) continue; + let message = null; + try { + message = emailTemplates.fmRecurringDonationReminder({ + fmName: donation.foodManufacturer.foodManufacturerName, + resubmitDonationId: donation.donationId, + }); + + await this.emailsService.sendEmails( + [donation.foodManufacturer.foodManufacturerRepresentative.email], + message.subject, + message.bodyHTML, + ); + } catch { + this.logger.warn( + `Automated email failed to send. Skipping recurrence update for donation id ${donation.donationId}`, + ); + continue; + } dates.splice(i, 1); i--; @@ -228,11 +245,22 @@ export class DonationService { // cascading recalculation of next dates when replacement dates are also expired while (nextDate.getTime() <= today.getTime() && occurrences > 0) { - this.logger.log( - `Placeholder for sending automated email for replacement date`, - ); - const cascadeEmailSent = true; - if (!cascadeEmailSent) break; + try { + await this.emailsService.sendEmails( + [ + donation.foodManufacturer.foodManufacturerRepresentative + .email, + ], + message.subject, + message.bodyHTML, + ); + } catch { + // Early escape to prevent getting stuck in while loop + this.logger.warn( + `Cascading recalculation of next dates failed for donation id ${donation.donationId}, exiting early`, + ); + break; + } occurrences -= 1; diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 7d4b8c0f3..9c51d2e84 100644 --- a/apps/backend/src/emails/emailTemplates.ts +++ b/apps/backend/src/emails/emailTemplates.ts @@ -15,8 +15,8 @@ export const emailTemplates = {

Hi ${params.name},

We're excited to let you know that your Securing Safe Food account has been - approved and is now active. You can now log in using the credentials created - during registration to begin submitting requests, managing donations, and + approved and is now active. You can now log in + using the credentials created during registration to begin submitting requests, managing donations, and coordinating with our network.

@@ -91,11 +91,109 @@ export const emailTemplates = {

Hi,

A new food request has been submitted by ${params.pantryName}. - Please log on to the SSF platform to review these request details and begin coordination when ready. + Please log on to the SSF platform + to review these request details and begin coordination when ready.

Thank you for your continued support of our network and mission!

Best regards,
The Securing Safe Food Team

`, }), + + fmRecurringDonationReminder: (params: { + fmName: string; + resubmitDonationId: number; + }): EmailTemplate => ({ + subject: 'Reminder: Submit Your Scheduled Recurring Donation with SSF', + bodyHTML: ` +

Hi ${params.fmName},

+

+ This is a friendly reminder from Securing Safe Food that your recurring donation + schedule indicates a new donation submission is due. +

+

+ When you have a moment, please log into your account and submit your current + donation availability so we can continue matching your contributions with pantry requests. +

+

+ You can resubmit this donation by visiting your donation management portal. +

+

+ We greatly appreciate your continued generosity and support of our mission. Your + recurring donations make a meaningful and consistent impact for the communities we serve. +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + trackingLinkAvailable: (params: { + pantryName: string; + fmName: string; + trackingLink: string; + volunteerName: string; + volunteerEmail: string; + }): EmailTemplate => ({ + subject: `Tracking Information for your ${params.fmName} delivery (Securing Safe Food)`, + bodyHTML: ` +

Hi ${params.pantryName},

+

+ Good news! Tracking information is now available for your upcoming SSF delivery + from ${params.fmName}. You can use this tracking information to monitor the + status of your shipment or log into your portal for more information on your + expected donation. +

+

+ Tracking Link: ${params.trackingLink} +

+

+ You can use the tracking link above to monitor your shipment, or log into your portal for full order details and updates. +

+

+ If you experience any issues or have questions, please contact your coordinator, + ${params.volunteerName}, at ${params.volunteerEmail}, and our team will be happy to assist. +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + pantryConfirmsOrderDelivery: (params: { + volunteerName: string; + pantryName: string; + fmName: string; + }): EmailTemplate => ({ + subject: `${params.pantryName} Confirmed for your ${params.fmName} Order`, + bodyHTML: ` +

Hi ${params.volunteerName},

+

+ ${params.pantryName} has confirmed receipt of the most recent ${params.fmName} + order you are assigned to. Please log into the platform + to review the completed request or check for additional information. +

+

+ Thank you for your coordination and support in helping reach this order to completion! +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + volunteerPantryAssignmentChanged: (params: { + volunteerName: string; + }): EmailTemplate => ({ + subject: 'Your SSF Pantry Assignment has been updated', + bodyHTML: ` +

Hi ${params.volunteerName},

+

+ Your pantry assignment with SSF has been updated. Please log into the platform + to review your current assignments and any active requests that may require your attention. +

+

+ Thank you for your continued support of our partners and mission. +

+

Best regards,
The Securing Safe Food Team

+

+ To view your pantry assignments, please click the following link: + ${EMAIL_REDIRECT_URL}/volunteer-assigned-pantries +

+ `, + }), }; diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 4d3321e23..cd4c9fda1 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -189,7 +189,7 @@ describe('FoodManufacturersService', () => { const pending = await service.getPendingManufacturers(); const manufacturer = pending[0]; const id = manufacturer.foodManufacturerId; - const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({ + const message = emailTemplates.pantryFmApplicationApproved({ name: manufacturer.foodManufacturerRepresentative.firstName, }); @@ -198,8 +198,8 @@ describe('FoodManufacturersService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( [manufacturer.foodManufacturerRepresentative.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 13be82b49..f98e0e469 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -250,7 +250,7 @@ describe('RequestsService', () => { ]); if (!pantry) throw new Error('Missing pantry test object'); - const { subject, bodyHTML } = emailTemplates.pantrySubmitsFoodRequest({ + const message = emailTemplates.pantrySubmitsFoodRequest({ pantryName: pantry.pantryName, }); const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); @@ -258,8 +258,8 @@ describe('RequestsService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( volunteerEmails, - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); @@ -277,7 +277,7 @@ describe('RequestsService', () => { ]); if (!pantry) throw new Error('Missing pantry test object'); - const { subject, bodyHTML } = emailTemplates.pantrySubmitsFoodRequest({ + const message = emailTemplates.pantrySubmitsFoodRequest({ pantryName: pantry.pantryName, }); const volunteerEmails = (pantry.volunteers ?? []).map((v) => v.email); @@ -286,8 +286,8 @@ describe('RequestsService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( volunteerEmails, - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index 7e4c638de..0e3367fb0 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -17,6 +17,7 @@ import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; import { DonationItemsModule } from '../donationItems/donationItems.module'; import { Allocation } from '../allocations/allocations.entity'; import { Donation } from '../donations/donations.entity'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { Donation } from '../donations/donations.entity'; ManufacturerModule, DonationItemsModule, DonationModule, + EmailsModule, ], controllers: [OrdersController], providers: [OrdersService], diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 92d37acc3..b06a98159 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -6,7 +6,11 @@ import { testDataSource } from '../config/typeormTestDataSource'; import { OrderStatus, VolunteerAction } from './types'; import { Pantry } from '../pantries/pantries.entity'; import { OrderDetailsDto } from './dtos/order-details.dto'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; import { FoodType } from '../donationItems/types'; import { FoodRequest } from '../foodRequests/request.entity'; @@ -29,14 +33,20 @@ import { CreateOrderDto } from './dtos/create-order.dto'; import { DataSource } 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'; // Set 1 minute timeout for async DB operations jest.setTimeout(60000); +const mockEmailsService = mock(); + describe('OrdersService', () => { let service: OrdersService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + // Initialize DataSource once if (!testDataSource.isInitialized) { await testDataSource.initialize(); @@ -62,9 +72,7 @@ describe('OrdersService', () => { }, { provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, + useValue: mockEmailsService, }, { provide: getRepositoryToken(Order), @@ -109,6 +117,7 @@ describe('OrdersService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); @@ -530,6 +539,64 @@ describe('OrdersService', () => { expect(updatedOrder.status).toEqual(OrderStatus.SHIPPED); expect(updatedOrder.shippedAt).toBeDefined(); }); + + it('sends trackingLinkAvailable email to pantry user when order is shipped', async () => { + const orderId = 4; + const order = await testDataSource.getRepository(Order).findOne({ + where: { orderId }, + relations: [ + 'request', + 'request.pantry', + 'request.pantry.pantryUser', + 'foodManufacturer', + 'assignee', + ], + }); + + if (!order) throw new Error('Missing order test object'); + + const dto: TrackingCostDto = { + trackingLink: 'testtracking.com', + shippingCost: 5.0, + }; + + await service.updateTrackingCostInfo(orderId, dto); + + const message = emailTemplates.trackingLinkAvailable({ + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + trackingLink: 'https://testtracking.com/', + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + volunteerEmail: order.assignee.email, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [order.request.pantry.pantryUser.email], + message.subject, + message.bodyHTML, + ); + }); + + it('still updates order to shipped if tracking link email fails to send', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect( + service.updateTrackingCostInfo(4, { + trackingLink: 'testtracking.com', + shippingCost: 5.0, + }), + ).rejects.toThrow( + new InternalServerErrorException( + 'Failed to send new tracking link available email to pantry', + ), + ); + + const order = await service.findOne(4); + expect(order.status).toBe(OrderStatus.SHIPPED); + }); }); describe('checkAndFulfillDonations', () => { @@ -762,6 +829,61 @@ describe('OrdersService', () => { new BadRequestException('Can only confirm delivery for shipped orders'), ); }); + + it('sends pantryConfirmsOrderDelivery email to volunteer when delivery is confirmed', async () => { + const orderId = 3; + const order = await testDataSource.getRepository(Order).findOne({ + where: { orderId }, + relations: [ + 'request', + 'request.pantry', + 'foodManufacturer', + 'assignee', + ], + }); + + if (!order) throw new Error('Missing order test object'); + + await service.confirmDelivery( + orderId, + { dateReceived: new Date().toISOString(), feedback: 'Great!' }, + [], + ); + + const message = emailTemplates.pantryConfirmsOrderDelivery({ + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [order.assignee.email], + message.subject, + message.bodyHTML, + ); + }); + + it('still updates order to delivered if delivery confirmation email fails to send', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect( + service.confirmDelivery( + 3, + { dateReceived: new Date().toISOString(), feedback: 'Great!' }, + [], + ), + ).rejects.toThrow( + new InternalServerErrorException( + 'Failed to send order delivery confirmation email to volunteer', + ), + ); + + const order = await service.findOne(3); + expect(order.status).toBe(OrderStatus.DELIVERED); + }); }); describe('createOrder', () => { diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index ff41610fa..b868b8e50 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + InternalServerErrorException, NotFoundException, } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; @@ -24,6 +25,8 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { AllocationsService } from '../allocations/allocations.service'; import { ApplicationStatus } from '../shared/types'; import { VolunteerOrder } from '../volunteers/types'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; @Injectable() export class OrdersService { @@ -39,6 +42,7 @@ export class OrdersService { private donationItemsService: DonationItemsService, private allocationsService: AllocationsService, @InjectDataSource() private dataSource: DataSource, + private emailsService: EmailsService, ) {} // TODO: when order is created, set FM @@ -428,7 +432,10 @@ export class OrdersService { throw new BadRequestException('Invalid date format for dateReceived'); } - const order = await this.repo.findOneBy({ orderId }); + const order = await this.repo.findOne({ + where: { orderId }, + relations: ['request', 'request.pantry', 'foodManufacturer', 'assignee'], + }); if (!order) { throw new NotFoundException(`Order ${orderId} not found`); @@ -449,6 +456,24 @@ export class OrdersService { await this.requestsService.updateRequestStatus(order.requestId); + try { + const message = emailTemplates.pantryConfirmsOrderDelivery({ + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + }); + + await this.emailsService.sendEmails( + [order.assignee.email], + message.subject, + message.bodyHTML, + ); + } catch { + throw new InternalServerErrorException( + 'Failed to send order delivery confirmation email to volunteer', + ); + } + return updatedOrder; } @@ -493,7 +518,16 @@ export class OrdersService { } dto.trackingLink = sanitized; - const order = await this.repo.findOneBy({ orderId }); + const order = await this.repo.findOne({ + where: { orderId }, + relations: [ + 'request', + 'request.pantry', + 'request.pantry.pantryUser', + 'foodManufacturer', + 'assignee', + ], + }); if (!order) { throw new NotFoundException(`Order ${orderId} not found`); } @@ -513,6 +547,26 @@ export class OrdersService { await this.repo.save(order); await this.checkAndFulfillDonations(orderId); + + try { + const message = emailTemplates.trackingLinkAvailable({ + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + trackingLink: dto.trackingLink, + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + volunteerEmail: order.assignee.email, + }); + + await this.emailsService.sendEmails( + [order.request.pantry.pantryUser.email], + message.subject, + message.bodyHTML, + ); + } catch { + throw new InternalServerErrorException( + 'Failed to send new tracking link available email to pantry', + ); + } } async checkAndFulfillDonations(orderId: number): Promise { diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 83208c3ef..f1b37b282 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -204,7 +204,7 @@ describe('PantriesService', () => { it('sends approval email to pantry user', async () => { const pantry = await service.findOne(5); - const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({ + const message = emailTemplates.pantryFmApplicationApproved({ name: pantry.pantryUser.firstName, }); @@ -213,8 +213,8 @@ describe('PantriesService', () => { expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( [pantry.pantryUser.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); }); diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 0f7902798..93b260bdc 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -156,12 +156,12 @@ describe('UsersService', () => { const result = await service.create(createUserDto); - const { subject, bodyHTML } = emailTemplates.volunteerAccountCreated(); + const message = emailTemplates.volunteerAccountCreated(); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( [createUserDto.email], - subject, - bodyHTML, + message.subject, + message.bodyHTML, ); expect(mockAuthService.adminCreateUser).toHaveBeenCalledWith({ firstName: createUserDto.firstName, diff --git a/apps/backend/src/volunteers/volunteers.module.ts b/apps/backend/src/volunteers/volunteers.module.ts index 003910968..01997c832 100644 --- a/apps/backend/src/volunteers/volunteers.module.ts +++ b/apps/backend/src/volunteers/volunteers.module.ts @@ -8,6 +8,7 @@ import { VolunteersService } from './volunteers.service'; import { UsersModule } from '../users/users.module'; import { RequestsModule } from '../foodRequests/request.module'; import { OrdersModule } from '../orders/order.module'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { OrdersModule } from '../orders/order.module'; forwardRef(() => AuthModule), RequestsModule, OrdersModule, + EmailsModule, ], controllers: [VolunteersController], providers: [VolunteersService], diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index b665751b0..44d36fd03 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -1,4 +1,7 @@ -import { NotFoundException } from '@nestjs/common'; +import { + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; @@ -22,6 +25,10 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { AllocationsService } from '../allocations/allocations.service'; import { DonationService } from '../donations/donations.service'; import { Allocation } from '../allocations/allocations.entity'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates } from '../emails/emailTemplates'; + +const mockEmailsService = mock(); jest.setTimeout(60000); @@ -29,6 +36,8 @@ describe('VolunteersService', () => { let service: VolunteersService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -58,9 +67,7 @@ describe('VolunteersService', () => { }, { provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, + useValue: mockEmailsService, }, { provide: getRepositoryToken(User), @@ -101,6 +108,7 @@ describe('VolunteersService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); @@ -264,6 +272,44 @@ describe('VolunteersService', () => { const pantryIds = result.pantries?.map((p) => p.pantryId); expect(pantryIds).toEqual([2, 3]); }); + + it('sends volunteerPantryAssignmentChanged email to volunteer when pantries are assigned', async () => { + const volunteerId = 7; + const volunteer = await testDataSource + .getRepository(User) + .findOne({ where: { id: volunteerId } }); + + if (!volunteer) throw new Error('Missing volunteer test object'); + + await service.assignPantriesToVolunteer(volunteerId, [1]); + + const message = emailTemplates.volunteerPantryAssignmentChanged({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [volunteer.email], + message.subject, + message.bodyHTML, + ); + }); + + it('still assigns pantries if email fails to send', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect(service.assignPantriesToVolunteer(6, [2])).rejects.toThrow( + new InternalServerErrorException( + 'Failed to send new food request notification email to volunteers', + ), + ); + + const pantries = await service.getVolunteerPantries(6); + const pantryIds = pantries.map((p) => p.pantryId); + expect(pantryIds).toContain(2); + }); }); describe('findRequestsByVolunteer', () => { diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index b3cfe114d..69e024aa9 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../users/users.entity'; @@ -9,6 +13,8 @@ import { PantriesService } from '../pantries/pantries.service'; import { UsersService } from '../users/users.service'; import { Assignments, VolunteerOrder } from './types'; import { RequestsService } from '../foodRequests/request.service'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; import { OrdersService } from '../orders/order.service'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; @@ -20,6 +26,7 @@ export class VolunteersService { private usersService: UsersService, private pantriesService: PantriesService, private requestsService: RequestsService, + private emailsService: EmailsService, private ordersService: OrdersService, ) {} @@ -85,7 +92,24 @@ export class VolunteersService { ); volunteer.pantries = [...existingPantries, ...newPantries]; - return this.repo.save(volunteer); + const saved = await this.repo.save(volunteer); + + try { + const message = emailTemplates.volunteerPantryAssignmentChanged({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + await this.emailsService.sendEmails( + [volunteer.email], + message.subject, + message.bodyHTML, + ); + } catch { + throw new InternalServerErrorException( + 'Failed to send new food request notification email to volunteers', + ); + } + + return saved; } async findRequestsByVolunteer( diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index 4f2e7821d..abee7b77b 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -21,6 +21,7 @@ import ApiClient from '@api/apiClient'; import { CreateDonationDto, DayOfWeek, + DonationItem, FoodType, RecurrenceEnum, RepeatOnState, @@ -35,6 +36,8 @@ interface NewDonationFormModalProps { onDonationSuccess: () => void; isOpen: boolean; onClose: () => void; + prefillItems?: DonationItem[]; + hideRecurring?: boolean; } interface DonationRow { @@ -106,19 +109,35 @@ const NewDonationFormModal: React.FC = ({ onDonationSuccess, isOpen, onClose, + prefillItems, + hideRecurring = false, }) => { useModalBodyCleanup(); - const [rows, setRows] = useState([ - { - id: 1, - foodItem: '', - foodType: '', - numItems: '', - ozPerItem: '', - valuePerItem: '', - foodRescue: false, - }, - ]); + const [rows, setRows] = useState(() => { + if (prefillItems && prefillItems.length > 0) { + return prefillItems.map((item, index) => ({ + id: index + 1, + foodItem: item.itemName, + foodType: item.foodType, + numItems: String(item.quantity), + ozPerItem: item.ozPerItem != null ? String(item.ozPerItem) : '', + valuePerItem: + item.estimatedValue != null ? String(item.estimatedValue) : '', + foodRescue: item.foodRescue, + })); + } + return [ + { + id: 1, + foodItem: '', + foodType: '', + numItems: '', + ozPerItem: '', + valuePerItem: '', + foodRescue: false, + }, + ]; + }); const [isRecurring, setIsRecurring] = useState(false); const [repeatEvery, setRepeatEvery] = useState('1'); @@ -321,25 +340,27 @@ const NewDonationFormModal: React.FC = ({ > Add New Row + - { - setIsRecurring(!!e.checked); - setRepeatInterval( - e.checked - ? RecurrenceEnum.WEEKLY - : RecurrenceEnum.NONE, - ); - }} - > - - - - - - Make Donation Recurring - - + {!hideRecurring && ( + { + setIsRecurring(!!e.checked); + setRepeatInterval( + e.checked + ? RecurrenceEnum.WEEKLY + : RecurrenceEnum.NONE, + ); + }} + > + + + + + + Make Donation Recurring + + + )} diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index 2d0b85d62..ea921becb 100644 --- a/apps/frontend/src/components/forms/orderDetailsModal.tsx +++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx @@ -118,7 +118,7 @@ const OrderDetailsModal: React.FC = ({ - + Fulfilled by {orderDetails?.foodManufacturerName} @@ -176,8 +176,8 @@ const OrderDetailsModal: React.FC = ({ {foodRequest.status === FoodRequestStatus.CLOSED ? ( Closed @@ -185,7 +185,7 @@ const OrderDetailsModal: React.FC = ({ Active @@ -279,7 +279,7 @@ const OrderDetailsModal: React.FC = ({ {orderDetails?.trackingLink ? ( = ({ - + {pantryName} @@ -196,16 +196,16 @@ const RequestDetailsModal: React.FC = ({ {currentOrder.status === OrderStatus.DELIVERED ? ( Received ) : ( In Progress diff --git a/apps/frontend/src/components/forms/requestFormModal.tsx b/apps/frontend/src/components/forms/requestFormModal.tsx index 4de9e6061..43f3cbfc0 100644 --- a/apps/frontend/src/components/forms/requestFormModal.tsx +++ b/apps/frontend/src/components/forms/requestFormModal.tsx @@ -149,7 +149,9 @@ const FoodRequestFormModal: React.FC = ({ justifyContent="space-between" > {requestedSize || 'Select size'} - + + + diff --git a/apps/frontend/src/components/forms/resubmitDonationModal.tsx b/apps/frontend/src/components/forms/resubmitDonationModal.tsx new file mode 100644 index 000000000..1a6d4c238 --- /dev/null +++ b/apps/frontend/src/components/forms/resubmitDonationModal.tsx @@ -0,0 +1,388 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Box, + Button, + CloseButton, + Dialog, + Flex, + Portal, + Text, + VStack, + Badge, +} from '@chakra-ui/react'; +import { ChevronDown } from 'lucide-react'; +import { + CreateDonationDto, + DonationDetails, + DonationItem, + RecurrenceEnum, +} from '../../types/types'; +import ApiClient from '@api/apiClient'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useAlert } from '../../hooks/alert'; +import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; +import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; + +interface ResubmitDonationModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + donations: DonationDetails[]; + initialDonationId?: number | null; +} + +const formatDonationDate = (dateString: string) => + new Date(dateString).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); + +const ResubmitDonationModal: React.FC = ({ + isOpen, + onClose, + onSuccess, + donations, + initialDonationId, +}) => { + useModalBodyCleanup(); + const [errorAlertState, setErrorMessage] = useAlert(); + const [selectedDonationId, setSelectedDonationId] = useState( + null, + ); + const [items, setItems] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const groupedItems = useGroupedItemsByFoodType(items); + + const sortedDonations = [...donations].sort( + (a, b) => + new Date(b.donation.dateDonated).getTime() - + new Date(a.donation.dateDonated).getTime(), + ); + + const selectedDonation = donations.find( + (d) => d.donation.donationId === selectedDonationId, + ); + + const fetchItemsForDonation = useCallback( + async (donationId: number) => { + try { + const fetchedItems = await ApiClient.getDonationItemsByDonationId( + donationId, + ); + setItems(fetchedItems); + } catch { + setErrorMessage('Error loading donation details'); + } + }, + [setErrorMessage], + ); + + useEffect(() => { + if (isOpen && initialDonationId != null) { + setSelectedDonationId(initialDonationId); + fetchItemsForDonation(initialDonationId); + } + }, [isOpen, initialDonationId, fetchItemsForDonation]); + + const handleSelect = (donationId: number) => { + setSelectedDonationId(donationId); + fetchItemsForDonation(donationId); + }; + + const handleClose = () => { + setSelectedDonationId(null); + setItems([]); + setIsDropdownOpen(false); + onClose(); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); + const dto: CreateDonationDto = { + foodManufacturerId: fmId, + recurrence: RecurrenceEnum.NONE, + items: items.map((item) => ({ + itemName: item.itemName, + quantity: item.quantity, + ozPerItem: + item.ozPerItem != null ? Number(item.ozPerItem) : undefined, + estimatedValue: + item.estimatedValue != null + ? Number(item.estimatedValue) + : undefined, + foodType: item.foodType, + foodRescue: item.foodRescue, + })), + }; + await ApiClient.postDonation(dto); + onSuccess(); + handleClose(); + } catch { + setErrorMessage('Error submitting donation'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + { + if (!e.open) handleClose(); + }} + closeOnInteractOutside + > + {errorAlertState && ( + + )} + + + + + + + + + + + Previous Donations + + + + + + + + Select a Previous Donation + + + setIsDropdownOpen(!isDropdownOpen)} + border="1px solid" + borderColor="neutral.100" + borderRadius="md" + h="40px" + px={3} + align="center" + w="full" + cursor="pointer" + > + {selectedDonation ? ( + + + {formatDonationDate( + selectedDonation.donation.dateDonated, + )} + + {selectedDonation.donation.recurrence !== + RecurrenceEnum.NONE && ( + + Recurring + + )} + + ) : ( + + Select a previous donation + + )} + + + + {isDropdownOpen && ( + <> + setIsDropdownOpen(false)} + zIndex={10} + /> + + {sortedDonations.map((d) => ( + { + handleSelect(d.donation.donationId); + setIsDropdownOpen(false); + }} + > + + {formatDonationDate(d.donation.dateDonated)} + + {d.donation.recurrence !== + RecurrenceEnum.NONE && ( + + Recurring + + )} + + ))} + + + )} + + + + {selectedDonationId !== null && ( + + + Donation Details + + + + {Object.entries(groupedItems).map( + ([foodType, typeItems]) => ( + + + {foodType} + + {typeItems.map((item) => ( + + + {item.itemName} + + + + {item.quantity} + + + ))} + + ), + )} + + + + )} + + + + + + + + + + + ); +}; + +export default ResubmitDonationModal; diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index d4a588e5d..fb65e4e5b 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -689,7 +689,7 @@ const OrderStatusSection: React.FC = ({ ); diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index f60109c27..e74e4f172 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Box, Button, + Flex, Table, Heading, Pagination, @@ -15,9 +16,18 @@ import ApiClient from '@api/apiClient'; import { DonationDetails, DonationStatus } from '../types/types'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; +import ResubmitDonationModal from '@components/forms/resubmitDonationModal'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { ROUTES } from '../routes'; const FoodManufacturerDonationManagement: React.FC = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const resubmitDonationId = searchParams.get('resubmitDonationId'); + const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); + const [isResubmitOpen, setIsResubmitOpen] = useState(false); + // State to hold donations grouped by status const [statusDonations, setStatusDonations] = useState<{ [key in DonationStatus]: DonationDetails[]; @@ -44,7 +54,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { const MAX_PER_STATUS = 5; // Fetch all donations on component mount and sorts them into their appropriate status lists - const fetchDonations = async () => { + const fetchDonations = async (checkResubmit = false) => { try { const data = await ApiClient.getAllDonationsByFoodManufacturer(1); // Replace with actual food manufacturer ID @@ -75,15 +85,34 @@ const FoodManufacturerDonationManagement: React.FC = () => { [DonationStatus.MATCHED]: 1, }; setCurrentPages(initialPages); + + // Only run this on mount, not every single time + if (checkResubmit && resubmitDonationId) { + const id = parseInt(resubmitDonationId, 10); + const allDonations: DonationDetails[] = Object.values(grouped).flat(); + const exists = allDonations.some((d) => d.donation.donationId === id); + if (exists) { + setIsResubmitOpen(true); + } else { + navigate(ROUTES.FM_DONATION_MANAGEMENT); + } + } } catch (error) { alert('Error fetching donations: ' + error); } }; useEffect(() => { - fetchDonations(); + fetchDonations(true); }, []); + const handleResubmitClose = () => { + setIsResubmitOpen(false); + if (resubmitDonationId) { + navigate(ROUTES.FM_DONATION_MANAGEMENT); + } + }; + const handlePageChange = (status: DonationStatus, page: number) => { setCurrentPages((prev) => ({ ...prev, @@ -97,22 +126,38 @@ const FoodManufacturerDonationManagement: React.FC = () => { Donation Management - + + + + {isLogDonationOpen && ( { /> )} + {isResubmitOpen && ( + + )} + {Object.values(DonationStatus).map((status) => { const allDonationsByStatus = statusDonations[status] || []; diff --git a/apps/frontend/src/containers/formRequests.tsx b/apps/frontend/src/containers/formRequests.tsx index 7adba8da6..486b41eff 100644 --- a/apps/frontend/src/containers/formRequests.tsx +++ b/apps/frontend/src/containers/formRequests.tsx @@ -76,7 +76,7 @@ const FormRequests: React.FC = () => { return ( - + Food Request Management {alertState && ( @@ -93,7 +93,7 @@ const FormRequests: React.FC = () => { fontWeight="semibold" fontSize="14px" color="neutral.50" - bgColor="#2B4E60" + bgColor="blue.core" onClick={newRequestDisclosure.onOpen} px={2} > @@ -164,9 +164,9 @@ const FormRequests: React.FC = () => { {paginatedRequests.map((request) => ( - + setOpenReadOnlyRequest(request)} > @@ -176,8 +176,8 @@ const FormRequests: React.FC = () => { {request.status === FoodRequestStatus.ACTIVE ? ( { ) : ( { const pageSize = 8; - const USER_ICON_COLORS = ['#F89E19', '#CC3538', '#2795A5', '#2B4E60']; + const USER_ICON_COLORS = ['yellow.core', 'red', 'teal.ssf', 'blue.core']; useEffect(() => { const fetchVolunteers = async () => { @@ -67,7 +67,7 @@ const VolunteerManagement: React.FC = () => { return ( - + Volunteer Management {alertState && ( @@ -90,7 +90,12 @@ const VolunteerManagement: React.FC = () => { } + startElement={ + + } maxW={200} > = ({ = ({ {order.assignee?.id === currentUser?.id &&