From 364ec55a5ca182a3e14025b87af704067fc72209 Mon Sep 17 00:00:00 2001 From: mrteeednut007-dotcom Date: Mon, 29 Jun 2026 06:54:07 +0000 Subject: [PATCH] feat: add GET /api/dashboard/stats endpoint - Add lib/dashboard/stats.ts with query functions for active/completed contracts, total earnings (released escrow), and escrow volume - Add app/api/dashboard/stats/route.ts with auth, error handling, and Cache-Control: private, max-age=60 - Add unit tests (8 tests, all passing) --- app/api/dashboard/stats/route.ts | 47 +++++++ app/api/dashboard/stats/stats.test.ts | 191 ++++++++++++++++++++++++++ lib/dashboard/stats.ts | 92 +++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 app/api/dashboard/stats/route.ts create mode 100644 app/api/dashboard/stats/stats.test.ts create mode 100644 lib/dashboard/stats.ts diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..f8adf6d --- /dev/null +++ b/app/api/dashboard/stats/route.ts @@ -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 } + ) + } +}) diff --git a/app/api/dashboard/stats/stats.test.ts b/app/api/dashboard/stats/stats.test.ts new file mode 100644 index 0000000..616f823 --- /dev/null +++ b/app/api/dashboard/stats/stats.test.ts @@ -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({ + 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({ + 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({ + 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) => + (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) => + (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) => + (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') + }) +}) diff --git a/lib/dashboard/stats.ts b/lib/dashboard/stats.ts new file mode 100644 index 0000000..024ba16 --- /dev/null +++ b/lib/dashboard/stats.ts @@ -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 { + 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 { + 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 { + const userId = await getUserIdByWallet(walletAddress) + if (!userId) { + throw new Error('USER_NOT_FOUND') + } + return queryStats(userId) +} + +// Export internals for unit testing +export { getUserIdByWallet, queryStats }