diff --git a/packages/api/src/__tests__/bounties.test.ts b/packages/api/src/__tests__/bounties.test.ts new file mode 100644 index 0000000..b04c49f --- /dev/null +++ b/packages/api/src/__tests__/bounties.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sign } from 'hono/jwt'; + +const { findManyMock, findUserMock } = vi.hoisted(() => ({ + findManyMock: vi.fn(), + findUserMock: vi.fn(), +})); + +vi.mock('../db', () => ({ + db: { + query: { + users: { + findFirst: findUserMock, + }, + bounties: { + findMany: findManyMock, + }, + }, + }, +})); + +import { createApp } from '../app'; + +const baseBountyRow = { + id: 'bounty-1', + githubIssueId: 13, + repoOwner: 'devasignhq', + repoName: 'mobile-app', + title: 'Build endpoint', + description: 'Implement endpoint.', + amountUsdc: '10.0', + techTags: ['typescript'], + difficulty: 'beginner', + status: 'open', + deadline: null, + creatorId: 'creator-1', + assigneeId: null, + createdAt: new Date('2026-02-23T00:00:00.000Z'), + updatedAt: new Date('2026-02-23T00:00:00.000Z'), +}; + +async function buildAuthHeader(userId: string) { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error('JWT_SECRET must be set for tests'); + } + + const token = await sign( + { + sub: userId, + exp: Math.floor(Date.now() / 1000) + 3600, + }, + secret, + 'HS256', + ); + + return { Authorization: `Bearer ${token}` }; +} + +describe('GET /bounties/recommended', () => { + const app = createApp(); + + beforeEach(() => { + findManyMock.mockReset(); + findUserMock.mockReset(); + }); + + it('returns 401 without bearer token', async () => { + const res = await app.request('/bounties/recommended'); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('Authorization bearer token is required'); + expect(findUserMock).not.toHaveBeenCalled(); + expect(findManyMock).not.toHaveBeenCalled(); + }); + + it('returns 400 when limit is invalid', async () => { + const res = await app.request('/bounties/recommended?limit=0', { + headers: await buildAuthHeader('reco-limit-user'), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('limit must be an integer'); + expect(findUserMock).not.toHaveBeenCalled(); + expect(findManyMock).not.toHaveBeenCalled(); + }); + + it('returns ranked recommendations with relevance scores', async () => { + findUserMock.mockResolvedValue({ + techStack: ['TypeScript', 'Rust'], + }); + findManyMock.mockResolvedValue([ + { + ...baseBountyRow, + id: 'bounty-typescript', + techTags: ['TypeScript', 'Hono'], + createdAt: new Date('2026-02-22T00:00:00.000Z'), + }, + { + ...baseBountyRow, + id: 'bounty-rust', + techTags: ['Rust', 'Wasm'], + createdAt: new Date('2026-02-23T00:00:00.000Z'), + }, + { + ...baseBountyRow, + id: 'bounty-python', + techTags: ['Python'], + createdAt: new Date('2026-02-24T00:00:00.000Z'), + }, + ]); + + const res = await app.request('/bounties/recommended?limit=2', { + headers: await buildAuthHeader('reco-ranked-user'), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.meta.cached).toBe(false); + expect(body.data).toHaveLength(2); + expect(body.data[0].id).toBe('bounty-typescript'); + expect(body.data[1].id).toBe('bounty-rust'); + expect(body.data[0].relevanceScore).toBeGreaterThan(body.data[1].relevanceScore); + expect(body.data.every((row: { relevanceScore: number }) => row.relevanceScore > 0)).toBe(true); + expect(findUserMock).toHaveBeenCalledTimes(1); + expect(findManyMock).toHaveBeenCalledTimes(1); + }); + + it('reuses cached recommendations for repeated requests within ttl', async () => { + findUserMock.mockResolvedValue({ + techStack: ['Go'], + }); + findManyMock.mockResolvedValue([ + { + ...baseBountyRow, + id: 'bounty-go', + techTags: ['Go', 'API'], + }, + ]); + + const headers = await buildAuthHeader('reco-cache-user'); + + const first = await app.request('/bounties/recommended?limit=5', { headers }); + expect(first.status).toBe(200); + const firstBody = await first.json(); + expect(firstBody.meta.cached).toBe(false); + + const second = await app.request('/bounties/recommended?limit=5', { headers }); + expect(second.status).toBe(200); + const secondBody = await second.json(); + expect(secondBody.meta.cached).toBe(true); + + expect(findUserMock).toHaveBeenCalledTimes(1); + expect(findManyMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index c3b84d9..a3aac4c 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import auth from './routes/auth'; +import bounties from './routes/bounties'; /** * Creates and configures the Hono application with all routes and middleware. @@ -33,6 +34,7 @@ export function createApp() { // API Routes app.route('/auth', auth); + app.route('/bounties', bounties); app.get('/health', (c) => { return c.json({ status: 'ok' }); diff --git a/packages/api/src/routes/bounties.ts b/packages/api/src/routes/bounties.ts new file mode 100644 index 0000000..fe7c7fa --- /dev/null +++ b/packages/api/src/routes/bounties.ts @@ -0,0 +1,229 @@ +import { Hono } from 'hono'; +import { verify } from 'hono/jwt'; +import { db } from '../db'; + +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 50; +const RECOMMENDATION_POOL_LIMIT = 200; +const RECOMMENDATION_CACHE_TTL_MS = 15 * 60 * 1000; +const RECOMMENDATION_CACHE_TTL_SECONDS = RECOMMENDATION_CACHE_TTL_MS / 1000; + +type RecommendationCacheEntry = { + expiresAt: number; + generatedAt: string; + data: Array>; +}; + +const recommendationCache = new Map(); + +function parseLimit(raw: string | undefined): number | null { + if (raw === undefined) { + return DEFAULT_LIMIT; + } + + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_LIMIT) { + return null; + } + + return parsed; +} + +function getBearerToken(rawAuthorizationHeader: string | undefined): string | null { + if (!rawAuthorizationHeader) { + return null; + } + + const [scheme, token] = rawAuthorizationHeader.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) { + return null; + } + + return token.trim(); +} + +function normalizeTags(raw: unknown): string[] { + if (!Array.isArray(raw)) { + return []; + } + + const tags = new Set(); + for (const value of raw) { + if (typeof value !== 'string') { + continue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized.length > 0) { + tags.add(normalized); + } + } + + return [...tags]; +} + +function calculateRelevanceScore(userTechStack: string[], bountyTags: string[]): number { + if (userTechStack.length === 0 || bountyTags.length === 0) { + return 0; + } + + const weights = new Map(); + let maxScore = 0; + + for (let i = 0; i < userTechStack.length; i += 1) { + const skill = userTechStack[i]; + const weight = Math.max(1, userTechStack.length - i); + maxScore += weight; + + const existingWeight = weights.get(skill) ?? 0; + if (weight > existingWeight) { + weights.set(skill, weight); + } + } + + if (maxScore === 0) { + return 0; + } + + let score = 0; + for (const tag of bountyTags) { + const exactWeight = weights.get(tag); + if (exactWeight !== undefined) { + score += exactWeight; + continue; + } + + let partialWeight = 0; + for (const [skill, weight] of weights.entries()) { + if (skill.includes(tag) || tag.includes(skill)) { + partialWeight = Math.max(partialWeight, weight * 0.5); + } + } + score += partialWeight; + } + + return Number(Math.min(score / maxScore, 1).toFixed(4)); +} + +const bountiesRoute = new Hono(); + +bountiesRoute.get('/recommended', async (c) => { + const limit = parseLimit(c.req.query('limit')); + if (limit === null) { + return c.json({ error: `limit must be an integer between 1 and ${MAX_LIMIT}` }, 400); + } + + const token = getBearerToken(c.req.header('Authorization')); + if (!token) { + return c.json({ error: 'Authorization bearer token is required' }, 401); + } + + const secret = process.env.JWT_SECRET; + if (!secret) { + console.error('GET /bounties/recommended failed: JWT_SECRET is missing'); + return c.json({ error: 'Internal server configuration error' }, 500); + } + + let userId: string; + try { + const payload = await verify(token, secret, 'HS256'); + if (typeof payload.sub !== 'string' || payload.sub.length === 0) { + return c.json({ error: 'Invalid token payload' }, 401); + } + userId = payload.sub; + } catch { + return c.json({ error: 'Invalid or expired token' }, 401); + } + + const cacheKey = `${userId}:${limit}`; + const now = Date.now(); + const cachedEntry = recommendationCache.get(cacheKey); + + if (cachedEntry && cachedEntry.expiresAt > now) { + return c.json({ + data: cachedEntry.data, + meta: { + cached: true, + generated_at: cachedEntry.generatedAt, + ttl_seconds: RECOMMENDATION_CACHE_TTL_SECONDS, + }, + }); + } + + if (cachedEntry) { + recommendationCache.delete(cacheKey); + } + + try { + const user = await db.query.users.findFirst({ + columns: { + techStack: true, + }, + where: (table, { eq }) => eq(table.id, userId), + }); + + if (!user) { + return c.json({ error: 'User not found' }, 404); + } + + const userTechStack = normalizeTags(user.techStack); + const generatedAt = new Date(now).toISOString(); + + if (userTechStack.length === 0) { + recommendationCache.set(cacheKey, { + expiresAt: now + RECOMMENDATION_CACHE_TTL_MS, + generatedAt, + data: [], + }); + + return c.json({ + data: [], + meta: { + cached: false, + generated_at: generatedAt, + ttl_seconds: RECOMMENDATION_CACHE_TTL_SECONDS, + }, + }); + } + + const rows = await db.query.bounties.findMany({ + where: (table, { eq }) => eq(table.status, 'open'), + orderBy: (table, { desc }) => [desc(table.createdAt), desc(table.id)], + limit: RECOMMENDATION_POOL_LIMIT, + }); + + const recommendations = rows + .map((row) => ({ + ...row, + relevanceScore: calculateRelevanceScore(userTechStack, normalizeTags(row.techTags)), + })) + .filter((row) => row.relevanceScore > 0) + .sort((a, b) => { + if (b.relevanceScore !== a.relevanceScore) { + return b.relevanceScore - a.relevanceScore; + } + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }) + .slice(0, limit); + + recommendationCache.set(cacheKey, { + expiresAt: now + RECOMMENDATION_CACHE_TTL_MS, + generatedAt, + data: recommendations as Array>, + }); + + return c.json({ + data: recommendations, + meta: { + cached: false, + generated_at: generatedAt, + ttl_seconds: RECOMMENDATION_CACHE_TTL_SECONDS, + }, + }); + } catch (error) { + console.error('GET /bounties/recommended failed:', error); + return c.json({ error: 'Failed to fetch recommended bounties' }, 500); + } +}); + +export default bountiesRoute;