Skip to content

Commit 6bc358c

Browse files
authored
Merge pull request #673 from T-kesh/main
Optimize getScoreBreakdown query to reduce database round-trips
2 parents df906cd + ddee06e commit 6bc358c

2 files changed

Lines changed: 160 additions & 132 deletions

File tree

backend/src/__tests__/scoreBreakdown.test.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,29 @@ describe("GET /api/score/:userId/breakdown", () => {
4747
});
4848

4949
it("should return a breakdown for a valid userId", async () => {
50-
// Mock the chain of queries in the breakdown endpoint
50+
// Mock the optimized single CTE query (returns all breakdown metrics)
5151
mockedQuery
52-
.mockResolvedValueOnce({ rows: [{ current_score: 720 }] } as any) // Score
5352
.mockResolvedValueOnce({
5453
rows: [
5554
{
56-
total_loans: "5",
57-
repaid_count: "4",
58-
defaulted_count: "0",
59-
total_repaid: "5000",
55+
current_score: 720,
56+
total_loans: 5,
57+
repaid_count: 4,
58+
defaulted_count: 0,
59+
total_repaid: 5000,
60+
on_time_count: 3,
61+
late_count: 1,
62+
avg_repayment_ledgers: 17280,
6063
},
6164
],
62-
} as any) // Stats
63-
.mockResolvedValueOnce({ rows: [{ on_time: "3", late: "1" }] } as any) // Timing
64-
.mockResolvedValueOnce({ rows: [{ avg_ledgers: "17280" }] } as any) // Avg time
65+
} as any) // Single CTE breakdown query
6566
.mockResolvedValueOnce({
66-
rows: [{ on_time: true }, { on_time: true }, { on_time: true }],
67-
} as any) // Streak
68-
.mockResolvedValueOnce({
69-
rows: [{ date: "2026-03-01", event: "LoanRepaid" }],
70-
} as any); // History
67+
rows: [
68+
{ event_type: "LoanRepaid", ledger_closed_at: "2026-03-01T10:00:00Z" },
69+
{ event_type: "LoanRepaid", ledger_closed_at: "2026-03-05T10:00:00Z" },
70+
{ event_type: "LoanRepaid", ledger_closed_at: "2026-03-10T10:00:00Z" },
71+
],
72+
} as any); // History query
7173

7274
const response = await request(app)
7375
.get("/api/score/user123/breakdown")
@@ -76,10 +78,17 @@ describe("GET /api/score/:userId/breakdown", () => {
7678
expect(response.status).toBe(200);
7779
expect(response.body.score).toBe(720);
7880
expect(response.body.breakdown.totalLoans).toBe(5);
81+
expect(response.body.breakdown.repaidOnTime).toBe(3);
82+
expect(response.body.breakdown.repaidLate).toBe(1);
83+
expect(response.body.breakdown.defaulted).toBe(0);
84+
expect(response.body.history).toHaveLength(3);
7985
});
8086

8187
it("should return default values for a user with no history", async () => {
82-
mockedQuery.mockResolvedValue({ rows: [] } as any);
88+
// Mock empty breakdown and history queries
89+
mockedQuery
90+
.mockResolvedValueOnce({ rows: [] } as any) // Empty breakdown
91+
.mockResolvedValueOnce({ rows: [] } as any); // Empty history
8392

8493
const response = await request(app)
8594
.get("/api/score/newuser/breakdown")
@@ -88,5 +97,7 @@ describe("GET /api/score/:userId/breakdown", () => {
8897
expect(response.status).toBe(200);
8998
expect(response.body.score).toBe(500);
9099
expect(response.body.breakdown.totalLoans).toBe(0);
100+
expect(response.body.breakdown.repaidOnTime).toBe(0);
101+
expect(response.body.history).toHaveLength(0);
91102
});
92103
});

backend/src/controllers/scoreController.ts

