Skip to content
Open
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
27 changes: 27 additions & 0 deletions app/api/dashboard/stats/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
78 changes: 78 additions & 0 deletions lib/dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[][]) {
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<string, unknown>[][]) {
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')
})
})
57 changes: 57 additions & 0 deletions lib/dashboard.ts
Original file line number Diff line number Diff line change
@@ -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<DashboardStats> {
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,
}
}
24 changes: 20 additions & 4 deletions lib/db.ts
Original file line number Diff line number Diff line change
@@ -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<false, false> | null = null

export function getSql(): NeonQueryFunction<false, false> {
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<false, false> = new Proxy({} as NeonQueryFunction<false, false>, {
apply(_target, _thisArg, args) {
return (getSql() as unknown as (...a: unknown[]) => unknown)(...args)
},
get(_target, prop) {
return (getSql() as unknown as Record<string | symbol, unknown>)[prop]
},
})