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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 14:03:08 +07:00
parent 25edb3579c
commit b60b327508
2 changed files with 153 additions and 106 deletions

View File

@@ -5,16 +5,12 @@ import { PrismaAgentRepository } from '../repositories/prisma-agent.repository';
describe('PrismaAgentRepository', () => { describe('PrismaAgentRepository', () => {
let repository: PrismaAgentRepository; let repository: PrismaAgentRepository;
let mockPrisma: { let mockPrisma: {
$queryRaw: ReturnType<typeof vi.fn>;
agent: { agent: {
findUnique: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn>;
findUniqueOrThrow: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>;
}; };
lead: { lead: {
groupBy: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
inquiry: {
count: ReturnType<typeof vi.fn>; count: ReturnType<typeof vi.fn>;
}; };
listing: { listing: {
@@ -43,16 +39,12 @@ describe('PrismaAgentRepository', () => {
beforeEach(() => { beforeEach(() => {
mockPrisma = { mockPrisma = {
$queryRaw: vi.fn(),
agent: { agent: {
findUnique: vi.fn(), findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(), update: vi.fn(),
}, },
lead: { lead: {
groupBy: vi.fn(),
count: vi.fn(),
},
inquiry: {
count: vi.fn(), count: vi.fn(),
}, },
listing: { listing: {
@@ -198,32 +190,31 @@ describe('PrismaAgentRepository', () => {
}); });
describe('getDashboard', () => { describe('getDashboard', () => {
it('returns full dashboard data', async () => { const mockStatsRow = {
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({ agentId: 'agent-1',
id: 'agent-1', qualityScore: 85,
qualityScore: 85, totalDeals: 12,
totalDeals: 12, responseTimeAvg: 600,
responseTimeAvg: 600, isVerified: true,
isVerified: true, leadsByStatus: { NEW: 5, CONTACTED: 10, CONVERTED: 3 },
}); totalLeads: 18,
mockPrisma.lead.groupBy.mockResolvedValue([ convertedLeads: 3,
{ status: 'NEW', _count: { id: 5 } }, totalListings: 15,
{ status: 'CONTACTED', _count: { id: 10 } }, activeListings: 10,
{ status: 'CONVERTED', _count: { id: 3 } }, totalInquiries: 45,
]); unreadInquiries: 3,
mockPrisma.inquiry.count avgRating: 4.5,
.mockResolvedValueOnce(45) // total totalReviews: 20,
.mockResolvedValueOnce(3); // unread };
mockPrisma.listing.count
.mockResolvedValueOnce(15) // total it('returns full dashboard data using single aggregate query', async () => {
.mockResolvedValueOnce(10); // active mockPrisma.$queryRaw.mockResolvedValue([mockStatsRow]);
mockPrisma.review.aggregate.mockResolvedValue({
_avg: { rating: 4.5 },
_count: { rating: 20 },
});
const result = await repository.getDashboard('agent-1'); const result = await repository.getDashboard('agent-1');
// Verify single DB call
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(result.agentId).toBe('agent-1'); expect(result.agentId).toBe('agent-1');
expect(result.qualityScore).toBe(85); expect(result.qualityScore).toBe(85);
expect(result.totalDeals).toBe(12); expect(result.totalDeals).toBe(12);
@@ -240,21 +231,23 @@ describe('PrismaAgentRepository', () => {
expect(result.totalReviews).toBe(20); expect(result.totalReviews).toBe(20);
}); });
it('handles agent with zero leads', async () => { it('handles agent with zero leads and reviews', async () => {
mockPrisma.agent.findUniqueOrThrow.mockResolvedValue({ mockPrisma.$queryRaw.mockResolvedValue([{
id: 'agent-1', ...mockStatsRow,
qualityScore: 0, qualityScore: 0,
totalDeals: 0, totalDeals: 0,
responseTimeAvg: null, responseTimeAvg: null,
isVerified: false, isVerified: false,
}); leadsByStatus: {},
mockPrisma.lead.groupBy.mockResolvedValue([]); totalLeads: 0,
mockPrisma.inquiry.count.mockResolvedValue(0); convertedLeads: 0,
mockPrisma.listing.count.mockResolvedValue(0); totalListings: 0,
mockPrisma.review.aggregate.mockResolvedValue({ activeListings: 0,
_avg: { rating: null }, totalInquiries: 0,
_count: { rating: 0 }, unreadInquiries: 0,
}); avgRating: 0,
totalReviews: 0,
}]);
const result = await repository.getDashboard('agent-1'); const result = await repository.getDashboard('agent-1');
@@ -264,6 +257,14 @@ describe('PrismaAgentRepository', () => {
expect(result.avgReviewRating).toBe(0); expect(result.avgReviewRating).toBe(0);
expect(result.totalReviews).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', () => { describe('getPublicProfile', () => {

View File

@@ -10,6 +10,24 @@ import {
import { QualityScore } from '../../domain/value-objects/quality-score.vo'; import { QualityScore } from '../../domain/value-objects/quality-score.vo';
import { buildPublicProfile } from './agent-profile.queries'; 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() @Injectable()
export class PrismaAgentRepository implements IAgentRepository { export class PrismaAgentRepository implements IAgentRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -34,56 +52,104 @@ export class PrismaAgentRepository implements IAgentRepository {
} }
async getDashboard(agentId: string): Promise<AgentDashboardData> { async getDashboard(agentId: string): Promise<AgentDashboardData> {
const [agent, leads, inquiryStats, listingStats, reviewStats] = // Single aggregate query — replaces 7 separate round-trips.
await Promise.all([ const rows = await this.prisma.$queryRaw<AgentStatsRow[]>`
this.prisma.agent.findUniqueOrThrow({ WITH
where: { id: agentId }, agent_base AS (
select: { SELECT
id: true, qualityScore: true, totalDeals: true, id,
responseTimeAvg: true, isVerified: true, "qualityScore",
}, "totalDeals",
}), "responseTimeAvg",
this.prisma.lead.groupBy({ "isVerified"
by: ['status'], FROM "Agent"
where: { agentId }, WHERE id = ${agentId}
_count: { id: true }, ),
}), lead_stats AS (
this.getInquiryStats(agentId), SELECT
this.getListingStats(agentId), status,
this.prisma.review.aggregate({ COUNT(*)::int AS cnt
where: { targetType: 'AGENT', targetId: agentId }, FROM "Lead"
_avg: { rating: true }, WHERE "agentId" = ${agentId}
_count: { rating: true }, 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<string, number> = {}; if (rows.length === 0) {
let totalLeads = 0; throw new Error(`Agent not found: ${agentId}`);
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;
} }
const row = rows[0]!;
const totalLeads = row.totalLeads ?? 0;
const convertedLeads = row.convertedLeads ?? 0;
const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0; const conversionRate = totalLeads > 0 ? convertedLeads / totalLeads : 0;
return { return {
agentId: agent.id, agentId: row.agentId,
qualityScore: agent.qualityScore, qualityScore: row.qualityScore,
totalDeals: agent.totalDeals, totalDeals: row.totalDeals,
responseTimeAvg: agent.responseTimeAvg, responseTimeAvg: row.responseTimeAvg,
isVerified: agent.isVerified, isVerified: row.isVerified,
totalLeads, totalLeads,
leadsByStatus, leadsByStatus: (row.leadsByStatus as Record<string, number>) ?? {},
conversionRate: Math.round(conversionRate * 1000) / 1000, conversionRate: Math.round(conversionRate * 1000) / 1000,
totalInquiries: inquiryStats.total, totalInquiries: row.totalInquiries ?? 0,
unreadInquiries: inquiryStats.unread, unreadInquiries: row.unreadInquiries ?? 0,
totalListings: listingStats.total, totalListings: row.totalListings ?? 0,
activeListings: listingStats.active, activeListings: row.activeListings ?? 0,
avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10, avgReviewRating: Math.round((row.avgRating ?? 0) * 10) / 10,
totalReviews: reviewStats._count.rating, 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: { private toDomain(row: {
id: string; id: string;
userId: string; userId: string;