diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 26b4e0f44..09c687ecc 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -33,6 +33,7 @@ jobs: DATABASE_NAME_TEST: securing-safe-food-test DATABASE_USERNAME: postgres DATABASE_PASSWORD: postgres + SEND_AUTOMATED_EMAILS: 'true' NX_DAEMON: 'false' CYPRESS_INSTALL_BINARY: '0' steps: diff --git a/apps/backend/src/emails/awsSes.wrapper.ts b/apps/backend/src/emails/awsSes.wrapper.ts index fd0750d58..4d2849bcb 100644 --- a/apps/backend/src/emails/awsSes.wrapper.ts +++ b/apps/backend/src/emails/awsSes.wrapper.ts @@ -57,6 +57,9 @@ export class AmazonSESWrapper { const messageData = await new MailComposer(mailOptions).compile().build(); const command = new SendEmailCommand({ + Destination: { + ToAddresses: recipientEmails, + }, Content: { Raw: { Data: messageData, diff --git a/apps/backend/src/emails/email.service.ts b/apps/backend/src/emails/email.service.ts index a319e1331..6792a61ce 100644 --- a/apps/backend/src/emails/email.service.ts +++ b/apps/backend/src/emails/email.service.ts @@ -21,7 +21,7 @@ export class EmailsService { * @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 inlude in the email + * @param attachments any base64 encoded attachments to include in the email * @resolves if the email was sent successfully * @rejects if the email was not sent successfully */ @@ -31,11 +31,19 @@ export class EmailsService { bodyHTML: string, attachments?: EmailAttachment[], ): Promise { - return this.amazonSESWrapper.sendEmails( - recipientEmails, - subject, - bodyHTML, - attachments, - ); + if ( + process.env.SEND_AUTOMATED_EMAILS && + process.env.SEND_AUTOMATED_EMAILS === 'true' && + recipientEmails.length > 0 + ) { + return this.amazonSESWrapper.sendEmails( + recipientEmails, + subject, + bodyHTML, + attachments, + ); + } + 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 new file mode 100644 index 000000000..3519f1bd4 --- /dev/null +++ b/apps/backend/src/emails/emailTemplates.ts @@ -0,0 +1,101 @@ +export type EmailTemplate = { + subject: string; + bodyHTML: string; + additionalContent?: string; +}; + +export const EMAIL_REDIRECT_URL = 'localhost:4200'; +// TODO: Change this before production to be the actual ssf email +export const SSF_PARTNER_EMAIL = 'example@gmail.com'; + +export const emailTemplates = { + pantryFmApplicationApproved: (params: { name: string }): EmailTemplate => ({ + subject: 'Your Securing Safe Food Account Has Been Approved', + bodyHTML: ` +

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 + coordinating with our network. +

+

+ If you have any questions as you get started or need help navigating the + platform, please do not hesitate to reach out — we are happy to help! +

+

+ We are grateful to have you as part of the SSF community and look forward + to working together to expand access to allergen-safe food. +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + volunteerAccountCreated: (): EmailTemplate => ({ + subject: 'Welcome to Securing Safe Food: Your Volunteer Account Is Ready', + bodyHTML: ` +

Welcome to Securing Safe Food!

+

+ Your volunteer account has been successfully created and you can now log in + to begin supporting pantry coordination, order matching, and delivery logistics. +

+

+ Once logged in, you'll be able to view your assignments, track active requests, + and collaborate with partner organizations. +

+

+ Thank you for being part of our mission. Your time and effort directly help + increase access to safe food for individuals with dietary restrictions. +

+

Best regards,
The Securing Safe Food Team

+

+ To log in to your account, please click the following link: ${EMAIL_REDIRECT_URL}/login +

+ `, + }), + + pantryFmApplicationSubmittedToAdmin: (): EmailTemplate => ({ + subject: 'New Partner Application Submitted', + bodyHTML: ` +

Hi,

+

+ A new partner application has been submitted through the SSF platform. + Please log in to the dashboard to review and take action. +

+

Best regards,
The Securing Safe Food Team

+

+ To review this application, please enter the admin pantry approval dashboard: ${EMAIL_REDIRECT_URL}/approve-pantries +

+ `, + }), + + pantryFmApplicationSubmittedToUser: (params: { + name: string; + }): EmailTemplate => ({ + subject: 'Your Application Has Been Submitted', + bodyHTML: ` +

Hi ${params.name},

+

+ Thank you for your interest in partnering with Securing Safe Food! + Your application has been successfully submitted and is currently under review. We will notify you via email once a decision has been made. +

+

Best regards,
The Securing Safe Food Team

+ `, + }), + + pantrySubmitsFoodRequest: (params: { + pantryName: string; + }): EmailTemplate => ({ + subject: `${params.pantryName} Request Requires Your Review`, + bodyHTML: ` +

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. +

+

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

Best regards,
The Securing Safe Food Team

+ `, + }), +}; diff --git a/apps/backend/src/foodManufacturers/manufacturers.module.ts b/apps/backend/src/foodManufacturers/manufacturers.module.ts index e80b08f5e..8fe5e5c6c 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.module.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.module.ts @@ -5,11 +5,13 @@ import { FoodManufacturersController } from './manufacturers.controller'; import { FoodManufacturersService } from './manufacturers.service'; import { UsersModule } from '../users/users.module'; import { Donation } from '../donations/donations.entity'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ TypeOrmModule.forFeature([FoodManufacturer, Donation]), forwardRef(() => UsersModule), + EmailsModule, ], controllers: [FoodManufacturersController], providers: [FoodManufacturersService], diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts new file mode 100644 index 000000000..42d822614 --- /dev/null +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -0,0 +1,354 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FoodManufacturersService } from './manufacturers.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { FoodManufacturer } from './manufacturers.entity'; +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; +import { ApplicationStatus } from '../shared/types'; +import { testDataSource } from '../config/typeormTestDataSource'; +import { Donation } from '../donations/donations.entity'; +import { User } from '../users/users.entity'; +import { UsersService } from '../users/users.service'; +import { AuthService } from '../auth/auth.service'; +import { EmailsService } from '../emails/email.service'; +import { Pantry } from '../pantries/pantries.entity'; +import { Order } from '../orders/order.entity'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { DonationService } from '../donations/donations.service'; +import { PantriesService } from '../pantries/pantries.service'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; +import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; + +jest.setTimeout(60000); + +const dto: FoodManufacturerApplicationDto = { + foodManufacturerName: 'Test Manufacturer', + foodManufacturerWebsite: 'https://testmanufacturer.com', + contactFirstName: 'Jane', + contactLastName: 'Doe', + contactEmail: 'jane.doe@example.com', + contactPhone: '555-555-5555', + unlistedProductAllergens: [Allergen.SHELLFISH, Allergen.TREE_NUTS], + facilityFreeAllergens: [Allergen.PEANUT, Allergen.FISH], + productsGlutenFree: false, + productsContainSulfites: false, + productsSustainableExplanation: 'none', + inKindDonations: false, + donateWastedFood: DonateWastedFood.ALWAYS, +}; + +const mockEmailsService = mock(); + +describe('FoodManufacturersService', () => { + let service: FoodManufacturersService; + let testModule: TestingModule; + + beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + + if (!testDataSource.isInitialized) { + await testDataSource.initialize(); + } + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + + testModule = await Test.createTestingModule({ + providers: [ + FoodManufacturersService, + UsersService, + DonationService, + PantriesService, + { + provide: AuthService, + useValue: { + adminCreateUser: jest.fn().mockResolvedValue('test-sub'), + }, + }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, + { + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), + }, + { + provide: getRepositoryToken(User), + useValue: testDataSource.getRepository(User), + }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), + }, + { + provide: getRepositoryToken(Pantry), + useValue: testDataSource.getRepository(Pantry), + }, + { + provide: getRepositoryToken(Order), + useValue: testDataSource.getRepository(Order), + }, + { + provide: getRepositoryToken(FoodRequest), + useValue: testDataSource.getRepository(FoodRequest), + }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, + ], + }).compile(); + + service = testModule.get( + FoodManufacturersService, + ); + }); + + beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); + await testDataSource.runMigrations(); + }); + + afterEach(async () => { + await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + }); + + afterAll(async () => { + if (testDataSource.isInitialized) { + await testDataSource.destroy(); + } + }); + + it('service should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findOne', () => { + it('returns manufacturer by existing ID', async () => { + const manufacturer = await service.findOne(1); + expect(manufacturer).toBeDefined(); + expect(manufacturer.foodManufacturerId).toBe(1); + }); + + it('throws NotFoundException for missing ID', async () => { + await expect(service.findOne(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + }); + + describe('getPendingManufacturers', () => { + it('returns manufacturers with pending status', async () => { + const pending = await service.getPendingManufacturers(); + expect(pending.length).toBeGreaterThan(0); + expect(pending.every((m) => m.status === ApplicationStatus.PENDING)).toBe( + true, + ); + }); + }); + + describe('approve', () => { + it('approves a pending manufacturer', async () => { + const pending = await service.getPendingManufacturers(); + const id = pending[0].foodManufacturerId; + + await service.approve(id); + + const approved = await service.findOne(id); + expect(approved.status).toBe(ApplicationStatus.APPROVED); + }); + + it('sends approval email to manufacturer representative', async () => { + const pending = await service.getPendingManufacturers(); + const manufacturer = pending[0]; + const id = manufacturer.foodManufacturerId; + const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({ + name: manufacturer.foodManufacturerRepresentative.firstName, + }); + + await service.approve(id); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [manufacturer.foodManufacturerRepresentative.email], + subject, + bodyHTML, + ); + }); + + it('should still update manufacturer status to approved if email send fails', async () => { + const pending = await service.getPendingManufacturers(); + const id = pending[0].foodManufacturerId; + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect(service.approve(id)).rejects.toThrow('Email failed'); + + const approved = await service.findOne(id); + expect(approved.status).toBe(ApplicationStatus.APPROVED); + }); + + it('throws ConflictException when approving an already approved manufacturer', async () => { + const beforeUserCount = await testDataSource.getRepository(User).count(); + + await expect(service.approve(1)).rejects.toThrow( + new ConflictException( + 'Cannot approve a Food Manufacturer with status: approved', + ), + ); + + const afterUserCount = await testDataSource.getRepository(User).count(); + expect(afterUserCount).toBe(beforeUserCount); + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('throws when approving non-existent manufacturer', async () => { + await expect(service.approve(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + }); + + describe('deny', () => { + it('denies a pending manufacturer', async () => { + const pending = await service.getPendingManufacturers(); + const id = pending[0].foodManufacturerId; + + await service.deny(id); + + const denied = await service.findOne(id); + expect(denied.status).toBe(ApplicationStatus.DENIED); + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('throws ConflictException when denying an already approved manufacturer', async () => { + // FM 1 ('FoodCorp Industries') has status 'approved' in dummy data + await expect(service.deny(1)).rejects.toThrow( + new ConflictException( + 'Cannot deny a Food Manufacturer with status: approved', + ), + ); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + + it('throws when denying non-existent manufacturer', async () => { + await expect(service.deny(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + }); + + describe('addFoodManufacturer', () => { + it('creates manufacturer with minimal required fields', async () => { + await service.addFoodManufacturer(dto); + const saved = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ + where: { foodManufacturerName: 'Test Manufacturer' }, + relations: ['foodManufacturerRepresentative'], + }); + expect(saved).toBeDefined(); + expect(saved?.foodManufacturerRepresentative?.email).toBe( + 'jane.doe@example.com', + ); + expect(saved?.status).toBe(ApplicationStatus.PENDING); + }); + + it('creates manufacturer with all optional fields included', async () => { + const optionalDto: FoodManufacturerApplicationDto = { + ...dto, + foodManufacturerName: 'Test Full Manufacturer', + contactEmail: 'john.smith@example.com', + secondaryContactFirstName: 'Sarah', + secondaryContactLastName: 'Johnson', + secondaryContactEmail: 'sarah.johnson@example.com', + secondaryContactPhone: '555-555-5557', + manufacturerAttribute: ManufacturerAttribute.ORGANIC, + additionalComments: 'We specialize in allergen-free products', + newsletterSubscription: true, + }; + + await service.addFoodManufacturer(optionalDto); + const saved = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ + where: { foodManufacturerName: 'Test Full Manufacturer' }, + relations: ['foodManufacturerRepresentative'], + }); + expect(saved).toBeDefined(); + expect(saved?.foodManufacturerRepresentative?.email).toBe( + 'john.smith@example.com', + ); + expect(saved?.status).toBe(ApplicationStatus.PENDING); + expect(saved?.secondaryContactFirstName).toBe('Sarah'); + expect(saved?.manufacturerAttribute).toBe(ManufacturerAttribute.ORGANIC); + }); + + it('should still save manufacturer to database if email send fails', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect(service.addFoodManufacturer(dto)).rejects.toThrow( + 'Email failed', + ); + + const saved = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ + where: { foodManufacturerName: 'Test Manufacturer' }, + relations: ['foodManufacturerRepresentative'], + }); + expect(saved).toBeDefined(); + expect(saved?.status).toBe(ApplicationStatus.PENDING); + }); + + it('sends confirmation email to applicant and notification email to admin', async () => { + await service.addFoodManufacturer(dto); + + const userMessage = emailTemplates.pantryFmApplicationSubmittedToUser({ + name: dto.contactFirstName, + }); + 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).toHaveBeenCalledTimes(2); + }); + }); + + describe('getFMDonations', () => { + it('returns donations for an existing manufacturer', async () => { + const donations = await service.getFMDonations(1); + expect(Array.isArray(donations)).toBe(true); + }); + + it('returns empty array for manufacturer with no donations', async () => { + await service.addFoodManufacturer(dto); + const saved = await testDataSource + .getRepository(FoodManufacturer) + .findOne({ where: { foodManufacturerName: 'Test Manufacturer' } }); + const donations = await service.getFMDonations(saved!.foodManufacturerId); + expect(donations).toEqual([]); + }); + + it('throws NotFoundException for non-existent manufacturer', async () => { + await expect(service.getFMDonations(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + }); +}); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index ecab69bd3..38bbca7ee 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -14,6 +14,8 @@ import { ApplicationStatus } from '../shared/types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; import { Donation } from '../donations/donations.entity'; +import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; +import { EmailsService } from '../emails/email.service'; @Injectable() export class FoodManufacturersService { @@ -22,6 +24,7 @@ export class FoodManufacturersService { private repo: Repository, private usersService: UsersService, + private emailsService: EmailsService, @InjectRepository(Donation) private donationsRepo: Repository, @@ -119,12 +122,36 @@ export class FoodManufacturersService { foodManufacturerData.newsletterSubscription ?? null; await this.repo.save(foodManufacturer); + + const manufacturerMessage = + emailTemplates.pantryFmApplicationSubmittedToUser({ + name: foodManufacturerContact.firstName, + }); + + await this.emailsService.sendEmails( + [foodManufacturerContact.email], + manufacturerMessage.subject, + manufacturerMessage.bodyHTML, + ); + + const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); + await this.emailsService.sendEmails( + [SSF_PARTNER_EMAIL], + adminMessage.subject, + adminMessage.bodyHTML, + ); } async approve(id: number) { validateId(id, 'Food Manufacturer'); - const foodManufacturer = await this.findOne(id); + const foodManufacturer = await this.repo.findOne({ + where: { foodManufacturerId: id }, + relations: ['foodManufacturerRepresentative'], + }); + if (!foodManufacturer) { + throw new NotFoundException(`Food Manufacturer ${id} not found`); + } if (foodManufacturer.status !== ApplicationStatus.PENDING) { throw new ConflictException( @@ -133,10 +160,7 @@ export class FoodManufacturersService { } const createUserDto: userSchemaDto = { - email: foodManufacturer.foodManufacturerRepresentative.email, - firstName: foodManufacturer.foodManufacturerRepresentative.firstName, - lastName: foodManufacturer.foodManufacturerRepresentative.lastName, - phone: foodManufacturer.foodManufacturerRepresentative.phone, + ...foodManufacturer.foodManufacturerRepresentative, role: Role.FOODMANUFACTURER, }; @@ -146,6 +170,16 @@ export class FoodManufacturersService { status: ApplicationStatus.APPROVED, foodManufacturerRepresentative: newFoodManufacturer, }); + + const message = emailTemplates.pantryFmApplicationApproved({ + name: newFoodManufacturer.firstName, + }); + + await this.emailsService.sendEmails( + [newFoodManufacturer.email], + message.subject, + message.bodyHTML, + ); } async deny(id: number) { diff --git a/apps/backend/src/foodRequests/request.module.ts b/apps/backend/src/foodRequests/request.module.ts index d1cadf066..dcec30601 100644 --- a/apps/backend/src/foodRequests/request.module.ts +++ b/apps/backend/src/foodRequests/request.module.ts @@ -8,6 +8,7 @@ import { Order } from '../orders/order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { DonationItem } from '../donationItems/donationItems.entity'; DonationItem, ]), AuthModule, + EmailsModule, ], controllers: [RequestsController], providers: [RequestsService], diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 452193a16..17fc34373 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -11,13 +11,20 @@ import { FoodType } from '../donationItems/types'; import { DonationItem } from '../donationItems/donationItems.entity'; import { testDataSource } from '../config/typeormTestDataSource'; import { NotFoundException } from '@nestjs/common'; +import { EmailsService } from '../emails/email.service'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates } from '../emails/emailTemplates'; jest.setTimeout(60000); +const mockEmailsService = mock(); + describe('RequestsService', () => { let service: RequestsService; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } @@ -45,6 +52,10 @@ describe('RequestsService', () => { provide: getRepositoryToken(DonationItem), useValue: testDataSource.getRepository(DonationItem), }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, ], }).compile(); @@ -52,6 +63,7 @@ describe('RequestsService', () => { }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); @@ -215,6 +227,72 @@ describe('RequestsService', () => { expect(result.additionalInformation).toBeNull(); }); + it('should send food request email to pantry volunteers', async () => { + const pantryId = 1; + const pantry = await testDataSource.getRepository(Pantry).findOne({ + where: { pantryId }, + relations: ['pantryUser', 'volunteers'], + }); + + await service.create(pantryId, RequestSize.MEDIUM, [ + FoodType.DRIED_BEANS, + FoodType.REFRIGERATED_MEALS, + ]); + + const { subject, bodyHTML } = 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, + ); + }); + + it('should send emails to nobody if request creation succeeds wthout any volunteers', async () => { + // Harbor Community Center - no volunteers assigned + const pantryId = 5; + const pantry = await testDataSource.getRepository(Pantry).findOne({ + where: { pantryId }, + relations: ['pantryUser', 'volunteers'], + }); + + await service.create(pantryId, RequestSize.MEDIUM, [ + FoodType.DRIED_BEANS, + FoodType.REFRIGERATED_MEALS, + ]); + + const { subject, bodyHTML } = 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, + ); + }); + + it('should still save food request to database if email send fails', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + const pantryId = 1; + await expect( + service.create(pantryId, RequestSize.MEDIUM, [FoodType.DRIED_BEANS]), + ).rejects.toThrow('Email failed'); + + const requests = await service.find(pantryId); + expect(requests.length).toBe(3); + }); + it('should throw NotFoundException for non-existent pantry', async () => { await expect( service.create( diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 08660ad96..53be49a80 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -15,6 +15,8 @@ import { } from './dtos/matching.dto'; import { FoodType } from '../donationItems/types'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; @Injectable() export class RequestsService { @@ -26,6 +28,7 @@ export class RequestsService { private foodManufacturerRepo: Repository, @InjectRepository(DonationItem) private donationItemRepo: Repository, + private emailsService: EmailsService, ) {} async findOne(requestId: number): Promise { @@ -215,7 +218,10 @@ export class RequestsService { ): Promise { validateId(pantryId, 'Pantry'); - const pantry = await this.pantryRepo.findOneBy({ pantryId }); + const pantry = await this.pantryRepo.findOne({ + where: { pantryId }, + relations: ['pantryUser', 'volunteers'], + }); if (!pantry) { throw new NotFoundException(`Pantry ${pantryId} not found`); } @@ -227,7 +233,22 @@ export class RequestsService { additionalInformation, }); - return await this.repo.save(foodRequest); + await this.repo.save(foodRequest); + + const volunteers = pantry.volunteers || []; + const volunteerEmails = volunteers.map((v) => v.email); + + const message = emailTemplates.pantrySubmitsFoodRequest({ + pantryName: pantry.pantryName, + }); + + await this.emailsService.sendEmails( + volunteerEmails, + message.subject, + message.bodyHTML, + ); + + return foodRequest; } async find(pantryId: number) { diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index d0e3da93a..b0aa45889 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -15,6 +15,7 @@ import { FoodRequestStatus } from '../foodRequests/types'; import { RequestsService } from '../foodRequests/request.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { EmailsService } from '../emails/email.service'; // Set 1 minute timeout for async DB operations jest.setTimeout(60000); @@ -36,6 +37,13 @@ describe('OrdersService', () => { providers: [ OrdersService, RequestsService, + EmailsService, + { + provide: EmailsService, + useValue: { + sendEmails: jest.fn().mockResolvedValue(undefined), + }, + }, { provide: getRepositoryToken(Order), useValue: testDataSource.getRepository(Order), diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index f99e0d35d..cd06bfb4c 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -29,8 +29,6 @@ import { import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; -import { EmailsService } from '../emails/email.service'; -import { SendEmailDTO } from '../emails/dto/send-email.dto'; import { Public } from '../auth/public.decorator'; import { AuthenticatedRequest } from '../auth/authenticated-request'; @@ -39,7 +37,6 @@ export class PantriesController { constructor( private pantriesService: PantriesService, private ordersService: OrdersService, - private emailsService: EmailsService, ) {} @Roles(Role.ADMIN) @@ -359,16 +356,4 @@ export class PantriesController { ): Promise { return this.pantriesService.deny(pantryId); } - - @Post('/email') - async sendEmail(@Body() sendEmailDTO: SendEmailDTO): Promise { - const { toEmails, subject, bodyHtml, attachments } = sendEmailDTO; - - await this.emailsService.sendEmails( - toEmails, - subject, - bodyHtml, - attachments, - ); - } } diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index e6ed91f03..bfea458d0 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -2,7 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PantriesService } from './pantries.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Pantry } from './pantries.entity'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { ClientVisitFrequency, @@ -27,6 +31,9 @@ import { Donation } from '../donations/donations.entity'; import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { User } from '../users/users.entity'; +import { EmailsService } from '../emails/email.service'; +import { mock } from 'jest-mock-extended'; +import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; jest.setTimeout(60000); @@ -58,17 +65,48 @@ const makePantryDto = (i: number): PantryApplicationDto => needMoreOptions: 'none', } as PantryApplicationDto); +const dto: PantryApplicationDto = { + contactFirstName: 'Jane', + contactLastName: 'Doe', + contactEmail: 'jane.doe@example.com', + contactPhone: '555-555-5555', + hasEmailContact: true, + pantryName: 'Test Pantry', + shipmentAddressLine1: '1 Test St', + shipmentAddressCity: 'Testville', + shipmentAddressState: 'TX', + shipmentAddressZip: '11111', + mailingAddressLine1: '1 Test St', + mailingAddressCity: 'Testville', + mailingAddressState: 'TX', + mailingAddressZip: '11111', + allergenClients: 'none', + restrictions: ['none'], + refrigeratedDonation: RefrigeratedDonation.NO, + acceptFoodDeliveries: false, + reserveFoodForAllergic: ReserveFoodForAllergic.NO, + dedicatedAllergyFriendly: false, + activities: [Activity.CREATE_LABELED_SHELF], + itemsInStock: 'none', + needMoreOptions: 'none', +}; + +const mockEmailsService = mock(); + describe('PantriesService', () => { let service: PantriesService; + let testModule: TestingModule; beforeAll(async () => { + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); - const module: TestingModule = await Test.createTestingModule({ + testModule = await Test.createTestingModule({ providers: [ PantriesService, OrdersService, @@ -83,6 +121,10 @@ describe('PantriesService', () => { adminCreateUser: jest.fn().mockResolvedValue('test-sub'), }, }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, { provide: getRepositoryToken(Pantry), useValue: testDataSource.getRepository(Pantry), @@ -114,10 +156,11 @@ describe('PantriesService', () => { ], }).compile(); - service = module.get(PantriesService); + service = testModule.get(PantriesService); }); beforeEach(async () => { + mockEmailsService.sendEmails.mockClear(); await testDataSource.runMigrations(); }); @@ -169,6 +212,45 @@ describe('PantriesService', () => { expect(pantryAfter.status).toBe(ApplicationStatus.APPROVED); }); + it('sends approval email to pantry user', async () => { + const pantry = await service.findOne(5); + const { subject, bodyHTML } = emailTemplates.pantryFmApplicationApproved({ + name: pantry.pantryUser.firstName, + }); + + await service.approve(5); + + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [pantry.pantryUser.email], + subject, + bodyHTML, + ); + }); + + it('should still update pantry status to approved if email send fails', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect(service.approve(5)).rejects.toThrow('Email failed'); + + const pantry = await service.findOne(5); + expect(pantry.status).toBe(ApplicationStatus.APPROVED); + }); + + it('throws ConflictException when approving an already approved manufacturer', async () => { + const beforeCount = await testDataSource.getRepository(User).count(); + + await expect(service.approve(1)).rejects.toThrow( + new ConflictException('Cannot approve a pantry with status: approved'), + ); + + const afterCount = await testDataSource.getRepository(User).count(); + expect(afterCount).toBe(beforeCount); + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + it('throws when approving non-existent', async () => { await expect(service.approve(9999)).rejects.toThrow( new NotFoundException('Pantry 9999 not found'), @@ -185,6 +267,15 @@ describe('PantriesService', () => { expect(pantryAfter.status).toBe(ApplicationStatus.DENIED); }); + it('throws ConflictException when denying an already approved pantry', async () => { + // Pantry 1 ('Community Food Pantry Downtown') has status 'approved' in dummy data + await expect(service.deny(1)).rejects.toThrow( + new ConflictException('Cannot deny a pantry with status: approved'), + ); + + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); + }); + it('throws when denying non-existent', async () => { await expect(service.deny(9999)).rejects.toThrow( new NotFoundException('Pantry 9999 not found'), @@ -194,35 +285,9 @@ describe('PantriesService', () => { describe('addPantry', () => { it('creates pantry with minimal required fields', async () => { - const dto: PantryApplicationDto = { - contactFirstName: 'Jane', - contactLastName: 'Doe', - contactEmail: 'jane.doe@example.com', - contactPhone: '555-555-5555', - hasEmailContact: true, - pantryName: 'Test Minimal Pantry', - shipmentAddressLine1: '1 Test St', - shipmentAddressCity: 'Testville', - shipmentAddressState: 'TX', - shipmentAddressZip: '11111', - mailingAddressLine1: '1 Test St', - mailingAddressCity: 'Testville', - mailingAddressState: 'TX', - mailingAddressZip: '11111', - allergenClients: 'none', - restrictions: ['none'], - refrigeratedDonation: RefrigeratedDonation.NO, - acceptFoodDeliveries: false, - reserveFoodForAllergic: ReserveFoodForAllergic.NO, - dedicatedAllergyFriendly: false, - activities: [Activity.CREATE_LABELED_SHELF], - itemsInStock: 'none', - needMoreOptions: 'none', - }; - await service.addPantry(dto); const saved = await testDataSource.getRepository(Pantry).findOne({ - where: { pantryName: 'Test Minimal Pantry' }, + where: { pantryName: 'Test Pantry' }, relations: ['pantryUser'], }); expect(saved).toBeDefined(); @@ -231,32 +296,19 @@ describe('PantriesService', () => { }); it('creates pantry with all optional fields included', async () => { - const dto: PantryApplicationDto = { - contactFirstName: 'John', - contactLastName: 'Smith', + const optionalDto: PantryApplicationDto = { + ...dto, + pantryName: 'Test Full Pantry', contactEmail: 'john.smith@example.com', - contactPhone: '555-555-5556', - hasEmailContact: true, emailContactOther: 'Use work phone', secondaryContactFirstName: 'Sarah', secondaryContactLastName: 'Johnson', secondaryContactEmail: 'sarah.johnson@example.com', secondaryContactPhone: '555-555-5557', - pantryName: 'Test Full Pantry', - shipmentAddressLine1: '100 Main St', shipmentAddressLine2: 'Suite 200', - shipmentAddressCity: 'Springfield', - shipmentAddressState: 'IL', - shipmentAddressZip: '62701', shipmentAddressCountry: 'USA', - mailingAddressLine1: '100 Main St', mailingAddressLine2: 'Suite 200', - mailingAddressCity: 'Springfield', - mailingAddressState: 'IL', - mailingAddressZip: '62701', mailingAddressCountry: 'USA', - allergenClients: '10 to 20', - restrictions: ['Peanut allergy', 'Tree nut allergy'], refrigeratedDonation: RefrigeratedDonation.YES, acceptFoodDeliveries: true, deliveryWindowInstructions: 'Weekdays 9am-5pm', @@ -268,12 +320,10 @@ describe('PantriesService', () => { serveAllergicChildren: ServeAllergicChildren.YES_MANY, activities: [Activity.CREATE_LABELED_SHELF, Activity.COLLECT_FEEDBACK], activitiesComments: 'We are committed to allergen management', - itemsInStock: 'Canned goods, pasta', - needMoreOptions: 'Fresh produce', newsletterSubscription: true, - } as PantryApplicationDto; + }; - await service.addPantry(dto); + await service.addPantry(optionalDto); const saved = await testDataSource.getRepository(Pantry).findOne({ where: { pantryName: 'Test Full Pantry' }, relations: ['pantryUser'], @@ -284,6 +334,42 @@ describe('PantriesService', () => { expect(saved?.secondaryContactFirstName).toBe('Sarah'); expect(saved?.shipmentAddressLine2).toBe('Suite 200'); }); + + it('should still save pantry to database if email send fails', async () => { + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); + + await expect(service.addPantry(dto)).rejects.toThrow('Email failed'); + + const saved = await testDataSource.getRepository(Pantry).findOne({ + where: { pantryName: 'Test Pantry' }, + relations: ['pantryUser'], + }); + expect(saved).toBeDefined(); + expect(saved?.status).toBe(ApplicationStatus.PENDING); + }); + + it('sends confirmation email to applicant and notification email to admin', async () => { + await service.addPantry(dto); + + const userMessage = emailTemplates.pantryFmApplicationSubmittedToUser({ + name: dto.contactFirstName, + }); + 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).toHaveBeenCalledTimes(2); + }); }); describe('getPantryStats (single pantry)', () => { @@ -447,7 +533,6 @@ describe('PantriesService', () => { }); it('validates all names before paginating — throws if any name is invalid regardless of page', async () => { - // Create 12 valid pantries so we have enough to paginate for (let i = 0; i < 12; i++) { await service.addPantry(makePantryDto(i)); } diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 542cf100a..ab47b5e73 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -18,6 +18,8 @@ import { Role } from '../users/types'; import { PantryStats, TotalStats } from './types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; +import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; +import { EmailsService } from '../emails/email.service'; @Injectable() export class PantriesService { @@ -27,6 +29,9 @@ export class PantriesService { @Inject(forwardRef(() => UsersService)) private usersService: UsersService, + + @Inject(forwardRef(() => EmailsService)) + private emailsService: EmailsService, ) {} async findOne(pantryId: number): Promise { @@ -313,6 +318,23 @@ export class PantriesService { // pantry contact is automatically added to User table await this.repo.save(pantry); + + const pantryMessage = emailTemplates.pantryFmApplicationSubmittedToUser({ + name: pantryContact.firstName, + }); + + await this.emailsService.sendEmails( + [pantryContact.email], + pantryMessage.subject, + pantryMessage.bodyHTML, + ); + + const adminMessage = emailTemplates.pantryFmApplicationSubmittedToAdmin(); + await this.emailsService.sendEmails( + [SSF_PARTNER_EMAIL], + adminMessage.subject, + adminMessage.bodyHTML, + ); } async approve(id: number) { @@ -343,6 +365,16 @@ export class PantriesService { status: ApplicationStatus.APPROVED, pantryUser: newPantryUser, }); + + const message = emailTemplates.pantryFmApplicationApproved({ + name: newPantryUser.firstName, + }); + + await this.emailsService.sendEmails( + [newPantryUser.email], + message.subject, + message.bodyHTML, + ); } async deny(id: number) { diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index f7bf1c194..7408b6c1f 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -5,12 +5,14 @@ import { UsersService } from './users.service'; import { User } from './users.entity'; import { PantriesModule } from '../pantries/pantries.module'; import { AuthModule } from '../auth/auth.module'; +import { EmailsModule } from '../emails/email.module'; @Module({ imports: [ TypeOrmModule.forFeature([User]), forwardRef(() => PantriesModule), forwardRef(() => AuthModule), + EmailsModule, ], controllers: [UsersController], providers: [UsersService], diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index b4885c72d..b7042e415 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -1,27 +1,32 @@ +import { NotFoundException, BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { UsersService } from './users.service'; import { User } from './users.entity'; import { Role } from './types'; -import { testDataSource } from '../config/typeormTestDataSource'; -import { NotFoundException, BadRequestException } from '@nestjs/common'; -import { Pantry } from '../pantries/pantries.entity'; -import { AuthService } from '../auth/auth.service'; import { mock } from 'jest-mock-extended'; -import { userSchemaDto } from './dtos/userSchema.dto'; +import { AuthService } from '../auth/auth.service'; +import { EmailsService } from '../emails/email.service'; +import { emailTemplates } from '../emails/emailTemplates'; +import { testDataSource } from '../config/typeormTestDataSource'; jest.setTimeout(60000); -const mockAuthService = mock(); +const mockAuthService = { + adminCreateUser: jest.fn().mockResolvedValue('mock-sub'), +}; +const mockEmailsService = mock(); describe('UsersService', () => { let service: UsersService; beforeAll(async () => { + process.env.SEND_AUTOMATED_EMAILS = 'true'; + mockEmailsService.sendEmails.mockResolvedValue(undefined); + if (!testDataSource.isInitialized) { await testDataSource.initialize(); } - await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); @@ -29,16 +34,16 @@ describe('UsersService', () => { providers: [ UsersService, { - provide: getRepositoryToken(User), - useValue: testDataSource.getRepository(User), + provide: AuthService, + useValue: mockAuthService, }, { - provide: getRepositoryToken(Pantry), - useValue: testDataSource.getRepository(Pantry), + provide: EmailsService, + useValue: mockEmailsService, }, { - provide: AuthService, - useValue: mockAuthService, + provide: getRepositoryToken(User), + useValue: testDataSource.getRepository(User), }, ], }).compile(); @@ -47,12 +52,12 @@ describe('UsersService', () => { }); beforeEach(async () => { + mockAuthService.adminCreateUser.mockClear(); + mockEmailsService.sendEmails.mockClear(); await testDataSource.runMigrations(); - mockAuthService.adminCreateUser.mockResolvedValue('mock-cognito-sub'); }); afterEach(async () => { - mockAuthService.adminCreateUser.mockReset(); await testDataSource.query(`DROP SCHEMA public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); }); @@ -68,147 +73,135 @@ describe('UsersService', () => { }); describe('create', () => { - it('should create a new volunteer user', async () => { - const dto: userSchemaDto = { - email: 'newuser@example.com', + it('should send a welcome email when creating a volunteer', async () => { + const createUserDto = { + email: 'newvolunteer@example.com', firstName: 'Jane', lastName: 'Smith', phone: '9876543210', role: Role.VOLUNTEER, }; - const result = await service.create(dto); + await service.create(createUserDto); - const dbUser = await service.findOne(result.id); - expect(dbUser).toMatchObject({ - email: dto.email, - firstName: dto.firstName, - lastName: dto.lastName, - phone: dto.phone, - role: Role.VOLUNTEER, - }); - expect(result.id).toBeDefined(); - expect(mockAuthService.adminCreateUser).toHaveBeenCalledWith({ - firstName: dto.firstName, - lastName: dto.lastName, - email: dto.email, - }); + const { subject, bodyHTML } = emailTemplates.volunteerAccountCreated(); + expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1); + expect(mockEmailsService.sendEmails).toHaveBeenCalledWith( + [createUserDto.email], + subject, + bodyHTML, + ); }); - it('should throw NotFoundException when creating pantry user with unknown email', async () => { - const dto: userSchemaDto = { - email: 'nonexistent@example.com', + it('should still save user to database if email send fails', async () => { + const createUserDto = { + email: 'newvolunteer2@example.com', firstName: 'Jane', lastName: 'Smith', phone: '9876543210', - role: Role.PANTRY, + role: Role.VOLUNTEER, }; + mockEmailsService.sendEmails.mockRejectedValueOnce( + new Error('Email failed'), + ); - await expect(service.create(dto)).rejects.toThrow( - new NotFoundException(`User with email ${dto.email} not found`), + await expect(service.create(createUserDto)).rejects.toThrow( + 'Email failed', ); - }); - }); - describe('findOne', () => { - it('should return a user by id', async () => { - const result = await service.findOne(1); + const saved = await testDataSource + .getRepository(User) + .findOneBy({ email: createUserDto.email }); + expect(saved).toBeDefined(); + expect(saved?.firstName).toBe('Jane'); + }); - expect(result).toMatchObject({ - id: 1, - email: 'john.smith@ssf.org', - firstName: 'John', - lastName: 'Smith', + it('should create a new user with auto-generated ID', async () => { + const createUserDto = { + email: 'newadmin@example.com', + firstName: 'New', + lastName: 'Admin', + phone: '1112223333', role: Role.ADMIN, - }); - }); + }; - it('should throw NotFoundException when user is not found', async () => { - await expect(service.findOne(999)).rejects.toThrow( - new NotFoundException('User 999 not found'), - ); + const result = await service.create(createUserDto); + + expect(result.id).toBeDefined(); + expect(result.email).toBe(createUserDto.email); + expect(result.firstName).toBe(createUserDto.firstName); + expect(result.userCognitoSub).toBe('mock-sub'); + expect(mockAuthService.adminCreateUser).toHaveBeenCalledWith({ + firstName: createUserDto.firstName, + lastName: createUserDto.lastName, + email: createUserDto.email, + }); + expect(mockEmailsService.sendEmails).not.toHaveBeenCalled(); }); }); - describe('update', () => { - it('should update firstName', async () => { - await service.update(1, { firstName: 'Updated' }); + describe('findOne', () => { + it('should return a user by id', async () => { + const user = await service.findOne(1); - const dbUser = await service.findOne(1); - expect(dbUser.firstName).toBe('Updated'); + expect(user).toBeDefined(); + expect(user.id).toBe(1); }); - it('should update lastName', async () => { - await service.update(1, { lastName: 'Smith' }); - - const dbUser = await service.findOne(1); - expect(dbUser.lastName).toBe('Smith'); + it('should throw NotFoundException when user is not found', async () => { + await expect(service.findOne(9999)).rejects.toThrow( + new NotFoundException('User 9999 not found'), + ); }); - it('should update phone', async () => { - await service.update(1, { phone: '0987654321' }); - - const dbUser = await service.findOne(1); - expect(dbUser.phone).toBe('0987654321'); + it('should throw error for invalid id', async () => { + await expect(service.findOne(-1)).rejects.toThrow( + new BadRequestException('Invalid User ID'), + ); }); + }); - it('should update multiple fields at once', async () => { - await service.update(1, { - firstName: 'Updated', - lastName: 'Smith', - }); - - const dbUser = await service.findOne(1); - expect(dbUser.firstName).toBe('Updated'); - expect(dbUser.lastName).toBe('Smith'); - }); + describe('update', () => { + it('should update user attributes', async () => { + const result = await service.update(1, { firstName: 'Updated' }); - it('should not overwrite fields absent from the DTO', async () => { - const original = await service.findOne(1); - await service.update(1, { firstName: 'OnlyFirst' }); + expect(result.firstName).toBe('Updated'); + expect(result.lastName).toBe('Smith'); - const dbUser = await service.findOne(1); - expect(dbUser.firstName).toBe('OnlyFirst'); - expect(dbUser.lastName).toBe(original.lastName); - expect(dbUser.email).toBe(original.email); - expect(dbUser.phone).toBe(original.phone); - expect(dbUser.role).toBe(original.role); + const fromDb = await testDataSource + .getRepository(User) + .findOneBy({ id: 1 }); + expect(fromDb?.firstName).toBe('Updated'); }); - it('should throw BadRequestException when DTO is empty', async () => { - await expect(service.update(1, {})).rejects.toThrow( - new BadRequestException( - 'At least one field must be provided to update', - ), - ); + it('should throw NotFoundException when user is not found', async () => { + await expect( + service.update(9999, { firstName: 'Updated' }), + ).rejects.toThrow(new NotFoundException('User 9999 not found')); }); - it('should throw NotFoundException when user is not found', async () => { + it('should throw error for invalid id', async () => { await expect( - service.update(999, { firstName: 'Updated' }), - ).rejects.toThrow(new NotFoundException('User 999 not found')); + service.update(-1, { firstName: 'Updated' }), + ).rejects.toThrow(new BadRequestException('Invalid User ID')); }); }); describe('remove', () => { it('should remove a user by id', async () => { - const created = await service.create({ - email: 'remove@example.com', - firstName: 'John', - lastName: 'Doe', - phone: '1234567890', - role: Role.VOLUNTEER, - }); + const result = await service.remove(6); - await service.remove(created.id); - await expect(service.findOne(created.id)).rejects.toThrow( - NotFoundException, - ); + expect(result.email).toBe('james.t@volunteer.org'); + + const fromDb = await testDataSource + .getRepository(User) + .findOneBy({ id: 6 }); + expect(fromDb).toBeNull(); }); it('should throw NotFoundException when user is not found', async () => { - await expect(service.remove(999)).rejects.toThrow( - new NotFoundException('User 999 not found'), + await expect(service.remove(9999)).rejects.toThrow( + new NotFoundException('User 9999 not found'), ); }); @@ -221,20 +214,19 @@ describe('UsersService', () => { describe('findUsersByRoles', () => { it('should return users by roles', async () => { - const result = await service.findUsersByRoles([Role.VOLUNTEER]); + const result = await service.findUsersByRoles([Role.ADMIN]); - expect(result.length).toBeGreaterThan(0); - expect(result.every((u) => u.role === Role.VOLUNTEER)).toBe(true); + expect(result.length).toBe(2); + expect(result.every((u) => u.role === Role.ADMIN)).toBe(true); }); - it('should return empty array when no users match roles', async () => { - await testDataSource.query(`DELETE FROM "allocations"`); - await testDataSource.query(`DELETE FROM "orders"`); - await testDataSource.query(`DELETE FROM "food_requests"`); - await testDataSource.query(`DELETE FROM "pantries"`); - await testDataSource.query(`DELETE FROM "users" WHERE role = 'pantry'`); + it('should return empty array when no users found', async () => { + await testDataSource.query( + `DELETE FROM public.users WHERE role = 'admin'`, + ); + + const result = await service.findUsersByRoles([Role.ADMIN]); - const result = await service.findUsersByRoles([Role.PANTRY]); expect(result).toEqual([]); }); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 0a5bd8413..6ea3c2916 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -11,6 +11,8 @@ import { validateId } from '../utils/validation.utils'; import { UpdateUserInfoDto } from './dtos/update-user-info.dto'; import { AuthService } from '../auth/auth.service'; import { userSchemaDto } from './dtos/userSchema.dto'; +import { emailTemplates } from '../emails/emailTemplates'; +import { EmailsService } from '../emails/email.service'; @Injectable() export class UsersService { @@ -18,11 +20,27 @@ export class UsersService { @InjectRepository(User) private repo: Repository, private authService: AuthService, + private emailsService: EmailsService, ) {} async create(createUserDto: userSchemaDto): Promise { const { email, firstName, lastName, phone, role } = createUserDto; + const emailsEnabled = process.env.SEND_AUTOMATED_EMAILS === 'true'; + // Just save to DB if emails are disabled (no Cognito creation) + if (!emailsEnabled) { + const user = this.repo.create({ + role, + firstName, + lastName, + email, + phone, + }); + return this.repo.save(user); + } + + // Pantry and food manufacturer users must already exist in the DB + // (created during application) before a Cognito account is made if (role === Role.PANTRY || role === Role.FOODMANUFACTURER) { const existingUser = await this.repo.findOneBy({ email }); if (!existingUser) { @@ -36,6 +54,7 @@ export class UsersService { return this.repo.save(existingUser); } + // All other roles (e.g. VOLUNTEER): create Cognito user and save to DB const userCognitoSub = await this.authService.adminCreateUser({ firstName, lastName, @@ -49,7 +68,19 @@ export class UsersService { phone, userCognitoSub, }); - return this.repo.save(user); + await this.repo.save(user); + + // Send welcome email to new volunteers + if (role === Role.VOLUNTEER) { + const message = emailTemplates.volunteerAccountCreated(); + await this.emailsService.sendEmails( + [email], + message.subject, + message.bodyHTML, + ); + } + + return user; } async findOne(id: number): Promise { diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 75e0da925..34f889a9c 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -12,6 +12,7 @@ import { Order } from '../orders/order.entity'; import { RequestsService } from '../foodRequests/request.service'; import { FoodRequest } from '../foodRequests/request.entity'; import { AuthService } from '../auth/auth.service'; +import { EmailsService } from '../emails/email.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { DonationItem } from '../donationItems/donationItems.entity'; @@ -35,6 +36,7 @@ describe('VolunteersService', () => { VolunteersService, UsersService, PantriesService, + EmailsService, OrdersService, RequestsService, FoodManufacturersService, @@ -74,6 +76,12 @@ describe('VolunteersService', () => { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), }, + { + provide: EmailsService, + useValue: { + sendEmails: jest.fn().mockResolvedValue(undefined), + }, + }, ], }).compile();