From e9348f9d2eff45b31ba3297b79a86c68d57a9b88 Mon Sep 17 00:00:00 2001 From: Polina Smolyanskiy Date: Thu, 8 Oct 2026 00:10:13 -0400 Subject: [PATCH 1/3] MSPCA-24: add animal_updates column and foster_type array migration --- apps/backend/src/config/migrations.ts | 2 ++ ...3582-AddAnimalUpdatesAndFosterTypeArray.ts | 27 +++++++++++++++++++ .../volunteers/dtos/create-volunteer.dto.ts | 8 ++++-- .../src/volunteers/volunteers.entity.ts | 7 +++-- 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/migrations/1791429623582-AddAnimalUpdatesAndFosterTypeArray.ts diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 112296ae4..d8f4a3dfa 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -2,12 +2,14 @@ import { InitialSchemas1789354568323 } from '../migrations/1789354568323-Initial import { AddFosterVolunteerCoordinatorAssignmentRelation1790820106597 } from '../migrations/1790820106597-AddFosterVolunteerCoordinatorAssignmentRelation'; import { ReplaceVolunteerActiveWithStatus1790998545137 } from '../migrations/1790998545137-ReplaceVolunteerActiveWithStatus'; import { AddVolunteerCognitoSub1791000000000 } from '../migrations/1791000000000-AddVolunteerCognitoSub'; +import { AddAnimalUpdatesAndFosterTypeArray1791429623582 } from '../migrations/1791429623582-AddAnimalUpdatesAndFosterTypeArray'; const schemaMigrations = [ InitialSchemas1789354568323, AddFosterVolunteerCoordinatorAssignmentRelation1790820106597, ReplaceVolunteerActiveWithStatus1790998545137, AddVolunteerCognitoSub1791000000000, + AddAnimalUpdatesAndFosterTypeArray1791429623582, ]; export default schemaMigrations; diff --git a/apps/backend/src/migrations/1791429623582-AddAnimalUpdatesAndFosterTypeArray.ts b/apps/backend/src/migrations/1791429623582-AddAnimalUpdatesAndFosterTypeArray.ts new file mode 100644 index 000000000..4afe3092f --- /dev/null +++ b/apps/backend/src/migrations/1791429623582-AddAnimalUpdatesAndFosterTypeArray.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAnimalUpdatesAndFosterTypeArray1791429623582 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "foster_volunteers" ADD "animal_updates" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "foster_volunteers" + ALTER COLUMN "foster_type" TYPE "foster_type_enum"[] + USING ARRAY["foster_type"]`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "foster_volunteers" + ALTER COLUMN "foster_type" TYPE "foster_type_enum" + USING "foster_type"[1]`, + ); + await queryRunner.query( + `ALTER TABLE "foster_volunteers" DROP COLUMN "animal_updates"`, + ); + } +} diff --git a/apps/backend/src/volunteers/dtos/create-volunteer.dto.ts b/apps/backend/src/volunteers/dtos/create-volunteer.dto.ts index 1c6d0a0c6..e9e061340 100644 --- a/apps/backend/src/volunteers/dtos/create-volunteer.dto.ts +++ b/apps/backend/src/volunteers/dtos/create-volunteer.dto.ts @@ -9,6 +9,8 @@ import { } from 'class-validator'; import { Homebase } from '../../types'; import { FosterType } from '../volunteers.types'; +import { ArrayNotEmpty } from 'class-validator'; +import { IsArray } from 'class-validator'; export class CreateVolunteerDto { @IsString() @@ -61,8 +63,10 @@ export class CreateVolunteerDto { @IsString() notes?: string; - @IsEnum(FosterType) - fosterType!: FosterType; + @IsArray() + @ArrayNotEmpty() + @IsEnum(FosterType, { each: true }) + fosterType!: FosterType[]; @IsOptional() @IsBoolean() diff --git a/apps/backend/src/volunteers/volunteers.entity.ts b/apps/backend/src/volunteers/volunteers.entity.ts index 61b5b32cf..dc9654d6e 100644 --- a/apps/backend/src/volunteers/volunteers.entity.ts +++ b/apps/backend/src/volunteers/volunteers.entity.ts @@ -52,8 +52,11 @@ export class FosterVolunteer { @Column({ type: 'text', nullable: true }) notes!: string | null; - @Column({ name: 'foster_type', type: 'enum', enum: FosterType }) - fosterType!: FosterType; + @Column({ name: 'foster_type', type: 'enum', enum: FosterType, array: true }) + fosterType!: FosterType[]; + + @Column({ name: 'animal_updates', type: 'boolean', default: true }) + animalUpdates!: boolean; @Column({ name: 'completed_canine_training', From 1b7f35ce85fd136f717ed769d9642dcf953525c1 Mon Sep 17 00:00:00 2001 From: Polina Smolyanskiy Date: Thu, 8 Oct 2026 01:48:21 -0400 Subject: [PATCH 2/3] MSPCA-24: add animal-updates endpoint and fix specs for fosterType list --- .../dtos/update-animal-updates.dto.ts | 6 +++++ .../volunteers/volunteers.controller.spec.ts | 2 +- .../src/volunteers/volunteers.controller.ts | 27 +++++++++++++++++++ .../src/volunteers/volunteers.service.spec.ts | 2 +- .../src/volunteers/volunteers.service.ts | 21 +++++++++++++++ 5 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/volunteers/dtos/update-animal-updates.dto.ts diff --git a/apps/backend/src/volunteers/dtos/update-animal-updates.dto.ts b/apps/backend/src/volunteers/dtos/update-animal-updates.dto.ts new file mode 100644 index 000000000..7665e6b32 --- /dev/null +++ b/apps/backend/src/volunteers/dtos/update-animal-updates.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class UpdateAnimalUpdatesDto { + @IsBoolean() + animalUpdates!: boolean; +} diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index e6d526ad0..274678393 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -56,7 +56,7 @@ describe('VolunteersController', () => { zipcode: '02130', homebase: Homebase.BOSTON, residentAnimals: 'One cat', - fosterType: FosterType.CAT, + fosterType: [FosterType.CAT], }; it('is public so volunteers can sign up without a token', () => { diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index 7b1709fec..59590f72d 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -16,6 +16,7 @@ import { validateId } from '../utils/validation.utils'; import { FosterVolunteer } from './volunteers.entity'; import { CreateVolunteerDto } from './dtos/create-volunteer.dto'; import { Public } from '../aws/cognito/cognito.decorator'; +import { UpdateAnimalUpdatesDto } from './dtos/update-animal-updates.dto'; @ApiTags('Volunteers') @Controller('volunteers') @@ -96,4 +97,30 @@ export class VolunteersController { validateId(id, 'Volunteer'); return this.volunteersService.reactivate(id); } + + @ApiOperation({ + summary: 'Opt a foster volunteer in or out of animal updates', + }) + @ApiParam({ name: 'id', type: Number, description: 'Volunteer ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The volunteer was updated', + type: FosterVolunteer, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'The ID is malformed or the body is not a boolean', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'No volunteer with this ID exists', + }) + @Patch('/:id/animal-updates') + async updateAnimalUpdates( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateAnimalUpdatesDto, + ): Promise { + validateId(id, 'Volunteer'); + return this.volunteersService.updateAnimalUpdates(id, dto); + } } diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index b6367f786..8ef98ddef 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -128,7 +128,7 @@ describe('VolunteersService', () => { homebase: Homebase.BOSTON, residentAnimals: 'One cat', notes: 'Prefers kittens', - fosterType: FosterType.CAT, + fosterType: [FosterType.CAT], }; beforeEach(() => { diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index f529f189e..65ca44362 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -12,6 +12,7 @@ import { VolunteerStatus } from './volunteers.types'; import { CreateVolunteerDto } from './dtos/create-volunteer.dto'; import { CognitoService } from '../aws/cognito/cognito.service'; import { CognitoRole } from '../aws/cognito/cognito.types'; +import { UpdateAnimalUpdatesDto } from './dtos/update-animal-updates.dto'; @Injectable() export class VolunteersService { @@ -136,6 +137,26 @@ export class VolunteersService { return this.repo.save(volunteer); } + /** + * Opts a Volunteer in or out of hearing what happens to an animal after it + * returns to MSPCA. + * + * @param id - The Volunteer's ID. + * @param dto - Contains the new animalUpdates value. + * @returns The updated Volunteer. + * @throws {NotFoundException} If no Volunteer with the ID exists. + */ + async updateAnimalUpdates( + id: number, + dto: UpdateAnimalUpdatesDto, + ): Promise { + const volunteer = await this.findByIdOrFail(id); + + volunteer.animalUpdates = dto.animalUpdates; + + return this.repo.save(volunteer); + } + /** * Fetches a Volunteer by ID, requiring that they are active. * From 783df231d984deab7a2e8640d6cd1e4748e31ef9 Mon Sep 17 00:00:00 2001 From: Polina Smolyanskiy Date: Thu, 8 Oct 2026 20:49:37 -0400 Subject: [PATCH 3/3] MSPCA-24: add tests for animal-updates endpoint and fosterType validation --- .../dtos/create-volunteer.dto.spec.ts | 53 +++++++++++++++++++ .../dtos/update-animal-updates.dto.spec.ts | 33 ++++++++++++ .../volunteers/volunteers.controller.spec.ts | 32 +++++++++++ .../src/volunteers/volunteers.service.spec.ts | 53 +++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 apps/backend/src/volunteers/dtos/create-volunteer.dto.spec.ts create mode 100644 apps/backend/src/volunteers/dtos/update-animal-updates.dto.spec.ts diff --git a/apps/backend/src/volunteers/dtos/create-volunteer.dto.spec.ts b/apps/backend/src/volunteers/dtos/create-volunteer.dto.spec.ts new file mode 100644 index 000000000..4c54ff778 --- /dev/null +++ b/apps/backend/src/volunteers/dtos/create-volunteer.dto.spec.ts @@ -0,0 +1,53 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateVolunteerDto } from './create-volunteer.dto'; +import { FosterType } from '../volunteers.types'; +import { Homebase } from '../../types'; + +const validBody = { + firstName: 'Jane', + lastName: 'Doe', + phone: '617-555-0100', + email: 'jane@example.com', + address: '350 S Huntington Ave', + city: 'Boston', + zipcode: '02130', + homebase: Homebase.BOSTON, + residentAnimals: 'One cat', + fosterType: [FosterType.CAT], +}; + +async function fosterTypeErrors(fosterType: unknown) { + const errors = await validate( + plainToInstance(CreateVolunteerDto, { ...validBody, fosterType }), + ); + return errors.filter((e) => e.property === 'fosterType'); +} + +describe('CreateVolunteerDto fosterType', () => { + it('accepts a list with one type', async () => { + expect(await fosterTypeErrors([FosterType.DOG])).toHaveLength(0); + }); + + it('accepts a list with several types', async () => { + expect( + await fosterTypeErrors([FosterType.DOG, FosterType.CAT]), + ).toHaveLength(0); + }); + + it('rejects an empty list', async () => { + expect(await fosterTypeErrors([])).not.toHaveLength(0); + }); + + it('rejects a single value that is not in a list', async () => { + expect(await fosterTypeErrors(FosterType.DOG)).not.toHaveLength(0); + }); + + it('rejects a list containing an invalid type', async () => { + expect(await fosterTypeErrors(['Dragon'])).not.toHaveLength(0); + }); + + it('rejects a missing fosterType', async () => { + expect(await fosterTypeErrors(undefined)).not.toHaveLength(0); + }); +}); diff --git a/apps/backend/src/volunteers/dtos/update-animal-updates.dto.spec.ts b/apps/backend/src/volunteers/dtos/update-animal-updates.dto.spec.ts new file mode 100644 index 000000000..1112cf0ee --- /dev/null +++ b/apps/backend/src/volunteers/dtos/update-animal-updates.dto.spec.ts @@ -0,0 +1,33 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { UpdateAnimalUpdatesDto } from './update-animal-updates.dto'; + +async function errorsFor(body: unknown) { + return validate(plainToInstance(UpdateAnimalUpdatesDto, body)); +} + +describe('UpdateAnimalUpdatesDto', () => { + it('accepts true', async () => { + expect(await errorsFor({ animalUpdates: true })).toHaveLength(0); + }); + + it('accepts false', async () => { + expect(await errorsFor({ animalUpdates: false })).toHaveLength(0); + }); + + it('rejects an empty body', async () => { + expect(await errorsFor({})).not.toHaveLength(0); + }); + + it('rejects a string', async () => { + expect(await errorsFor({ animalUpdates: 'true' })).not.toHaveLength(0); + }); + + it('rejects a number', async () => { + expect(await errorsFor({ animalUpdates: 1 })).not.toHaveLength(0); + }); + + it('rejects null', async () => { + expect(await errorsFor({ animalUpdates: null })).not.toHaveLength(0); + }); +}); diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index 274678393..40b156bc9 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -18,6 +18,7 @@ describe('VolunteersController', () => { findByIdOrFail: jest.Mock; deactivate: jest.Mock; reactivate: jest.Mock; + updateAnimalUpdates: jest.Mock; }; let matchesService: { findByVolunteerId: jest.Mock }; @@ -27,6 +28,7 @@ describe('VolunteersController', () => { findByIdOrFail: jest.fn(), deactivate: jest.fn(), reactivate: jest.fn(), + updateAnimalUpdates: jest.fn(), }; matchesService = { findByVolunteerId: jest.fn() }; @@ -181,4 +183,34 @@ describe('VolunteersController', () => { ); }); }); + describe('updateAnimalUpdates', () => { + it('should call service.updateAnimalUpdates with the parsed id and body', async () => { + const dto = { animalUpdates: false }; + const volunteer = { + volunteerId: 1, + animalUpdates: false, + } as FosterVolunteer; + volunteersService.updateAnimalUpdates.mockResolvedValue(volunteer); + + const result = await controller.updateAnimalUpdates(1, dto); + + expect(volunteersService.updateAnimalUpdates).toHaveBeenCalledWith( + 1, + dto, + ); + expect(result).toEqual(volunteer); + }); + + it('propagates errors thrown by the service', async () => { + volunteersService.updateAnimalUpdates.mockRejectedValue( + new NotFoundException('Volunteer with ID 999 not found'), + ); + + await expect( + controller.updateAnimalUpdates(999, { animalUpdates: true }), + ).rejects.toThrow( + new NotFoundException('Volunteer with ID 999 not found'), + ); + }); + }); }); diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 8ef98ddef..a0254035b 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -299,6 +299,59 @@ describe('VolunteersService', () => { }); }); + describe('updateAnimalUpdates', () => { + it('opts the volunteer out and returns the updated volunteer', async () => { + const volunteer = { + volunteerId: 1, + animalUpdates: true, + } as FosterVolunteer; + repo.findOne.mockResolvedValue(volunteer); + repo.save.mockImplementation(async (v) => v); + + const result = await service.updateAnimalUpdates(1, { + animalUpdates: false, + }); + + expect(repo.findOne).toHaveBeenCalledWith({ + where: { volunteerId: 1 }, + relations: ['assignedCoordinator'], + }); + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ volunteerId: 1, animalUpdates: false }), + ); + expect(result.animalUpdates).toBe(false); + }); + + it('opts the volunteer back in', async () => { + const volunteer = { + volunteerId: 1, + animalUpdates: false, + } as FosterVolunteer; + repo.findOne.mockResolvedValue(volunteer); + repo.save.mockImplementation(async (v) => v); + + const result = await service.updateAnimalUpdates(1, { + animalUpdates: true, + }); + + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ volunteerId: 1, animalUpdates: true }), + ); + expect(result.animalUpdates).toBe(true); + }); + + it('throws NotFoundException if volunteer does not exist', async () => { + repo.findOne.mockResolvedValue(null); + + await expect( + service.updateAnimalUpdates(999, { animalUpdates: false }), + ).rejects.toThrow( + new NotFoundException('Volunteer with ID 999 not found'), + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + }); + describe('reactivate', () => { it('sets status to Active and saves the volunteer', async () => { const volunteer = {