diff --git a/src/adoption/adoption.controller.ts b/src/adoption/adoption.controller.ts index 4b0820d..546a74b 100644 --- a/src/adoption/adoption.controller.ts +++ b/src/adoption/adoption.controller.ts @@ -1,17 +1,6 @@ import { Controller, - Post, - Patch, - Param, - Body, - UseGuards, - Req, - HttpCode, - HttpStatus, - UseInterceptors, - BadRequestException, - UploadedFiles, - InternalServerErrorException, + } from '@nestjs/common'; import { Request } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -19,46 +8,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { Role } from '../auth/enums/role.enum'; import { AdoptionService } from './adoption.service'; -import { CreateAdoptionDto } from './dto/create-adoption.dto'; -import { DocumentsService } from '../documents/documents.service'; -import { FilesInterceptor } from '@nestjs/platform-express'; -import { EventsService } from '../events/events.service'; -import { EventEntityType, EventType } from '@prisma/client'; - -interface AuthRequest extends Request { - user: { userId: string; email: string; role: string; sub?: string }; -} - -@Controller('adoption') -@UseGuards(JwtAuthGuard) -export class AdoptionController { - constructor( - private readonly adoptionService: AdoptionService, - private readonly documentsService: DocumentsService, - private readonly eventsService: EventsService, - ) {} - /** - * POST /adoption/requests - * Any authenticated user can request to adopt a pet. - * Fires ADOPTION_REQUESTED event on success. - */ - @Post('requests') - @HttpCode(HttpStatus.CREATED) - requestAdoption(@Req() req: AuthRequest, @Body() dto: CreateAdoptionDto) { - return this.adoptionService.requestAdoption( - (req.user.userId || req.user.sub) as string, - dto, - ); - } - - /** - * PATCH /adoption/:id/approve - * Admin-only. Approves a pending adoption request. - * Fires ADOPTION_APPROVED event on success. - */ - @Patch(':id/approve') - @UseGuards(RolesGuard) @Roles(Role.ADMIN) approveAdoption(@Req() req: AuthRequest, @Param('id') id: string) { return this.adoptionService.updateAdoptionStatus(id, req.user.userId, { diff --git a/src/adoption/adoption.module.ts b/src/adoption/adoption.module.ts index 39d3a5b..b23b2b3 100644 --- a/src/adoption/adoption.module.ts +++ b/src/adoption/adoption.module.ts @@ -1,8 +1,7 @@ import { Module } from '@nestjs/common'; import { AdoptionController } from './adoption.controller'; import { AdoptionService } from './adoption.service'; -import { PrismaModule } from '../prisma/prisma.module'; -import { DocumentsModule } from '../documents/documents.module'; + @Module({ imports: [PrismaModule, DocumentsModule], diff --git a/src/adoption/adoption.service.spec.ts b/src/adoption/adoption.service.spec.ts index be0166a..22c38ee 100644 --- a/src/adoption/adoption.service.spec.ts +++ b/src/adoption/adoption.service.spec.ts @@ -1,45 +1,9 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException, ConflictException } from '@nestjs/common'; -import { AdoptionService } from './adoption.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { EventsService } from '../events/events.service'; -import { EventType, EventEntityType, AdoptionStatus } from '@prisma/client'; -const ADOPTER_ID = 'adopter-uuid'; -const PET_ID = 'pet-uuid'; -const OWNER_ID = 'owner-uuid'; -const ADOPTION_ID = 'adoption-uuid'; -const ACTOR_ID = 'admin-uuid'; - -const mockAdoption = { - id: ADOPTION_ID, - petId: PET_ID, - ownerId: OWNER_ID, - adopterId: ADOPTER_ID, - status: AdoptionStatus.REQUESTED, - notes: null, - escrowId: null, - createdAt: new Date(), - updatedAt: new Date(), -}; describe('AdoptionService', () => { let service: AdoptionService; - const mockPrisma = { - pet: { findUnique: jest.fn() }, - user: { findUnique: jest.fn() }, - adoption: { - create: jest.fn(), - findUnique: jest.fn(), - findFirst: jest.fn(), - update: jest.fn(), - }, - $transaction: jest.fn().mockImplementation(async (cb) => cb(mockPrisma)), - }; - const mockEvents = { - logEvent: jest.fn(), }; beforeEach(async () => { @@ -48,178 +12,13 @@ describe('AdoptionService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AdoptionService, - { provide: PrismaService, useValue: mockPrisma }, - { provide: EventsService, useValue: mockEvents }, + ], }).compile(); service = module.get(AdoptionService); }); - // ─── requestAdoption ────────────────────────────────── - - describe('requestAdoption', () => { - const dto = { petId: PET_ID, ownerId: OWNER_ID }; - - it('creates the adoption record and fires ADOPTION_REQUESTED', async () => { - mockPrisma.pet.findUnique.mockResolvedValue({ id: PET_ID, currentOwnerId: OWNER_ID }); - mockPrisma.adoption.findFirst.mockResolvedValue(null); - mockPrisma.adoption.create.mockResolvedValue(mockAdoption); - mockEvents.logEvent.mockResolvedValue({}); - - const result = await service.requestAdoption(ADOPTER_ID, dto); - - expect(mockPrisma.adoption.create).toHaveBeenCalledWith({ - data: { - petId: PET_ID, - ownerId: OWNER_ID, - adopterId: ADOPTER_ID, - notes: undefined, - status: AdoptionStatus.REQUESTED, - }, - }); - - expect(mockEvents.logEvent).toHaveBeenCalledWith( - expect.objectContaining({ - entityType: EventEntityType.ADOPTION, - entityId: ADOPTION_ID, - eventType: EventType.ADOPTION_REQUESTED, - actorId: ADOPTER_ID, - }), - ); - - expect(result).toEqual(mockAdoption); - }); - - it('throws NotFoundException when the pet does not exist', async () => { - mockPrisma.pet.findUnique.mockResolvedValue(null); - - await expect(service.requestAdoption(ADOPTER_ID, dto)).rejects.toThrow( - NotFoundException, - ); - - expect(mockPrisma.adoption.create).not.toHaveBeenCalled(); - expect(mockEvents.logEvent).not.toHaveBeenCalled(); - }); - - it('throws ConflictException when the pet has no owner assigned', async () => { - mockPrisma.pet.findUnique.mockResolvedValue({ id: PET_ID, currentOwnerId: null }); - - await expect(service.requestAdoption(ADOPTER_ID, dto)).rejects.toThrow( - ConflictException, - ); - - expect(mockPrisma.adoption.create).not.toHaveBeenCalled(); - }); - - it('throws ConflictException when there is an active adoption', async () => { - mockPrisma.pet.findUnique.mockResolvedValue({ id: PET_ID, currentOwnerId: OWNER_ID }); - mockPrisma.adoption.findFirst.mockResolvedValue(mockAdoption); - - await expect(service.requestAdoption(ADOPTER_ID, dto)).rejects.toThrow( - ConflictException, - ); - - expect(mockPrisma.adoption.create).not.toHaveBeenCalled(); - }); - - it('propagates logEvent errors (no silent failure)', async () => { - mockPrisma.pet.findUnique.mockResolvedValue({ id: PET_ID, currentOwnerId: OWNER_ID }); - mockPrisma.adoption.findFirst.mockResolvedValue(null); - mockPrisma.adoption.create.mockResolvedValue(mockAdoption); - mockEvents.logEvent.mockRejectedValue(new Error('DB connection lost')); - - await expect(service.requestAdoption(ADOPTER_ID, dto)).rejects.toThrow( - 'DB connection lost', - ); - }); - }); - - // ─── updateAdoptionStatus ───────────────────────────── - - describe('updateAdoptionStatus', () => { - it('updates status to APPROVED and fires ADOPTION_APPROVED', async () => { - const updated = { ...mockAdoption, status: AdoptionStatus.APPROVED }; - mockPrisma.adoption.findUnique.mockResolvedValue(mockAdoption); - mockPrisma.adoption.update.mockResolvedValue(updated); - mockEvents.logEvent.mockResolvedValue({}); - - const result = await service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, { - status: 'APPROVED', - }); - - expect(mockPrisma.adoption.update).toHaveBeenCalledWith({ - where: { id: ADOPTION_ID }, - data: { status: 'APPROVED' }, - }); - - expect(mockEvents.logEvent).toHaveBeenCalledWith( - expect.objectContaining({ - eventType: EventType.ADOPTION_APPROVED, - entityId: ADOPTION_ID, - actorId: ACTOR_ID, - }), - ); - - expect(result.status).toBe(AdoptionStatus.APPROVED); - }); - - it('updates status to COMPLETED and fires ADOPTION_COMPLETED', async () => { - const updated = { ...mockAdoption, status: AdoptionStatus.COMPLETED }; - mockPrisma.adoption.findUnique.mockResolvedValue(mockAdoption); - mockPrisma.adoption.update.mockResolvedValue(updated); - mockEvents.logEvent.mockResolvedValue({}); - - await service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, { - status: 'COMPLETED', - }); - - expect(mockEvents.logEvent).toHaveBeenCalledWith( - expect.objectContaining({ - eventType: EventType.ADOPTION_COMPLETED, - }), - ); - }); - - it('updates status to REJECTED without firing an event', async () => { - const updated = { ...mockAdoption, status: AdoptionStatus.REJECTED }; - mockPrisma.adoption.findUnique.mockResolvedValue(mockAdoption); - mockPrisma.adoption.update.mockResolvedValue(updated); - - await service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, { - status: 'REJECTED', - }); - - // REJECTED has no mapped EventType — logEvent should NOT be called - expect(mockEvents.logEvent).not.toHaveBeenCalled(); - }); - - it('throws NotFoundException when adoption does not exist', async () => { - mockPrisma.adoption.findUnique.mockResolvedValue(null); - - await expect( - service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, { - status: 'APPROVED', - }), - ).rejects.toThrow(NotFoundException); - - expect(mockPrisma.adoption.update).not.toHaveBeenCalled(); - expect(mockEvents.logEvent).not.toHaveBeenCalled(); - }); - - it('propagates logEvent errors (no silent failure)', async () => { - const updated = { ...mockAdoption, status: AdoptionStatus.APPROVED }; - mockPrisma.adoption.findUnique.mockResolvedValue(mockAdoption); - mockPrisma.adoption.update.mockResolvedValue(updated); - mockEvents.logEvent.mockRejectedValue( - new Error('Event store unavailable'), - ); - await expect( - service.updateAdoptionStatus(ADOPTION_ID, ACTOR_ID, { - status: 'APPROVED', - }), - ).rejects.toThrow('Event store unavailable'); - }); }); }); diff --git a/src/adoption/adoption.service.ts b/src/adoption/adoption.service.ts index 24a4629..e69de29 100644 --- a/src/adoption/adoption.service.ts +++ b/src/adoption/adoption.service.ts @@ -1,177 +0,0 @@ -import { - Injectable, - Logger, - NotFoundException, - ConflictException, - Optional, -} from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; -import { EventsService } from '../events/events.service'; -import { - EventType, - EventEntityType, - AdoptionStatus, - Prisma, -} from '@prisma/client'; -import { CreateAdoptionDto } from './dto/create-adoption.dto'; -import { UpdateAdoptionStatusDto } from './dto/update-adoption-status.dto'; -import { NotificationQueueService } from '../jobs/services/notification-queue.service'; - -/** Maps an AdoptionStatus to its corresponding EventType, if one exists. */ -const ADOPTION_STATUS_EVENT_MAP: Partial> = { - [AdoptionStatus.APPROVED]: EventType.ADOPTION_APPROVED, - [AdoptionStatus.COMPLETED]: EventType.ADOPTION_COMPLETED, -}; - -@Injectable() -export class AdoptionService { - private readonly logger = new Logger(AdoptionService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly events: EventsService, - @Optional() - private readonly notificationQueueService?: NotificationQueueService, - ) {} - - /** - * Creates an adoption request and fires an ADOPTION_REQUESTED event. - * Throws NotFoundException if the pet does not exist. - * Throws ConflictException if the pet has no owner or already has an active adoption. - */ - async requestAdoption(adopterId: string, dto: CreateAdoptionDto) { - return this.prisma.$transaction(async (tx) => { - const pet = await tx.pet.findUnique({ where: { id: dto.petId } }); - - if (!pet) { - throw new NotFoundException(`Pet with id "${dto.petId}" not found`); - } - - if (!pet.currentOwnerId) { - throw new ConflictException('Pet has no owner assigned'); - } - - const activeAdoption = await tx.adoption.findFirst({ - where: { - petId: dto.petId, - status: { - in: [ - AdoptionStatus.REQUESTED, - AdoptionStatus.PENDING, - AdoptionStatus.APPROVED, - AdoptionStatus.ESCROW_FUNDED, - ], - }, - }, - }); - - if (activeAdoption) { - throw new ConflictException('Pet is not available for adoption'); - } - - const adoption = await tx.adoption.create({ - data: { - petId: dto.petId, - ownerId: pet.currentOwnerId, - adopterId, - notes: dto.notes, - status: AdoptionStatus.REQUESTED, - }, - }); - - this.logger.log( - `Adoption ${adoption.id} requested by adopter ${adopterId} for pet ${dto.petId}`, - ); - - await this.events.logEvent({ - entityType: EventEntityType.ADOPTION, - entityId: adoption.id, - eventType: EventType.ADOPTION_REQUESTED, - actorId: adopterId, - payload: { - adoptionId: adoption.id, - petId: dto.petId, - ownerId: pet.currentOwnerId, - adopterId, - } satisfies Prisma.InputJsonValue, - }); - - return adoption; - }); - } - - /** - * Updates an adoption's status and fires the corresponding event when one exists. - * Throws NotFoundException if the adoption record does not exist. - * Any failure in logEvent propagates to the caller (no silent failures). - */ - async updateAdoptionStatus( - adoptionId: string, - actorId: string, - dto: UpdateAdoptionStatusDto, - ) { - const existing = await this.prisma.adoption.findUnique({ - where: { id: adoptionId }, - }); - - if (!existing) { - throw new NotFoundException(`Adoption with id "${adoptionId}" not found`); - } - - const updated = await this.prisma.adoption.update({ - where: { id: adoptionId }, - data: { status: dto.status }, - }); - - this.logger.log( - `Adoption ${adoptionId} status updated to ${dto.status} by actor ${actorId}`, - ); - - const eventType = ADOPTION_STATUS_EVENT_MAP[dto.status]; - if (eventType) { - await this.events.logEvent({ - entityType: EventEntityType.ADOPTION, - entityId: adoptionId, - eventType, - actorId, - payload: { - adoptionId, - newStatus: dto.status, - petId: updated.petId, - adopterId: updated.adopterId, - } satisfies Prisma.InputJsonValue, - }); - - // Best-effort: enqueue a notification email without blocking status updates. - if (this.notificationQueueService) { - try { - const adopter = await this.prisma.user.findUnique({ - where: { id: updated.adopterId }, - select: { email: true }, - }); - - if (adopter?.email) { - await this.notificationQueueService.enqueueSendTransactionalEmail( - { - dto: { - to: adopter.email, - subject: `PetAd: Adoption ${dto.status}`, - text: `Hello! Your adoption has been updated to ${dto.status}.`, - }, - metadata: { adoptionId, newStatus: dto.status }, - }, - ); - } - } catch (error) { - const reason = - error instanceof Error ? error.message : String(error); - this.logger.error( - `Failed to enqueue adoption notification email | adoptionId=${adoptionId} | reason=${reason}`, - ); - } - } - } - - return updated; - } -} diff --git a/src/adoption/dto/filter-adoptions.dto.ts b/src/adoption/dto/filter-adoptions.dto.ts new file mode 100644 index 0000000..57b715c --- /dev/null +++ b/src/adoption/dto/filter-adoptions.dto.ts @@ -0,0 +1,21 @@ +import { AdoptionStatus } from '@prisma/client'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +export class FilterAdoptionsDto { + @ApiPropertyOptional({ + enum: AdoptionStatus, + description: 'Filter adoption requests by status', + }) + @IsOptional() + @IsEnum(AdoptionStatus) + status?: AdoptionStatus; + + @ApiPropertyOptional({ + description: 'Filter adoption requests by pet id', + example: 'pet-123', + }) + @IsOptional() + @IsString() + petId?: string; +}