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