diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 390a0d1c5..d23aae40e 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import typeorm from './config/typeorm'; import { CognitoModule } from './aws/cognito/cognito.module'; +import { RecommendationsModule } from './recommendations/recommendations.module'; import { MatchesModule } from './matches/matches.module'; import { CoordinatorsModule } from './coordinators/coordinators.module'; import { VolunteersModule } from './volunteers/volunteers.module'; @@ -19,6 +20,7 @@ import { VolunteersModule } from './volunteers/volunteers.module'; configService.getOrThrow('typeorm'), }), CognitoModule, + RecommendationsModule, MatchesModule, CoordinatorsModule, VolunteersModule, diff --git a/apps/backend/src/recommendations/dto/create-recommendation.dto.ts b/apps/backend/src/recommendations/dto/create-recommendation.dto.ts new file mode 100644 index 000000000..0976d2321 --- /dev/null +++ b/apps/backend/src/recommendations/dto/create-recommendation.dto.ts @@ -0,0 +1,11 @@ +import { IsInt, IsPositive } from 'class-validator'; + +export class CreateRecommendationDTO { + @IsInt() + @IsPositive() + volunteerId!: number; + + @IsInt() + @IsPositive() + chameleonAnimalId!: number; +} diff --git a/apps/backend/src/recommendations/recommendations.controller.spec.ts b/apps/backend/src/recommendations/recommendations.controller.spec.ts index 748a2c3fc..5149a8e40 100644 --- a/apps/backend/src/recommendations/recommendations.controller.spec.ts +++ b/apps/backend/src/recommendations/recommendations.controller.spec.ts @@ -1,17 +1,35 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; import { RecommendationsController } from './recommendations.controller'; import { RecommendationsService } from './recommendations.service'; +import { Recommendation } from './recommendations.entity'; +import { CreateRecommendationDTO } from './dto/create-recommendation.dto'; +import { VolunteersService } from '../volunteers/volunteers.service'; describe('RecommendationsController', () => { let controller: RecommendationsController; + let recommendationsService: { create: jest.Mock }; + let volunteersService: { findActiveOrFail: jest.Mock }; + + const body = { + volunteerId: 7, + chameleonAnimalId: 42, + } as CreateRecommendationDTO; beforeEach(async () => { + recommendationsService = { create: jest.fn() }; + volunteersService = { findActiveOrFail: jest.fn() }; + const module: TestingModule = await Test.createTestingModule({ controllers: [RecommendationsController], providers: [ { provide: RecommendationsService, - useValue: {}, + useValue: recommendationsService, + }, + { + provide: VolunteersService, + useValue: volunteersService, }, ], }).compile(); @@ -24,4 +42,28 @@ describe('RecommendationsController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('createRecommendation', () => { + it('returns the created recommendation when the volunteer is active', async () => { + const recommendation = { ...body, isActive: true } as Recommendation; + volunteersService.findActiveOrFail.mockResolvedValue({}); + recommendationsService.create.mockResolvedValue(recommendation); + + const result = await controller.createRecommendation(body); + + expect(result).toBe(recommendation); + expect(volunteersService.findActiveOrFail).toHaveBeenCalledWith(7); + expect(recommendationsService.create).toHaveBeenCalledWith(body); + }); + + it('does not create a recommendation when the volunteer does not exist or is not active', async () => { + const error = new NotFoundException(); + volunteersService.findActiveOrFail.mockRejectedValue(error); + + await expect(controller.createRecommendation(body)).rejects.toThrow( + error, + ); + expect(recommendationsService.create).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/recommendations/recommendations.controller.ts b/apps/backend/src/recommendations/recommendations.controller.ts index ed58b1def..3ddf5ec42 100644 --- a/apps/backend/src/recommendations/recommendations.controller.ts +++ b/apps/backend/src/recommendations/recommendations.controller.ts @@ -1,7 +1,37 @@ -import { Controller } from '@nestjs/common'; +import { Body, Controller, HttpStatus, Post } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { RecommendationsService } from './recommendations.service'; +import { Recommendation } from './recommendations.entity'; +import { CreateRecommendationDTO } from './dto/create-recommendation.dto'; +import { VolunteersService } from '../volunteers/volunteers.service'; +import { validateId } from '../utils/validation.utils'; +@ApiTags('Recommendations') +// @ApiBearerAuth() @Controller('recommendations') export class RecommendationsController { - constructor(private recommendationsService: RecommendationsService) {} + constructor( + private recommendationsService: RecommendationsService, + private volunteersService: VolunteersService, + ) {} + + @Post() + @ApiOperation({ + summary: 'Recommend a Chameleon Animal to an active Volunteer', + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The created or reactivated recommendation', + type: Recommendation, + }) + async createRecommendation( + @Body() body: CreateRecommendationDTO, + ): Promise { + validateId(body.volunteerId, 'Volunteer'); + validateId(body.chameleonAnimalId, 'Chameleon Animal'); + + await this.volunteersService.findActiveOrFail(body.volunteerId); + + return this.recommendationsService.create(body); + } } diff --git a/apps/backend/src/recommendations/recommendations.module.ts b/apps/backend/src/recommendations/recommendations.module.ts index af7c536b8..3dd562311 100644 --- a/apps/backend/src/recommendations/recommendations.module.ts +++ b/apps/backend/src/recommendations/recommendations.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Recommendation } from './recommendations.entity'; import { RecommendationsController } from './recommendations.controller'; import { RecommendationsService } from './recommendations.service'; +import { VolunteersModule } from '../volunteers/volunteers.module'; @Module({ - imports: [TypeOrmModule.forFeature([Recommendation])], + imports: [TypeOrmModule.forFeature([Recommendation]), VolunteersModule], controllers: [RecommendationsController], providers: [RecommendationsService], exports: [RecommendationsService], diff --git a/apps/backend/src/recommendations/recommendations.service.spec.ts b/apps/backend/src/recommendations/recommendations.service.spec.ts index 5424b2ecd..d60296250 100644 --- a/apps/backend/src/recommendations/recommendations.service.spec.ts +++ b/apps/backend/src/recommendations/recommendations.service.spec.ts @@ -5,14 +5,26 @@ import { Recommendation } from './recommendations.entity'; describe('RecommendationsService', () => { let service: RecommendationsService; + let repo: { upsert: jest.Mock; findOneByOrFail: jest.Mock }; + + const saved = { + volunteerId: 7, + chameleonAnimalId: 42, + isActive: true, + } as Recommendation; beforeEach(async () => { + repo = { + upsert: jest.fn().mockResolvedValue({ identifiers: [] }), + findOneByOrFail: jest.fn().mockResolvedValue(saved), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ RecommendationsService, { provide: getRepositoryToken(Recommendation), - useValue: {}, + useValue: repo, }, ], }).compile(); @@ -23,4 +35,56 @@ describe('RecommendationsService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('create', () => { + it('upserts the recommendation as active on the composite key', async () => { + await service.create({ volunteerId: 7, chameleonAnimalId: 42 }); + + expect(repo.upsert).toHaveBeenCalledWith( + { volunteerId: 7, chameleonAnimalId: 42, isActive: true }, + ['volunteerId', 'chameleonAnimalId'], + ); + }); + + it('reactivates an inactive recommendation for the same pair', async () => { + const result = await service.create({ + volunteerId: 7, + chameleonAnimalId: 42, + }); + + expect(repo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true }), + ['volunteerId', 'chameleonAnimalId'], + ); + expect(result.isActive).toBe(true); + }); + + it('returns the persisted recommendation', async () => { + const result = await service.create({ + volunteerId: 7, + chameleonAnimalId: 42, + }); + + expect(result).toBe(saved); + expect(repo.findOneByOrFail).toHaveBeenCalledWith({ + volunteerId: 7, + chameleonAnimalId: 42, + }); + }); + + it('reads the row back only after the upsert has resolved', async () => { + const order: string[] = []; + repo.upsert.mockImplementation(async () => { + order.push('upsert'); + }); + repo.findOneByOrFail.mockImplementation(async () => { + order.push('read'); + return saved; + }); + + await service.create({ volunteerId: 7, chameleonAnimalId: 42 }); + + expect(order).toEqual(['upsert', 'read']); + }); + }); }); diff --git a/apps/backend/src/recommendations/recommendations.service.ts b/apps/backend/src/recommendations/recommendations.service.ts index 72688a007..7dfa3be98 100644 --- a/apps/backend/src/recommendations/recommendations.service.ts +++ b/apps/backend/src/recommendations/recommendations.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Recommendation } from './recommendations.entity'; +import { CreateRecommendationDTO } from './dto/create-recommendation.dto'; @Injectable() export class RecommendationsService { @@ -9,4 +10,29 @@ export class RecommendationsService { @InjectRepository(Recommendation) private repo: Repository, ) {} + + /** + * Recommends a Chameleon Animal to a Volunteer. + * + * (volunteerId, chameleonAnimalId) is the primary key, so recommending the + * same animal again reactivates the existing row instead of duplicating it. + * This upserts rather than save()-ing so that two concurrent requests for + * the same pair can't race into a primary key violation. Does not check + * that the Volunteer exists or is active; callers are expected to. + * + * @param dto - The Volunteer and Chameleon Animal IDs to link. + * @returns The active recommendation as persisted. + * @throws {EntityNotFoundError} If the row can't be read back after the upsert. + */ + async create({ + volunteerId, + chameleonAnimalId, + }: CreateRecommendationDTO): Promise { + await this.repo.upsert({ volunteerId, chameleonAnimalId, isActive: true }, [ + 'volunteerId', + 'chameleonAnimalId', + ]); + + return this.repo.findOneByOrFail({ volunteerId, chameleonAnimalId }); + } } diff --git a/apps/backend/src/utils/validation.utils.spec.ts b/apps/backend/src/utils/validation.utils.spec.ts index 6b3908403..9ac7b4b8d 100644 --- a/apps/backend/src/utils/validation.utils.spec.ts +++ b/apps/backend/src/utils/validation.utils.spec.ts @@ -11,4 +11,16 @@ describe('validateId', () => { new BadRequestException('Invalid User ID'), ); }); + + it('should throw BadRequestException for a non-integer ID', () => { + expect(() => validateId(1.5, 'User')).toThrow( + new BadRequestException('Invalid User ID'), + ); + }); + + it('should throw BadRequestException for a missing ID', () => { + expect(() => validateId(undefined as unknown as number, 'User')).toThrow( + new BadRequestException('Invalid User ID'), + ); + }); }); diff --git a/apps/backend/src/utils/validation.utils.ts b/apps/backend/src/utils/validation.utils.ts index 5c4f14a78..a02ac8d94 100644 --- a/apps/backend/src/utils/validation.utils.ts +++ b/apps/backend/src/utils/validation.utils.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; export function validateId(id: number, entityName: string): void { - if (!id || id < 1) { + if (!Number.isInteger(id) || id < 1) { throw new BadRequestException(`Invalid ${entityName} ID`); } }