From b60b3275085a94e935c4d9282ef8d0441b60858e Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 24 Apr 2026 14:03:08 +0700 Subject: [PATCH] feat(agents): consolidate getDashboard into single aggregate SQL query Replaces 7 separate Prisma/DB round-trips (findUniqueOrThrow + groupBy + 2x inquiry.count + 2x listing.count + review.aggregate) with a single parameterised CTE query via \$queryRaw. Response shape is unchanged. - Adds AgentStatsRow interface for typed raw result - Removes now-unused getInquiryStats / getListingStats private helpers - Updates test to mock \$queryRaw; adds "agent not found" error path test - All agents tests pass (35 tests, pre-existing env-secret failure skipped) Co-Authored-By: Paperclip --- .../__tests__/prisma-agent.repository.spec.ts | 89 ++++----- .../repositories/prisma-agent.repository.ts | 170 +++++++++++------- 2 files changed, 153 insertions(+), 106 deletions(-) diff --git a/apps/api/src/modules/agents/infrastructure/__tests__/prisma-agent.repository.spec.ts b/apps/api/src/modules/agents/infrastructure/__tests__/prisma-agent.repository.spec.ts index e0df241..a4967a6 100644 --- a/apps/api/src/modules/agents/infrastructure/__tests__/prisma-agent.repository.spec.ts +++ b/apps/api/src/modules/agents/infrastructure/__tests__/prisma-agent.repository.spec.ts @@ -5,16 +5,12 @@ import { PrismaAgentRepository } from '../repositories/prisma-agent.repository'; describe('PrismaAgentRepository', () => { let repository: PrismaAgentRepository; let mockPrisma: { + $queryRaw: ReturnType; agent: { findUnique: ReturnType; - findUniqueOrThrow: ReturnType; update: ReturnType; }; lead: { - groupBy: ReturnType; - count: ReturnType; - }; - inquiry: { count: ReturnType; }; listing: { @@ -43,16 +39,12 @@ describe('PrismaAgentRepository', () => { beforeEach(() => { mockPrisma = { + $queryRaw: vi.fn(), agent: { findUnique: vi.fn(), - findUniqueOrThrow: vi.fn(), update: vi.fn(), }, lead: { - groupBy: vi.fn(), - count: vi.fn(), - }, - inquiry: { count: vi.fn(), }, listing: { @@ -198,32 +190,31 @@ describe('PrismaAgentRepository', () => { }); describe('getDashboard', () => { - it('returns full dashboard data', async () => { - mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({ - id: 'agent-1', - qualityScore: 85, - totalDeals: 12, - responseTimeAvg: 600, - isVerified: true, - }); - mockPrisma.lead.groupBy.mockResolvedValue([ - { status: 'NEW', _count: { id: 5 } }, - { status: 'CONTACTED', _count: { id: 10 } }, - { status: 'CONVERTED', _count: { id: 3 } }, - ]); - mockPrisma.inquiry.count - .mockResolvedValueOnce(45) // total - .mockResolvedValueOnce(3); // unread - mockPrisma.listing.count - .mockResolvedValueOnce(15) // total - .mockResolvedValueOnce(10); // active - mockPrisma.review.aggregate.mockResolvedValue({ - _avg: { rating: 4.5 }, - _count: { rating: 20 }, - }); + const mockStatsRow = { + agentId: 'agent-1', + qualityScore: 85, + totalDeals: 12, + responseTimeAvg: 600, + isVerified: true, + leadsByStatus: { NEW: 5, CONTACTED: 10, CONVERTED: 3 }, + totalLeads: 18, + convertedLeads: 3, + totalListings: 15, + activeListings: 10, + totalInquiries: 45, + unreadInquiries: 3, + avgRating: 4.5, + totalReviews: 20, + }; + + it('returns full dashboard data using single aggregate query', async () => { + mockPrisma.$queryRaw.mockResolvedValue([mockStatsRow]); const result = await repository.getDashboard('agent-1'); + // Verify single DB call + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); + expect(result.agentId).toBe('agent-1'); expect(result.qualityScore).toBe(85); expect(result.totalDeals).toBe(12); @@ -240,21 +231,23 @@ describe('PrismaAgentRepository', () => { expect(result.totalReviews).toBe(20); }); - it('handles agent with zero leads', async () => { - mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({ - id: 'agent-1', + it('handles agent with zero leads and reviews', async () => { + mockPrisma.$queryRaw.mockResolvedValue([{ + ...mockStatsRow, qualityScore: 0, totalDeals: 0, responseTimeAvg: null, isVerified: false, - }); - mockPrisma.lead.groupBy.mockResolvedValue([]); - mockPrisma.inquiry.count.mockResolvedValue(0); - mockPrisma.listing.count.mockResolvedValue(0); - mockPrisma.review.aggregate.mockResolvedValue({ - _avg: { rating: null }, - _count: { rating: 0 }, - }); + leadsByStatus: {}, + totalLeads: 0, + convertedLeads: 0, + totalListings: 0, + activeListings: 0, + totalInquiries: 0, + unreadInquiries: 0, + avgRating: 0, + totalReviews: 0, + }]); const result = await repository.getDashboard('agent-1'); @@ -264,6 +257,14 @@ describe('PrismaAgentRepository', () => { expect(result.avgReviewRating).toBe(0); expect(result.totalReviews).toBe(0); }); + + it('throws when agent not found (empty result set)', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + await expect(repository.getDashboard('nonexistent')).rejects.toThrow( + 'Agent not found: nonexistent', + ); + }); }); describe('getPublicProfile', () => { 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 97ac51e..a84693b 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 @@ -10,6 +10,24 @@ import { import { QualityScore } from '../../domain/value-objects/quality-score.vo'; import { buildPublicProfile } from './agent-profile.queries'; +/** Shape returned by the single-aggregate getDashboard SQL query. */ +interface AgentStatsRow { + agentId: string; + qualityScore: number; + totalDeals: number; + responseTimeAvg: number | null; + isVerified: boolean; + leadsByStatus: unknown; + totalLeads: number; + convertedLeads: number; + totalListings: number; + activeListings: number; + totalInquiries: number; + unreadInquiries: number; + avgRating: number; + totalReviews: number; +} + @Injectable() export class PrismaAgentRepository implements IAgentRepository { constructor(private readonly prisma: PrismaService) {} @@ -34,56 +52,104 @@ export class PrismaAgentRepository implements IAgentRepository { } async getDashboard(agentId: string): Promise { - const [agent, leads, inquiryStats, listingStats, reviewStats] = - await Promise.all([ - this.prisma.agent.findUniqueOrThrow({ - where: { id: agentId }, - select: { - id: true, qualityScore: true, totalDeals: true, - responseTimeAvg: true, isVerified: true, - }, - }), - this.prisma.lead.groupBy({ - by: ['status'], - where: { agentId }, - _count: { id: true }, - }), - this.getInquiryStats(agentId), - this.getListingStats(agentId), - this.prisma.review.aggregate({ - where: { targetType: 'AGENT', targetId: agentId }, - _avg: { rating: true }, - _count: { rating: true }, - }), - ]); + // Single aggregate query — replaces 7 separate round-trips. + const rows = await this.prisma.$queryRaw` + WITH + agent_base AS ( + SELECT + id, + "qualityScore", + "totalDeals", + "responseTimeAvg", + "isVerified" + FROM "Agent" + WHERE id = ${agentId} + ), + lead_stats AS ( + SELECT + status, + COUNT(*)::int AS cnt + FROM "Lead" + WHERE "agentId" = ${agentId} + GROUP BY status + ), + listing_agg AS ( + SELECT + COUNT(*)::int AS total_listings, + COUNT(*) FILTER (WHERE status = 'ACTIVE')::int AS active_listings + FROM "Listing" + WHERE "agentId" = ${agentId} + ), + inquiry_agg AS ( + SELECT + COUNT(*)::int AS total_inquiries, + COUNT(*) FILTER (WHERE i."isRead" = false)::int AS unread_inquiries + FROM "Inquiry" i + JOIN "Listing" l ON l.id = i."listingId" + WHERE l."agentId" = ${agentId} + ), + review_agg AS ( + SELECT + COALESCE(AVG(rating), 0)::float AS avg_rating, + COUNT(*)::int AS total_reviews + FROM "Review" + WHERE "targetType" = 'AGENT' + AND "targetId" = ${agentId} + ) + SELECT + a.id AS "agentId", + a."qualityScore", + a."totalDeals", + a."responseTimeAvg", + a."isVerified", + COALESCE( + (SELECT jsonb_object_agg(status, cnt) FROM lead_stats), + '{}'::jsonb + ) AS "leadsByStatus", + COALESCE( + (SELECT SUM(cnt)::int FROM lead_stats), + 0 + ) AS "totalLeads", + COALESCE( + (SELECT cnt FROM lead_stats WHERE status = 'CONVERTED'), + 0 + ) AS "convertedLeads", + la.total_listings AS "totalListings", + la.active_listings AS "activeListings", + ia.total_inquiries AS "totalInquiries", + ia.unread_inquiries AS "unreadInquiries", + ra.avg_rating AS "avgRating", + ra.total_reviews AS "totalReviews" + FROM agent_base a + CROSS JOIN listing_agg la + CROSS JOIN inquiry_agg ia + CROSS JOIN review_agg ra + `; - const leadsByStatus: Record = {}; - let totalLeads = 0; - let convertedLeads = 0; - - for (const group of leads) { - leadsByStatus[group.status] = group._count.id; - totalLeads += group._count.id; - if (group.status === 'CONVERTED') convertedLeads = group._count.id; + if (rows.length === 0) { + throw new Error(`Agent not found: ${agentId}`); } + const row = rows[0]!; + const totalLeads = row.totalLeads ?? 0; + const convertedLeads = row.convertedLeads ?? 0; const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0; return { - agentId: agent.id, - qualityScore: agent.qualityScore, - totalDeals: agent.totalDeals, - responseTimeAvg: agent.responseTimeAvg, - isVerified: agent.isVerified, + agentId: row.agentId, + qualityScore: row.qualityScore, + totalDeals: row.totalDeals, + responseTimeAvg: row.responseTimeAvg, + isVerified: row.isVerified, totalLeads, - leadsByStatus, + leadsByStatus: (row.leadsByStatus as Record) ?? {}, conversionRate: Math.round(conversionRate * 1000) / 1000, - totalInquiries: inquiryStats.total, - unreadInquiries: inquiryStats.unread, - totalListings: listingStats.total, - activeListings: listingStats.active, - avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10, - totalReviews: reviewStats._count.rating, + totalInquiries: row.totalInquiries ?? 0, + unreadInquiries: row.unreadInquiries ?? 0, + totalListings: row.totalListings ?? 0, + activeListings: row.activeListings ?? 0, + avgReviewRating: Math.round((row.avgRating ?? 0) * 10) / 10, + totalReviews: row.totalReviews ?? 0, }; } @@ -125,26 +191,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 }; - } - private toDomain(row: { id: string; userId: string;