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:
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user