diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index de33e1d0b..390a0d1c5 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import typeorm from './config/typeorm'; import { CognitoModule } from './aws/cognito/cognito.module'; +import { MatchesModule } from './matches/matches.module'; +import { CoordinatorsModule } from './coordinators/coordinators.module'; import { VolunteersModule } from './volunteers/volunteers.module'; @Module({ @@ -17,6 +19,8 @@ import { VolunteersModule } from './volunteers/volunteers.module'; configService.getOrThrow('typeorm'), }), CognitoModule, + MatchesModule, + CoordinatorsModule, VolunteersModule, ], }) diff --git a/apps/backend/src/aws/ses/awsSes.wrapper.spec.ts b/apps/backend/src/aws/ses/awsSes.wrapper.spec.ts new file mode 100644 index 000000000..25e58db45 --- /dev/null +++ b/apps/backend/src/aws/ses/awsSes.wrapper.spec.ts @@ -0,0 +1,170 @@ +import { Test } from '@nestjs/testing'; +import { + SendEmailCommand, + SendEmailCommandOutput, +} from '@aws-sdk/client-sesv2'; +import { AmazonSESWrapper } from './awsSes.wrapper'; +import { AMAZON_SES_CLIENT } from './awsSesClient.factory'; +import { SendEmailDTO } from './sendEmail.dto'; + +describe('AmazonSESWrapper', () => { + let wrapper: AmazonSESWrapper; + let mockClient: { send: jest.Mock }; + + const originalSenderEmail = process.env.AWS_SES_SENDER_EMAIL; + + const successOutput: SendEmailCommandOutput = { + MessageId: 'msg-1', + $metadata: { httpStatusCode: 200 }, + } as SendEmailCommandOutput; + + const validDto: SendEmailDTO = { + toEmail: 'recipient@example.com', + subject: 'Hello', + bodyHtml: '

Hi there

', + }; + + /** The SendEmailCommand the wrapper handed to the client on its only send. */ + const sentCommand = (): SendEmailCommand => + mockClient.send.mock.calls[0][0] as SendEmailCommand; + + /** The composed MIME message, decoded so headers can be inspected. */ + const sentRawMessage = (): string => { + const data = sentCommand().input.Content?.Raw?.Data; + if (!data) { + throw new Error('Expected the sent command to carry a raw MIME message'); + } + return Buffer.from(data).toString('utf8'); + }; + + beforeEach(async () => { + mockClient = { send: jest.fn().mockResolvedValue(successOutput) }; + process.env.AWS_SES_SENDER_EMAIL = 'sender@example.com'; + + const moduleRef = await Test.createTestingModule({ + providers: [ + AmazonSESWrapper, + { provide: AMAZON_SES_CLIENT, useValue: mockClient }, + ], + }).compile(); + + wrapper = moduleRef.get(AmazonSESWrapper); + }); + + afterEach(() => { + if (originalSenderEmail === undefined) { + delete process.env.AWS_SES_SENDER_EMAIL; + } else { + process.env.AWS_SES_SENDER_EMAIL = originalSenderEmail; + } + }); + + describe('sendEmail', () => { + it('returns the client output and sends exactly one command', async () => { + const result = await wrapper.sendEmail(validDto); + + expect(mockClient.send).toHaveBeenCalledTimes(1); + expect(sentCommand()).toBeInstanceOf(SendEmailCommand); + expect(result).toBe(successOutput); + }); + + it('addresses the recipient in Destination and in the MIME headers', async () => { + await wrapper.sendEmail(validDto); + + expect(mockClient.send).toHaveBeenCalledTimes(1); + expect(sentCommand().input.Destination).toEqual({ + ToAddresses: ['recipient@example.com'], + }); + + const raw = sentRawMessage(); + expect(raw).toContain('To: recipient@example.com'); + expect(raw).toContain('Subject: Hello'); + expect(raw).toContain('

Hi there

'); + }); + + it('uses AWS_SES_SENDER_EMAIL as the From address', async () => { + process.env.AWS_SES_SENDER_EMAIL = 'noreply@mspca.org'; + + await wrapper.sendEmail(validDto); + + expect(mockClient.send).toHaveBeenCalledTimes(1); + expect(sentRawMessage()).toContain('From: noreply@mspca.org'); + }); + + it('puts cc addresses in both Destination and the MIME headers', async () => { + await wrapper.sendEmail({ + ...validDto, + ccEmails: ['cc1@example.com', 'cc2@example.com'], + }); + + expect(mockClient.send).toHaveBeenCalledTimes(1); + expect(sentCommand().input.Destination?.CcAddresses).toEqual([ + 'cc1@example.com', + 'cc2@example.com', + ]); + expect(sentRawMessage()).toContain( + 'Cc: cc1@example.com, cc2@example.com', + ); + }); + + it('puts bcc addresses in Destination but never in the MIME headers', async () => { + await wrapper.sendEmail({ + ...validDto, + bccEmails: ['bcc@example.com'], + }); + + expect(mockClient.send).toHaveBeenCalledTimes(1); + expect(sentCommand().input.Destination?.BccAddresses).toEqual([ + 'bcc@example.com', + ]); + + // A `Bcc:` header would leak the hidden recipient list to everyone else + // on the message, so it must not appear anywhere in the raw MIME. + // Note: MailComposer also strips Bcc unless `keepBcc: true` is passed, + // so this locks in the end behaviour rather than the wrapper's choice + // to leave `mailOptions.bcc` unset. + const raw = sentRawMessage(); + expect(raw).not.toMatch(/^Bcc:/im); + expect(raw).not.toContain('bcc@example.com'); + }); + + it('omits CcAddresses and BccAddresses when the lists are absent or empty', async () => { + await wrapper.sendEmail({ ...validDto, ccEmails: [], bccEmails: [] }); + + expect(mockClient.send).toHaveBeenCalledTimes(1); + expect(sentCommand().input.Destination).toEqual({ + ToAddresses: ['recipient@example.com'], + }); + }); + + it('encodes attachments into the MIME message', async () => { + await wrapper.sendEmail({ + ...validDto, + attachments: [ + { filename: 'notes.txt', content: Buffer.from('hello attachment') }, + ], + }); + + expect(mockClient.send).toHaveBeenCalledTimes(1); + const raw = sentRawMessage(); + expect(raw).toContain('filename=notes.txt'); + expect(raw).toContain(Buffer.from('hello attachment').toString('base64')); + }); + + it('propagates errors thrown by the SES client', async () => { + mockClient.send.mockRejectedValue(new Error('SES rejected: throttled')); + + await expect(wrapper.sendEmail(validDto)).rejects.toThrow( + 'SES rejected: throttled', + ); + expect(mockClient.send).toHaveBeenCalledTimes(1); + }); + + it('wraps non-Error rejections in an Error', async () => { + mockClient.send.mockRejectedValue('throttled'); + + await expect(wrapper.sendEmail(validDto)).rejects.toThrow('throttled'); + expect(mockClient.send).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/backend/src/matches/dtos/create-match.dto.ts b/apps/backend/src/matches/dtos/create-match.dto.ts new file mode 100644 index 000000000..8a55fe6b3 --- /dev/null +++ b/apps/backend/src/matches/dtos/create-match.dto.ts @@ -0,0 +1,11 @@ +import { IsInt, IsPositive } from 'class-validator'; + +export class CreateMatchDto { + @IsInt() + @IsPositive() + volunteerId!: number; + + @IsInt() + @IsPositive() + chameleonAnimalId!: number; +} diff --git a/apps/backend/src/matches/matches.controller.spec.ts b/apps/backend/src/matches/matches.controller.spec.ts index 7e028c33e..951548f3b 100644 --- a/apps/backend/src/matches/matches.controller.spec.ts +++ b/apps/backend/src/matches/matches.controller.spec.ts @@ -1,17 +1,31 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { + BadRequestException, + NotFoundException, + ValidationPipe, +} from '@nestjs/common'; import { MatchesController } from './matches.controller'; import { MatchesService } from './matches.service'; +import { MatchStatus } from './matches.types'; +import { Match } from './matches.entity'; +import { CreateMatchDto } from './dtos/create-match.dto'; describe('MatchesController', () => { let controller: MatchesController; + let service: { create: jest.Mock; withdraw: jest.Mock }; beforeEach(async () => { + service = { + create: jest.fn(), + withdraw: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ controllers: [MatchesController], providers: [ { provide: MatchesService, - useValue: {}, + useValue: service, }, ], }).compile(); @@ -22,4 +36,80 @@ describe('MatchesController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('createMatch', () => { + it('passes the body through to the service', async () => { + service.create.mockResolvedValue({ matchId: 10 } as Match); + const body = { volunteerId: 1, chameleonAnimalId: 42 }; + + const match = await controller.createMatch(body); + + expect(service.create).toHaveBeenCalledWith(body); + expect(match.matchId).toEqual(10); + }); + + // The handler adds no guards of its own, so whatever the service throws + // has to come back out untouched. + it('surfaces a NotFoundException from the service', async () => { + service.create.mockRejectedValue( + new NotFoundException('Volunteer not found'), + ); + + await expect( + controller.createMatch({ volunteerId: 999, chameleonAnimalId: 42 }), + ).rejects.toThrow(NotFoundException); + }); + + // Invalid bodies never reach the handler - the global ValidationPipe + // rejects them first, so run the real pipe over the DTO here. + it.each([ + { volunteerId: 0, chameleonAnimalId: 42 }, + { volunteerId: -1, chameleonAnimalId: 42 }, + { volunteerId: 1.5, chameleonAnimalId: 42 }, + { volunteerId: 1, chameleonAnimalId: 0 }, + { volunteerId: 1, chameleonAnimalId: 'abc' }, + { chameleonAnimalId: 42 }, + ])('rejects the invalid body %j', async (body) => { + const pipe = new ValidationPipe({ whitelist: true }); + + await expect( + pipe.transform(body, { type: 'body', metatype: CreateMatchDto }), + ).rejects.toThrow(BadRequestException); + expect(service.create).not.toHaveBeenCalled(); + }); + }); + + describe('withdrawMatch', () => { + it('withdraws a match with a valid ID', async () => { + service.withdraw.mockResolvedValue({ + matchId: 10, + status: MatchStatus.WITHDRAWN, + } as Match); + + const match = await controller.withdrawMatch(10); + + expect(service.withdraw).toHaveBeenCalledWith(10); + expect(match.status).toEqual(MatchStatus.WITHDRAWN); + }); + + it('surfaces a NotFoundException from the service', async () => { + service.withdraw.mockRejectedValue( + new NotFoundException('Match not found'), + ); + + await expect(controller.withdrawMatch(999)).rejects.toThrow( + NotFoundException, + ); + }); + + it('surfaces a BadRequestException from the service', async () => { + service.withdraw.mockRejectedValue( + new BadRequestException('Only pending matches can be withdrawn'), + ); + + await expect(controller.withdrawMatch(10)).rejects.toThrow( + BadRequestException, + ); + }); + }); }); diff --git a/apps/backend/src/matches/matches.controller.ts b/apps/backend/src/matches/matches.controller.ts index 9d62ae7e9..c09fe61f2 100644 --- a/apps/backend/src/matches/matches.controller.ts +++ b/apps/backend/src/matches/matches.controller.ts @@ -1,7 +1,78 @@ -import { Controller } from '@nestjs/common'; +import { + Body, + Controller, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { MatchesService } from './matches.service'; +import { Match } from './matches.entity'; +import { CreateMatchDto } from './dtos/create-match.dto'; +import { validateId } from '../utils/validation.utils'; +@ApiTags('Matches') @Controller('matches') export class MatchesController { constructor(private matchesService: MatchesService) {} + + @Post() + @ApiOperation({ + summary: 'Create a match', + description: + "Creates a PENDING match between a volunteer and a Chameleon animal and emails the volunteer's assigned foster coordinator.", + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The created match, with a PENDING status.', + type: Match, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: + 'The request body is invalid or the volunteer is not active, so a match cannot be made.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'No volunteer exists with the given ID.', + }) + async createMatch(@Body() body: CreateMatchDto): Promise { + return this.matchesService.create(body); + } + + @Patch(':matchId/withdraw') + @ApiOperation({ + summary: 'Withdraw a match', + description: + 'Sets a PENDING match to WITHDRAWN. The record is kept so volunteers can still see their withdrawn applications.', + }) + @ApiParam({ + name: 'matchId', + type: Number, + description: 'The ID of the match to withdraw.', + example: 10, + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The updated match, with a WITHDRAWN status.', + type: Match, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: + 'The match ID is not a positive integer, or the match is not PENDING and so cannot be withdrawn.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'No match exists with the given ID.', + }) + async withdrawMatch( + @Param('matchId', ParseIntPipe) matchId: number, + ): Promise { + validateId(matchId, 'Match'); + + return this.matchesService.withdraw(matchId); + } } diff --git a/apps/backend/src/matches/matches.module.ts b/apps/backend/src/matches/matches.module.ts index 511bf15ff..339685f8c 100644 --- a/apps/backend/src/matches/matches.module.ts +++ b/apps/backend/src/matches/matches.module.ts @@ -1,11 +1,17 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Match } from './matches.entity'; import { MatchesController } from './matches.controller'; import { MatchesService } from './matches.service'; +import { VolunteersModule } from '../volunteers/volunteers.module'; +import { AWSSESModule } from '../aws/ses/email.module'; @Module({ - imports: [TypeOrmModule.forFeature([Match])], + imports: [ + TypeOrmModule.forFeature([Match]), + forwardRef(() => VolunteersModule), + AWSSESModule, + ], controllers: [MatchesController], providers: [MatchesService], exports: [MatchesService], diff --git a/apps/backend/src/matches/matches.service.spec.ts b/apps/backend/src/matches/matches.service.spec.ts index b52bac1e6..101d3fc28 100644 --- a/apps/backend/src/matches/matches.service.spec.ts +++ b/apps/backend/src/matches/matches.service.spec.ts @@ -1,12 +1,43 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { MatchesService } from './matches.service'; import { Match } from './matches.entity'; import { MatchStatus } from './matches.types'; +import { FosterVolunteer } from '../volunteers/volunteers.entity'; +import { FosterCoordinator } from '../coordinators/coordinators.entity'; +import { VolunteersService } from '../volunteers/volunteers.service'; +import { EmailsService } from '../aws/ses/email.service'; +import { SendEmailDTO } from '../aws/ses/sendEmail.dto'; + +const coordinator = { + coordinatorId: 7, + email: 'coordinator@gmail.com', +} as FosterCoordinator; + +const volunteerWithCoordinator = { + volunteerId: 1, + firstName: 'Ada', + lastName: 'Lovelace', + active: true, + assignedCoordinator: coordinator, +} as FosterVolunteer; + +const volunteerWithoutCoordinator = { + ...volunteerWithCoordinator, + assignedCoordinator: null, +} as FosterVolunteer; describe('MatchesService', () => { let service: MatchesService; - let repo: { find: jest.Mock }; + let matchRepo: { + create: jest.Mock; + save: jest.Mock; + findOneBy: jest.Mock; + find: jest.Mock; + }; + let volunteersService: { findActiveOrFail: jest.Mock }; + let emailsService: { sendEmail: jest.Mock }; const matches = [ { @@ -26,40 +57,227 @@ describe('MatchesService', () => { ] as Match[]; beforeEach(async () => { - repo = { find: jest.fn() }; + matchRepo = { + create: jest.fn(), + save: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + }; + volunteersService = { findActiveOrFail: jest.fn() }; + emailsService = { sendEmail: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ providers: [ MatchesService, - { - provide: getRepositoryToken(Match), - useValue: repo, - }, + { provide: getRepositoryToken(Match), useValue: matchRepo }, + { provide: VolunteersService, useValue: volunteersService }, + { provide: EmailsService, useValue: emailsService }, ], }).compile(); service = module.get(MatchesService); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); + describe('create', () => { + it('creates the match with a PENDING status', async () => { + volunteersService.findActiveOrFail.mockResolvedValue( + volunteerWithCoordinator, + ); + emailsService.sendEmail.mockResolvedValue(undefined); + matchRepo.create.mockImplementation((attrs) => attrs); + matchRepo.save.mockImplementation((match) => + Promise.resolve({ matchId: 10, ...match }), + ); + + const match = await service.create({ + volunteerId: 1, + chameleonAnimalId: 42, + }); + + expect(volunteersService.findActiveOrFail).toHaveBeenCalledWith(1); + expect(matchRepo.create).toHaveBeenCalledWith({ + volunteerId: 1, + chameleonAnimalId: 42, + status: MatchStatus.PENDING, + deniedReason: null, + }); + expect(match.status).toEqual(MatchStatus.PENDING); + }); + + it("emails the volunteer's assigned coordinator", async () => { + volunteersService.findActiveOrFail.mockResolvedValue( + volunteerWithCoordinator, + ); + emailsService.sendEmail.mockResolvedValue(undefined); + matchRepo.create.mockImplementation((attrs) => attrs); + matchRepo.save.mockImplementation((match) => + Promise.resolve({ matchId: 10, ...match }), + ); + + await service.create({ volunteerId: 1, chameleonAnimalId: 42 }); + + expect(emailsService.sendEmail).toHaveBeenCalledTimes(1); + expect(emailsService.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + toEmail: coordinator.email, + subject: 'New foster match created', + }), + ); + }); + + it('names the volunteer and the animal in the email body', async () => { + volunteersService.findActiveOrFail.mockResolvedValue( + volunteerWithCoordinator, + ); + emailsService.sendEmail.mockResolvedValue(undefined); + matchRepo.create.mockImplementation((attrs) => attrs); + matchRepo.save.mockImplementation((match) => + Promise.resolve({ matchId: 10, ...match }), + ); + + await service.create({ volunteerId: 1, chameleonAnimalId: 42 }); + + const { bodyHtml } = emailsService.sendEmail.mock + .calls[0][0] as SendEmailDTO; + + expect(bodyHtml).toContain('Ada Lovelace'); + expect(bodyHtml).toContain('volunteer 1'); + expect(bodyHtml).toContain('42'); + }); + + it('still creates the match when the volunteer has no coordinator', async () => { + volunteersService.findActiveOrFail.mockResolvedValue( + volunteerWithoutCoordinator, + ); + matchRepo.create.mockImplementation((attrs) => attrs); + matchRepo.save.mockImplementation((match) => + Promise.resolve({ matchId: 10, ...match }), + ); + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + const match = await service.create({ + volunteerId: 1, + chameleonAnimalId: 42, + }); + + expect(match.status).toEqual(MatchStatus.PENDING); + expect(emailsService.sendEmail).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + }); + + it('creates the match even when the email fails to send', async () => { + volunteersService.findActiveOrFail.mockResolvedValue( + volunteerWithCoordinator, + ); + emailsService.sendEmail.mockRejectedValue(new Error('SES is down')); + matchRepo.create.mockImplementation((attrs) => attrs); + matchRepo.save.mockImplementation((match) => + Promise.resolve({ matchId: 10, ...match }), + ); + const error = jest.spyOn(Logger.prototype, 'error').mockImplementation(); + + const match = await service.create({ + volunteerId: 1, + chameleonAnimalId: 42, + }); + + expect(match.matchId).toEqual(10); + expect(error).toHaveBeenCalled(); + }); + + it('throws when the volunteer does not exist', async () => { + volunteersService.findActiveOrFail.mockRejectedValue( + new NotFoundException('Volunteer with ID 999 not found'), + ); + + await expect( + service.create({ volunteerId: 999, chameleonAnimalId: 42 }), + ).rejects.toThrow(NotFoundException); + expect(matchRepo.save).not.toHaveBeenCalled(); + expect(emailsService.sendEmail).not.toHaveBeenCalled(); + }); + + it('throws when the volunteer is not active', async () => { + volunteersService.findActiveOrFail.mockRejectedValue( + new BadRequestException('Volunteer with ID 1 is not active'), + ); + + await expect( + service.create({ volunteerId: 1, chameleonAnimalId: 42 }), + ).rejects.toThrow(BadRequestException); + expect(matchRepo.save).not.toHaveBeenCalled(); + expect(emailsService.sendEmail).not.toHaveBeenCalled(); + }); + }); + + describe('withdraw', () => { + it('sets the status to WITHDRAWN and keeps the record', async () => { + matchRepo.findOneBy.mockResolvedValue({ + matchId: 10, + status: MatchStatus.PENDING, + }); + + matchRepo.save.mockImplementation((match) => Promise.resolve(match)); + + const match = await service.withdraw(10); + + expect(matchRepo.findOneBy).toHaveBeenCalledWith({ matchId: 10 }); + expect(matchRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + matchId: 10, + status: MatchStatus.WITHDRAWN, + }), + ); + expect(match.status).toEqual(MatchStatus.WITHDRAWN); + }); + + it('throws when the match does not exist', async () => { + matchRepo.findOneBy.mockResolvedValue(null); + + await expect(service.withdraw(999)).rejects.toThrow(NotFoundException); + expect(matchRepo.findOneBy).toHaveBeenCalledWith({ matchId: 999 }); + expect(matchRepo.save).not.toHaveBeenCalled(); + }); + + it('throws when the match is not pending', async () => { + matchRepo.findOneBy.mockResolvedValue({ + matchId: 10, + status: MatchStatus.ACTIVE, + }); + + await expect(service.withdraw(10)).rejects.toThrow(BadRequestException); + expect(matchRepo.findOneBy).toHaveBeenCalledWith({ matchId: 10 }); + expect(matchRepo.save).not.toHaveBeenCalled(); + }); + }); + describe('findByVolunteerId', () => { it('returns the matches for the volunteer', async () => { - repo.find.mockResolvedValue(matches); + matchRepo.find.mockResolvedValue(matches); const result = await service.findByVolunteerId(7); expect(result).toBe(matches); - expect(repo.find).toHaveBeenCalledWith({ where: { volunteerId: 7 } }); + expect(matchRepo.find).toHaveBeenCalledWith({ + where: { volunteerId: 7 }, + }); }); it('returns an empty array when the volunteer has no matches', async () => { - repo.find.mockResolvedValue([]); + matchRepo.find.mockResolvedValue([]); await expect(service.findByVolunteerId(7)).resolves.toEqual([]); - expect(repo.find).toHaveBeenCalledWith({ where: { volunteerId: 7 } }); + expect(matchRepo.find).toHaveBeenCalledWith({ + where: { volunteerId: 7 }, + }); }); }); }); diff --git a/apps/backend/src/matches/matches.service.ts b/apps/backend/src/matches/matches.service.ts index d8ff90f48..0c8c60272 100644 --- a/apps/backend/src/matches/matches.service.ts +++ b/apps/backend/src/matches/matches.service.ts @@ -1,15 +1,132 @@ -import { Injectable } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + Logger, + NotFoundException, + forwardRef, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Match } from './matches.entity'; +import { MatchStatus } from './matches.types'; +import { CreateMatchDto } from './dtos/create-match.dto'; +import { FosterVolunteer } from '../volunteers/volunteers.entity'; +import { VolunteersService } from '../volunteers/volunteers.service'; +import { EmailsService } from '../aws/ses/email.service'; @Injectable() export class MatchesService { + private readonly logger = new Logger(MatchesService.name); + constructor( @InjectRepository(Match) private repo: Repository, + @Inject(forwardRef(() => VolunteersService)) + private volunteersService: VolunteersService, + private emailsService: EmailsService, ) {} + /** + * Creates a PENDING match between a volunteer and a Chameleon animal, then + * notifies the volunteer's assigned foster coordinator by email. + * + * The email is best-effort: the match is already committed when it is sent, + * so a send failure is logged rather than surfaced to the caller. Volunteers + * with no assigned coordinator still get a match; no email goes out. + * + * @param dto the volunteer and Chameleon animal to match + * @returns the created match + * @throws NotFoundException if the volunteer does not exist + * @throws BadRequestException if the volunteer is not active + */ + async create(dto: CreateMatchDto): Promise { + const volunteer = await this.volunteersService.findActiveOrFail( + dto.volunteerId, + ); + + const match = await this.repo.save( + this.repo.create({ + volunteerId: dto.volunteerId, + chameleonAnimalId: dto.chameleonAnimalId, + status: MatchStatus.PENDING, + deniedReason: null, + }), + ); + + await this.notifyCoordinator(match, volunteer); + + return match; + } + + /** + * Withdraws a match by setting its status to WITHDRAWN. The record is kept so + * volunteers can still see their withdrawn applications. + * + * Only PENDING matches can be withdrawn - once a coordinator has acted on an + * application, taking it back is their call, not the volunteer's. + * + * @param matchId the match to withdraw + * @returns the updated match + * @throws NotFoundException if the match does not exist + * @throws BadRequestException if the match is not PENDING + */ + async withdraw(matchId: number): Promise { + const match = await this.repo.findOneBy({ matchId }); + + if (!match) { + throw new NotFoundException('Match not found'); + } + + if (match.status !== MatchStatus.PENDING) { + throw new BadRequestException( + `Only pending matches can be withdrawn; match ${matchId} is ${match.status}`, + ); + } + + match.status = MatchStatus.WITHDRAWN; + + return this.repo.save(match); + } + + /** + * Emails the volunteer's assigned foster coordinator about a new match. + * + * Never throws: the match is already committed by the time this runs, so a + * missing coordinator is logged as a warning and a failed send as an error + * rather than failing the request. + * + * @param match the match that was just created + * @param volunteer the matched volunteer, with assignedCoordinator loaded + */ + private async notifyCoordinator( + match: Match, + volunteer: FosterVolunteer, + ): Promise { + const coordinator = volunteer.assignedCoordinator; + + if (!coordinator) { + this.logger.warn( + `Volunteer ${volunteer.volunteerId} has no assigned coordinator; no match email sent for match ${match.matchId}.`, + ); + return; + } + + // Placeholder copy - TODO: put real template here later. + try { + await this.emailsService.sendEmail({ + toEmail: coordinator.email, + subject: 'New foster match created', + bodyHtml: `

A match has been created for ${volunteer.firstName} ${volunteer.lastName} (volunteer ${volunteer.volunteerId}) and Chameleon animal ${match.chameleonAnimalId}.

`, + }); + } catch (error) { + this.logger.error( + `Failed to send match creation email for match ${match.matchId}.`, + error instanceof Error ? error.stack : String(error), + ); + } + } + /** * Fetches all Matches for a Volunteer. * diff --git a/apps/backend/src/volunteers/volunteers.module.ts b/apps/backend/src/volunteers/volunteers.module.ts index 4697b4cdd..0a844ee57 100644 --- a/apps/backend/src/volunteers/volunteers.module.ts +++ b/apps/backend/src/volunteers/volunteers.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FosterVolunteer } from './volunteers.entity'; import { VolunteersController } from './volunteers.controller'; @@ -10,7 +10,7 @@ import { MatchesModule } from '../matches/matches.module'; imports: [ TypeOrmModule.forFeature([FosterVolunteer]), CoordinatorsModule, - MatchesModule, + forwardRef(() => MatchesModule), ], 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 7ff6ef097..b98863ea9 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -1,15 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { getRepositoryToken } from '@nestjs/typeorm'; import { VolunteersService } from './volunteers.service'; import { FosterVolunteer } from './volunteers.entity'; describe('VolunteersService', () => { let service: VolunteersService; - let repo: { findOneBy: jest.Mock }; + let repo: { findOne: jest.Mock }; beforeEach(async () => { - repo = { findOneBy: jest.fn() }; + repo = { findOne: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -31,21 +31,57 @@ describe('VolunteersService', () => { describe('findByIdOrFail', () => { it('returns the volunteer when one with the id exists', async () => { const volunteer = { volunteerId: 7 } as FosterVolunteer; - repo.findOneBy.mockResolvedValue(volunteer); + repo.findOne.mockResolvedValue(volunteer); const result = await service.findByIdOrFail(7); expect(result).toBe(volunteer); - expect(repo.findOneBy).toHaveBeenCalledWith({ volunteerId: 7 }); + expect(repo.findOne).toHaveBeenCalledWith({ + where: { volunteerId: 7 }, + relations: ['assignedCoordinator'], + }); }); it('throws NotFoundException when no volunteer with the id exists', async () => { - repo.findOneBy.mockResolvedValue(null); + repo.findOne.mockResolvedValue(null); await expect(service.findByIdOrFail(7)).rejects.toThrow( new NotFoundException('Volunteer with ID 7 not found'), ); - expect(repo.findOneBy).toHaveBeenCalledWith({ volunteerId: 7 }); + expect(repo.findOne).toHaveBeenCalledWith({ + where: { volunteerId: 7 }, + relations: ['assignedCoordinator'], + }); + }); + }); + + describe('findActiveOrFail', () => { + it('returns the volunteer when they are active', async () => { + const volunteer = { volunteerId: 7, active: true } as FosterVolunteer; + repo.findOne.mockResolvedValue(volunteer); + + const result = await service.findActiveOrFail(7); + + expect(result).toBe(volunteer); + }); + + it('throws BadRequestException when the volunteer is not active', async () => { + repo.findOne.mockResolvedValue({ + volunteerId: 7, + active: false, + } as FosterVolunteer); + + await expect(service.findActiveOrFail(7)).rejects.toThrow( + new BadRequestException('Volunteer with ID 7 is not active'), + ); + }); + + it('throws NotFoundException when no volunteer with the id exists', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.findActiveOrFail(7)).rejects.toThrow( + new NotFoundException('Volunteer with ID 7 not found'), + ); }); }); }); diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index 8d89957c8..e3340c2f5 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { FosterVolunteer } from './volunteers.entity'; @@ -11,7 +15,7 @@ export class VolunteersService { ) {} /** - * Fetches a Volunteer by ID. + * Fetches a Volunteer by ID, with their assigned Foster Coordinator. * * Returns the Volunteer so callers can reuse it instead of fetching again. * @@ -20,7 +24,10 @@ export class VolunteersService { * @throws {NotFoundException} If no Volunteer with the ID exists. */ async findByIdOrFail(id: number): Promise { - const volunteer = await this.repo.findOneBy({ volunteerId: id }); + const volunteer = await this.repo.findOne({ + where: { volunteerId: id }, + relations: ['assignedCoordinator'], + }); if (!volunteer) { throw new NotFoundException(`Volunteer with ID ${id} not found`); @@ -28,4 +35,24 @@ export class VolunteersService { return volunteer; } + + /** + * Fetches a Volunteer by ID, requiring that they are active. + * + * Returns the Volunteer so callers can reuse it instead of fetching again. + * + * @param id - The Volunteer's ID. + * @returns The active Volunteer. + * @throws {NotFoundException} If no Volunteer with the ID exists. + * @throws {BadRequestException} If the Volunteer is not active. + */ + async findActiveOrFail(id: number): Promise { + const volunteer = await this.findByIdOrFail(id); + + if (!volunteer.active) { + throw new BadRequestException(`Volunteer with ID ${id} is not active`); + } + + return volunteer; + } }