diff --git a/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts b/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts index 54efcdd..932dd79 100644 --- a/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts +++ b/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts @@ -1,7 +1,23 @@ +import { AgentEntity } from '../../domain/entities/agent.entity'; import type { IAgentRepository, AgentDashboardData } from '../../domain/repositories/agent.repository'; +import { QualityScore } from '../../domain/value-objects/quality-score.vo'; import { GetAgentDashboardHandler } from '../queries/get-agent-dashboard/get-agent-dashboard.handler'; import { GetAgentDashboardQuery } from '../queries/get-agent-dashboard/get-agent-dashboard.query'; +function makeAgent(id: string, userId: string, qualityScore: number): AgentEntity { + return new AgentEntity(id, { + userId, + licenseNumber: null, + agency: null, + qualityScore: QualityScore.fromPersistence(qualityScore), + totalDeals: 0, + responseTimeAvg: null, + bio: null, + serviceAreas: [], + isVerified: false, + }); +} + describe('GetAgentDashboardHandler', () => { let handler: GetAgentDashboardHandler; let mockAgentRepo: { [K in keyof IAgentRepository]: ReturnType }; @@ -10,19 +26,17 @@ describe('GetAgentDashboardHandler', () => { mockAgentRepo = { findByUserId: vi.fn(), findById: vi.fn(), - updateQualityScore: vi.fn(), + save: vi.fn(), getDashboard: vi.fn(), + getPublicProfile: vi.fn(), + getQualityScoreInputs: vi.fn(), }; handler = new GetAgentDashboardHandler(mockAgentRepo as any); }); it('returns dashboard data', async () => { - mockAgentRepo.findByUserId.mockResolvedValue({ - id: 'agent-1', - userId: 'user-1', - qualityScore: 85, - }); + mockAgentRepo.findByUserId.mockResolvedValue(makeAgent('agent-1', 'user-1', 85)); const mockDashboard: AgentDashboardData = { agentId: 'agent-1', diff --git a/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts b/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts index 89b47ba..19bb7d1 100644 --- a/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts +++ b/apps/api/src/modules/agents/application/__tests__/recalculate-quality-score.handler.spec.ts @@ -1,69 +1,69 @@ +import type { EventBus } from '@nestjs/cqrs'; +import { AgentEntity } from '../../domain/entities/agent.entity'; import type { IAgentRepository } from '../../domain/repositories/agent.repository'; +import { QualityScore } from '../../domain/value-objects/quality-score.vo'; import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command'; import { RecalculateQualityScoreHandler } from '../commands/recalculate-quality-score/recalculate-quality-score.handler'; +function makeAgent(id: string, userId: string, qualityScore: number): AgentEntity { + return new AgentEntity(id, { + userId, + licenseNumber: null, + agency: null, + qualityScore: QualityScore.fromPersistence(qualityScore), + totalDeals: 0, + responseTimeAvg: null, + bio: null, + serviceAreas: [], + isVerified: false, + }); +} + describe('RecalculateQualityScoreHandler', () => { let handler: RecalculateQualityScoreHandler; let mockAgentRepo: { [K in keyof IAgentRepository]: ReturnType }; - let mockPrisma: { - review: { aggregate: ReturnType }; - lead: { count: ReturnType }; - listing: { count: ReturnType }; - agent: { findUnique: ReturnType }; - }; + let mockEventBus: { publish: ReturnType }; beforeEach(() => { mockAgentRepo = { findByUserId: vi.fn(), findById: vi.fn(), - updateQualityScore: vi.fn(), + save: vi.fn(), getDashboard: vi.fn(), + getPublicProfile: vi.fn(), + getQualityScoreInputs: vi.fn(), }; - mockPrisma = { - review: { aggregate: vi.fn() }, - lead: { count: vi.fn() }, - listing: { count: vi.fn() }, - agent: { findUnique: vi.fn() }, - }; + mockEventBus = { publish: vi.fn() }; const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; handler = new RecalculateQualityScoreHandler( mockAgentRepo as any, - mockPrisma as any, + mockEventBus as unknown as EventBus, mockLogger as any, ); }); it('recalculates quality score successfully', async () => { - mockAgentRepo.findById.mockResolvedValue({ id: 'agent-1', userId: 'user-1', qualityScore: 50 }); - mockPrisma.review.aggregate.mockResolvedValue({ - _avg: { rating: 4.5 }, - _count: { rating: 10 }, + mockAgentRepo.findById.mockResolvedValue(makeAgent('agent-1', 'user-1', 50)); + mockAgentRepo.getQualityScoreInputs.mockResolvedValue({ + avgRating: 4.5, + totalReviews: 10, + responseTimeAvg: 900, + conversionRate: 0.25, + activeListingRatio: 0.7, }); - mockPrisma.lead.count - .mockResolvedValueOnce(20) // totalLeads - .mockResolvedValueOnce(5); // convertedLeads - mockPrisma.listing.count - .mockResolvedValueOnce(10) // totalListings - .mockResolvedValueOnce(7); // activeListings - mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: 900 }); // 15 min const command = new RecalculateQualityScoreCommand('agent-1'); await handler.execute(command); - expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledTimes(1); - expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledWith( - 'agent-1', - expect.any(Number), - ); + expect(mockAgentRepo.save).toHaveBeenCalledTimes(1); - // Verify the score value is reasonable - const actualScore = mockAgentRepo.updateQualityScore.mock.calls[0][1]; - expect(actualScore).toBeGreaterThan(0); - expect(actualScore).toBeLessThanOrEqual(100); + const savedAgent = mockAgentRepo.save.mock.calls[0][0] as AgentEntity; + expect(savedAgent.qualityScore.value).toBeGreaterThan(0); + expect(savedAgent.qualityScore.value).toBeLessThanOrEqual(100); }); it('skips recalculation when agent not found', async () => { @@ -73,46 +73,61 @@ describe('RecalculateQualityScoreHandler', () => { await handler.execute(command); - expect(mockAgentRepo.updateQualityScore).not.toHaveBeenCalled(); - expect(mockPrisma.review.aggregate).not.toHaveBeenCalled(); + expect(mockAgentRepo.save).not.toHaveBeenCalled(); + expect(mockAgentRepo.getQualityScoreInputs).not.toHaveBeenCalled(); }); - it('updates agent record with calculated score', async () => { - mockAgentRepo.findById.mockResolvedValue({ id: 'agent-1', userId: 'user-1', qualityScore: 0 }); - mockPrisma.review.aggregate.mockResolvedValue({ - _avg: { rating: 5 }, - _count: { rating: 20 }, + it('updates agent with calculated score', async () => { + mockAgentRepo.findById.mockResolvedValue(makeAgent('agent-1', 'user-1', 0)); + mockAgentRepo.getQualityScoreInputs.mockResolvedValue({ + avgRating: 5, + totalReviews: 20, + responseTimeAvg: 0, + conversionRate: 1, + activeListingRatio: 1, }); - mockPrisma.lead.count - .mockResolvedValueOnce(10) - .mockResolvedValueOnce(10); // 100% conversion - mockPrisma.listing.count - .mockResolvedValueOnce(5) - .mockResolvedValueOnce(5); // 100% active - mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: 0 }); // instant response const command = new RecalculateQualityScoreCommand('agent-1'); await handler.execute(command); - expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledWith('agent-1', 100); + const savedAgent = mockAgentRepo.save.mock.calls[0][0] as AgentEntity; + expect(savedAgent.qualityScore.value).toBe(100); }); - it('handles null response time avg from agent record', async () => { - mockAgentRepo.findById.mockResolvedValue({ id: 'agent-1', userId: 'user-1', qualityScore: 0 }); - mockPrisma.review.aggregate.mockResolvedValue({ - _avg: { rating: null }, - _count: { rating: 0 }, + it('publishes domain events after save', async () => { + mockAgentRepo.findById.mockResolvedValue(makeAgent('agent-1', 'user-1', 50)); + mockAgentRepo.getQualityScoreInputs.mockResolvedValue({ + avgRating: 4.5, + totalReviews: 10, + responseTimeAvg: 900, + conversionRate: 0.25, + activeListingRatio: 0.7, }); - mockPrisma.lead.count.mockResolvedValue(0); - mockPrisma.listing.count.mockResolvedValue(0); - mockPrisma.agent.findUnique.mockResolvedValue({ responseTimeAvg: null }); const command = new RecalculateQualityScoreCommand('agent-1'); await handler.execute(command); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('handles null response time avg', async () => { + mockAgentRepo.findById.mockResolvedValue(makeAgent('agent-1', 'user-1', 0)); + mockAgentRepo.getQualityScoreInputs.mockResolvedValue({ + avgRating: 0, + totalReviews: 0, + responseTimeAvg: null, + conversionRate: 0, + activeListingRatio: 0, + }); + + const command = new RecalculateQualityScoreCommand('agent-1'); + + await handler.execute(command); + + const savedAgent = mockAgentRepo.save.mock.calls[0][0] as AgentEntity; // no reviews (50*0.4=20), null response (50*0.3=15), 0 conversion, 0 listing - expect(mockAgentRepo.updateQualityScore).toHaveBeenCalledWith('agent-1', 35); + expect(savedAgent.qualityScore.value).toBe(35); }); }); diff --git a/apps/api/src/modules/agents/domain/repositories/agent.repository.ts b/apps/api/src/modules/agents/domain/repositories/agent.repository.ts index 54b1f3f..6767ffe 100644 --- a/apps/api/src/modules/agents/domain/repositories/agent.repository.ts +++ b/apps/api/src/modules/agents/domain/repositories/agent.repository.ts @@ -1,3 +1,5 @@ +import { type AgentEntity } from '../entities/agent.entity'; + export const AGENT_REPOSITORY = Symbol('AGENT_REPOSITORY'); export interface AgentDashboardData { @@ -55,10 +57,19 @@ export interface AgentPublicProfileData { totalReviews: number; } +export interface QualityScoreInputData { + avgRating: number; + totalReviews: number; + responseTimeAvg: number | null; + conversionRate: number; + activeListingRatio: number; +} + export interface IAgentRepository { - findByUserId(userId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>; - findById(agentId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>; - updateQualityScore(agentId: string, score: number): Promise; + findByUserId(userId: string): Promise; + findById(agentId: string): Promise; + save(agent: AgentEntity): Promise; getDashboard(agentId: string): Promise; getPublicProfile(agentId: string): Promise; + getQualityScoreInputs(agentId: string): Promise; } diff --git a/apps/api/src/modules/agents/index.ts b/apps/api/src/modules/agents/index.ts index 4a2aa1b..5d72129 100644 --- a/apps/api/src/modules/agents/index.ts +++ b/apps/api/src/modules/agents/index.ts @@ -1,6 +1,12 @@ export { AgentsModule } from './agents.module'; +export { AgentEntity, type AgentProps } from './domain/entities/agent.entity'; +export { QualityScore } from './domain/value-objects/quality-score.vo'; +export { QualityScoreUpdatedEvent } from './domain/events/quality-score-updated.event'; +export { QualityScoreCalculator } from './domain/services/quality-score.service'; export { AGENT_REPOSITORY, type IAgentRepository, type AgentDashboardData, + type AgentPublicProfileData, + type AgentPublicListingItem, } from './domain/repositories/agent.repository'; diff --git a/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts index 758c9c1..0e0c890 100644 --- a/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts +++ b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts @@ -1,38 +1,41 @@ import { Injectable } from '@nestjs/common'; import { type PrismaService } from '@modules/shared'; +import { AgentEntity } from '../../domain/entities/agent.entity'; import { type AgentDashboardData, type AgentPublicProfileData, type AgentPublicListingItem, type IAgentRepository, + type QualityScoreInputData, } from '../../domain/repositories/agent.repository'; +import { QualityScore } from '../../domain/value-objects/quality-score.vo'; @Injectable() export class PrismaAgentRepository implements IAgentRepository { constructor(private readonly prisma: PrismaService) {} - async findByUserId( - userId: string, - ): Promise<{ id: string; userId: string; qualityScore: number } | null> { - return this.prisma.agent.findUnique({ + async findByUserId(userId: string): Promise { + const row = await this.prisma.agent.findUnique({ where: { userId }, - select: { id: true, userId: true, qualityScore: true }, }); + if (!row) return null; + return this.toDomain(row); } - async findById( - agentId: string, - ): Promise<{ id: string; userId: string; qualityScore: number } | null> { - return this.prisma.agent.findUnique({ + async findById(agentId: string): Promise { + const row = await this.prisma.agent.findUnique({ where: { id: agentId }, - select: { id: true, userId: true, qualityScore: true }, }); + if (!row) return null; + return this.toDomain(row); } - async updateQualityScore(agentId: string, score: number): Promise { + async save(agent: AgentEntity): Promise { await this.prisma.agent.update({ - where: { id: agentId }, - data: { qualityScore: score }, + where: { id: agent.id }, + data: { + qualityScore: agent.qualityScore.value, + }, }); } @@ -96,32 +99,6 @@ export class PrismaAgentRepository implements IAgentRepository { }; } - private async getInquiryStats( - agentId: string, - ): Promise<{ total: number; unread: number }> { - const [total, unread] = await Promise.all([ - this.prisma.inquiry.count({ - where: { listing: { agentId } }, - }), - this.prisma.inquiry.count({ - where: { listing: { agentId }, isRead: false }, - }), - ]); - return { total, unread }; - } - - private async getListingStats( - agentId: string, - ): Promise<{ total: number; active: number }> { - const [total, active] = await Promise.all([ - this.prisma.listing.count({ where: { agentId } }), - this.prisma.listing.count({ - where: { agentId, status: 'ACTIVE' }, - }), - ]); - return { total, active }; - } - async getPublicProfile(agentId: string): Promise { const agent = await this.prisma.agent.findUnique({ where: { id: agentId }, @@ -169,6 +146,70 @@ export class PrismaAgentRepository implements IAgentRepository { }; } + async getQualityScoreInputs(agentId: string): Promise { + const [reviewStats, leadCounts, listingCounts, agentRecord] = + await Promise.all([ + this.prisma.review.aggregate({ + where: { targetType: 'AGENT', targetId: agentId }, + _avg: { rating: true }, + _count: { rating: true }, + }), + Promise.all([ + this.prisma.lead.count({ where: { agentId } }), + this.prisma.lead.count({ + where: { agentId, status: 'CONVERTED' }, + }), + ]), + Promise.all([ + this.prisma.listing.count({ where: { agentId } }), + this.prisma.listing.count({ + where: { agentId, status: 'ACTIVE' }, + }), + ]), + this.prisma.agent.findUnique({ + where: { id: agentId }, + select: { responseTimeAvg: true }, + }), + ]); + + const [totalLeads, convertedLeads] = leadCounts; + const [totalListings, activeListings] = listingCounts; + + return { + avgRating: reviewStats._avg.rating ?? 0, + totalReviews: reviewStats._count.rating, + responseTimeAvg: agentRecord?.responseTimeAvg ?? null, + conversionRate: totalLeads > 0 ? convertedLeads / totalLeads : 0, + activeListingRatio: totalListings > 0 ? activeListings / totalListings : 0, + }; + } + + private async getInquiryStats( + agentId: string, + ): Promise<{ total: number; unread: number }> { + const [total, unread] = await Promise.all([ + this.prisma.inquiry.count({ + where: { listing: { agentId } }, + }), + this.prisma.inquiry.count({ + where: { listing: { agentId }, isRead: false }, + }), + ]); + return { total, unread }; + } + + private async getListingStats( + agentId: string, + ): Promise<{ total: number; active: number }> { + const [total, active] = await Promise.all([ + this.prisma.listing.count({ where: { agentId } }), + this.prisma.listing.count({ + where: { agentId, status: 'ACTIVE' }, + }), + ]); + return { total, active }; + } + private async getActiveListingsForAgent( agentId: string, ): Promise { @@ -222,4 +263,36 @@ export class PrismaAgentRepository implements IAgentRepository { }, })); } + + private toDomain(row: { + id: string; + userId: string; + licenseNumber: string | null; + agency: string | null; + qualityScore: number; + totalDeals: number; + responseTimeAvg: number | null; + bio: string | null; + serviceAreas: unknown; + isVerified: boolean; + createdAt: Date; + updatedAt: Date; + }): AgentEntity { + return new AgentEntity( + row.id, + { + userId: row.userId, + licenseNumber: row.licenseNumber, + agency: row.agency, + qualityScore: QualityScore.fromPersistence(row.qualityScore), + totalDeals: row.totalDeals, + responseTimeAvg: row.responseTimeAvg, + bio: row.bio, + serviceAreas: (row.serviceAreas as string[]) ?? [], + isVerified: row.isVerified, + }, + row.createdAt, + row.updatedAt, + ); + } } diff --git a/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts b/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts index eefa4dc..c354d04 100644 --- a/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts +++ b/apps/api/src/modules/auth/application/commands/login-user/login-user.handler.ts @@ -1,6 +1,6 @@ import { InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type LoggerService, DomainException, UnauthorizedException } from '@modules/shared'; +import { type LoggerService, DomainException } from '@modules/shared'; import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service'; import { LoginUserCommand } from './login-user.command'; diff --git a/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts index b2fca15..fb2d098 100644 --- a/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts +++ b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts @@ -1,7 +1,7 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { ConflictException, DomainException, LoggerService, ValidationException } from '@modules/shared'; +import { ConflictException, DomainException, type LoggerService, ValidationException } from '@modules/shared'; import { UserEntity } from '../../../domain/entities/user.entity'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { Email } from '../../../domain/value-objects/email.vo'; diff --git a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts index 236c1c7..faf8ab0 100644 --- a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts +++ b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts @@ -1,6 +1,6 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { DomainException, LoggerService, NotFoundException, CacheService, CachePrefix } from '@modules/shared'; +import { DomainException, type LoggerService, NotFoundException, CacheService, CachePrefix } from '@modules/shared'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { VerifyKycCommand } from './verify-kyc.command'; diff --git a/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts index d96cc37..fc6fbca 100644 --- a/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts +++ b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts @@ -1,6 +1,6 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { type PrismaService, DomainException, LoggerService } from '@modules/shared'; +import { type PrismaService, DomainException, type LoggerService } from '@modules/shared'; import { GetAgentByUserIdQuery } from './get-agent-by-user-id.query'; export interface AgentDto { diff --git a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts index 2ebbc03..b2a91f5 100644 --- a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts +++ b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts @@ -1,6 +1,6 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { DomainException, LoggerService, NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { DomainException, type LoggerService, NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { GetProfileQuery } from './get-profile.query'; diff --git a/apps/api/src/modules/inquiries/index.ts b/apps/api/src/modules/inquiries/index.ts index 0d3cd17..560a5df 100644 --- a/apps/api/src/modules/inquiries/index.ts +++ b/apps/api/src/modules/inquiries/index.ts @@ -1,3 +1,10 @@ export { InquiriesModule } from './inquiries.module'; -export { INQUIRY_REPOSITORY, type IInquiryRepository } from './domain/repositories/inquiry.repository'; -export { InquiryEntity } from './domain/entities/inquiry.entity'; +export { InquiryEntity, type InquiryProps } from './domain/entities/inquiry.entity'; +export { InquiryCreatedEvent } from './domain/events/inquiry-created.event'; +export { InquiryReadEvent } from './domain/events/inquiry-read.event'; +export { + INQUIRY_REPOSITORY, + type IInquiryRepository, + type PaginatedResult, +} from './domain/repositories/inquiry.repository'; +export { type InquiryReadDto } from './domain/repositories/inquiry-read.dto'; diff --git a/apps/api/src/modules/leads/index.ts b/apps/api/src/modules/leads/index.ts index cb82f9c..2ec03fa 100644 --- a/apps/api/src/modules/leads/index.ts +++ b/apps/api/src/modules/leads/index.ts @@ -1,3 +1,12 @@ export { LeadsModule } from './leads.module'; -export { LEAD_REPOSITORY, type ILeadRepository } from './domain/repositories/lead.repository'; -export { LeadEntity } from './domain/entities/lead.entity'; +export { LeadEntity, type LeadProps, type LeadStatus } from './domain/entities/lead.entity'; +export { LeadScore } from './domain/value-objects/lead-score.vo'; +export { LeadCreatedEvent } from './domain/events/lead-created.event'; +export { LeadStatusChangedEvent } from './domain/events/lead-status-changed.event'; +export { + LEAD_REPOSITORY, + type ILeadRepository, + type PaginatedResult, + type LeadStatsData, +} from './domain/repositories/lead.repository'; +export { type LeadReadDto } from './domain/repositories/lead-read.dto'; diff --git a/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts index e9d1e19..bc87e30 100644 --- a/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts +++ b/apps/api/src/modules/payments/application/queries/get-payment-status/get-payment-status.handler.ts @@ -1,6 +1,6 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { DomainException, ForbiddenException, LoggerService, NotFoundException } from '@modules/shared'; +import { DomainException, ForbiddenException, type LoggerService, NotFoundException } from '@modules/shared'; import { PAYMENT_REPOSITORY, type IPaymentRepository, diff --git a/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts b/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts index dc23d64..116d7e1 100644 --- a/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts +++ b/apps/api/src/modules/payments/application/queries/list-transactions/list-transactions.handler.ts @@ -1,6 +1,6 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { DomainException, LoggerService } from '@modules/shared'; +import { DomainException, type LoggerService } from '@modules/shared'; import { PAYMENT_REPOSITORY, type IPaymentRepository, diff --git a/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-user.handler.spec.ts b/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-user.handler.spec.ts index 6e3de66..010ab9d 100644 --- a/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-user.handler.spec.ts +++ b/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-user.handler.spec.ts @@ -48,4 +48,75 @@ describe('GetReviewsByUserHandler', () => { expect(result).toEqual(mockPaginatedResult); expect(mockReviewRepo.findByUserId).toHaveBeenCalledWith('user-1', 1, 20); }); + + it('returns empty results when user has no reviews', async () => { + const emptyResult = { + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }; + mockReviewRepo.findByUserId.mockResolvedValue(emptyResult); + + const query = new GetReviewsByUserQuery('user-no-reviews', 1, 20); + const result = await handler.execute(query); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + expect(result.totalPages).toBe(0); + expect(mockReviewRepo.findByUserId).toHaveBeenCalledWith('user-no-reviews', 1, 20); + }); + + it('passes custom pagination params', async () => { + mockReviewRepo.findByUserId.mockResolvedValue({ + ...mockPaginatedResult, + page: 3, + limit: 5, + }); + + const query = new GetReviewsByUserQuery('user-1', 3, 5); + await handler.execute(query); + + expect(mockReviewRepo.findByUserId).toHaveBeenCalledWith('user-1', 3, 5); + }); + + it('returns multiple reviews across pages', async () => { + const multiPageResult = { + data: [ + { + id: 'review-1', + userId: 'user-1', + userName: 'Nguyen Van A', + targetType: 'agent', + targetId: 'agent-1', + rating: 5, + comment: 'Excellent', + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'review-2', + userId: 'user-1', + userName: 'Nguyen Van A', + targetType: 'property', + targetId: 'prop-1', + rating: 3, + comment: null, + createdAt: '2026-01-02T00:00:00.000Z', + }, + ], + total: 15, + page: 1, + limit: 2, + totalPages: 8, + }; + mockReviewRepo.findByUserId.mockResolvedValue(multiPageResult); + + const query = new GetReviewsByUserQuery('user-1', 1, 2); + const result = await handler.execute(query); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(15); + expect(result.totalPages).toBe(8); + }); }); diff --git a/apps/api/src/modules/reviews/index.ts b/apps/api/src/modules/reviews/index.ts index 702f676..80bf9b9 100644 --- a/apps/api/src/modules/reviews/index.ts +++ b/apps/api/src/modules/reviews/index.ts @@ -1,2 +1,11 @@ export { ReviewsModule } from './reviews.module'; -export { REVIEW_REPOSITORY, type IReviewRepository } from './domain/repositories/review.repository'; +export { ReviewEntity, type ReviewProps } from './domain/entities/review.entity'; +export { Rating } from './domain/value-objects/rating.vo'; +export { ReviewCreatedEvent } from './domain/events/review-created.event'; +export { ReviewDeletedEvent } from './domain/events/review-deleted.event'; +export { + REVIEW_REPOSITORY, + type IReviewRepository, + type PaginatedResult, +} from './domain/repositories/review.repository'; +export { type ReviewItemData, type ReviewStatsData } from './domain/repositories/review-read.dto'; diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/endpoint-rate-limit.guard.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/endpoint-rate-limit.guard.spec.ts index 1344ca4..7b78261 100644 --- a/apps/api/src/modules/shared/infrastructure/__tests__/endpoint-rate-limit.guard.spec.ts +++ b/apps/api/src/modules/shared/infrastructure/__tests__/endpoint-rate-limit.guard.spec.ts @@ -78,6 +78,16 @@ function buildContext(opts: MockContextOptions = {}): ExecutionContext { // ── tests ──────────────────────────────────────────────────────────────────── describe('EndpointRateLimitGuard', () => { + const originalNodeEnv = process.env['NODE_ENV']; + + beforeEach(() => { + process.env['NODE_ENV'] = 'development'; + }); + + afterEach(() => { + process.env['NODE_ENV'] = originalNodeEnv; + }); + describe('when no @EndpointRateLimit decorator is present', () => { it('allows request (skips rate limiting)', async () => { const redis = mockRedis(); diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/user-rate-limit.guard.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/user-rate-limit.guard.spec.ts index 3ba9be5..a53f05f 100644 --- a/apps/api/src/modules/shared/infrastructure/__tests__/user-rate-limit.guard.spec.ts +++ b/apps/api/src/modules/shared/infrastructure/__tests__/user-rate-limit.guard.spec.ts @@ -65,6 +65,16 @@ function buildContext(opts: MockContextOptions = {}): ExecutionContext { // ── tests ──────────────────────────────────────────────────────────────────── describe('UserRateLimitGuard', () => { + const originalNodeEnv = process.env['NODE_ENV']; + + beforeEach(() => { + process.env['NODE_ENV'] = 'development'; + }); + + afterEach(() => { + process.env['NODE_ENV'] = originalNodeEnv; + }); + it('allows request when user is within rate limit', async () => { const redis = mockRedis({ evalResult: [1, 60] }); const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger()); diff --git a/apps/api/src/modules/shared/infrastructure/env-validation.ts b/apps/api/src/modules/shared/infrastructure/env-validation.ts index aca923c..8ca606d 100644 --- a/apps/api/src/modules/shared/infrastructure/env-validation.ts +++ b/apps/api/src/modules/shared/infrastructure/env-validation.ts @@ -15,7 +15,7 @@ const REQUIRED_IN_PRODUCTION: readonly string[] = [ 'DATABASE_URL', 'CORS_ORIGINS', 'REDIS_HOST', - 'KYC_ENCRYPTION_KEY', + 'FIELD_ENCRYPTION_KEY', ]; const REQUIRED_WHEN_USED: ReadonlyMap = new Map([ diff --git a/apps/api/src/modules/shared/infrastructure/guards/endpoint-rate-limit.guard.ts b/apps/api/src/modules/shared/infrastructure/guards/endpoint-rate-limit.guard.ts index 1629dab..f091671 100644 --- a/apps/api/src/modules/shared/infrastructure/guards/endpoint-rate-limit.guard.ts +++ b/apps/api/src/modules/shared/infrastructure/guards/endpoint-rate-limit.guard.ts @@ -97,6 +97,11 @@ export class EndpointRateLimitGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { + // In test mode, skip endpoint rate limiting to avoid flaky E2E tests + if (process.env['NODE_ENV'] === 'test') { + return true; + } + const options = this.reflector.getAllAndOverride( ENDPOINT_RATE_LIMIT_KEY, [context.getHandler(), context.getClass()], diff --git a/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts b/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts index 2f9a2ab..658d692 100644 --- a/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts +++ b/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts @@ -54,6 +54,11 @@ export class UserRateLimitGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { + // In test mode, skip user rate limiting to avoid flaky E2E tests + if (process.env['NODE_ENV'] === 'test') { + return true; + } + const request = context.switchToHttp().getRequest(); const user = request.user; diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index 13635bc..57415b8 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -1,5 +1,14 @@ export { Cacheable, type CacheableOptions } from './decorators/cacheable.decorator'; export { CircuitBreaker, CircuitOpenError, CircuitState, type CircuitBreakerOptions } from './circuit-breaker'; +export { encryptField, decryptField, isEncrypted, type FieldEncryptionConfig } from './field-encryption'; +export { + FieldEncryptionService, + PII_FIELD_MAP, + type EncryptionKeyConfig, + type ModelEncryptionConfig, + type ModelEncryptionFieldConfig, +} from './field-encryption.service'; +export { createEncryptionExtension } from './encryption-middleware'; export { PrismaService } from './prisma.service'; export { RedisService } from './redis.service'; export { CacheService, CachePrefix, CacheTTL } from './cache.service'; diff --git a/apps/api/src/modules/shared/infrastructure/prisma.service.ts b/apps/api/src/modules/shared/infrastructure/prisma.service.ts index 6f9373f..2c56115 100644 --- a/apps/api/src/modules/shared/infrastructure/prisma.service.ts +++ b/apps/api/src/modules/shared/infrastructure/prisma.service.ts @@ -2,40 +2,31 @@ import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/com import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; import pg from 'pg'; -import { encryptField, decryptField, type FieldEncryptionConfig } from './field-encryption'; - -function getKycEncryptionConfig(): FieldEncryptionConfig | null { - const key = process.env['KYC_ENCRYPTION_KEY']; - if (!key) return null; - return { - key, - keyVersion: Number(process.env['KYC_ENCRYPTION_KEY_VERSION'] ?? '1'), - }; -} +import { FieldEncryptionService } from './field-encryption.service'; +import { createEncryptionExtension } from './encryption-middleware'; +import { LoggerService } from './logger.service'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { private pool: pg.Pool; - private kycEncryption: FieldEncryptionConfig | null; + readonly fieldEncryption: FieldEncryptionService; - constructor() { + /** + * Extended client with encryption middleware applied. + * Use `this.encrypted` for all operations that should transparently + * encrypt/decrypt PII fields. For raw/unencrypted access use `this` directly. + */ + readonly encrypted: ReturnType; + + constructor(private readonly logger: LoggerService) { const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); const adapter = new PrismaPg(pool); super({ adapter }); this.pool = pool; - this.kycEncryption = getKycEncryptionConfig(); - } - /** Encrypt kycData before writing to the database. */ - encryptKycData(data: unknown): unknown { - if (!this.kycEncryption || data === null || data === undefined) return data; - return encryptField(data, this.kycEncryption); - } - - /** Decrypt kycData after reading from the database. */ - decryptKycData(data: unknown): unknown { - if (!this.kycEncryption || data === null || data === undefined) return data; - return decryptField(data, this.kycEncryption); + // Initialize field encryption service and create extended client + this.fieldEncryption = new FieldEncryptionService(this.logger); + this.encrypted = this.$extends(createEncryptionExtension(this.fieldEncryption)); } async onModuleInit(): Promise { diff --git a/apps/api/src/modules/shared/shared.module.ts b/apps/api/src/modules/shared/shared.module.ts index 6593aaf..de14e34 100644 --- a/apps/api/src/modules/shared/shared.module.ts +++ b/apps/api/src/modules/shared/shared.module.ts @@ -16,6 +16,7 @@ import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation import { CsrfMiddleware } from './infrastructure/middleware/csrf.middleware'; import { RequestLoggingMiddleware } from './infrastructure/middleware/request-logging.middleware'; import { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware'; +import { FieldEncryptionService } from './infrastructure/field-encryption.service'; import { PrismaService } from './infrastructure/prisma.service'; import { RedisService } from './infrastructure/redis.service'; @@ -27,10 +28,11 @@ import { RedisService } from './infrastructure/redis.service'; PrometheusModule.register({ path: '/metrics', defaultMetrics: { enabled: true } }), ], providers: [ + LoggerService, + FieldEncryptionService, PrismaService, RedisService, CacheService, - LoggerService, EventBusService, makeCounterProvider({ name: CACHE_HIT_TOTAL, @@ -52,7 +54,7 @@ import { RedisService } from './infrastructure/redis.service'; useClass: GlobalExceptionFilter, }, ], - exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, PrometheusModule], + exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, PrometheusModule], }) export class SharedModule implements NestModule { configure(consumer: MiddlewareConsumer): void {