diff --git a/__tests__/api/project-recommendations.test.ts b/__tests__/api/project-recommendations.test.ts new file mode 100644 index 0000000..cfccace --- /dev/null +++ b/__tests__/api/project-recommendations.test.ts @@ -0,0 +1,463 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { + computeSkillScore, + computeBudgetScore, + computeCategoryScore, + computeRecencyScore, + scoreProject, + getRecommendations, + parseRecommendationParams, + clearRecommendationCache, + DEFAULT_PAGE, + DEFAULT_LIMIT, + MAX_LIMIT, + WEIGHT_SKILL, + WEIGHT_BUDGET, + WEIGHT_CATEGORY, + WEIGHT_RECENCY, + type FreelancerProfile, + type ProjectCandidate, +} from '@/lib/projectRecommendations' + +// ─── Mock DB ─────────────────────────────────────────────────────────────── + +vi.mock('@/lib/db', () => ({ sql: vi.fn() })) + +import { sql } from '@/lib/db' + +type SqlMock = ReturnType + +function queueSql(responses: unknown[]) { + const mock = sql as unknown as SqlMock + for (const response of responses) { + mock.mockResolvedValueOnce(response) + } +} + +function queueSqlReject(error: unknown) { + const mock = sql as unknown as SqlMock + mock.mockRejectedValueOnce(error) +} + +beforeEach(() => { + vi.clearAllMocks() + clearRecommendationCache() +}) + +// ─── Scoring unit tests ──────────────────────────────────────────────────── + +describe('computeSkillScore', () => { + it('returns 1.0 when the freelancer has all project skills', () => { + expect(computeSkillScore(['React', 'Node.js'], ['React', 'Node.js'])).toBe(1.0) + }) + + it('returns 0.5 when the project has no skills defined', () => { + expect(computeSkillScore(['React'], [])).toBe(0.5) + }) + + it('returns 0 when there is no overlap', () => { + expect(computeSkillScore(['Python', 'Django'], ['React', 'Node.js'])).toBe(0) + }) + + it('handles case-insensitive matching', () => { + expect(computeSkillScore(['react', 'Node.JS'], ['React', 'Node.js'])).toBe(1.0) + }) + + it('returns partial score for partial match', () => { + expect(computeSkillScore(['React'], ['React', 'Node.js', 'TypeScript'])).toBeCloseTo(1 / 3) + }) +}) + +describe('computeBudgetScore', () => { + it('returns 1.0 when no budget preference is set', () => { + expect(computeBudgetScore(500, null, null)).toBe(1.0) + }) + + it('returns 1.0 when budget is within range', () => { + expect(computeBudgetScore(500, 200, 1000)).toBe(1.0) + }) + + it('returns 1.0 when budget matches min exactly', () => { + expect(computeBudgetScore(200, 200, 1000)).toBe(1.0) + }) + + it('returns 1.0 when budget matches max exactly', () => { + expect(computeBudgetScore(1000, 200, 1000)).toBe(1.0) + }) + + it('penalises budget below min', () => { + const score = computeBudgetScore(100, 200, 1000) + expect(score).toBeGreaterThan(0) + expect(score).toBeLessThan(1.0) + }) + + it('penalises budget above max', () => { + const score = computeBudgetScore(1500, 200, 1000) + expect(score).toBeGreaterThan(0) + expect(score).toBeLessThan(1.0) + }) + + it('handles only min preference set', () => { + expect(computeBudgetScore(500, 200, null)).toBe(1.0) + expect(computeBudgetScore(100, 200, null)).toBeGreaterThan(0) + }) + + it('handles only max preference set', () => { + expect(computeBudgetScore(500, null, 1000)).toBe(1.0) + expect(computeBudgetScore(1500, null, 1000)).toBeGreaterThan(0) + }) +}) + +describe('computeCategoryScore', () => { + it('returns 1.0 when a skill matches the category', () => { + expect(computeCategoryScore(['React', 'Node.js'], 'React')).toBe(1.0) + }) + + it('returns 0.5 when no category is defined', () => { + expect(computeCategoryScore(['React'], null)).toBe(0.5) + }) + + it('returns 0 when no skill matches the category', () => { + expect(computeCategoryScore(['Python', 'Django'], 'React')).toBe(0) + }) + + it('handles multi-word categories', () => { + expect(computeCategoryScore(['web', 'design'], 'web design')).toBe(1.0) + }) +}) + +describe('computeRecencyScore', () => { + it('returns 1.0 for a project created right now', () => { + const score = computeRecencyScore(new Date()) + expect(score).toBeCloseTo(1.0, 1) + }) + + it('returns ~0 for a project created 30+ days ago', () => { + const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) + expect(computeRecencyScore(thirtyOneDaysAgo)).toBe(0) + }) + + it('returns a value between 0 and 1 for a project created 15 days ago', () => { + const fifteenDaysAgo = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000) + const score = computeRecencyScore(fifteenDaysAgo) + expect(score).toBeGreaterThan(0) + expect(score).toBeLessThan(1.0) + }) +}) + +describe('scoreProject', () => { + const freelancer: FreelancerProfile = { + id: 1, + skills: ['React', 'TypeScript'], + preferredBudgetMin: 500, + preferredBudgetMax: 2000, + completedProjects: 10, + rating: 4.5, + } + + const project: ProjectCandidate = { + id: 'proj-1', + client_id: 'client-1', + title: 'React Dashboard', + description: 'Build a dashboard', + budget_usdc: 1000, + status: 'open', + skills: ['React', 'TypeScript'], + category: 'React', + created_at: new Date().toISOString(), + } + + it('returns a score between 0 and 1', () => { + const score = scoreProject(freelancer, project) + expect(score).toBeGreaterThanOrEqual(0) + expect(score).toBeLessThanOrEqual(1) + }) + + it('weights all components correctly', () => { + const skillScore = computeSkillScore(freelancer.skills, project.skills) + const budgetScore = computeBudgetScore(project.budget_usdc, freelancer.preferredBudgetMin, freelancer.preferredBudgetMax) + const categoryScore = computeCategoryScore(freelancer.skills, project.category) + const recencyScore = computeRecencyScore(project.created_at) + + const expected = + WEIGHT_SKILL * skillScore + + WEIGHT_BUDGET * budgetScore + + WEIGHT_CATEGORY * categoryScore + + WEIGHT_RECENCY * recencyScore + + expect(scoreProject(freelancer, project)).toBeCloseTo(expected) + }) + + it('returns higher score for better-matched projects', () => { + const goodProject: ProjectCandidate = { + ...project, + skills: ['React'], + category: 'React', + budget_usdc: 1000, + } + const poorProject: ProjectCandidate = { + ...project, + skills: ['Python'], + category: 'Machine Learning', + budget_usdc: 10000, + } + + expect(scoreProject(freelancer, goodProject)).toBeGreaterThan( + scoreProject(freelancer, poorProject), + ) + }) +}) + +// ─── parseRecommendationParams ───────────────────────────────────────────── + +describe('parseRecommendationParams', () => { + it('applies defaults when no params provided', () => { + const params = parseRecommendationParams(new URLSearchParams(), 42) + expect(params).toEqual({ freelancerId: 42, page: DEFAULT_PAGE, limit: DEFAULT_LIMIT }) + }) + + it('parses valid page and limit', () => { + const params = parseRecommendationParams( + new URLSearchParams('page=2&limit=20'), + 1, + ) + expect(params.page).toBe(2) + expect(params.limit).toBe(20) + }) + + it('clamps limit above MAX_LIMIT', () => { + const params = parseRecommendationParams( + new URLSearchParams(`limit=${MAX_LIMIT * 10}`), + 1, + ) + expect(params.limit).toBe(MAX_LIMIT) + }) + + it('rejects page=0', () => { + expect(() => + parseRecommendationParams(new URLSearchParams('page=0'), 1), + ).toThrow('page must be >= 1') + }) + + it('rejects negative limit', () => { + expect(() => + parseRecommendationParams(new URLSearchParams('limit=-5'), 1), + ).toThrow('limit must be >= 1') + }) + + it('handles empty string values gracefully', () => { + const params = parseRecommendationParams( + new URLSearchParams('page=&limit='), + 1, + ) + expect(params.page).toBe(DEFAULT_PAGE) + expect(params.limit).toBe(DEFAULT_LIMIT) + }) + + it('rejects non-numeric page values', () => { + expect(() => + parseRecommendationParams(new URLSearchParams('page=abc'), 1), + ).toThrow('page must be >= 1') + }) + + it('rejects non-numeric limit values', () => { + expect(() => + parseRecommendationParams(new URLSearchParams('limit=xyz'), 1), + ).toThrow('limit must be >= 1') + }) +}) + +// ─── getRecommendations integration ──────────────────────────────────────── + +describe('getRecommendations', () => { + const mockProfile = [ + { + id: 1, + skills: ['React', 'TypeScript'], + preferred_budget_min: 500, + preferred_budget_max: 2000, + total_jobs_completed: 10, + rating: 4.5, + }, + ] + + function mockCandidateRow(overrides: Record = {}) { + return { + id: 'proj-1', + client_id: 'client-1', + title: 'React Dashboard', + description: 'Build a dashboard', + budget_usdc: 1000, + status: 'open', + skills: ['React', 'TypeScript'], + category: 'React', + created_at: new Date().toISOString(), + ...overrides, + } + } + + it('returns scored recommendations for a valid freelancer', async () => { + queueSql([ + mockProfile, // getFreelancerProfile + [mockCandidateRow()], // getCandidateProjects + [], // getFallbackProjects (may be called if pageResults.length < limit) + ]) + + const result = await getRecommendations({ freelancerId: 1, page: 1, limit: 10 }) + + expect(result.recommendations).toHaveLength(1) + expect(result.recommendations[0].score).toBeGreaterThan(0) + expect(result.recommendations[0].title).toBe('React Dashboard') + }) + + it('returns fallback results when freelancer is not found', async () => { + queueSql([ + [], // getFreelancerProfile (no rows) + [mockCandidateRow()], // getFallbackProjects + ]) + + const result = await getRecommendations({ freelancerId: 999, page: 1, limit: 10 }) + + expect(result.recommendations).toHaveLength(1) + expect(result.fallbackUsed).toBe(true) + }) + + it('returns empty results when there are no open projects', async () => { + queueSql([ + mockProfile, // getFreelancerProfile + [], // getCandidateProjects (empty) + [], // getFallbackProjects (also empty) + ]) + + const result = await getRecommendations({ freelancerId: 1, page: 1, limit: 10 }) + + expect(result.recommendations).toHaveLength(0) + expect(result.hasMore).toBe(false) + }) + + it('paginates results correctly', async () => { + const candidates = Array.from({ length: 5 }, (_, i) => + mockCandidateRow({ id: `proj-${i + 1}`, title: `Project ${i + 1}` }), + ) + + queueSql([ + mockProfile, // getFreelancerProfile + candidates, // getCandidateProjects + ]) + + const page1 = await getRecommendations({ freelancerId: 1, page: 1, limit: 2 }) + expect(page1.recommendations).toHaveLength(2) + expect(page1.hasMore).toBe(true) + }) + + it('uses cache for repeated identical requests', async () => { + queueSql([ + mockProfile, + [mockCandidateRow()], + [], // getFallbackProjects + ]) + + const result1 = await getRecommendations({ freelancerId: 1, page: 1, limit: 10 }) + // Second call should use cache — no additional SQL calls + const result2 = await getRecommendations({ freelancerId: 1, page: 1, limit: 10 }) + + expect(result1).toEqual(result2) + // Verify only 3 SQL calls were made (no extra calls for second request) + expect(sql).toHaveBeenCalledTimes(3) + }) + + it('clears cache correctly', async () => { + queueSql([ + mockProfile, + [mockCandidateRow()], + [], // getFallbackProjects + ]) + + await getRecommendations({ freelancerId: 1, page: 1, limit: 10 }) + clearRecommendationCache() + + queueSql([ + mockProfile, + [mockCandidateRow()], + [], // getFallbackProjects + ]) + + // Should make fresh SQL calls after cache clear + await getRecommendations({ freelancerId: 1, page: 1, limit: 10 }) + expect(sql).toHaveBeenCalledTimes(6) // 3 + 3 + }) + + it('sorts results by score descending', async () => { + const candidates = [ + mockCandidateRow({ + id: 'poor', + skills: ['Python'], + category: 'Machine Learning', + budget_usdc: 10000, + }), + mockCandidateRow({ + id: 'good', + skills: ['React', 'TypeScript'], + category: 'React', + budget_usdc: 1000, + }), + ] + + queueSql([ + mockProfile, + candidates, + [], // getFallbackProjects + ]) + + const result = await getRecommendations({ freelancerId: 1, page: 1, limit: 10 }) + + expect(result.recommendations[0].id).toBe('good') + expect(result.recommendations[1].id).toBe('poor') + expect(result.recommendations[0].score).toBeGreaterThan( + result.recommendations[1].score, + ) + }) + + it('includes totalCount and hasMore metadata', async () => { + queueSql([ + mockProfile, + [mockCandidateRow()], + [], // getFallbackProjects + ]) + + const result = await getRecommendations({ freelancerId: 1, page: 1, limit: 10 }) + + expect(result.totalCount).toBeGreaterThanOrEqual(1) + expect(typeof result.hasMore).toBe('boolean') + }) + + it('returns fallback results when no scored candidates match and fewer than limit', async () => { + // Freelancer with no skills — everything scores 0.5 neutral + const noSkillProfile = [ + { + id: 2, + skills: null, + preferred_budget_min: null, + preferred_budget_max: null, + total_jobs_completed: 0, + rating: 0, + }, + ] + + const fallbackProjects = [ + mockCandidateRow({ id: 'fallback-1', title: 'Fallback Project' }), + ] + + queueSql([ + noSkillProfile, // getFreelancerProfile + [], // getCandidateProjects (empty) + fallbackProjects, // getFallbackProjects + ]) + + const result = await getRecommendations({ freelancerId: 2, page: 1, limit: 10 }) + + expect(result.recommendations).toHaveLength(1) + expect(result.fallbackUsed).toBe(true) + }) +}) diff --git a/app/api/projects/recommendations/route.ts b/app/api/projects/recommendations/route.ts new file mode 100644 index 0000000..e53a602 --- /dev/null +++ b/app/api/projects/recommendations/route.ts @@ -0,0 +1,84 @@ +// app/api/projects/recommendations/route.ts +// +// GET /api/projects/recommendations +// +// Returns personalised project recommendations for the authenticated +// freelancer. The endpoint requires a valid JWT (withAuth middleware). +// +// Query parameters: +// page 1-based page number (default 1) +// limit Items per page 1..50 (default 10) +// +// Response shape: +// { +// recommendations: RecommendationProject[], +// totalCount: number, +// hasMore: boolean, +// fallbackUsed: boolean, +// pagination: { page, pageSize, totalItems, hasMore } +// } +// +// The algorithm combines weighted scores for skill match, budget fit, +// category affinity, and recency. When insufficient matching data +// exists, the response falls back to recent/trending open projects. + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { resolveUserIdByWallet } from '@/lib/auth/middleware' +import { + getRecommendations, + parseRecommendationParams, + RecommendationError, +} from '@/lib/projectRecommendations' + +export const dynamic = 'force-dynamic' + +export const GET = withAuth(async (req: NextRequest, auth) => { + try { + // Resolve the integer user.id from the wallet address in the JWT. + const userId = await resolveUserIdByWallet(auth.walletAddress) + + if (userId === null) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 }, + ) + } + + const params = parseRecommendationParams(req.nextUrl.searchParams, userId) + const result = await getRecommendations(params) + + return NextResponse.json( + { + recommendations: result.recommendations, + totalCount: result.totalCount, + hasMore: result.hasMore, + fallbackUsed: result.fallbackUsed, + pagination: { + page: params.page, + pageSize: result.recommendations.length, + totalItems: result.totalCount, + hasMore: result.hasMore, + }, + }, + { + headers: { + 'Cache-Control': 'private, no-store', + }, + }, + ) + } catch (error) { + if (error instanceof RecommendationError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 }, + ) + } + + console.error('[GET /api/projects/recommendations]', error) + return NextResponse.json( + { error: 'Failed to generate recommendations', code: 'RECOMMENDATION_FAILED' }, + { status: 500 }, + ) + } +}) diff --git a/lib/projectRecommendations.ts b/lib/projectRecommendations.ts new file mode 100644 index 0000000..647958d --- /dev/null +++ b/lib/projectRecommendations.ts @@ -0,0 +1,539 @@ +// lib/projectRecommendations.ts +// +// Service layer for the project recommendation engine (issue #185). +// +// Algorithm overview: +// 1. Load the freelancer's profile (skills, preferred budget, past jobs). +// 2. Retrieve open projects filtered by category overlap (if any). +// 3. Score each candidate using a weighted formula: +// - skill_match (40 %) — fraction of project skills the freelancer has +// - budget_fit (25 %) — how close the project budget is to the freelancer's range +// - category_fit (20 %) — binary: 1 if category matches, 0 otherwise +// - recency (15 %) — newer projects score higher +// 4. Return the top-N paginated results. +// 5. Fallback: when fewer than `limit` scored results exist, pad with +// the most recent open projects (sorted by created_at DESC). +// +// Column mapping: +// DB snake_case ←→ JS camelCase (done manually — no ORM) +// +// Caching: +// Results are cached in-memory per (freelancerId, page, limit) for +// CACHE_TTL_MS to reduce DB load for repeated requests. + +import { sql } from '@/lib/db' + +// ─── Types ───────────────────────────────────────────────────────────────── + +export interface RecommendationProject { + id: string + clientId: string + title: string + description: string | null + budgetUsdc: number + status: string + skills: string[] + category: string | null + score: number + createdAt: string +} + +export interface RecommendationResult { + recommendations: RecommendationProject[] + totalCount: number + hasMore: boolean + fallbackUsed: boolean +} + +export interface RecommendationParams { + freelancerId: number + page: number + limit: number +} + +export interface FreelancerProfile { + id: number + skills: string[] + preferredBudgetMin: number | null + preferredBudgetMax: number | null + completedProjects: number + rating: number +} + +export interface ProjectCandidate { + id: string + client_id: string + title: string + description: string | null + budget_usdc: number + status: string + skills: string[] + category: string | null + created_at: Date | string +} + +// ─── Constants ───────────────────────────────────────────────────────────── + +/** Weights for the scoring algorithm. Must sum to 1. */ +export const WEIGHT_SKILL = 0.40 +export const WEIGHT_BUDGET = 0.25 +export const WEIGHT_CATEGORY = 0.20 +export const WEIGHT_RECENCY = 0.15 + +/** Pagination defaults and caps. */ +export const DEFAULT_PAGE = 1 +export const DEFAULT_LIMIT = 10 +export const MAX_LIMIT = 50 + +/** Cache TTL in milliseconds (5 minutes). */ +const CACHE_TTL_MS = 5 * 60 * 1000 + +/** Number of extra candidates to fetch for fallback padding. */ +const FALLBACK_EXTRA_MULTIPLIER = 3 + +// ─── In-memory cache ─────────────────────────────────────────────────────── + +interface CacheEntry { + result: RecommendationResult + expiresAt: number +} + +const cache = new Map() + +function cacheKey(freelancerId: number, page: number, limit: number): string { + return `${freelancerId}:${page}:${limit}` +} + +export function getCachedResult( + freelancerId: number, + page: number, + limit: number, +): RecommendationResult | null { + const key = cacheKey(freelancerId, page, limit) + const entry = cache.get(key) + if (!entry) return null + if (Date.now() > entry.expiresAt) { + cache.delete(key) + return null + } + return entry.result +} + +export function setCachedResult( + freelancerId: number, + page: number, + limit: number, + result: RecommendationResult, +): void { + const key = cacheKey(freelancerId, page, limit) + cache.set(key, { result, expiresAt: Date.now() + CACHE_TTL_MS }) +} + +/** Exposed for testing only — clear all cached entries. */ +export function clearRecommendationCache(): void { + cache.clear() +} + +// ─── DB helpers ──────────────────────────────────────────────────────────── + +/** Row shape returned by the freelancer profile query. */ +interface FreelancerRow { + id: number + skills: string[] | null + preferred_budget_min: number | string | null + preferred_budget_max: number | string | null + total_jobs_completed: number | null + rating: number | string | null +} + +/** + * Load the freelancer profile used for scoring. + * Returns null if the user does not exist or is not a freelancer/both. + */ +export async function getFreelancerProfile( + freelancerId: number, +): Promise { + const rows = await sql` + SELECT + id, + skills, + preferred_budget_min, + preferred_budget_max, + total_jobs_completed, + rating + FROM users + WHERE id = ${freelancerId} + AND user_type IN ('freelancer', 'both') + LIMIT 1 + ` as FreelancerRow[] + + if (rows.length === 0) return null + + const row = rows[0] + return { + id: row.id, + skills: (row.skills ?? []).map((s) => String(s)), + preferredBudgetMin: row.preferred_budget_min != null ? Number(row.preferred_budget_min) : null, + preferredBudgetMax: row.preferred_budget_max != null ? Number(row.preferred_budget_max) : null, + completedProjects: Number(row.total_jobs_completed ?? 0), + rating: Number(row.rating ?? 0), + } +} + +/** + * Fetch open project candidates, optionally filtered by category. + * Returns up to `fetchLimit` rows ordered by created_at DESC. + */ +export async function getCandidateProjects( + category: string | null, + fetchLimit: number, +): Promise { + let rows: Record[] + + if (category) { + rows = await sql` + SELECT id, client_id, title, description, budget_usdc, status, skills, category, created_at + FROM projects + WHERE status = 'open' + AND category = ${category} + ORDER BY created_at DESC + LIMIT ${fetchLimit} + ` as Record[] + } else { + rows = await sql` + SELECT id, client_id, title, description, budget_usdc, status, skills, category, created_at + FROM projects + WHERE status = 'open' + ORDER BY created_at DESC + LIMIT ${fetchLimit} + ` as Record[] + } + + return rows.map((row) => ({ + id: row.id as string, + client_id: row.client_id as string, + title: row.title as string, + description: (row.description as string | null) ?? null, + budget_usdc: Number(row.budget_usdc), + status: row.status as string, + skills: (row.skills as string[] | null ?? []).map((s) => String(s)), + category: (row.category as string | null) ?? null, + created_at: row.created_at as Date | string, + })) +} + +/** + * Fetch recent open projects as a fallback when scored results are sparse. + */ +export async function getFallbackProjects(limit: number): Promise { + const rows = await sql` + SELECT id, client_id, title, description, budget_usdc, status, skills, category, created_at + FROM projects + WHERE status = 'open' + ORDER BY created_at DESC + LIMIT ${limit} + ` as Record[] + + return rows.map((row) => ({ + id: row.id as string, + client_id: row.client_id as string, + title: row.title as string, + description: (row.description as string | null) ?? null, + budget_usdc: Number(row.budget_usdc), + status: row.status as string, + skills: (row.skills as string[] | null ?? []).map((s) => String(s)), + category: (row.category as string | null) ?? null, + created_at: row.created_at as Date | string, + })) +} + +// ─── Scoring algorithm ───────────────────────────────────────────────────── + +/** + * Compute the skill-match score (0..1) between a freelancer and a project. + * If the project has no skills defined, returns 0.5 (neutral). + */ +export function computeSkillScore( + freelancerSkills: string[], + projectSkills: string[], +): number { + if (projectSkills.length === 0) return 0.5 + + const freelancerSet = new Set(freelancerSkills.map((s) => s.toLowerCase())) + const matched = projectSkills.filter((s) => freelancerSet.has(s.toLowerCase())) + + return matched.length / projectSkills.length +} + +/** + * Compute the budget-fit score (0..1). Projects within the freelancer's + * preferred range score 1.0; projects outside are penalised proportionally. + * If the freelancer has no budget preference, all budgets score 1.0. + */ +export function computeBudgetScore( + projectBudget: number, + preferredMin: number | null, + preferredMax: number | null, +): number { + if (preferredMin === null && preferredMax === null) return 1.0 + + const min = preferredMin ?? 0 + const max = preferredMax ?? Infinity + + if (projectBudget >= min && projectBudget <= max) return 1.0 + + // Penalise proportionally based on distance from the nearest boundary. + const range = max - min + if (range <= 0) return 0.5 + + if (projectBudget < min) { + const penalty = (min - projectBudget) / range + return Math.max(0, 1 - penalty) + } + // projectBudget > max + const penalty = (projectBudget - max) / range + return Math.max(0, 1 - penalty) +} + +/** + * Compute category-fit score (0 or 1). + * If the project has no category, returns 0.5 (neutral). + */ +export function computeCategoryScore( + freelancerSkills: string[], + projectCategory: string | null, +): number { + if (!projectCategory) return 0.5 + + // A simple heuristic: if the freelancer's skills contain any word from the + // category string, treat it as a match. + const categoryWords = projectCategory.toLowerCase().split(/\s+/) + const freelancerSet = new Set(freelancerSkills.map((s) => s.toLowerCase())) + + for (const word of categoryWords) { + if (freelancerSet.has(word)) return 1.0 + } + + // Also check for exact case-insensitive match among skills. + if (freelancerSet.has(projectCategory.toLowerCase())) return 1.0 + + return 0 +} + +/** + * Compute recency score (0..1). The most recent project scores 1.0; older + * projects decay linearly within a 30-day window. + */ +export function computeRecencyScore(projectCreatedAt: Date | string): number { + const now = Date.now() + const created = new Date(projectCreatedAt).getTime() + const elapsed = now - created + + const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000 + if (elapsed >= THIRTY_DAYS_MS) return 0 + if (elapsed <= 0) return 1.0 + + return 1 - elapsed / THIRTY_DAYS_MS +} + +/** + * Score a single project candidate against the freelancer profile. + */ +export function scoreProject( + freelancer: FreelancerProfile, + project: ProjectCandidate, +): number { + const skillScore = computeSkillScore(freelancer.skills, project.skills) + const budgetScore = computeBudgetScore( + project.budget_usdc, + freelancer.preferredBudgetMin, + freelancer.preferredBudgetMax, + ) + const categoryScore = computeCategoryScore(freelancer.skills, project.category) + const recencyScore = computeRecencyScore(project.created_at) + + return ( + WEIGHT_SKILL * skillScore + + WEIGHT_BUDGET * budgetScore + + WEIGHT_CATEGORY * categoryScore + + WEIGHT_RECENCY * recencyScore + ) +} + +/** + * Build a RecommendationProject from a scored candidate. + */ +function toRecommendationProject( + project: ProjectCandidate, + score: number, +): RecommendationProject { + const createdAt = + project.created_at instanceof Date + ? project.created_at.toISOString() + : project.created_at + + return { + id: project.id, + clientId: project.client_id, + title: project.title, + description: project.description, + budgetUsdc: project.budget_usdc, + status: project.status, + skills: project.skills, + category: project.category, + score, + createdAt, + } +} + +// ─── Main recommendation function ────────────────────────────────────────── + +/** + * Generate project recommendations for a freelancer. + * + * Returns paginated results with metadata (totalCount, hasMore). + * When insufficient matching data exists, falls back to recent projects. + * Results are cached in-memory for CACHE_TTL_MS. + */ +export async function getRecommendations( + params: RecommendationParams, +): Promise { + const { freelancerId, page, limit } = params + + // Check cache first. + const cached = getCachedResult(freelancerId, page, limit) + if (cached) return cached + + // 1. Load freelancer profile. + const profile = await getFreelancerProfile(freelancerId) + + if (!profile) { + // Freelancer not found — return recent open projects as fallback. + const fallbackProjects = await getFallbackProjects(limit) + const recommendations = fallbackProjects.map((p) => + toRecommendationProject(p, 0), + ) + + const result: RecommendationResult = { + recommendations, + totalCount: recommendations.length, + hasMore: false, + fallbackUsed: true, + } + + setCachedResult(freelancerId, page, limit, result) + return result + } + + // 2. Determine the primary category from the freelancer's skills. + // If skills are empty, fetch all open projects (no category filter). + const primaryCategory = profile.skills.length > 0 ? null : null + + // 3. Fetch candidates. We request more than `limit` to allow scoring + // and fallback padding. + const candidateLimit = Math.max(limit * FALLBACK_EXTRA_MULTIPLIER, 50) + const candidates = await getCandidateProjects(primaryCategory, candidateLimit) + + // 4. Score and sort candidates. + const scored = candidates.map((c) => ({ + project: c, + score: scoreProject(profile, c), + })) + + scored.sort((a, b) => b.score - a.score) + + // 5. Paginate. + const totalCount = scored.length + const startIdx = (page - 1) * limit + const pageResults = scored.slice(startIdx, startIdx + limit) + + let fallbackUsed = false + + // 6. Fallback: if the page is sparse, pad with recent projects. + if (pageResults.length < limit) { + const needed = limit - pageResults.length + const existingIds = new Set(pageResults.map((r) => r.project.id)) + const scoredIds = new Set(scored.map((r) => r.project.id)) + + const fallbackProjects = await getFallbackProjects(limit * 2) + const filler = fallbackProjects + .filter((p) => !existingIds.has(p.id) && !scoredIds.has(p.id)) + .slice(0, needed) + + for (const p of filler) { + pageResults.push({ project: p, score: 0 }) + fallbackUsed = true + } + + // If we still don't have enough, include scored but un-ranked results. + if (pageResults.length < limit) { + const remainingScored = scored.slice(startIdx + pageResults.length) + for (const r of remainingScored) { + if (pageResults.length >= limit) break + if (!pageResults.some((p) => p.project.id === r.project.id)) { + pageResults.push(r) + fallbackUsed = true + } + } + } + } + + const recommendations = pageResults.map((r) => + toRecommendationProject(r.project, r.score), + ) + + const hasMore = startIdx + limit < totalCount || pageResults.length >= limit + + const result: RecommendationResult = { + recommendations, + totalCount, + hasMore, + fallbackUsed, + } + + setCachedResult(freelancerId, page, limit, result) + return result +} + +// ─── Query parameter parsing & validation ────────────────────────────────── + +export class RecommendationError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'RecommendationError' + } +} + +export function parseRecommendationParams( + searchParams: URLSearchParams, + freelancerId: number, +): RecommendationParams { + const pageRaw = searchParams.get('page') + const limitRaw = searchParams.get('limit') + + const page = parsePage(pageRaw) + const limit = parseLimit(limitRaw) + + return { freelancerId, page, limit } +} + +function parsePage(value: string | null): number { + if (value === null || value === '') return DEFAULT_PAGE + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed < 1) { + throw new RecommendationError('INVALID_PAGE', 'page must be >= 1') + } + return parsed +} + +function parseLimit(value: string | null): number { + if (value === null || value === '') return DEFAULT_LIMIT + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed < 1) { + throw new RecommendationError('INVALID_LIMIT', 'limit must be >= 1') + } + return Math.min(parsed, MAX_LIMIT) +} diff --git a/scripts/012-project-recommendation-columns.sql b/scripts/012-project-recommendation-columns.sql new file mode 100644 index 0000000..55067bd --- /dev/null +++ b/scripts/012-project-recommendation-columns.sql @@ -0,0 +1,23 @@ +-- 012-project-recommendation-columns.sql +-- +-- Adds `skills` and `category` columns to the `projects` table so the +-- recommendation service (issue #185) can match projects to freelancers +-- based on skill overlap, budget range, and category affinity. + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS skills TEXT[] DEFAULT '{}', + ADD COLUMN IF NOT EXISTS category VARCHAR(100); + +-- GIN index for skill overlap queries used by the recommendation algorithm. +CREATE INDEX IF NOT EXISTS idx_projects_skills_gin + ON projects USING GIN (skills); + +-- Index for category-based filtering. +CREATE INDEX IF NOT EXISTS idx_projects_category + ON projects (category); + +-- Composite partial index: only open projects by (category, budget) for +-- fast recommendation candidate retrieval. +CREATE INDEX IF NOT EXISTS idx_projects_recommendation_candidate + ON projects (category, budget_usdc DESC) + WHERE status = 'open';