Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"dev": "nodemon --watch src --ext ts --exec tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"start": "node dist/index.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
Expand Down
1 change: 1 addition & 0 deletions backend/src/__tests__/apiV1Mounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jest.unstable_mockModule("../db/connection.js", () => ({
query: mockQuery,
getClient: jest.fn(),
closePool: jest.fn(),
withTransaction: jest.fn(),
}));

// ── notificationService mock ─────────────────────────────────────────────────
Expand Down
7 changes: 6 additions & 1 deletion backend/src/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,14 @@ function makeRawEvent(params: {

switch (params.type) {
case "LoanRequested":
// Contract emits (Symbol, loan_id, borrower) with amount as the value.
return {
...base,
topic: [scSymbol("LoanRequested"), scAddress(borrower)],
topic: [
scSymbol("LoanRequested"),
scU32(params.loanId ?? 1),
scAddress(borrower),
],
value: scI128(params.amount ?? 500),
};
case "LoanApproved":
Expand Down
18 changes: 13 additions & 5 deletions backend/src/__tests__/notificationDigest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,18 @@ describe("notification digest batching", () => {
const user1 = "GUSER1111111111111111111111111111111111111111111111111111";
const user2 = "GUSER2222222222222222222222222222222222222222222222222222";

mockQuery
.mockResolvedValueOnce({ rows: [{ digest_frequency: "daily" }] })
.mockResolvedValueOnce({ rows: [{ digest_frequency: "weekly" }] })
.mockResolvedValueOnce({ rows: [{ digest_frequency: "off" }] });
// A user has a single digest preference, so the lookup must be keyed by
// user id rather than by call order: user1 is always daily, user2 weekly.
mockQuery.mockImplementation(async (_text, params) => {
const uid = (params as unknown[] | undefined)?.[0];
if (uid === user1) {
return { rows: [{ digest_frequency: "daily" }] };
}
if (uid === user2) {
return { rows: [{ digest_frequency: "weekly" }] };
}
return { rows: [] };
});

const notifications = [
{ userId: user1, message: "Loan 1 due", loanId: 1 },
Expand All @@ -125,7 +133,7 @@ describe("notification digest batching", () => {
notifications,
);

expect(grouped.size).toBe(3);
expect(grouped.size).toBe(2);
expect(grouped.get(`${user1}:daily`)).toHaveLength(2);
expect(grouped.get(`${user2}:weekly`)).toHaveLength(1);
});
Expand Down
4 changes: 0 additions & 4 deletions backend/src/__tests__/remittanceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,11 @@ describe("remittanceService.createRemittance", () => {
});

describe("remittanceService.getRemittances with filters", () => {
let mockQuery: jest.MockedFunction<any>;

beforeEach(() => {
jest.clearAllMocks();
mockQuery = jest.fn();
});

it("filters remittances by status", async () => {
const { query: queryModule } = await import("../db/connection.js");
mockQuery.mockResolvedValueOnce({
rows: [
{
Expand Down
10 changes: 9 additions & 1 deletion backend/src/middleware/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ const validateSource = (schema: ZodType, source: ValidationSource) => {
: source === "query"
? req.query
: req.params;
req[source] = schema.parse(data);
// In Express 5, `req.query` and `req.params` are getter-only and cannot
// be reassigned (`req.query = ...` throws). Shadow them with a writable
// data property so coerced/validated values are persisted for handlers.
Object.defineProperty(req, source, {
value: schema.parse(data),
writable: true,
enumerable: true,
configurable: true,
});
next();
} catch (error) {
next(error);
Expand Down
1 change: 1 addition & 0 deletions backend/src/services/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ beforeAll(async () => {
"PoolPaused",
"PoolUnpaused",
"LoanApprv",
"LoanLiquidated",
],
}));

Expand Down
16 changes: 12 additions & 4 deletions backend/src/utils/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,20 +142,28 @@ function parsePositiveInteger(
fallback: number,
max?: number,
): number {
if (typeof value !== "string") {
// Accept both raw query strings and values already coerced to numbers by
// Zod validation middleware (e.g. `z.coerce.number()`).
let parsed: number;
if (typeof value === "number") {
parsed = value;
} else if (typeof value === "string") {
parsed = Number.parseInt(value, 10);
} else {
return fallback;
}

const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return fallback;
}

const truncated = Math.trunc(parsed);

if (max !== undefined) {
return Math.min(parsed, max);
return Math.min(truncated, max);
}

return parsed;
return truncated;
}

function parseDateRange(value: unknown): { start: Date; end: Date } | null {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/components/charts/CreditScoreTrendChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Tooltip,
ResponsiveContainer,
Legend,
TooltipProps,
TooltipContentProps,
} from "recharts";
import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card";
import { TrendingUp, TrendingDown } from "lucide-react";
Expand All @@ -34,7 +34,7 @@ export function CreditScoreTrendChart({ data, className }: CreditScoreTrendChart
const isPositive = scoreDiff >= 0;

// Custom tooltip
const CustomTooltip = ({ active, payload }: TooltipProps<CreditScoreDataPoint, string>) => {
const CustomTooltip = ({ active, payload }: TooltipContentProps) => {
if (active && payload && payload.length) {
const data = (payload[0] as unknown as { payload: CreditScoreDataPoint }).payload;
return (
Expand Down Expand Up @@ -97,7 +97,7 @@ export function CreditScoreTrendChart({ data, className }: CreditScoreTrendChart
className: "text-xs text-gray-600 dark:text-zinc-400",
}}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={CustomTooltip} />
<Legend
wrapperStyle={{
paddingTop: "20px",
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/components/charts/RiskTierChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Tooltip,
ResponsiveContainer,
Cell,
TooltipProps,
TooltipContentProps,
} from "recharts";
import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card";

Expand All @@ -27,7 +27,7 @@ interface RiskTierChartProps {
export function RiskTierChart({ data, className }: RiskTierChartProps) {
const total = data.reduce((sum, d) => sum + d.count, 0);

const CustomTooltip = ({ active, payload }: TooltipProps<RiskTierDataPoint, string>) => {
const CustomTooltip = ({ active, payload }: TooltipContentProps) => {
if (active && payload && payload.length) {
const d = (payload[0] as unknown as { payload: RiskTierDataPoint }).payload;
const pct = total > 0 ? ((d.count / total) * 100).toFixed(1) : "0.0";
Expand Down Expand Up @@ -67,7 +67,7 @@ export function RiskTierChart({ data, className }: RiskTierChartProps) {
tickLine={{ stroke: "currentColor" }}
className="text-xs text-gray-600 dark:text-zinc-400"
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={CustomTooltip} />
<Bar dataKey="count" radius={[4, 4, 0, 0]} name="Loans">
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/components/charts/YieldEarningsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Tooltip,
ResponsiveContainer,
Legend,
TooltipProps,
TooltipContentProps,
} from "recharts";
import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card";
import { DollarSign, TrendingUp } from "lucide-react";
Expand All @@ -35,7 +35,7 @@ export function YieldEarningsChart({ data, className }: YieldEarningsChartProps)
: "0.00";

// Custom tooltip
const CustomTooltip = ({ active, payload }: TooltipProps<YieldDataPoint, string>) => {
const CustomTooltip = ({ active, payload }: TooltipContentProps) => {
if (active && payload && payload.length) {
const data = (payload[0] as unknown as { payload: YieldDataPoint }).payload;
return (
Expand Down Expand Up @@ -137,7 +137,7 @@ export function YieldEarningsChart({ data, className }: YieldEarningsChartProps)
className: "text-xs text-gray-600 dark:text-zinc-400",
}}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={CustomTooltip} />
<Legend
wrapperStyle={{
paddingTop: "20px",
Expand Down
33 changes: 4 additions & 29 deletions frontend/src/app/components/ui/CreditScoreGauge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ export function CreditScoreGauge({
}: CreditScoreGaugeProps) {
const numericScore = typeof score === "number" && Number.isFinite(score) ? score : null;

const band = useMemo(() => (numericScore == null ? BANDS[0] : getBand(numericScore)), [
numericScore,
]);
const band = useMemo(
() => (numericScore == null ? BANDS[0] : getBand(numericScore)),
[numericScore],
);
const delta = previousScore != null && numericScore != null ? numericScore - previousScore : null;

const cx = 120;
Expand Down Expand Up @@ -176,32 +177,6 @@ export function CreditScoreGauge({
);
}

const band = useMemo(() => getBand(numericScore), [numericScore]);
const delta = previousScore != null ? numericScore - previousScore : null;

const cx = 120;
const cy = 120;
const r = 100;
const startAngle = -120;
const endAngle = 120;
const totalArc = endAngle - startAngle;

const clampedScore = Math.max(min, Math.min(max, numericScore));
const fraction = (clampedScore - min) / (max - min);
const scoreAngle = startAngle + fraction * totalArc;

// Background arc segments per band
const bandArcs = useMemo(() => {
return BANDS.map((b) => {
const bStart = startAngle + ((b.range[0] - min) / (max - min)) * totalArc;
const bEnd = startAngle + ((Math.min(b.range[1], max) - min) / (max - min)) * totalArc;
return { ...b, path: describeArc(cx, cy, r, bStart, bEnd) };
});
}, [min, max]);

// Active arc from start to current score
const activePath = describeArc(cx, cy, r, startAngle, scoreAngle);

return (
<div className="flex flex-col items-center gap-3">
<div
Expand Down