diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index 00302ee6..c57f3b7a 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'; import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; @Module({ @@ -23,6 +24,7 @@ import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; forwardRef(() => AuthModule), DonationItemsModule, AllocationModule, + EmailsModule, ManufacturerModule, ], controllers: [DonationsController], diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 7fc1b0b6..c6e55678 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(); @@ -629,6 +641,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({ + toEmail: manufacturer.foodManufacturerRepresentative.email, + subject: message.subject, + bodyHtml: 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(); + }); }); }); @@ -996,7 +1146,7 @@ describe('DonationService', () => { }); it('throws when foodManufacturerId does not exist', async () => { - expect( + await expect( service.create({ foodManufacturerId: 99999, recurrence: RecurrenceEnum.NONE, diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 88263b43..2e4ee053 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', + ], }); } @@ -207,13 +213,25 @@ 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({ + toEmail: + donation.foodManufacturer.foodManufacturerRepresentative.email, + subject: message.subject, + bodyHtml: 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--; @@ -229,11 +247,21 @@ 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({ + toEmail: + donation.foodManufacturer.foodManufacturerRepresentative + .email, + subject: message.subject, + bodyHtml: 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} due to an email sending failure, exiting early`, + ); + break; + } occurrences -= 1; diff --git a/apps/backend/src/emails/awsSes.wrapper.ts b/apps/backend/src/emails/awsSes.wrapper.ts index a3218042..b5e2a7a2 100644 --- a/apps/backend/src/emails/awsSes.wrapper.ts +++ b/apps/backend/src/emails/awsSes.wrapper.ts @@ -4,13 +4,9 @@ import MailComposer from 'nodemailer/lib/mail-composer'; import * as dotenv from 'dotenv'; import Mail from 'nodemailer/lib/mailer'; import { AMAZON_SES_CLIENT } from './awsSesClient.factory'; +import { SendEmailDTO } from './dto/send-email.dto'; dotenv.config(); -export interface EmailAttachment { - filename: string; - content: Buffer; -} - @Injectable() export class AmazonSESWrapper { private client: SESv2Client; @@ -26,26 +22,29 @@ export class AmazonSESWrapper { /** * Sends an email via Amazon SES. * - * @param recipientEmails the email addresses of the recipients - * @param subject the subject of the email - * @param bodyHtml the HTML body of the email - * @param attachments any attachments to include in the email + * @param email the {@link SendEmailDTO} describing the message to send * @resolves if the email was sent successfully * @rejects if the email was not sent successfully */ - async sendEmails( - recipientEmails: string[], - subject: string, - bodyHtml: string, - attachments?: EmailAttachment[], - ) { + async sendEmails(email: SendEmailDTO) { + const { toEmail, subject, bodyHtml, ccEmails, bccEmails, attachments } = + email; + const mailOptions: Mail.Options = { from: process.env.AWS_SES_SENDER_EMAIL, - to: recipientEmails, + to: toEmail, subject: subject, html: bodyHtml, }; + if (ccEmails && ccEmails.length > 0) { + mailOptions.cc = ccEmails; + } + + if (bccEmails && bccEmails.length > 0) { + mailOptions.bcc = bccEmails; + } + if (attachments) { mailOptions.attachments = attachments.map((a) => ({ filename: a.filename, @@ -58,7 +57,9 @@ export class AmazonSESWrapper { const command = new SendEmailCommand({ Destination: { - ToAddresses: recipientEmails, + ToAddresses: [toEmail], + CcAddresses: ccEmails, + BccAddresses: bccEmails, }, Content: { Raw: { diff --git a/apps/backend/src/emails/dto/send-email.dto.ts b/apps/backend/src/emails/dto/send-email.dto.ts index 4639790d..fcda05d3 100644 --- a/apps/backend/src/emails/dto/send-email.dto.ts +++ b/apps/backend/src/emails/dto/send-email.dto.ts @@ -6,13 +6,16 @@ import { IsArray, Length, } from 'class-validator'; -import { EmailAttachment } from '../awsSes.wrapper'; + +export interface EmailAttachment { + filename: string; + content: Buffer; +} export class SendEmailDTO { - @IsArray() - @IsEmail({}, { each: true }) - @Length(1, 255, { each: true }) - toEmails!: string[]; + @IsEmail() + @Length(1, 255) + toEmail!: string; @IsString() @IsNotEmpty() @@ -23,6 +26,18 @@ export class SendEmailDTO { @IsNotEmpty() bodyHtml!: string; + @IsArray() + @IsOptional() + @IsEmail({}, { each: true }) + @Length(1, 255, { each: true }) + ccEmails?: string[]; + + @IsArray() + @IsOptional() + @IsEmail({}, { each: true }) + @Length(1, 255, { each: true }) + bccEmails?: string[]; + @IsArray() @IsOptional() attachments?: EmailAttachment[]; diff --git a/apps/backend/src/emails/email.service.ts b/apps/backend/src/emails/email.service.ts index 6792a61c..1eb55bf7 100644 --- a/apps/backend/src/emails/email.service.ts +++ b/apps/backend/src/emails/email.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import Bottleneck from 'bottleneck'; -import { AmazonSESWrapper, EmailAttachment } from './awsSes.wrapper'; +import { AmazonSESWrapper } from './awsSes.wrapper'; +import { SendEmailDTO } from './dto/send-email.dto'; @Injectable() export class EmailsService { @@ -18,30 +19,17 @@ export class EmailsService { /** * Sends an email. * - * @param recipientEmail the email address of the recipients - * @param subject the subject of the email - * @param bodyHtml the HTML body of the email - * @param attachments any base64 encoded attachments to include in the email + * @param email the {@link SendEmailDTO} describing the message to send * @resolves if the email was sent successfully * @rejects if the email was not sent successfully */ - public async sendEmails( - recipientEmails: string[], - subject: string, - bodyHTML: string, - attachments?: EmailAttachment[], - ): Promise { + public async sendEmails(email: SendEmailDTO): Promise { if ( process.env.SEND_AUTOMATED_EMAILS && process.env.SEND_AUTOMATED_EMAILS === 'true' && - recipientEmails.length > 0 + email.toEmail ) { - return this.amazonSESWrapper.sendEmails( - recipientEmails, - subject, - bodyHTML, - attachments, - ); + return this.amazonSESWrapper.sendEmails(email); } this.logger.warn('Automated emails are disabled. Email not sent.'); return Promise.resolve(); diff --git a/apps/backend/src/emails/emailTemplates.ts b/apps/backend/src/emails/emailTemplates.ts index 7d4b8c0f..0b34c3a6 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,110 @@ 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 the receipt ofan order from ${params.fmName} + which 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 8074c298..aeee345d 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -189,18 +189,18 @@ 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, }); await service.approve(id); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [manufacturer.foodManufacturerRepresentative.email], - subject, - bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: manufacturer.foodManufacturerRepresentative.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); }); it('should still update manufacturer status to approved if email send fails', async () => { @@ -367,16 +367,16 @@ describe('FoodManufacturersService', () => { }); const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [dto.contactEmail], - userMessage.subject, - userMessage.bodyHTML, - ); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [SSF_PARTNER_EMAIL], - adminMessage.subject, - adminMessage.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: dto.contactEmail, + subject: userMessage.subject, + bodyHtml: userMessage.bodyHTML, + }); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: SSF_PARTNER_EMAIL, + subject: adminMessage.subject, + bodyHtml: adminMessage.bodyHTML, + }); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index bc7a415f..d2b4b4d8 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -291,11 +291,11 @@ export class FoodManufacturersService { name: foodManufacturerContact.firstName, }); - await this.emailsService.sendEmails( - [foodManufacturerContact.email], - manufacturerMessage.subject, - manufacturerMessage.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: foodManufacturerContact.email, + subject: manufacturerMessage.subject, + bodyHtml: manufacturerMessage.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send food manufacturer application submitted confirmation email to representative', @@ -304,11 +304,11 @@ export class FoodManufacturersService { try { const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); - await this.emailsService.sendEmails( - [SSF_PARTNER_EMAIL], - adminMessage.subject, - adminMessage.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: SSF_PARTNER_EMAIL, + subject: adminMessage.subject, + bodyHtml: adminMessage.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send new food manufacturer application notification email to SSF', @@ -380,11 +380,11 @@ export class FoodManufacturersService { name: newFoodManufacturer.firstName, }); - await this.emailsService.sendEmails( - [newFoodManufacturer.email], - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: newFoodManufacturer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send food manufacturer account approved notification email to representative', diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 67f2ce89..7c72d2fb 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -237,7 +237,7 @@ describe('RequestsService', () => { expect(result.additionalInformation).toBeNull(); }); - it('should send food request email to pantry volunteers', async () => { + it('should send food request email to pantry user with volunteers BCCed', async () => { const pantryId = 1; const pantry = await testDataSource.getRepository(Pantry).findOne({ where: { pantryId }, @@ -250,20 +250,21 @@ 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); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - volunteerEmails, - subject, - bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + bccEmails: volunteerEmails, + }); }); - it('should send emails to nobody if request creation succeeds wthout any volunteers', async () => { + it('should send email to pantry user with empty BCC when pantry has no volunteers', async () => { // Harbor Community Center - no volunteers assigned const pantryId = 5; const pantry = await testDataSource.getRepository(Pantry).findOne({ @@ -277,18 +278,19 @@ 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); expect(volunteerEmails).toEqual([]); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - volunteerEmails, - subject, - bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + bccEmails: volunteerEmails, + }); }); it('should still save food request to database if email send fails', async () => { diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 12a4dd51..b8ccdaba 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -252,11 +252,12 @@ export class RequestsService { pantryName: pantry.pantryName, }); - await this.emailsService.sendEmails( - volunteerEmails, - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + bccEmails: volunteerEmails, + }); } catch { throw new InternalServerErrorException( 'Failed to send new food request notification email to volunteers', diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index 7e4c638d..0e3367fb 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 11e50eb7..cba6cf63 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 { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { FoodType } from '../donationItems/types'; import { FoodRequest } from '../foodRequests/request.entity'; @@ -26,18 +30,24 @@ import { AuthService } from '../auth/auth.service'; import { DonationService } from '../donations/donations.service'; import { PantriesService } from '../pantries/pantries.service'; import { CreateOrderDto } from './dtos/create-order.dto'; -import { DataSource } from 'typeorm'; +import { DataSource, 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'; // Set 1 minute timeout for async DB operations jest.setTimeout(60000); +const mockEmailsService = mock(); + describe('OrdersService', () => { let service: OrdersService; let donationService: DonationService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + // Initialize DataSource once if (!testDataSource.isInitialized) { await testDataSource.initialize(); @@ -63,9 +73,7 @@ describe('OrdersService', () => { }, { provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, + useValue: mockEmailsService, }, { provide: getRepositoryToken(Order), @@ -111,6 +119,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(); @@ -619,6 +628,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({ + toEmail: order.assignee.email, + subject: message.subject, + bodyHtml: 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', () => { @@ -1302,5 +1366,141 @@ describe('OrdersService', () => { expect(spy).toHaveBeenCalled(); }); + + it('sends trackingLinkAvailable email to pantry user for each updated order with a tracking link', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId); + const itemId2 = await insertDonationItem(donationId); + const orderId2 = await createPendingOrder(); + await insertAllocation(4, itemId1); + await insertAllocation(orderId2, itemId2); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [ + { + orderId: 4, + trackingLink: 'https://tracking1.com', + shippingCost: 5.0, + }, + { + orderId: orderId2, + trackingLink: 'https://tracking2.com', + shippingCost: 7.5, + }, + ], + }); + + const updatedOrders = await testDataSource.getRepository(Order).find({ + where: { orderId: In([4, orderId2]) }, + relations: [ + 'request', + 'request.pantry', + 'request.pantry.pantryUser', + 'foodManufacturer', + 'assignee', + ], + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes( + updatedOrders.length, + ); + for (const order of updatedOrders) { + const message = emailTemplates.trackingLinkAvailable({ + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + trackingLink: order.trackingLink!, + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + volunteerEmail: order.assignee.email, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: order.request.pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); + } + }); + + it('does not send email for orders without a tracking link', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, shippingCost: 5.0 }], + }); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('does not send email for orders that already had a tracking link when only shipping cost is updated', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, trackingLink: 'https://tracking.com' }], + }); + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + mockEmailsService.sendEmails.mockClear(); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, shippingCost: 5.0 }], + }); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('logs a warning when one email fails but still updates all orders without throwing', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId); + const itemId2 = await insertDonationItem(donationId); + const orderId2 = await createPendingOrder(); + await insertAllocation(4, itemId1); + await insertAllocation(orderId2, itemId2); + + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + const warnSpy = jest.spyOn(service['logger'], 'warn'); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [ + { + orderId: 4, + trackingLink: 'https://tracking1.com', + shippingCost: 5.0, + }, + { + orderId: orderId2, + trackingLink: 'https://tracking2.com', + shippingCost: 7.5, + }, + ], + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Automated tracking link email failed to send for order', + ), + ); + + const after1 = await service.findOne(4); + const after2 = await service.findOne(orderId2); + expect(after1.trackingLink).toEqual('https://tracking1.com'); + expect(after1.shippingCost).toEqual(5.0); + expect(after1.status).toEqual(OrderStatus.SHIPPED); + expect(after2.trackingLink).toEqual('https://tracking2.com'); + expect(after2.shippingCost).toEqual(7.5); + expect(after2.status).toEqual(OrderStatus.SHIPPED); + + warnSpy.mockRestore(); + }); }); }); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 892690c3..2096977e 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -1,6 +1,8 @@ import { BadRequestException, Injectable, + InternalServerErrorException, + Logger, NotFoundException, } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; @@ -24,9 +26,13 @@ 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 { + private readonly logger = new Logger(OrdersService.name); + constructor( @InjectRepository(Order) private repo: Repository, @InjectRepository(Pantry) private pantryRepo: Repository, @@ -39,6 +45,7 @@ export class OrdersService { private donationItemsService: DonationItemsService, private allocationsService: AllocationsService, @InjectDataSource() private dataSource: DataSource, + private emailsService: EmailsService, ) {} // TODO: when order is created, set FM @@ -429,7 +436,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`); @@ -448,6 +458,24 @@ export class OrdersService { await this.repo.save(order); 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({ + toEmail: order.assignee.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); + } catch { + throw new InternalServerErrorException( + 'Failed to send order delivery confirmation email to volunteer', + ); + } } async getOrdersByPantry( @@ -488,7 +516,7 @@ export class OrdersService { } const orders = new Set(dto.orders.map((o) => o.orderId)); - if (orders.size != dto.orders.length) { + if (orders.size !== dto.orders.length) { throw new BadRequestException( 'Cannot update duplicate entries for orders', ); @@ -507,14 +535,14 @@ export class OrdersService { } } - let donation: Donation | null; + const ordersGainedTrackingLink: Order[] = []; await this.dataSource.transaction(async (transactionManager) => { const orderTransactionRepo = transactionManager.getRepository(Order); const donationTransactionRepo = transactionManager.getRepository(Donation); - donation = await donationTransactionRepo.findOneBy({ + const donation = await donationTransactionRepo.findOneBy({ donationId: dto.donationId, }); if (!donation) { @@ -524,8 +552,15 @@ export class OrdersService { const ordersToUpdate: Order[] = []; for (const entry of dto.orders) { - const order = await orderTransactionRepo.findOneBy({ - orderId: entry.orderId, + const order = await orderTransactionRepo.findOne({ + where: { orderId: entry.orderId }, + relations: [ + 'request', + 'request.pantry', + 'request.pantry.pantryUser', + 'foodManufacturer', + 'assignee', + ], }); if (!order) { throw new NotFoundException(`Order ${entry.orderId} not found`); @@ -553,16 +588,25 @@ export class OrdersService { ); } + // Check to see if tracking link existed in the first place + const hadTrackingLink = !!order.trackingLink; + if (entry.trackingLink !== undefined) { order.trackingLink = entry.trackingLink; } if (entry.shippingCost !== undefined) { order.shippingCost = entry.shippingCost; } - if (order.trackingLink != null && order.shippingCost != null) { + if (order.trackingLink !== null && order.shippingCost !== null) { order.status = OrderStatus.SHIPPED; order.shippedAt = new Date(); } + + // If tracking link didn't exist previous, but does now, add it to the list to send an email + if (!hadTrackingLink && !!order.trackingLink) { + ordersGainedTrackingLink.push(order); + } + ordersToUpdate.push(order); } @@ -572,6 +616,29 @@ export class OrdersService { transactionManager, ); }); + + for (const order of ordersGainedTrackingLink) { + try { + const message = emailTemplates.trackingLinkAvailable({ + pantryName: order.request.pantry.pantryName, + fmName: order.foodManufacturer.foodManufacturerName, + trackingLink: order.trackingLink!, + volunteerName: `${order.assignee.firstName} ${order.assignee.lastName}`, + volunteerEmail: order.assignee.email, + }); + + await this.emailsService.sendEmails({ + toEmail: order.request.pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); + } catch { + this.logger.warn( + `Automated tracking link email failed to send for order ${order.orderId}`, + ); + continue; + } + } } async completeVolunteerAction( diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 0c9cc4dc..ebd2d46b 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -1,12 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PantriesService } from './pantries.service'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { In } from 'typeorm'; import { Pantry } from './pantries.entity'; import { BadRequestException, ConflictException, ForbiddenException, InternalServerErrorException, + Logger, NotFoundException, } from '@nestjs/common'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; @@ -204,18 +206,18 @@ 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, }); await service.approve(5); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [pantry.pantryUser.email], - subject, - bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: pantry.pantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); }); it('should still update pantry status to approved if email send fails', async () => { @@ -375,16 +377,16 @@ describe('PantriesService', () => { }); const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [dto.contactEmail], - userMessage.subject, - userMessage.bodyHTML, - ); - expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( - [SSF_PARTNER_EMAIL], - adminMessage.subject, - adminMessage.bodyHTML, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: dto.contactEmail, + subject: userMessage.subject, + bodyHtml: userMessage.bodyHTML, + }); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: SSF_PARTNER_EMAIL, + subject: adminMessage.subject, + bodyHtml: adminMessage.bodyHTML, + }); expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(2); }); }); @@ -1146,6 +1148,148 @@ describe('PantriesService', () => { .findOne({ where: { pantryId: 1 }, relations: ['volunteers'] }); expect(pantryBefore?.volunteers).toEqual(pantryAfter?.volunteers); }); + + it('sends volunteerPantryAssignmentChanged email to each newly added volunteer', async () => { + const addVolunteerIds = [7, 8]; + const volunteers = await testDataSource + .getRepository(User) + .find({ where: { id: In(addVolunteerIds) } }); + + expect(volunteers).toHaveLength(addVolunteerIds.length); + + await service.updatePantryVolunteers(1, { + addVolunteerIds, + removeVolunteerIds: [], + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes( + addVolunteerIds.length, + ); + for (const volunteer of volunteers) { + const message = emailTemplates.volunteerPantryAssignmentChanged({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: volunteer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); + } + }); + + it('does not send email when no new volunteers are added', async () => { + // volunteer 6 is already assigned to pantry 1 + await service.updatePantryVolunteers(1, { + addVolunteerIds: [6], + removeVolunteerIds: [], + }); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('logs a warning when one email fails but still sends the others without throwing', async () => { + const addVolunteerIds = [7, 8]; + + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + const warnSpy = jest.spyOn(service['logger'], 'warn'); + + await service.updatePantryVolunteers(1, { + addVolunteerIds, + removeVolunteerIds: [], + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes( + addVolunteerIds.length, + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Automated email failed to send. Skipping pantry assignment update for volunteer id 7 and pantryId 1`, + ), + ); + + const pantry = await testDataSource + .getRepository(Pantry) + .findOne({ where: { pantryId: 1 }, relations: ['volunteers'] }); + const pantryVolunteerIds = pantry?.volunteers?.map((v) => v.id) ?? []; + for (const id of addVolunteerIds) { + expect(pantryVolunteerIds).toContain(id); + } + + warnSpy.mockRestore(); + }); + + it('sends volunteerPantryAssignmentChanged email to each removed volunteer', async () => { + const removeVolunteerIds = [6, 9]; + const volunteers = await testDataSource + .getRepository(User) + .find({ where: { id: In(removeVolunteerIds) } }); + + expect(volunteers).toHaveLength(removeVolunteerIds.length); + + await service.updatePantryVolunteers(1, { + addVolunteerIds: [], + removeVolunteerIds, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes( + removeVolunteerIds.length, + ); + for (const volunteer of volunteers) { + const message = emailTemplates.volunteerPantryAssignmentChanged({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: volunteer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); + } + }); + + it('does not send email when removing a volunteer not assigned to the pantry', async () => { + // volunteer 8 is not assigned to pantry 1 + await service.updatePantryVolunteers(1, { + addVolunteerIds: [], + removeVolunteerIds: [8], + }); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('logs a warning when one removal email fails but still removes the others without throwing', async () => { + const removeVolunteerIds = [6, 9]; + + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + const warnSpy = jest.spyOn(service['logger'], 'warn'); + + await service.updatePantryVolunteers(1, { + addVolunteerIds: [], + removeVolunteerIds, + }); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes( + removeVolunteerIds.length, + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Automated email failed to send. Skipping pantry assignment update for volunteer id 6 and pantryId 1`, + ), + ); + + const pantry = await testDataSource + .getRepository(Pantry) + .findOne({ where: { pantryId: 1 }, relations: ['volunteers'] }); + const pantryVolunteerIds = pantry?.volunteers?.map((v) => v.id) ?? []; + for (const id of removeVolunteerIds) { + expect(pantryVolunteerIds).not.toContain(id); + } + + warnSpy.mockRestore(); + }); }); describe('getDashboardStats', () => { diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 07832531..43978fec 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -7,6 +7,7 @@ import { ConflictException, InternalServerErrorException, ForbiddenException, + Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; @@ -32,6 +33,7 @@ import { UpdatePantryVolunteersDto } from './dtos/update-pantry-volunteers-dto'; @Injectable() export class PantriesService { + private readonly logger = new Logger(PantriesService.name); constructor( @InjectRepository(Pantry) private repo: Repository, @InjectRepository(Order) private orderRepo: Repository, @@ -347,11 +349,11 @@ export class PantriesService { name: pantryContact.firstName, }); - await this.emailsService.sendEmails( - [pantryContact.email], - pantryMessage.subject, - pantryMessage.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: pantryContact.email, + subject: pantryMessage.subject, + bodyHtml: pantryMessage.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send pantry application submitted confirmation email to representative', @@ -360,11 +362,11 @@ export class PantriesService { try { const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); - await this.emailsService.sendEmails( - [SSF_PARTNER_EMAIL], - adminMessage.subject, - adminMessage.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: SSF_PARTNER_EMAIL, + subject: adminMessage.subject, + bodyHtml: adminMessage.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send new pantry application notification email to SSF', @@ -434,11 +436,11 @@ export class PantriesService { name: newPantryUser.firstName, }); - await this.emailsService.sendEmails( - [newPantryUser.email], - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: newPantryUser.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send pantry account approved notification email to representative', @@ -540,21 +542,58 @@ export class PantriesService { ); } - const volunteersToAdd = users.filter((u) => addSet.has(u.id)); - const currentVolunteers = pantry.volunteers ?? []; + const currentVolunteerIds = new Set(currentVolunteers.map((v) => v.id)); const volunteersToKeep = currentVolunteers.filter( (v) => !removeSet.has(v.id), ); - // avoid re-adding volunteers already associated with the pantry - const existingVolunteerIds = new Set(volunteersToKeep.map((v) => v.id)); - const newVolunteers = volunteersToAdd.filter( - (u) => !existingVolunteerIds.has(u.id), + // only notify volunteers who weren't already assigned to the pantry + const newVolunteers = users.filter( + (u) => addSet.has(u.id) && !currentVolunteerIds.has(u.id), + ); + + // only notify volunteers who were actually assigned before being removed + const removedVolunteers = users.filter( + (u) => removeSet.has(u.id) && currentVolunteerIds.has(u.id), ); pantry.volunteers = [...volunteersToKeep, ...newVolunteers]; await this.repo.save(pantry); + + for (const volunteer of newVolunteers) { + try { + const message = emailTemplates.volunteerPantryAssignmentChanged({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + await this.emailsService.sendEmails({ + toEmail: volunteer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); + } catch { + this.logger.warn( + `Automated email failed to send. Skipping pantry assignment update for volunteer id ${volunteer.id} and pantryId ${pantryId}`, + ); + } + } + + for (const volunteer of removedVolunteers) { + try { + const message = emailTemplates.volunteerPantryAssignmentChanged({ + volunteerName: `${volunteer.firstName} ${volunteer.lastName}`, + }); + await this.emailsService.sendEmails({ + toEmail: volunteer.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); + } catch { + this.logger.warn( + `Automated email failed to send. Skipping pantry assignment update for volunteer id ${volunteer.id} and pantryId ${pantryId}`, + ); + } + } } // given pantryIds should not have duplicates diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index a39c7faa..c81a6664 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -156,13 +156,13 @@ 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, - ); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({ + toEmail: createUserDto.email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); expect(mockAuthService.adminCreateUser).toHaveBeenCalledWith({ firstName: createUserDto.firstName, lastName: createUserDto.lastName, diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 4ae45b9a..a5ea7db2 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -104,11 +104,11 @@ export class UsersService { if (role === Role.VOLUNTEER) { try { const message = emailTemplates.volunteerAccountCreated(); - await this.emailsService.sendEmails( - [email], - message.subject, - message.bodyHTML, - ); + await this.emailsService.sendEmails({ + toEmail: email, + subject: message.subject, + bodyHtml: message.bodyHTML, + }); } catch { throw new InternalServerErrorException( 'Failed to send account created notification email to volunteer', diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index b2868620..b48684f4 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -31,11 +31,6 @@ const mockVolunteer2: Partial = { role: Role.VOLUNTEER, }; -const mockVolunteer3: Partial = { - id: 3, - role: Role.VOLUNTEER, -}; - const mockPantries: Partial[] = [ { pantryId: 1, @@ -146,30 +141,6 @@ describe('VolunteersController', () => { }); }); - describe('POST /:id/pantries', () => { - it('should assign pantries to a volunteer and return result', async () => { - const pantryIds = [1, 3]; - const updatedUser = { - ...mockVolunteer3, - pantries: [mockPantries[0] as Pantry, mockPantries[2] as Pantry], - } as User; - - mockVolunteersService.assignPantriesToVolunteer.mockResolvedValue( - updatedUser, - ); - - const result = await controller.assignPantries(3, pantryIds); - - expect(result).toEqual(updatedUser); - expect(result.pantries).toHaveLength(2); - expect(result.pantries?.[0].pantryId).toBe(1); - expect(result.pantries?.[1].pantryId).toBe(3); - expect( - mockVolunteersService.assignPantriesToVolunteer, - ).toHaveBeenCalledWith(3, pantryIds); - }); - }); - describe('GET /me/assigned-requests', () => { it('returns assigned requests when req.currentUser is present', async () => { const req: AuthenticatedRequest = { diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index ee330b44..fb901854 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -1,12 +1,4 @@ -import { - Controller, - Get, - Param, - ParseIntPipe, - Post, - Body, - Req, -} from '@nestjs/common'; +import { Controller, Get, Param, ParseIntPipe, Req } from '@nestjs/common'; import { User } from '../users/users.entity'; import { Pantry } from '../pantries/pantries.entity'; import { VolunteersService } from './volunteers.service'; @@ -57,14 +49,6 @@ export class VolunteersController { return this.volunteersService.getRecentOrders(id); } - @Post('/:id/pantries') - async assignPantries( - @Param('id', ParseIntPipe) id: number, - @Body('pantryIds') pantryIds: number[], - ): Promise { - return this.volunteersService.assignPantriesToVolunteer(id, pantryIds); - } - @Roles(Role.VOLUNTEER) @Get('/me/assigned-requests') async getAssignedRequests( diff --git a/apps/backend/src/volunteers/volunteers.module.ts b/apps/backend/src/volunteers/volunteers.module.ts index 00391096..01997c83 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 b665751b..86ec4f25 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -22,6 +22,9 @@ 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'; + +const mockEmailsService = mock(); jest.setTimeout(60000); @@ -29,6 +32,8 @@ describe('VolunteersService', () => { let service: VolunteersService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -58,9 +63,7 @@ describe('VolunteersService', () => { }, { provide: EmailsService, - useValue: { - sendEmails: jest.fn().mockResolvedValue(undefined), - }, + useValue: mockEmailsService, }, { provide: getRepositoryToken(User), @@ -101,6 +104,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(); @@ -226,46 +230,6 @@ describe('VolunteersService', () => { }); }); - describe('assignPantriesToVolunteer', () => { - it('assigns new pantries to a volunteer with existing assignments', async () => { - const beforeAssignment = await service.getVolunteerPantries(7); - expect(beforeAssignment).toHaveLength(2); - const beforePantryIds = beforeAssignment.map((p) => p.pantryId); - expect(beforePantryIds).toEqual([2, 3]); - - const result = await service.assignPantriesToVolunteer(7, [1, 4]); - expect(result.pantries).toHaveLength(4); - const afterPantryIds = result.pantries?.map((p) => p.pantryId); - expect(afterPantryIds).toEqual([2, 3, 1, 4]); - }); - - it('assigns pantries to a volunteer with no existing assignments', async () => { - await testDataSource.query( - `DELETE FROM "volunteer_assignments" WHERE volunteer_id = 6`, - ); - - const beforeAssignment = await service.getVolunteerPantries(6); - expect(beforeAssignment).toEqual([]); - - const result = await service.assignPantriesToVolunteer(6, [2, 3]); - expect(result.pantries).toHaveLength(2); - const pantryIds = result.pantries?.map((p) => p.pantryId); - expect(pantryIds).toEqual([2, 3]); - }); - - it('does not contain duplicate pantry assignments when called with ones that already exist', async () => { - const beforeAssignment = await service.getVolunteerPantries(7); - expect(beforeAssignment).toHaveLength(2); - const beforePantryIds = beforeAssignment.map((p) => p.pantryId); - expect(beforePantryIds).toEqual([2, 3]); - - const result = await service.assignPantriesToVolunteer(7, [2, 3]); - expect(result.pantries).toHaveLength(2); - const pantryIds = result.pantries?.map((p) => p.pantryId); - expect(pantryIds).toEqual([2, 3]); - }); - }); - describe('findRequestsByVolunteer', () => { it('returned requests include pantry info', async () => { const requests = await service.findRequestsByVolunteer(7); diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index b3cfe114..376b27c6 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -5,7 +5,6 @@ import { User } from '../users/users.entity'; import { Role } from '../users/types'; import { validateId } from '../utils/validation.utils'; import { Pantry } from '../pantries/pantries.entity'; -import { PantriesService } from '../pantries/pantries.service'; import { UsersService } from '../users/users.service'; import { Assignments, VolunteerOrder } from './types'; import { RequestsService } from '../foodRequests/request.service'; @@ -18,7 +17,6 @@ export class VolunteersService { @InjectRepository(User) private repo: Repository, private usersService: UsersService, - private pantriesService: PantriesService, private requestsService: RequestsService, private ordersService: OrdersService, ) {} @@ -69,25 +67,6 @@ export class VolunteersService { return this.ordersService.getRecentOrdersByAssignee(volunteerId); } - async assignPantriesToVolunteer( - volunteerId: number, - pantryIds: number[], - ): Promise { - const volunteer = await this.findOne(volunteerId); - - const uniquePantryIds = new Set(pantryIds); - - const pantries = await this.pantriesService.findByIds([...uniquePantryIds]); - const existingPantries = volunteer.pantries || []; - const existingPantryIds = new Set(existingPantries.map((p) => p.pantryId)); - const newPantries = pantries.filter( - (p) => !existingPantryIds.has(p.pantryId), - ); - - volunteer.pantries = [...existingPantries, ...newPantries]; - return this.repo.save(volunteer); - } - async findRequestsByVolunteer( volunteerId: number, ): Promise { diff --git a/apps/frontend/src/components/forms/editableFMApplication.tsx b/apps/frontend/src/components/forms/editableFMApplication.tsx index bb9b60ca..a82f61a4 100644 --- a/apps/frontend/src/components/forms/editableFMApplication.tsx +++ b/apps/frontend/src/components/forms/editableFMApplication.tsx @@ -101,11 +101,13 @@ function validateRequired(form: FormState): boolean { interface EditableFMApplicationProps { isEditing: boolean; onEditingChange: (v: boolean) => void; + foodManufacturerId: number; } const EditableFMApplication: React.FC = ({ isEditing, onEditingChange, + foodManufacturerId, }) => { const [application, setApplication] = useState(null); const [error, setError] = useState(null); @@ -114,16 +116,13 @@ const EditableFMApplication: React.FC = ({ const fetchApplication = useCallback(async () => { try { - const manufacturerId = await ApiClient.getCurrentUserFoodManufacturerId(); - if (manufacturerId) { - const data = await ApiClient.getFoodManufacturer(manufacturerId); - setApplication(data); - setForm(buildFormState(data)); - } + const data = await ApiClient.getFoodManufacturer(foodManufacturerId); + setApplication(data); + setForm(buildFormState(data)); } catch { setError('Could not load application details. Please try again later.'); } - }, []); + }, [foodManufacturerId]); useEffect(() => { fetchApplication(); diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index 2d0b85d6..ea921bec 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 ? ( Promise; + foodManufacturerId?: number | null; } type ProfileFieldProps = @@ -70,6 +71,7 @@ const ProfileAccountInfo: React.FC = ({ profile, showTabs, onSave, + foodManufacturerId, }) => { const { firstName, lastName, email, phone } = profile; const [activeTab, setActiveTab] = useState('Account'); @@ -230,10 +232,13 @@ const ProfileAccountInfo: React.FC = ({ {fields} {profile.role === Role.FOODMANUFACTURER ? ( - + foodManufacturerId != null && ( + + ) ) : ( = ({ - + {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 4de9e606..43f3cbfc 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 00000000..afa69729 --- /dev/null +++ b/apps/frontend/src/components/forms/resubmitDonationModal.tsx @@ -0,0 +1,399 @@ +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[]; + foodManufacturerId: number; + initialDonationId?: number | null; + onSelect: (donationId: number) => void; +} + +const formatDonationDate = (dateString: string) => + new Date(dateString).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); + +const ResubmitDonationModal: React.FC = ({ + isOpen, + onClose, + onSuccess, + donations, + foodManufacturerId, + initialDonationId, + onSelect, +}) => { + 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 && + initialDonationId !== selectedDonationId + ) { + handleSelect(initialDonationId); + } + }, [isOpen, initialDonationId, selectedDonationId, fetchItemsForDonation]); + + const handleSelect = (donationId: number) => { + setSelectedDonationId(donationId); + fetchItemsForDonation(donationId); + onSelect(donationId); + }; + + const handleClose = () => { + setSelectedDonationId(null); + setItems([]); + setIsDropdownOpen(false); + onClose(); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + const dto: CreateDonationDto = { + foodManufacturerId, + 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 1cd4896d..2fcebb55 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -719,7 +719,7 @@ const OrderStatusSection: React.FC = ({ ); diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index a5b1b2a8..4c34a201 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,13 +16,21 @@ import ApiClient from '@api/apiClient'; import { DonationDetails, DonationStatus } from '../types/types'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; -import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequiredActionsModal'; +import ResubmitDonationModal from '@components/forms/resubmitDonationModal'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { ROUTES } from '../routes'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; +import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequiredActionsModal'; const MAX_PER_STATUS = 5; const FoodManufacturerDonationManagement: React.FC = () => { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const resubmitDonationId: string | null = + searchParams.get('resubmitDonationId'); + const [isResubmitOpen, setIsResubmitOpen] = useState(false); const [errorAlertState, setErrorMessage] = useAlert(); const [successAlertState, setSuccessMessage] = useAlert(); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); @@ -83,18 +92,37 @@ const FoodManufacturerDonationManagement: React.FC = () => { [DonationStatus.MATCHED]: 1, }; setCurrentPages(initialPages); - } catch { - setErrorMessage('Error fetching donations'); + + return grouped; + } catch (error) { + setErrorMessage('Error fetching donations: ' + error); + return; + } + }; + + const openResubmitFromQueryParam = ( + grouped: Record, + ) => { + if (!resubmitDonationId) return; + 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); } }; - // On page load, get the food manufacturer id and all appropriate donations + // On page load, get the food manufacturer id, fetch its donations, + // and open the resubmit modal if the URL specifies one. useEffect(() => { const init = async () => { try { const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); setManufacturerId(fmId); - await fetchDonations(fmId); + const grouped = await fetchDonations(fmId); + if (grouped) openResubmitFromQueryParam(grouped); } catch { setErrorMessage('Error initializing donation management'); } @@ -102,6 +130,13 @@ const FoodManufacturerDonationManagement: React.FC = () => { init(); }, []); + const handleResubmitClose = () => { + setIsResubmitOpen(false); + if (resubmitDonationId) { + navigate(ROUTES.FM_DONATION_MANAGEMENT); + } + }; + const handlePageChange = (status: DonationStatus, page: number) => { setCurrentPages((prev) => ({ ...prev, @@ -131,24 +166,40 @@ const FoodManufacturerDonationManagement: React.FC = () => { Donation Management - + + + + - {isLogDonationOpen && manufacturerId !== null && ( + {manufacturerId !== null && ( fetchDonations(manufacturerId)} @@ -157,6 +208,22 @@ const FoodManufacturerDonationManagement: React.FC = () => { /> )} + {manufacturerId !== null && ( + fetchDonations(manufacturerId)} + donations={Object.values(statusDonations).flat()} + foodManufacturerId={manufacturerId} + initialDonationId={ + resubmitDonationId ? parseInt(resubmitDonationId, 10) : null + } + onSelect={(donationId) => + setSearchParams({ resubmitDonationId: String(donationId) }) + } + /> + )} + {selectedActionDonation && ( { 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 ProfilePage: React.FC = () => { const [profile, setProfile] = useState(null); const [orgName, setOrgName] = useState(null); + const [foodManufacturerId, setFoodManufacturerId] = useState( + null, + ); const [isLoading, setIsLoading] = useState(true); const [alertState, setAlertMessage] = useAlert(); @@ -37,9 +40,9 @@ const ProfilePage: React.FC = () => { } } else if (user.role === Role.FOODMANUFACTURER) { try { - const foodManufacturerId = - await ApiClient.getCurrentUserFoodManufacturerId(); - const fm = await ApiClient.getFoodManufacturer(foodManufacturerId); + const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); + setFoodManufacturerId(fmId); + const fm = await ApiClient.getFoodManufacturer(fmId); setOrgName(fm.foodManufacturerName); } catch { setAlertMessage('Failed to fetch food manufacturer data.'); @@ -141,6 +144,7 @@ const ProfilePage: React.FC = () => { profile={profile} showTabs={hasTabs} onSave={handleSave} + foodManufacturerId={foodManufacturerId} /> diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index 20ee2137..5249f81d 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -30,7 +30,7 @@ const VolunteerManagement: React.FC = () => { 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 {errorAlertState && ( @@ -98,7 +98,12 @@ const VolunteerManagement: React.FC = () => { } + startElement={ + + } maxW={200} > = ({ = ({ {order.assignee?.id === currentUser?.id &&