Skip to content

Commit fc2ba89

Browse files
Merge pull request #143 from mrteeednut007-dotcom/feature/dashboard-stats
feat: add GET /api/dashboard/stats endpoint
2 parents 1398d6b + 364ec55 commit fc2ba89

3 files changed

Lines changed: 330 additions & 0 deletions

File tree

app/api/dashboard/stats/route.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* GET /api/dashboard/stats
3+
*
4+
* Returns aggregated statistics for the authenticated user:
5+
* - activeContracts – contracts with status = 'active'
6+
* - completedContracts – contracts with status = 'completed'
7+
* - totalEarnings – sum of confirmed milestone_release escrow transactions
8+
* - escrowVolume – sum of total_amount on funded/partially_released contracts
9+
*
10+
* Cache-Control: private, max-age=60 (1 minute client-side cache)
11+
*/
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
import { NextRequest, NextResponse } from 'next/server'
16+
import { withAuth } from '@/lib/auth/middleware'
17+
import { getDashboardStats } from '@/lib/dashboard/stats'
18+
19+
export const GET = withAuth(async (_request: NextRequest, auth) => {
20+
try {
21+
const stats = await getDashboardStats(auth.walletAddress)
22+
23+
return NextResponse.json(
24+
{
25+
data: stats,
26+
meta: { generatedAt: new Date().toISOString() },
27+
},
28+
{
29+
status: 200,
30+
headers: { 'Cache-Control': 'private, max-age=60' },
31+
}
32+
)
33+
} catch (err) {
34+
if (err instanceof Error && err.message === 'USER_NOT_FOUND') {
35+
return NextResponse.json(
36+
{ error: 'Authenticated wallet has no platform account', code: 'USER_NOT_FOUND' },
37+
{ status: 404 }
38+
)
39+
}
40+
41+
console.error('[GET /api/dashboard/stats]', err)
42+
return NextResponse.json(
43+
{ error: 'Failed to fetch dashboard statistics', code: 'INTERNAL_ERROR' },
44+
{ status: 500 }
45+
)
46+
}
47+
})
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* app/api/dashboard/stats/stats.test.ts
3+
*
4+
* Unit tests for:
5+
* - lib/dashboard/stats (query layer)
6+
* - GET /api/dashboard/stats (route handler)
7+
*
8+
* The `sql` export from @/lib/db is a Proxy, so it cannot be spied on
9+
* directly. Instead we replace the entire module with a vi.mock factory
10+
* that exposes a plain vi.fn() which tests can configure per-call.
11+
*/
12+
13+
import { describe, it, expect, vi, beforeEach } from 'vitest'
14+
import type { DashboardStats } from '@/lib/dashboard/stats'
15+
16+
// ─── Module-level mock for @/lib/db ─────────────────────────────────────────
17+
18+
const mockSqlFn = vi.fn()
19+
20+
vi.mock('@/lib/db', () => ({
21+
sql: mockSqlFn,
22+
}))
23+
24+
// ─── queryStats ──────────────────────────────────────────────────────────────
25+
26+
describe('queryStats', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks()
29+
})
30+
31+
it('maps DB row to DashboardStats correctly', async () => {
32+
mockSqlFn.mockResolvedValueOnce([{
33+
active_contracts: 3,
34+
completed_contracts: 7,
35+
total_earnings: '1500.00',
36+
escrow_volume: '2500.50',
37+
}])
38+
39+
const { queryStats } = await import('@/lib/dashboard/stats')
40+
const result = await queryStats('user-uuid-1')
41+
42+
expect(result).toEqual<DashboardStats>({
43+
activeContracts: 3,
44+
completedContracts: 7,
45+
totalEarnings: '1500.00',
46+
escrowVolume: '2500.50',
47+
})
48+
})
49+
50+
it('returns zero values when query returns no rows', async () => {
51+
mockSqlFn.mockResolvedValueOnce([])
52+
53+
const { queryStats } = await import('@/lib/dashboard/stats')
54+
const result = await queryStats('user-uuid-no-data')
55+
56+
expect(result).toEqual<DashboardStats>({
57+
activeContracts: 0,
58+
completedContracts: 0,
59+
totalEarnings: '0',
60+
escrowVolume: '0',
61+
})
62+
})
63+
64+
it('handles null numeric fields by defaulting to "0"', async () => {
65+
mockSqlFn.mockResolvedValueOnce([{
66+
active_contracts: 0,
67+
completed_contracts: 0,
68+
total_earnings: null,
69+
escrow_volume: null,
70+
}])
71+
72+
const { queryStats } = await import('@/lib/dashboard/stats')
73+
const result = await queryStats('user-uuid-nulls')
74+
75+
expect(result.totalEarnings).toBe('0')
76+
expect(result.escrowVolume).toBe('0')
77+
})
78+
})
79+
80+
// ─── getDashboardStats ───────────────────────────────────────────────────────
81+
82+
describe('getDashboardStats', () => {
83+
beforeEach(() => {
84+
vi.clearAllMocks()
85+
})
86+
87+
it('throws USER_NOT_FOUND when wallet is not registered', async () => {
88+
mockSqlFn.mockResolvedValueOnce([]) // getUserIdByWallet → no rows
89+
90+
const { getDashboardStats } = await import('@/lib/dashboard/stats')
91+
await expect(getDashboardStats('GUNKNOWN')).rejects.toThrow('USER_NOT_FOUND')
92+
})
93+
94+
it('returns stats for a registered wallet', async () => {
95+
mockSqlFn
96+
.mockResolvedValueOnce([{ id: 'user-uuid-1' }]) // getUserIdByWallet
97+
.mockResolvedValueOnce([{ // queryStats
98+
active_contracts: 2,
99+
completed_contracts: 5,
100+
total_earnings: '800.00',
101+
escrow_volume: '400.00',
102+
}])
103+
104+
const { getDashboardStats } = await import('@/lib/dashboard/stats')
105+
const stats = await getDashboardStats('GABC123')
106+
107+
expect(stats).toEqual<DashboardStats>({
108+
activeContracts: 2,
109+
completedContracts: 5,
110+
totalEarnings: '800.00',
111+
escrowVolume: '400.00',
112+
})
113+
})
114+
})
115+
116+
// ─── Route handler ───────────────────────────────────────────────────────────
117+
118+
describe('GET /api/dashboard/stats route', () => {
119+
beforeEach(() => {
120+
vi.resetModules()
121+
})
122+
123+
it('returns 200 with stats and cache header on success', async () => {
124+
vi.doMock('@/lib/dashboard/stats', () => ({
125+
getDashboardStats: vi.fn().mockResolvedValue({
126+
activeContracts: 1,
127+
completedContracts: 4,
128+
totalEarnings: '300.00',
129+
escrowVolume: '100.00',
130+
} satisfies DashboardStats),
131+
}))
132+
133+
vi.doMock('@/lib/auth/middleware', () => ({
134+
withAuth: (handler: (req: Request, auth: { walletAddress: string }) => Promise<Response>) =>
135+
(req: Request) => handler(req, { walletAddress: 'GABC123' }),
136+
}))
137+
138+
const { GET } = await import('@/app/api/dashboard/stats/route')
139+
const req = new Request('http://localhost/api/dashboard/stats')
140+
const res = await GET(req as never)
141+
142+
expect(res.status).toBe(200)
143+
const body = await res.json() as { data: DashboardStats; meta: { generatedAt: string } }
144+
expect(body.data).toEqual({
145+
activeContracts: 1,
146+
completedContracts: 4,
147+
totalEarnings: '300.00',
148+
escrowVolume: '100.00',
149+
})
150+
expect(body.meta.generatedAt).toBeDefined()
151+
expect(res.headers.get('Cache-Control')).toBe('private, max-age=60')
152+
})
153+
154+
it('returns 404 when wallet is not registered', async () => {
155+
vi.doMock('@/lib/dashboard/stats', () => ({
156+
getDashboardStats: vi.fn().mockRejectedValue(new Error('USER_NOT_FOUND')),
157+
}))
158+
159+
vi.doMock('@/lib/auth/middleware', () => ({
160+
withAuth: (handler: (req: Request, auth: { walletAddress: string }) => Promise<Response>) =>
161+
(req: Request) => handler(req, { walletAddress: 'GUNKNOWN' }),
162+
}))
163+
164+
const { GET } = await import('@/app/api/dashboard/stats/route')
165+
const req = new Request('http://localhost/api/dashboard/stats')
166+
const res = await GET(req as never)
167+
168+
expect(res.status).toBe(404)
169+
const body = await res.json() as { code: string }
170+
expect(body.code).toBe('USER_NOT_FOUND')
171+
})
172+
173+
it('returns 500 on unexpected DB error', async () => {
174+
vi.doMock('@/lib/dashboard/stats', () => ({
175+
getDashboardStats: vi.fn().mockRejectedValue(new Error('connection refused')),
176+
}))
177+
178+
vi.doMock('@/lib/auth/middleware', () => ({
179+
withAuth: (handler: (req: Request, auth: { walletAddress: string }) => Promise<Response>) =>
180+
(req: Request) => handler(req, { walletAddress: 'GABC123' }),
181+
}))
182+
183+
const { GET } = await import('@/app/api/dashboard/stats/route')
184+
const req = new Request('http://localhost/api/dashboard/stats')
185+
const res = await GET(req as never)
186+
187+
expect(res.status).toBe(500)
188+
const body = await res.json() as { code: string }
189+
expect(body.code).toBe('INTERNAL_ERROR')
190+
})
191+
})

