diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..68d6ffe --- /dev/null +++ b/app/api/dashboard/stats/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDashboardStats } from '@/lib/dashboard' + +// Cache stats for 60 seconds (revalidate on next request after expiry) +export const revalidate = 60 + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + const userIdParam = searchParams.get('userId') + + if (!userIdParam || isNaN(Number(userIdParam))) { + return NextResponse.json({ error: 'Missing or invalid userId' }, { status: 400 }) + } + + const userId = Number(userIdParam) + + try { + const stats = await getDashboardStats(userId) + return NextResponse.json({ + data: stats, + meta: { userId, generatedAt: new Date().toISOString() }, + }) + } catch (err) { + console.error('[dashboard/stats] query failed:', err) + return NextResponse.json({ error: 'Failed to fetch dashboard stats' }, { status: 500 }) + } +} diff --git a/lib/dashboard.test.ts b/lib/dashboard.test.ts new file mode 100644 index 0000000..5a6320a --- /dev/null +++ b/lib/dashboard.test.ts @@ -0,0 +1,78 @@ +/** + * Unit tests for lib/dashboard.ts query logic. + * Uses Node.js built-in test runner (Node 18+). + * Run: npx tsx --test lib/dashboard.test.ts + */ +import { describe, it, mock } from 'node:test' +import assert from 'node:assert/strict' + +// --------------------------------------------------------------------------- +// Build a mockSql factory that returns controlled per-call results +// --------------------------------------------------------------------------- +function makeMockSql(results: Record[][]) { + let i = 0 + return function (_strings: TemplateStringsArray, ..._values: unknown[]) { + return Promise.resolve(results[i++] ?? []) + } +} + +// --------------------------------------------------------------------------- +// Helpers to create a getDashboardStats bound to a given mockSql +// --------------------------------------------------------------------------- +async function importWithMock(results: Record[][]) { + const mockSql = makeMockSql(results) + mock.module('./db', { namedExports: { sql: mockSql } }) + // Re-import to pick up the new mock + const mod = await import(`./dashboard?t=${Date.now()}`) + return mod.getDashboardStats as (userId: number) => Promise<{ + activeContracts: number + completedContracts: number + totalEarnings: number + escrowVolume: number + }> +} + +describe('getDashboardStats', () => { + it('returns mapped stats from query results', async () => { + const getDashboardStats = await importWithMock([ + [{ count: 3 }], + [{ count: 10 }], + [{ total: 2500.50 }], + [{ total: 800.00 }], + ]) + + const stats = await getDashboardStats(1) + assert.equal(stats.activeContracts, 3) + assert.equal(stats.completedContracts, 10) + assert.equal(stats.totalEarnings, 2500.50) + assert.equal(stats.escrowVolume, 800.00) + }) + + it('handles zero values when no data exists', async () => { + const getDashboardStats = await importWithMock([ + [{ count: 0 }], + [{ count: 0 }], + [{ total: 0 }], + [{ total: 0 }], + ]) + + const stats = await getDashboardStats(99) + assert.equal(stats.activeContracts, 0) + assert.equal(stats.completedContracts, 0) + assert.equal(stats.totalEarnings, 0) + assert.equal(stats.escrowVolume, 0) + }) + + it('issues exactly 4 concurrent queries via Promise.all', async (t) => { + let callCount = 0 + const mockSql = (_strings: TemplateStringsArray, ..._values: unknown[]) => { + callCount++ + return Promise.resolve([{ count: 0, total: 0 }]) + } + t.mock.module('./db', { namedExports: { sql: mockSql } }) + const { getDashboardStats } = await import(`./dashboard?t=${Date.now()}`) + + await getDashboardStats(1) + assert.equal(callCount, 4, 'Expected exactly 4 SQL queries') + }) +}) diff --git a/lib/dashboard.ts b/lib/dashboard.ts new file mode 100644 index 0000000..991f2f6 --- /dev/null +++ b/lib/dashboard.ts @@ -0,0 +1,57 @@ +import { sql } from './db' + +export interface DashboardStats { + activeContracts: number + completedContracts: number + totalEarnings: number + escrowVolume: number +} + +/** + * Fetch aggregated dashboard stats for a user. + * - activeContracts: jobs in active states where user is client or freelancer + * - completedContracts: jobs with status 'completed' + * - totalEarnings: sum of confirmed 'release' transactions to the user's wallet + * - escrowVolume: sum of confirmed 'deposit' transactions minus confirmed 'release'/'refund' + * for jobs still in escrow (escrow_status = 'funded') + */ +export async function getDashboardStats(userId: number): Promise { + const [active, completed, earnings, escrow] = await Promise.all([ + sql` + SELECT COUNT(*)::int AS count + FROM jobs + WHERE (client_id = ${userId} OR freelancer_id = ${userId}) + AND status IN ('assigned', 'in_progress', 'in_review') + `, + sql` + SELECT COUNT(*)::int AS count + FROM jobs + WHERE (client_id = ${userId} OR freelancer_id = ${userId}) + AND status = 'completed' + `, + sql` + SELECT COALESCE(SUM(et.amount), 0)::float AS total + FROM escrow_transactions et + JOIN jobs j ON j.id = et.job_id + WHERE et.transaction_type = 'release' + AND et.status = 'confirmed' + AND (j.client_id = ${userId} OR j.freelancer_id = ${userId}) + `, + sql` + SELECT COALESCE(SUM(et.amount), 0)::float AS total + FROM escrow_transactions et + JOIN jobs j ON j.id = et.job_id + WHERE et.transaction_type = 'deposit' + AND et.status = 'confirmed' + AND j.escrow_status = 'funded' + AND (j.client_id = ${userId} OR j.freelancer_id = ${userId}) + `, + ]) + + return { + activeContracts: active[0].count, + completedContracts: completed[0].count, + totalEarnings: earnings[0].total, + escrowVolume: escrow[0].total, + } +} diff --git a/lib/db.ts b/lib/db.ts index 4ffd72a..158de1e 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,7 +1,23 @@ -import { neon } from '@neondatabase/serverless' +import { neon, NeonQueryFunction } from '@neondatabase/serverless' -if (!process.env.DATABASE_URL) { - throw new Error('DATABASE_URL is not set') +let _sql: NeonQueryFunction | null = null + +export function getSql(): NeonQueryFunction { + if (!_sql) { + if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL is not set') + } + _sql = neon(process.env.DATABASE_URL) + } + return _sql } -export const sql = neon(process.env.DATABASE_URL) +// Keep `sql` as a convenience export — lazily resolved on first use. +export const sql: NeonQueryFunction = new Proxy({} as NeonQueryFunction, { + apply(_target, _thisArg, args) { + return (getSql() as unknown as (...a: unknown[]) => unknown)(...args) + }, + get(_target, prop) { + return (getSql() as unknown as Record)[prop] + }, +})