Skip to content
Merged
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
47 changes: 47 additions & 0 deletions app/api/dashboard/stats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* GET /api/dashboard/stats
*
* Returns aggregated statistics for the authenticated user:
* - activeContracts – contracts with status = 'active'
* - completedContracts – contracts with status = 'completed'
* - totalEarnings – sum of confirmed milestone_release escrow transactions
* - escrowVolume – sum of total_amount on funded/partially_released contracts
*
* Cache-Control: private, max-age=60 (1 minute client-side cache)
*/

export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { getDashboardStats } from '@/lib/dashboard/stats'

export const GET = withAuth(async (_request: NextRequest, auth) => {
try {
const stats = await getDashboardStats(auth.walletAddress)

return NextResponse.json(
{
data: stats,
meta: { generatedAt: new Date().toISOString() },
},
{
status: 200,
headers: { 'Cache-Control': 'private, max-age=60' },
}
)
} catch (err) {
if (err instanceof Error && err.message === 'USER_NOT_FOUND') {
return NextResponse.json(
{ error: 'Authenticated wallet has no platform account', code: 'USER_NOT_FOUND' },
{ status: 404 }
)
}

console.error('[GET /api/dashboard/stats]', err)
return NextResponse.json(
{ error: 'Failed to fetch dashboard statistics', code: 'INTERNAL_ERROR' },
{ status: 500 }
)
}
})
191 changes: 191 additions & 0 deletions app/api/dashboard/stats/stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* app/api/dashboard/stats/stats.test.ts
*
* Unit tests for:
* - lib/dashboard/stats (query layer)
* - GET /api/dashboard/stats (route handler)
*
* The `sql` export from @/lib/db is a Proxy, so it cannot be spied on
* directly. Instead we replace the entire module with a vi.mock factory
* that exposes a plain vi.fn() which tests can configure per-call.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { DashboardStats } from '@/lib/dashboard/stats'

// ─── Module-level mock for @/lib/db ─────────────────────────────────────────

const mockSqlFn = vi.fn()

vi.mock('@/lib/db', () => ({
sql: mockSqlFn,
}))

// ─── queryStats ──────────────────────────────────────────────────────────────

describe('queryStats', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('maps DB row to DashboardStats correctly', async () => {
mockSqlFn.mockResolvedValueOnce([{
active_contracts: 3,
completed_contracts: 7,
total_earnings: '1500.00',
escrow_volume: '2500.50',
}])

const { queryStats } = await import('@/lib/dashboard/stats')
const result = await queryStats('user-uuid-1')

expect(result).toEqual<DashboardStats>({
activeContracts: 3,
completedContracts: 7,
totalEarnings: '1500.00',
escrowVolume: '2500.50',
})
})

it('returns zero values when query returns no rows', async () => {
mockSqlFn.mockResolvedValueOnce([])

const { queryStats } = await import('@/lib/dashboard/stats')
const result = await queryStats('user-uuid-no-data')

expect(result).toEqual<DashboardStats>({
activeContracts: 0,
completedContracts: 0,
totalEarnings: '0',
escrowVolume: '0',
})
})

it('handles null numeric fields by defaulting to "0"', async () => {
mockSqlFn.mockResolvedValueOnce([{
active_contracts: 0,
completed_contracts: 0,
total_earnings: null,
escrow_volume: null,
}])

const { queryStats } = await import('@/lib/dashboard/stats')
const result = await queryStats('user-uuid-nulls')

expect(result.totalEarnings).toBe('0')
expect(result.escrowVolume).toBe('0')
})
})

// ─── getDashboardStats ───────────────────────────────────────────────────────

describe('getDashboardStats', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('throws USER_NOT_FOUND when wallet is not registered', async () => {
mockSqlFn.mockResolvedValueOnce([]) // getUserIdByWallet → no rows

const { getDashboardStats } = await import('@/lib/dashboard/stats')
await expect(getDashboardStats('GUNKNOWN')).rejects.toThrow('USER_NOT_FOUND')
})

it('returns stats for a registered wallet', async () => {
mockSqlFn
.mockResolvedValueOnce([{ id: 'user-uuid-1' }]) // getUserIdByWallet
.mockResolvedValueOnce([{ // queryStats
active_contracts: 2,
completed_contracts: 5,
total_earnings: '800.00',
escrow_volume: '400.00',
}])

const { getDashboardStats } = await import('@/lib/dashboard/stats')
const stats = await getDashboardStats('GABC123')

expect(stats).toEqual<DashboardStats>({
activeContracts: 2,
completedContracts: 5,
totalEarnings: '800.00',
escrowVolume: '400.00',
})
})
})

// ─── Route handler ───────────────────────────────────────────────────────────