lib/dashboard/stats.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* lib/dashboard/stats.ts
3+
*
4+
* DB query functions for dashboard statistics.
5+
* All queries are scoped to a wallet address (resolved to a user id).
6+
*/
7+
8+
import { sql } from '@/lib/db'
9+
10+
export interface DashboardStats {
11+
activeContracts: number
12+
completedContracts: number
13+
totalEarnings: string // NUMERIC as string to preserve precision
14+
escrowVolume: string // NUMERIC as string to preserve precision
15+
}
16+
17+
/**
18+
* Resolve a wallet address to a user UUID.
19+
* Returns null if the wallet is not registered.
20+
*/
21+
async function getUserIdByWallet(walletAddress: string): Promise<string | null> {
22+
const rows = (await sql`
23+
SELECT id FROM users
24+
WHERE wallet_address = ${walletAddress}
25+
LIMIT 1
26+
`) as unknown as { id: string }[]
27+
return rows[0]?.id ?? null
28+
}
29+
30+
/**
31+
* Fetch all four dashboard metrics in a single query for a given user.
32+
* The user is treated as either client OR freelancer so the stats are
33+
* unified from their perspective.
34+
*/
35+
async function queryStats(userId: string): Promise<DashboardStats> {
36+
const rows = (await sql`
37+
SELECT
38+
COUNT(*) FILTER (
39+
WHERE status = 'active'
40+
AND (client_id = ${userId} OR freelancer_id = ${userId})
41+
)::int AS active_contracts,
42+
43+
COUNT(*) FILTER (
44+
WHERE status = 'completed'
45+
AND (client_id = ${userId} OR freelancer_id = ${userId})
46+
)::int AS completed_contracts,
47+
48+
COALESCE(SUM(etl.amount) FILTER (
49+
WHERE etl.transaction_type = 'milestone_release'
50+
AND etl.status = 'confirmed'
51+
AND etl.actor_user_id = ${userId}
52+
), 0)::text AS total_earnings,
53+
54+
COALESCE(SUM(c.total_amount) FILTER (
55+
WHERE c.escrow_status IN ('funded', 'partially_released')
56+
AND (c.client_id = ${userId} OR c.freelancer_id = ${userId})
57+
), 0)::text AS escrow_volume
58+
59+
FROM contracts c
60+
LEFT JOIN escrow_transaction_logs etl ON etl.contract_id = c.id
61+
WHERE c.client_id = ${userId} OR c.freelancer_id = ${userId}
62+
`) as unknown as {
63+
active_contracts: number
64+
completed_contracts: number
65+
total_earnings: string | null
66+
escrow_volume: string | null
67+
}[]
68+
69+
const row = rows[0]
70+
71+
return {
72+
activeContracts: row?.active_contracts ?? 0,
73+
completedContracts: row?.completed_contracts ?? 0,
74+
totalEarnings: row?.total_earnings ?? '0',
75+
escrowVolume: row?.escrow_volume ?? '0',
76+
}
77+
}
78+
79+
/**
80+
* Public entry point: resolves wallet → user id → stats.
81+
* Throws if the wallet is not registered.
82+
*/
83+
export async function getDashboardStats(walletAddress: string): Promise<DashboardStats> {
84+
const userId = await getUserIdByWallet(walletAddress)
85+
if (!userId) {
86+
throw new Error('USER_NOT_FOUND')
87+
}
88+
return queryStats(userId)
89+
}
90+
91+
// Export internals for unit testing
92+
export { getUserIdByWallet, queryStats }

0 commit comments

Comments
 (0)