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', () => {
let repository: PrismaAgentRepository;
let mockPrisma: {
$queryRaw: ReturnType<typeof vi.fn>;
agent: {
findUnique: ReturnType<typeof vi.fn>;
findUniqueOrThrow: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
lead: {
groupBy: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
inquiry: {
count: ReturnType<typeof vi.fn>;
};
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', () => {

View File

@@ -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<AgentDashboardData> {
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<AgentStatsRow[]>`
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<string, number> = {};
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<string, number>) ?? {},
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;