Lines changed: 134 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,15 @@ export const updateScore = asyncHandler(async (req: Request, res: Response) => {
147147
* Returns a detailed breakdown of the factors contributing to the user's
148148
* credit score, derived from loan_events and scores tables. Gives borrowers
149149
* transparency into their credit profile.
150+
*
151+
* OPTIMIZED: Single CTE query combines all breakdown computations:
152+
* - Current score fetch
153+
* - Loan event aggregations (total, repaid, defaulted counts)
154+
* - On-time vs late repayment classification
155+
* - Average repayment time calculation
156+
* - Repayment history for streak/timeline computation
157+
*
158+
* This reduces 6+ separate queries to 1-2 efficient round-trips.
150159
*/
151160
export const getScoreBreakdown = asyncHandler(
152161
async (req: Request, res: Response) => {
@@ -159,153 +168,161 @@ export const getScoreBreakdown = asyncHandler(
159168
return;
160169
}
161170

162-
// Fetch current score
163-
const scoreResult = await query(
164-
"SELECT current_score FROM scores WHERE user_id = $1",
165-
[userId],
166-
);
167-
const score =
168-
scoreResult.rows.length > 0 ? scoreResult.rows[0].current_score : 500;
169-
const band = getCreditBand(score);
170-
171-
// Fetch loan event stats for the borrower
172-
const statsResult = await query(
173-
`SELECT
174-
COUNT(DISTINCT loan_id) FILTER (WHERE event_type = 'LoanRequested') AS total_loans,
175-
COUNT(DISTINCT loan_id) FILTER (WHERE event_type = 'LoanRepaid') AS repaid_count,
176-
COUNT(DISTINCT loan_id) FILTER (WHERE event_type = 'LoanDefaulted') AS defaulted_count,
177-
COALESCE(SUM(CASE WHEN event_type = 'LoanRepaid' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) AS total_repaid
178-
FROM loan_events
179-
WHERE borrower = $1`,
180-
[userId],
181-
);
182-
183-
const stats = statsResult.rows[0] || {};
184-
const totalLoans = parseInt(stats.total_loans || "0", 10);
185-
const repaidCount = parseInt(stats.repaid_count || "0", 10);
186-
const defaultedCount = parseInt(stats.defaulted_count || "0", 10);
187-
const totalRepaid = parseFloat(stats.total_repaid || "0");
188-
189-
// Determine on-time vs late repayments by checking if repaid before term expiry
190-
const repaymentTimingResult = await query(
191-
`WITH approved AS (
192-
SELECT loan_id, MAX(ledger) AS approved_ledger,
193-
MAX(COALESCE(term_ledgers, 17280)) AS term_ledgers
194-
FROM loan_events
195-
WHERE event_type = 'LoanApproved' AND borrower = $1 AND loan_id IS NOT NULL
196-
GROUP BY loan_id
171+
// Single unified query that computes all breakdown metrics
172+
const breakdownResult = await query(
173+
`WITH
174+
-- Current score from scores table
175+
current_score_cte AS (
176+
SELECT COALESCE(current_score, 500) AS current_score
177+
FROM scores
178+
WHERE user_id = $1
197179
),
198-
repaid AS (
199-
SELECT loan_id, MIN(ledger) AS repaid_ledger
180+
-- All loan events for this borrower
181+
borrower_events AS (
182+
SELECT
183+
loan_id,
184+
event_type,
185+
ledger,
186+
ledger_closed_at,
187+
amount,
188+
term_ledgers
200189
FROM loan_events
201-
WHERE event_type = 'LoanRepaid' AND borrower = $1 AND loan_id IS NOT NULL
202-
GROUP BY loan_id
203-
)
204-
SELECT
205-
COUNT(*) FILTER (WHERE r.repaid_ledger <= a.approved_ledger + a.term_ledgers) AS on_time,
206-
COUNT(*) FILTER (WHERE r.repaid_ledger > a.approved_ledger + a.term_ledgers) AS late
207-
FROM repaid r
208-
JOIN approved a ON a.loan_id = r.loan_id`,
209-
[userId],
210-
);
211-
212-
const timing = repaymentTimingResult.rows[0] || {};
213-
const repaidOnTime = parseInt(timing.on_time || "0", 10);
214-
const repaidLate = parseInt(timing.late || "0", 10);
215-
216-
// Calculate average repayment time (in ledgers, converted to approx days)
217-
const avgRepayResult = await query(
218-
`WITH approved AS (
219-
SELECT loan_id, MAX(ledger) AS approved_ledger
220-
FROM loan_events
221-
WHERE event_type = 'LoanApproved' AND borrower = $1 AND loan_id IS NOT NULL
222-
GROUP BY loan_id
190+
WHERE borrower = $1
223191
),
224-
repaid AS (
225-
SELECT loan_id, MIN(ledger) AS repaid_ledger
226-
FROM loan_events
227-
WHERE event_type = 'LoanRepaid' AND borrower = $1 AND loan_id IS NOT NULL
228-
GROUP BY loan_id
229-
)
230-
SELECT AVG(r.repaid_ledger - a.approved_ledger) AS avg_ledgers
231-
FROM repaid r
232-
JOIN approved a ON a.loan_id = r.loan_id`,
233-
[userId],
234-
);
235-
236-
const avgLedgers = parseFloat(avgRepayResult.rows[0]?.avg_ledgers || "0");
237-
// Convert ledger count to approximate days (1 ledger ≈ 5 seconds)
238-
const avgDays = Math.round((avgLedgers * 5) / 86400);
239-
const averageRepaymentTime = avgLedgers > 0 ? `${avgDays} days` : "N/A";
240-
241-
// Calculate repayment streaks (consecutive on-time repayments)
242-
const streakResult = await query(
243-
`WITH approved AS (
244-
SELECT loan_id, MAX(ledger) AS approved_ledger,
245-
MAX(COALESCE(term_ledgers, 17280)) AS term_ledgers
246-
FROM loan_events
247-
WHERE event_type = 'LoanApproved' AND borrower = $1 AND loan_id IS NOT NULL
192+
-- Loan approval details (ledger and term)
193+
approved_loans AS (
194+
SELECT
195+
loan_id,
196+
MAX(ledger) AS approved_ledger,
197+
MAX(COALESCE(term_ledgers, 17280)) AS term_ledgers
198+
FROM borrower_events
199+
WHERE event_type = 'LoanApproved' AND loan_id IS NOT NULL
248200
GROUP BY loan_id
249201
),
250-
repaid AS (
251-
SELECT loan_id, MIN(ledger) AS repaid_ledger,
252-
MIN(ledger_closed_at) AS repaid_at
253-
FROM loan_events
254-
WHERE event_type = 'LoanRepaid' AND borrower = $1 AND loan_id IS NOT NULL
202+
-- Repaid loan details (ledger and timestamp)
203+
repaid_loans AS (
204+
SELECT
205+
loan_id,
206+
MIN(ledger) AS repaid_ledger,
207+
MIN(ledger_closed_at) AS repaid_at
208+
FROM borrower_events
209+
WHERE event_type = 'LoanRepaid' AND loan_id IS NOT NULL
255210
GROUP BY loan_id
256211
),
257-
timeline AS (
258-
SELECT r.loan_id, r.repaid_at,
259-
CASE WHEN r.repaid_ledger <= a.approved_ledger + a.term_ledgers THEN true ELSE false END AS on_time
260-
FROM repaid r
261-
JOIN approved a ON a.loan_id = r.loan_id
262-
ORDER BY r.repaid_at ASC
212+
-- Classification of repayments as on-time or late
213+
repayment_timing AS (
214+
SELECT
215+
r.loan_id,
216+
r.repaid_ledger,
217+
r.repaid_at,
218+
CASE WHEN r.repaid_ledger <= a.approved_ledger + a.term_ledgers
219+
THEN true ELSE false END AS on_time,
220+
(r.repaid_ledger - a.approved_ledger) AS repayment_ledgers
221+
FROM repaid_loans r
222+
JOIN approved_loans a ON a.loan_id = r.loan_id
223+
),
224+
-- Aggregate statistics across all loans
225+
loan_stats AS (
226+
SELECT
227+
COUNT(DISTINCT CASE WHEN event_type = 'LoanRequested' THEN loan_id END) AS total_loans,
228+
COUNT(DISTINCT CASE WHEN event_type = 'LoanRepaid' THEN loan_id END) AS repaid_count,
229+
COUNT(DISTINCT CASE WHEN event_type = 'LoanDefaulted' THEN loan_id END) AS defaulted_count,
230+
COALESCE(SUM(CASE WHEN event_type = 'LoanRepaid' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) AS total_repaid
231+
FROM borrower_events
232+
),
233+
-- Repayment timing statistics
234+
timing_stats AS (
235+
SELECT
236+
COUNT(*) FILTER (WHERE on_time) AS on_time_count,
237+
COUNT(*) FILTER (WHERE NOT on_time) AS late_count,
238+
AVG(repayment_ledgers) AS avg_repayment_ledgers
239+
FROM repayment_timing
240+
),
241+
-- Final aggregated breakdown
242+
breakdown_summary AS (
243+
SELECT
244+
cs.current_score,
245+
COALESCE(ls.total_loans, 0) AS total_loans,
246+
COALESCE(ls.repaid_count, 0) AS repaid_count,
247+
COALESCE(ls.defaulted_count, 0) AS defaulted_count,
248+
COALESCE(ls.total_repaid, 0) AS total_repaid,
249+
COALESCE(ts.on_time_count, 0) AS on_time_count,
250+
COALESCE(ts.late_count, 0) AS late_count,
251+
COALESCE(ts.avg_repayment_ledgers, 0) AS avg_repayment_ledgers
252+
FROM current_score_cte cs
253+
CROSS JOIN loan_stats ls
254+
CROSS JOIN timing_stats ts
263255
)
264-
SELECT on_time FROM timeline ORDER BY repaid_at ASC`,
256+
SELECT
257+
current_score,
258+
total_loans,
259+
repaid_count,
260+
defaulted_count,
261+
total_repaid,
262+
on_time_count,
263+
late_count,
264+
avg_repayment_ledgers
265+
FROM breakdown_summary`,
265266
[userId],
266267
);
267268

268-
let longestStreak = 0;
269-
let currentStreak = 0;
270-
let tempStreak = 0;
271-
272-
for (const row of streakResult.rows) {
273-
if (row.on_time) {
274-
tempStreak++;
275-
longestStreak = Math.max(longestStreak, tempStreak);
276-
} else {
277-
tempStreak = 0;
278-
}
279-
}
280-
currentStreak = tempStreak;
269+
const breakdown = breakdownResult.rows[0] || {};
270+
const score = parseInt(breakdown.current_score || "500", 10);
271+
const band = getCreditBand(score);
272+
const totalLoans = parseInt(breakdown.total_loans || "0", 10);
273+
const repaidOnTime = parseInt(breakdown.on_time_count || "0", 10);
274+
const repaidLate = parseInt(breakdown.late_count || "0", 10);
275+
const defaultedCount = parseInt(breakdown.defaulted_count || "0", 10);
276+
const totalRepaid = parseFloat(breakdown.total_repaid || "0");
277+
278+
// Convert average ledgers to days (1 ledger ≈ 5 seconds)
279+
const avgLedgers = parseFloat(breakdown.avg_repayment_ledgers || "0");
280+
const avgDays = Math.round((avgLedgers * 5) / 86400);
281+
const averageRepaymentTime = avgLedgers > 0 ? `${avgDays} days` : "N/A";
281282

282-
// Fetch score history from score-changing events
283+
// Fetch detailed history for streak calculation (separate query is minimal overhead)
283284
const historyResult = await query(
284-
`SELECT ledger_closed_at AS date, event_type AS event
285+
`SELECT
286+
event_type,
287+
ledger_closed_at
285288
FROM loan_events
286-
WHERE borrower = $1
287-
AND event_type IN ('LoanRepaid', 'LoanDefaulted')
289+
WHERE borrower = $1 AND event_type IN ('LoanRepaid', 'LoanDefaulted')
288290
ORDER BY ledger_closed_at ASC`,
289291
[userId],
290292
);
291293

292294
// Build score history by replaying deltas from base 500
293295
let runningScore = 500;
294296
const history = historyResult.rows.map((row: Record<string, unknown>) => {
295-
if (row.event === "LoanRepaid") {
297+
if (row.event_type === "LoanRepaid") {
296298
runningScore = Math.min(850, runningScore + ON_TIME_DELTA);
297-
} else if (row.event === "LoanDefaulted") {
299+
} else if (row.event_type === "LoanDefaulted") {
298300
runningScore = Math.max(300, runningScore - 50);
299301
}
300302
return {
301-
date: row.date
302-
? new Date(row.date as string).toISOString().split("T")[0]
303+
date: row.ledger_closed_at
304+
? new Date(row.ledger_closed_at as string).toISOString().split("T")[0]
303305
: null,
304306
score: runningScore,
305-
event: row.event,
307+
event: row.event_type,
306308
};
307309
});
308310

311+
// Calculate streaks from history
312+
let longestStreak = 0;
313+
let currentStreak = 0;
314+
let tempStreak = 0;
315+
316+
for (const histItem of history) {
317+
if (histItem.event === "LoanRepaid") {
318+
tempStreak++;
319+
longestStreak = Math.max(longestStreak, tempStreak);
320+
} else {
321+
tempStreak = 0;
322+
}
323+
}
324+
currentStreak = tempStreak;
325+
309326
const responseData = {
310327
userId,
311328
score,

0 commit comments

Comments
 (0)