describe('GET /api/dashboard/stats route', () => {
beforeEach(() => {
vi.resetModules()
})

it('returns 200 with stats and cache header on success', async () => {
vi.doMock('@/lib/dashboard/stats', () => ({
getDashboardStats: vi.fn().mockResolvedValue({
activeContracts: 1,
completedContracts: 4,
totalEarnings: '300.00',
escrowVolume: '100.00',
} satisfies DashboardStats),
}))

vi.doMock('@/lib/auth/middleware', () => ({
withAuth: (handler: (req: Request, auth: { walletAddress: string }) => Promise<Response>) =>
(req: Request) => handler(req, { walletAddress: 'GABC123' }),
}))

const { GET } = await import('@/app/api/dashboard/stats/route')
const req = new Request('http://localhost/api/dashboard/stats')
const res = await GET(req as never)

expect(res.status).toBe(200)
const body = await res.json() as { data: DashboardStats; meta: { generatedAt: string } }
expect(body.data).toEqual({
activeContracts: 1,
completedContracts: 4,
totalEarnings: '300.00',
escrowVolume: '100.00',
})
expect(body.meta.generatedAt).toBeDefined()
expect(res.headers.get('Cache-Control')).toBe('private, max-age=60')
})

it('returns 404 when wallet is not registered', async () => {
vi.doMock('@/lib/dashboard/stats', () => ({
getDashboardStats: vi.fn().mockRejectedValue(new Error('USER_NOT_FOUND')),
}))

vi.doMock('@/lib/auth/middleware', () => ({
withAuth: (handler: (req: Request, auth: { walletAddress: string }) => Promise<Response>) =>
(req: Request) => handler(req, { walletAddress: 'GUNKNOWN' }),
}))

const { GET } = await import('@/app/api/dashboard/stats/route')
const req = new Request('http://localhost/api/dashboard/stats')
const res = await GET(req as never)

expect(res.status).toBe(404)
const body = await res.json() as { code: string }
expect(body.code).toBe('USER_NOT_FOUND')
})

it('returns 500 on unexpected DB error', async () => {
vi.doMock('@/lib/dashboard/stats', () => ({
getDashboardStats: vi.fn().mockRejectedValue(new Error('connection refused')),
}))

vi.doMock('@/lib/auth/middleware', () => ({
withAuth: (handler: (req: Request, auth: { walletAddress: string }) => Promise<Response>) =>
(req: Request) => handler(req, { walletAddress: 'GABC123' }),
}))

const { GET } = await import('@/app/api/dashboard/stats/route')
const req = new Request('http://localhost/api/dashboard/stats')
const res = await GET(req as never)

expect(res.status).toBe(500)
const body = await res.json() as { code: string }
expect(body.code).toBe('INTERNAL_ERROR')
})
})
92 changes: 92 additions & 0 deletions lib/dashboard/stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* lib/dashboard/stats.ts
*
* DB query functions for dashboard statistics.
* All queries are scoped to a wallet address (resolved to a user id).
*/

import { sql } from '@/lib/db'

export interface DashboardStats {
activeContracts: number
completedContracts: number
totalEarnings: string // NUMERIC as string to preserve precision
escrowVolume: string // NUMERIC as string to preserve precision
}

/**
* Resolve a wallet address to a user UUID.
* Returns null if the wallet is not registered.
*/
async function getUserIdByWallet(walletAddress: string): Promise<string | null> {
const rows = (await sql`
SELECT id FROM users
WHERE wallet_address = ${walletAddress}
LIMIT 1
`) as unknown as { id: string }[]
return rows[0]?.id ?? null
}

/**
* Fetch all four dashboard metrics in a single query for a given user.
* The user is treated as either client OR freelancer so the stats are
* unified from their perspective.
*/
async function queryStats(userId: string): Promise<DashboardStats> {
const rows = (await sql`
SELECT
COUNT(*) FILTER (
WHERE status = 'active'
AND (client_id = ${userId} OR freelancer_id = ${userId})
)::int AS active_contracts,

COUNT(*) FILTER (
WHERE status = 'completed'
AND (client_id = ${userId} OR freelancer_id = ${userId})
)::int AS completed_contracts,

COALESCE(SUM(etl.amount) FILTER (
WHERE etl.transaction_type = 'milestone_release'
AND etl.status = 'confirmed'
AND etl.actor_user_id = ${userId}
), 0)::text AS total_earnings,

COALESCE(SUM(c.total_amount) FILTER (
WHERE c.escrow_status IN ('funded', 'partially_released')
AND (c.client_id = ${userId} OR c.freelancer_id = ${userId})
), 0)::text AS escrow_volume

FROM contracts c
LEFT JOIN escrow_transaction_logs etl ON etl.contract_id = c.id
WHERE c.client_id = ${userId} OR c.freelancer_id = ${userId}
`) as unknown as {
active_contracts: number
completed_contracts: number
total_earnings: string | null
escrow_volume: string | null
}[]

const row = rows[0]

return {
activeContracts: row?.active_contracts ?? 0,
completedContracts: row?.completed_contracts ?? 0,
totalEarnings: row?.total_earnings ?? '0',
escrowVolume: row?.escrow_volume ?? '0',
}
}

/**
* Public entry point: resolves wallet → user id → stats.
* Throws if the wallet is not registered.
*/
export async function getDashboardStats(walletAddress: string): Promise<DashboardStats> {
const userId = await getUserIdByWallet(walletAddress)
if (!userId) {
throw new Error('USER_NOT_FOUND')
}
return queryStats(userId)
}

// Export internals for unit testing
export { getUserIdByWallet, queryStats }
Loading