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;
+ }
}