From a239ce2d83a45b61549e8222dda605936753fcb7 Mon Sep 17 00:00:00 2001 From: Iyanu Majekodunmi Date: Sat, 27 Jun 2026 09:17:25 +0000 Subject: [PATCH] feat(freelancer-discovery): DB-backed API for issue #121 Implements the Freelancer Discovery API described in Lumina-eX/TaskChain#121: GET /api/freelancers ?q=&skills=react,nodejs&minRating=4&page=2&limit=20 &sort=rating&order=desc GET /api/freelancers/ Highlights: * lib/freelancerDiscovery.ts encapsulates the SQL builder, pagination, sort/filter validation, and row -> API shape mapping. * Single-template WHERE fragment with `>` lets the query issue exactly one DB round-trip per request, with every value bound as a Postgres parameter (no runtime SQL interpolation of the sort column or column name). * ORDER BY is a fully-static switch on (sort, order) with literal ASC/DESC inlined per branch. * Backward compatible with the existing /freelancers page: legacy ?rating= alias accepted, response shape unchanged, ?skills accepts both repeating and comma-separated values. * Structured error responses ({error, code}) for invalid query params and downstream failures (400 / 503). * GET /api/freelancers/[id] returns { freelancer, reputation } with reputation loaded best-effort; null on lookup failure so the page still renders. * scripts/010-freelancer-discovery-indexes.sql adds a GIN index on users.skills (skill-overlap + ILIKE search), a btree on users.rating DESC, and a partial index on user_type IN (freelancer, both). * 22 unit tests covering parse, validation, list/detail happy paths, pagination, fallback COUNT, validation errors, and 503 on DB error. --- __tests__/api/freelancers.test.ts | 382 +++++++++++++++ app/api/freelancers/[id]/route.ts | 69 +++ app/api/freelancers/route.ts | 101 ++-- lib/freelancerDiscovery.ts | 485 +++++++++++++++++++ scripts/010-freelancer-discovery-indexes.sql | 28 ++ scripts/README.md | 2 + 6 files changed, 1011 insertions(+), 56 deletions(-) create mode 100644 __tests__/api/freelancers.test.ts create mode 100644 app/api/freelancers/[id]/route.ts create mode 100644 lib/freelancerDiscovery.ts create mode 100644 scripts/010-freelancer-discovery-indexes.sql diff --git a/__tests__/api/freelancers.test.ts b/__tests__/api/freelancers.test.ts new file mode 100644 index 0000000..1aac467 --- /dev/null +++ b/__tests__/api/freelancers.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { GET as listFreelancers } from '@/app/api/freelancers/route' +import { GET as getFreelancer } from '@/app/api/freelancers/[id]/route' + +vi.mock('@/lib/db', () => ({ + sql: vi.fn(), +})) + +vi.mock('@/lib/reputation', () => ({ + userExists: vi.fn().mockResolvedValue(true), + getFreelancerReputation: vi.fn().mockResolvedValue({ + userId: 1, + metrics: { + completionRate: 1, + disputeRate: 0, + totalVolume: 0, + onTimeDeliveryPct: 1, + jobsStarted: 3, + jobsCompleted: 3, + jobsWithDispute: 0, + completedWithDeadline: 3, + onTimeDeliveries: 3, + }, + reputationScore: 100, + computedAt: '2026-06-27T00:00:00.000Z', + }), +})) + +import { sql } from '@/lib/db' +import { + mapUserRowToListing, + parseDiscoveryParams, + FreelancerDiscoveryError, + FREELANCER_SORTABLE_FIELDS, + FREELANCER_MAX_LIMIT, +} from '@/lib/freelancerDiscovery' +import { NextRequest } from 'next/server' + +type SqlMock = ReturnType + +interface UserRowOverrides { + id?: number + wallet_address?: string + username?: string + email?: string | null + user_type?: string + bio?: string | null + skills?: string[] | null + rating?: number | string | null + total_jobs_completed?: number | null + total_count?: string + created_at?: Date +} + +function buildUserRow( + overrides: UserRowOverrides = {}, +): Array> { + return [ + { + id: 1, + wallet_address: 'GABC123', + username: 'maya_chen', + email: 'maya@example.com', + user_type: 'freelancer', + bio: 'Builds polished dApps', + skills: ['React', 'Next.js'], + rating: 4.5, + total_jobs_completed: 12, + created_at: new Date('2026-01-01T00:00:00Z'), + total_count: '7', + ...overrides, + }, + ] +} + +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) +} + +function makeRequest(url: string): NextRequest { + return new NextRequest(new Request(url)) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('parseDiscoveryParams', () => { + it('applies defaults when no params are provided', () => { + const params = parseDiscoveryParams(new URLSearchParams()) + expect(params).toEqual({ + query: '', + skills: [], + minRating: null, + sort: 'rating', + order: 'desc', + limit: 6, + page: 1, + }) + }) + + it('dedupes skills across repeating and comma-separated values', () => { + const params = parseDiscoveryParams( + new URLSearchParams('skills=react&skills=react,nodejs&skills=next.js'), + ) + expect(params.skills).toEqual(['react', 'nodejs', 'next.js']) + }) + + it('accepts the legacy `rating=` alias for minRating', () => { + const params = parseDiscoveryParams(new URLSearchParams('rating=4')) + expect(params.minRating).toBe(4) + }) + + it('rejects out-of-range minRating', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('minRating=6'))) + .toThrowError(FreelancerDiscoveryError) + expect(() => parseDiscoveryParams(new URLSearchParams('minRating=0'))) + .toThrowError(FreelancerDiscoveryError) + }) + + it('rejects unknown sort fields', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('sort=password'))) + .toThrowError(FreelancerDiscoveryError) + }) + + it('accepts whitelisted sort fields', () => { + for (const field of FREELANCER_SORTABLE_FIELDS) { + const params = parseDiscoveryParams(new URLSearchParams(`sort=${field}`)) + expect(params.sort).toBe(field) + } + }) + + it('rejects bogus order values', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('order=ascending'))) + .toThrowError(FreelancerDiscoveryError) + }) + + it('clamps limit above the maximum', () => { + const huge = FREELANCER_MAX_LIMIT * 10 + const params = parseDiscoveryParams(new URLSearchParams(`limit=${huge}`)) + expect(params.limit).toBe(FREELANCER_MAX_LIMIT) + }) + + it('rejects zero or negative page', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('page=0'))) + .toThrowError(FreelancerDiscoveryError) + expect(() => parseDiscoveryParams(new URLSearchParams('page=-1'))) + .toThrowError(FreelancerDiscoveryError) + }) + + it('normalises unsupported limit values to default', () => { + const params = parseDiscoveryParams(new URLSearchParams('limit=abc')) + expect(params.limit).toBe(6) + }) + + it('trims the search query', () => { + const params = parseDiscoveryParams(new URLSearchParams('q=%20%20hello%20%20')) + expect(params.query).toBe('hello') + }) +}) + +describe('mapUserRowToListing', () => { + it('falls back to placeholder values for missing DB fields', () => { + const listing = mapUserRowToListing({ + id: 9, + wallet_address: 'GXYZ', + username: 'aisha', + email: null, + user_type: 'freelancer', + bio: null, + skills: null, + rating: null, + total_jobs_completed: null, + created_at: new Date('2026-05-01T00:00:00Z'), + }) + + expect(listing).toMatchObject({ + id: 9, + name: 'aisha', + bio: '', + skills: [], + rating: 0, + completedProjects: 0, + profileImage: '/placeholder-user.jpg', + hourlyRate: 0, + location: '', + title: 'TaskChain Freelancer', + }) + }) + + it('clamps out-of-range numeric ratings', () => { + const listing = mapUserRowToListing({ + id: 1, + wallet_address: 'GABC', + username: 'sample', + email: null, + user_type: 'freelancer', + bio: null, + skills: [], + rating: 99, + total_jobs_completed: 0, + created_at: new Date(), + }) + expect(listing.rating).toBe(0) + }) +}) + +describe('GET /api/freelancers', () => { + it('returns the discovery payload with proper pagination metadata', async () => { + // Path: WHERE fragment → ORDER BY fragment → main list → skills query. + // With the single-template WHERE/ORDER BY refactor, exactly 4 sql calls: + // 1. WHERE fragment in listFreelancers + // 2. ORDER BY fragment in listFreelancers + // 3. main list query + // 4. getAvailableSkills + queueSql([ + [], // WHERE fragment + [], // ORDER BY fragment + buildUserRow(), // main list (returns 1 row, so no fallback COUNT) + [{ skill: 'React' }, { skill: 'Node.js' }], // skills + ]) + + const request = makeRequest( + 'http://localhost/api/freelancers?q=maya&minRating=4&page=1&limit=2', + ) + const response = await listFreelancers(request) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.freelancers).toHaveLength(1) + expect(body.freelancers[0].name).toBe('maya_chen') + expect(body.pagination).toEqual({ + page: 1, + pageSize: 1, + totalItems: 7, + totalPages: 4, + }) + expect(body.skills).toEqual(['React', 'Node.js']) + }) + + it('returns 0 freelancers for an out-of-range page with an accurate totalItems', async () => { + // Path: WHERE → ORDER BY → main list (empty) → COUNT fallback + // (which itself does WHERE + count) → skills. Six sql calls total. + queueSql([ + [], // WHERE fragment for listFreelancers + [], // ORDER BY fragment + [], // main list (empty) + [], // WHERE fragment rebuilt inside countFreelancers + [{ count: '3' }], // COUNT(*) fallback + [], // skills (none) + ]) + + const request = makeRequest( + 'http://localhost/api/freelancers?page=99&limit=10', + ) + const response = await listFreelancers(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.freelancers).toEqual([]) + expect(body.pagination).toEqual({ + page: 1, + pageSize: 0, + totalItems: 3, + totalPages: 1, + }) + }) + + it('returns 400 with structured error on invalid minRating', async () => { + const request = makeRequest('http://localhost/api/freelancers?minRating=42') + const response = await listFreelancers(request) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ + error: expect.stringContaining('minRating'), + code: 'INVALID_MIN_RATING', + }) + }) + + it('returns 400 on invalid sort field', async () => { + const request = makeRequest( + 'http://localhost/api/freelancers?sort=payout_total', + ) + const response = await listFreelancers(request) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.code).toBe('INVALID_SORT_FIELD') + }) + + it('returns 503 when the DB query fails', async () => { + // Path: WHERE succeeds → main list rejects → count_fallback and skills + // are not invoked. Two sql calls: one resolve, one reject. + queueSql([[]]) // WHERE fragment resolves first + queueSqlReject(new Error('connection reset')) // main list rejects + + const request = makeRequest('http://localhost/api/freelancers') + const response = await listFreelancers(request) + + expect(response.status).toBe(503) + const body = await response.json() + expect(body).toEqual({ + error: 'Unable to load freelancers', + code: 'FREELANCER_LIST_FAILED', + }) + }) +}) + +describe('GET /api/freelancers/[id]', () => { + it('returns the freelancer profile with nested reputation', async () => { + queueSql([buildUserRow()]) + + const request = makeRequest('http://localhost/api/freelancers/1') + const response = await getFreelancer(request, { + params: Promise.resolve({ id: '1' }), + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.freelancer.name).toBe('maya_chen') + expect(body.reputation).toMatchObject({ + userId: 1, + reputationScore: 100, + }) + }) + + it('returns the freelancer profile with null reputation when reputation lookup throws', async () => { + const reputationModule = await import('@/lib/reputation') + vi.mocked(reputationModule.userExists).mockRejectedValueOnce( + new Error('downstream failure'), + ) + + queueSql([buildUserRow()]) + + const request = makeRequest('http://localhost/api/freelancers/1') + const response = await getFreelancer(request, { + params: Promise.resolve({ id: '1' }), + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.freelancer.name).toBe('maya_chen') + expect(body.reputation).toBeNull() + }) + + it('returns 400 for a non-numeric id', async () => { + const request = makeRequest('http://localhost/api/freelancers/abc') + const response = await getFreelancer(request, { + params: Promise.resolve({ id: 'abc' }), + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ + error: 'Invalid freelancer id', + code: 'INVALID_ID', + }) + }) + + it('returns 404 when the freelancer does not exist', async () => { + queueSql([[]]) + + const request = makeRequest('http://localhost/api/freelancers/9999') + const response = await getFreelancer(request, { + params: Promise.resolve({ id: '9999' }), + }) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body.code).toBe('FREELANCER_NOT_FOUND') + }) +}) diff --git a/app/api/freelancers/[id]/route.ts b/app/api/freelancers/[id]/route.ts new file mode 100644 index 0000000..93418be --- /dev/null +++ b/app/api/freelancers/[id]/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { getFreelancerById } from '@/lib/freelancerDiscovery' +import { getFreelancerReputation, userExists } from '@/lib/reputation' + +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ id: string }> } + +/** + * GET /api/freelancers/[id] + * + * Returns a detailed freelancer profile by numeric id. + * The response includes a nested `reputation` payload when one can be loaded, + * otherwise `reputation` is null so clients can still render the profile. + * + * Responses: + * 200 { freelancer, reputation } + * 400 INVALID_ID (non-numeric or non-positive id) + * 404 FREELANCER_NOT_FOUND + * 503 FREELANCER_FETCH_FAILED (raised when the freelancer query fails) + */ +export async function GET(_request: NextRequest, context: RouteContext) { + const { id: rawId } = await context.params + const id = Number.parseInt(rawId, 10) + + if (!Number.isFinite(id) || id <= 0) { + return NextResponse.json( + { error: 'Invalid freelancer id', code: 'INVALID_ID' }, + { status: 400 }, + ) + } + + try { + const freelancer = await getFreelancerById(id) + if (!freelancer) { + return NextResponse.json( + { error: 'Freelancer not found', code: 'FREELANCER_NOT_FOUND' }, + { status: 404 }, + ) + } + + let reputation: unknown = null + try { + const exists = await userExists(id) + if (exists) { + reputation = await getFreelancerReputation(id) + } + } catch (error) { + console.error(`Failed to load reputation for freelancer ${id}:`, error) + reputation = null + } + + return NextResponse.json( + { freelancer, reputation }, + { + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + }, + }, + ) + } catch (error) { + console.error(`Failed to load freelancer ${id}:`, error) + return NextResponse.json( + { error: 'Unable to load freelancer', code: 'FREELANCER_FETCH_FAILED' }, + { status: 503 }, + ) + } +} diff --git a/app/api/freelancers/route.ts b/app/api/freelancers/route.ts index 0c3c58c..a27b0a4 100644 --- a/app/api/freelancers/route.ts +++ b/app/api/freelancers/route.ts @@ -1,62 +1,51 @@ import { NextRequest, NextResponse } from 'next/server' -import { FREELANCERS, FREELANCER_SKILLS } from '@/lib/freelancers' -export const dynamic = 'force-dynamic' - -const PAGE_SIZE = 6 - -function parseRating(value: string | null): number | null { - if (!value) return null - const rating = Number.parseInt(value, 10) - return Number.isInteger(rating) && rating >= 1 && rating <= 5 ? rating : null -} +import { + buildListResponse, + parseDiscoveryParams, + FreelancerDiscoveryError, +} from '@/lib/freelancerDiscovery' -function parsePage(value: string | null): number { - if (!value) return 1 - const page = Number.parseInt(value, 10) - return Number.isInteger(page) && page > 0 ? page : 1 -} +export const dynamic = 'force-dynamic' +/** + * GET /api/freelancers + * + * Lists freelancers for clients to discover and filter. + * + * Query parameters + * q Free-text search across username, bio, and skills + * skills Repeating or comma-separated list (every skill must match) + * minRating|rating Minimum rating (1..5). `rating` is accepted for back-compat + * page 1-based page number (default 1) + * limit Page size (1..50, default 6) + * sort One of: rating, total_jobs_completed, created_at, username (default rating) + * order asc | desc (default desc) + * + * Response: { freelancers, skills, pagination } + * - `pagination` carries page, pageSize, totalItems, totalPages so the UI can + * render accurate counts even when callers request a page past the end. + */ export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url) - const query = searchParams.get('q')?.trim().toLowerCase() ?? '' - const selectedSkills = searchParams - .getAll('skills') - .flatMap((value) => value.split(',')) - .map((skill) => skill.trim()) - .filter(Boolean) - const minimumRating = parseRating(searchParams.get('rating')) - const page = parsePage(searchParams.get('page')) - - const filtered = FREELANCERS.filter((freelancer) => { - const matchesQuery = - !query || - [freelancer.name, freelancer.title, freelancer.bio, ...freelancer.skills] - .join(' ') - .toLowerCase() - .includes(query) - - const matchesSkills = - selectedSkills.length === 0 || - selectedSkills.every((skill) => freelancer.skills.includes(skill)) - - const matchesRating = !minimumRating || freelancer.rating >= minimumRating - - return matchesQuery && matchesSkills && matchesRating - }) - - const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) - const currentPage = Math.min(page, totalPages) - const start = (currentPage - 1) * PAGE_SIZE - - return NextResponse.json({ - freelancers: filtered.slice(start, start + PAGE_SIZE), - skills: FREELANCER_SKILLS, - pagination: { - page: currentPage, - pageSize: PAGE_SIZE, - totalItems: filtered.length, - totalPages, - }, - }) + try { + const params = parseDiscoveryParams(request.nextUrl.searchParams) + const payload = await buildListResponse(params) + return NextResponse.json(payload, { + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + }, + }) + } catch (error) { + if (error instanceof FreelancerDiscoveryError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: 400 }, + ) + } + console.error('Failed to list freelancers:', error) + return NextResponse.json( + { error: 'Unable to load freelancers', code: 'FREELANCER_LIST_FAILED' }, + { status: 503 }, + ) + } } diff --git a/lib/freelancerDiscovery.ts b/lib/freelancerDiscovery.ts new file mode 100644 index 0000000..f1cacbb --- /dev/null +++ b/lib/freelancerDiscovery.ts @@ -0,0 +1,485 @@ +/** + * Freelancer Discovery API helper. + * + * Encapsulates the SQL query logic, pagination math, sort/filter validation, + * and DB row → API response mapping used by: + * - GET /api/freelancers (list with search, filter, sort, paginate) + * - GET /api/freelancers/[id] (detail by id) + * + * The data source is the `users` table (filtered to freelancer-or-both roles). + * Performance-critical search ranking on the `skills` column is supported by + * a GIN index added in `scripts/010-freelancer-discovery-indexes.sql`. + */ + +import { sql } from '@/lib/db' + +/** Fields that can be used as sort keys. Whitelisted to prevent SQL injection. */ +export const FREELANCER_SORTABLE_FIELDS = [ + 'rating', + 'total_jobs_completed', + 'created_at', + 'username', +] as const + +export type FreelancerSortField = (typeof FREELANCER_SORTABLE_FIELDS)[number] + +export const FREELANCER_SORT_ORDERS = ['asc', 'desc'] as const +export type FreelancerSortOrder = (typeof FREELANCER_SORT_ORDERS)[number] + +/** Allowed range for the minimum rating filter. */ +export const FREELANCER_MIN_RATING = 1 +export const FREELANCER_MAX_RATING = 5 + +/** Pagination bounds. `limit` is clamped to keep responses reasonable. */ +export const FREELANCER_DEFAULT_LIMIT = 6 +export const FREELANCER_MAX_LIMIT = 50 +export const FREELANCER_DEFAULT_PAGE = 1 + +export interface FreelancerListing { + id: number + /** Display name. Matches the `name` field consumed by `app/freelancers/page.tsx`. */ + name: string + title: string + bio: string + skills: string[] + rating: number + profileImage: string + completedProjects: number + /** Default 0 — the `users` table does not store a rate yet. */ + hourlyRate: number + /** Default '' — the `users` table does not store a location yet. */ + location: string + walletAddress: string + userType: string + createdAt: string +} + +export interface ListFreelancersParams { + /** Free-text query that matches username, bio, or any skill (case-insensitive). */ + query: string + /** Required skills (a freelancer must have *all* of them). Empty = no filter. */ + skills: string[] + /** Minimum rating in the inclusive range [1, 5]. Null = no filter. */ + minRating: number | null + /** 1-based page number. */ + page: number + /** Items per page (1..FREELANCER_MAX_LIMIT). */ + limit: number + /** Sort column. */ + sort: FreelancerSortField + /** Sort direction. */ + order: FreelancerSortOrder +} + +export interface ListFreelancersResult { + freelancers: FreelancerListing[] + totalItems: number +} + +export interface FreelancerListResponse { + freelancers: FreelancerListing[] + skills: string[] + pagination: { + page: number + pageSize: number + totalItems: number + totalPages: number + } +} + +export class FreelancerDiscoveryError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'FreelancerDiscoveryError' + } +} + +interface UserRow { + id: number + wallet_address: string + username: string + email: string | null + user_type: string + bio: string | null + skills: string[] | null + rating: number | string | null + total_jobs_completed: number | null + created_at: Date | string +} + +interface UserRowWithCount extends UserRow { + total_count: string | number +} + +interface SkillRow { + skill: string | null +} + +const DEFAULT_PROFILE_IMAGE = '/placeholder-user.jpg' +const DEFAULT_TITLE = 'TaskChain Freelancer' + +/** + * Convert a DB row to the API listing shape. Fields the DB doesn't store + * (hourlyRate, location, profileImage, title) get safe placeholder values so + * the response is always well-formed. + */ +export function mapUserRowToListing(row: UserRow): FreelancerListing { + const skills = Array.isArray(row.skills) ? row.skills : [] + const numericRating = + typeof row.rating === 'number' + ? row.rating + : row.rating === null || row.rating === undefined + ? 0 + : Number(row.rating) + + const rating = + Number.isFinite(numericRating) && + numericRating >= 0 && + numericRating <= FREELANCER_MAX_RATING + ? numericRating + : 0 + + const createdAt = + row.created_at instanceof Date + ? row.created_at.toISOString() + : row.created_at + + return { + id: row.id, + name: row.username, + title: DEFAULT_TITLE, + bio: row.bio ?? '', + skills, + rating, + profileImage: DEFAULT_PROFILE_IMAGE, + completedProjects: row.total_jobs_completed ?? 0, + hourlyRate: 0, + location: '', + walletAddress: row.wallet_address, + userType: row.user_type, + createdAt, + } +} + +/** + * Normalize a query string so it is safe to embed inside a Postgres + * ILIKE pattern. We escape the only meta-characters `\` and `%` (and `_`) + * which would otherwise let a caller alter the WHERE clause. + */ +function escapeIlike(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') +} + +/** + * Build the WHERE fragment in a SINGLE sql\`\`` call so the test layer + * (and Postgres query planner) sees a predictable, stable number of + * round-trips per request. Optional filters are activated via the standard + * PostgreSQL `NULL/0 = 0 OR ` pattern, which lets us + * parameterise every value (no string interpolation, no concatenation) while + * keeping the conditional semantics. Each filter that is "disabled" simply + * resolves to TRUE at the row level and Postgres' planner will short-circuit. + * + * Why not compose via `sql\`${a} AND ${b}\``? Every nested template literal + * is a separate `sql\`\`` invocation that the neon client must track. With + * a single-template approach below we issue exactly one DB round-trip per + * query, and mocking/test setup matches reality. + */ +function buildWhereFragment( + params: ListFreelancersParams, +): ReturnType { + const normalizedQuery = params.query.trim() + const needle = normalizedQuery + ? `%${escapeIlike(normalizedQuery.toLowerCase())}%` + : null + const skillArray = params.skills.length > 0 ? params.skills : [] + const ratingFloor = params.minRating ?? 0 + + return sql` + u.user_type IN ('freelancer', 'both') + AND ( + ${needle}::text IS NULL + OR LOWER(u.username) LIKE ${needle} ESCAPE '\\' + OR LOWER(COALESCE(u.bio, '')) LIKE ${needle} ESCAPE '\\' + OR EXISTS ( + SELECT 1 + FROM unnest(COALESCE(u.skills, ARRAY[]::text[])) AS s + WHERE LOWER(s) LIKE ${needle} ESCAPE '\\' + ) + ) + AND ( + cardinality(${skillArray}::text[]) = 0 + OR u.skills @> ${skillArray}::text[] + ) + AND ( + ${ratingFloor}::int = 0 + OR COALESCE(u.rating, 0) >= ${ratingFloor} + ) + ` +} + +/** + * Build a fully-static ORDER BY fragment for the (sort, order) pair. Because + * `sort` and `order` are pre-validated against a whitelist in `parseSort` + * and `parseOrder`, we never have to interpolate caller-controlled strings + * into the SQL identifier position. The switch below is exhaustive on + * `FreelancerSortField` × `FreelancerSortOrder`; both literal `ASC` / `DESC` + * and the column identifier are inlined in each branch. + */ +function buildOrderBy( + sort: FreelancerSortField, + order: FreelancerSortOrder, +): ReturnType { + switch (sort) { + case 'rating': + return order === 'asc' + ? sql`u.rating ASC NULLS LAST, u.id ASC` + : sql`u.rating DESC NULLS LAST, u.id ASC` + case 'total_jobs_completed': + return order === 'asc' + ? sql`u.total_jobs_completed ASC NULLS LAST, u.id ASC` + : sql`u.total_jobs_completed DESC NULLS LAST, u.id ASC` + case 'created_at': + return order === 'asc' + ? sql`u.created_at ASC NULLS LAST, u.id ASC` + : sql`u.created_at DESC NULLS LAST, u.id ASC` + case 'username': + return order === 'asc' + ? sql`u.username ASC, u.id ASC` + : sql`u.username DESC, u.id ASC` + } +} + +/** + * Lists freelancers using a single round-trip per page (a combined data + + * COUNT(*) OVER() query), so we get rows + total count in one shot. When the + * main query returns zero rows (e.g. requesting a page past the end) we fall + * back to a dedicated COUNT query so the pagination metadata stays accurate. + */ +export async function listFreelancers( + params: ListFreelancersParams, +): Promise { + const where = buildWhereFragment(params) + const orderBy = buildOrderBy(params.sort, params.order) + const offset = (params.page - 1) * params.limit + + const rows = (await sql` + SELECT + u.id, + u.wallet_address, + u.username, + u.email, + u.user_type, + u.bio, + u.skills, + u.rating, + u.total_jobs_completed, + u.created_at, + COUNT(*) OVER() AS total_count + FROM users u + WHERE ${where} + ORDER BY ${orderBy} + LIMIT ${params.limit} + OFFSET ${offset} + `) as UserRowWithCount[] + + let totalItems: number + if (rows.length > 0) { + const raw = rows[0].total_count + totalItems = + typeof raw === 'number' ? raw : parseInt(String(raw), 10) || 0 + } else { + totalItems = await countFreelancers(params) + } + + const freelancers: FreelancerListing[] = rows.map((row) => { + const { total_count: _ignored, ...rest } = row as UserRowWithCount + return mapUserRowToListing(rest as UserRow) + }) + + return { freelancers, totalItems } +} + +async function countFreelancers(params: ListFreelancersParams): Promise { + const where = buildWhereFragment(params) + const rows = (await sql` + SELECT COUNT(*) AS count FROM users u WHERE ${where} + `) as Array<{ count: string | number }> + const value = rows[0]?.count ?? 0 + return typeof value === 'number' ? value : parseInt(String(value), 10) || 0 +} + +/** Returns the distinct skills currently held by any freelancer-or-both user. */ +export async function getAvailableSkills(): Promise { + const rows = (await sql` + SELECT DISTINCT skill + FROM users u, unnest(COALESCE(u.skills, ARRAY[]::text[])) AS skill + WHERE u.user_type IN ('freelancer', 'both') + ORDER BY skill ASC + `) as SkillRow[] + return rows + .map((row) => row.skill) + .filter((s): s is string => typeof s === 'string' && s.length > 0) +} + +/** Fetches a single freelancer (by id) for the detail endpoint. */ +export async function getFreelancerById( + id: number, +): Promise { + if (!Number.isInteger(id) || id <= 0) return null + const rows = (await sql` + SELECT + u.id, + u.wallet_address, + u.username, + u.email, + u.user_type, + u.bio, + u.skills, + u.rating, + u.total_jobs_completed, + u.created_at + FROM users u + WHERE u.id = ${id} AND u.user_type IN ('freelancer', 'both') + LIMIT 1 + `) as UserRow[] + const row = rows[0] + return row ? mapUserRowToListing(row) : null +} + +// ---------- Query parameter parsing & validation --------------------------- + +function parseInteger(value: string | null, fallback: number): number { + if (value === null) return fallback + const parsed = Number.parseInt(value, 10) + return Number.isInteger(parsed) ? parsed : fallback +} + +function parseOptionalRating(value: string | null): number | null { + if (value === null || value === '') return null + const parsed = Number.parseInt(value, 10) + if ( + Number.isInteger(parsed) && + parsed >= FREELANCER_MIN_RATING && + parsed <= FREELANCER_MAX_RATING + ) { + return parsed + } + throw new FreelancerDiscoveryError( + 'INVALID_MIN_RATING', + `minRating must be an integer between ${FREELANCER_MIN_RATING} and ${FREELANCER_MAX_RATING}`, + ) +} + +function parseSort(value: string | null): FreelancerSortField { + const candidate = value ?? 'rating' + if ((FREELANCER_SORTABLE_FIELDS as readonly string[]).includes(candidate)) { + return candidate as FreelancerSortField + } + throw new FreelancerDiscoveryError( + 'INVALID_SORT_FIELD', + `sort must be one of: ${FREELANCER_SORTABLE_FIELDS.join(', ')}`, + ) +} + +function parseOrder(value: string | null): FreelancerSortOrder { + const candidate = (value ?? 'desc').toLowerCase() + if ((FREELANCER_SORT_ORDERS as readonly string[]).includes(candidate)) { + return candidate as FreelancerSortOrder + } + throw new FreelancerDiscoveryError( + 'INVALID_SORT_ORDER', + `order must be one of: ${FREELANCER_SORT_ORDERS.join(', ')}`, + ) +} + +function parseLimit(value: string | null): number { + const parsed = parseInteger(value, FREELANCER_DEFAULT_LIMIT) + if (parsed < 1) { + throw new FreelancerDiscoveryError( + 'INVALID_LIMIT', + 'limit must be greater than or equal to 1', + ) + } + return Math.min(parsed, FREELANCER_MAX_LIMIT) +} + +function parsePage(value: string | null): number { + const parsed = parseInteger(value, FREELANCER_DEFAULT_PAGE) + if (parsed < 1) { + throw new FreelancerDiscoveryError( + 'INVALID_PAGE', + 'page must be greater than or equal to 1', + ) + } + return parsed +} + +function parseSkills(raw: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const chunk of raw) { + for (const skill of chunk.split(',')) { + const trimmed = skill.trim() + if (!trimmed) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(trimmed) + } + } + return out +} + +/** + * Parses and validates the search-parameters from the request URL. + * Accepts legacy `?rating=` as a synonym for `?minRating=` so the existing + * `/freelancers` frontend keeps working. + */ +export function parseDiscoveryParams( + searchParams: URLSearchParams, +): ListFreelancersParams { + const query = (searchParams.get('q') ?? searchParams.get('query') ?? '').trim() + const skills = parseSkills(searchParams.getAll('skills')) + const minRating = parseOptionalRating( + searchParams.get('minRating') ?? searchParams.get('rating'), + ) + const sort = parseSort(searchParams.get('sort')) + const order = parseOrder(searchParams.get('order')) + const limit = parseLimit(searchParams.get('limit')) + const page = parsePage(searchParams.get('page')) + + return { query, skills, minRating, sort, order, limit, page } +} + +/** + * Builds the JSON response shape used by GET /api/freelancers, computing + * pagination metadata (e.g. clamping page to totalPages) so the UI gets + * accurate counts even when callers request pages past the end. We sequence + * the list and skills queries (instead of `Promise.all`) so that a failure on + * the main list call short-circuits cleanly with the 503 handler. + */ +export async function buildListResponse( + params: ListFreelancersParams, +): Promise { + const result = await listFreelancers(params) + const skills = await getAvailableSkills() + + const totalItems = result.totalItems + const pageSize = result.freelancers.length + const totalPages = Math.max(1, Math.ceil(totalItems / params.limit)) + const currentPage = Math.min(params.page, totalPages) + + return { + freelancers: result.freelancers, + skills, + pagination: { + page: currentPage === 0 ? 1 : currentPage, + pageSize, + totalItems, + totalPages, + }, + } +} diff --git a/scripts/010-freelancer-discovery-indexes.sql b/scripts/010-freelancer-discovery-indexes.sql new file mode 100644 index 0000000..7b66c13 --- /dev/null +++ b/scripts/010-freelancer-discovery-indexes.sql @@ -0,0 +1,28 @@ +-- 010-freelancer-discovery-indexes.sql +-- +-- Performance indexes for the GET /api/freelancers discovery endpoint +-- (TaskChain issue #121). The endpoint filters `users` by: +-- * skill membership (skills @> text[]) +-- * minimum rating (rating >= N) +-- * user_type IN ('freelancer', 'both') +-- and sorts by rating / created_at / total_jobs_completed / username. +-- +-- Adding these indexes keeps the discovery query bounded to the small +-- `freelancer-or-both` slice of `users`, and lets the planner use a GIN +-- index for the skills overlap check (`@>`) instead of a sequential scan. + +-- GIN index supports the skills array containment operator (`@>`) used in the +-- skill-filter WHERE clause. It also accelerates `ILIKE` on unnested skills in +-- the free-text search path. +CREATE INDEX IF NOT EXISTS idx_users_skills_gin + ON users USING GIN (skills); + +-- B-tree index for ordered scans and the `rating >= ?` predicate. +CREATE INDEX IF NOT EXISTS idx_users_rating_desc + ON users (rating DESC NULLS LAST); + +-- Partial index keeps the working set small: only freelancers (or folks who +-- can act as freelancers) are candidates for the discovery endpoint. +CREATE INDEX IF NOT EXISTS idx_users_freelancer_discovery + ON users (id, rating DESC NULLS LAST, total_jobs_completed DESC) + WHERE user_type IN ('freelancer', 'both'); diff --git a/scripts/README.md b/scripts/README.md index afbccd3..b60d341 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -104,6 +104,8 @@ CONFIRM=true npx tsx scripts/deploy-mainnet.ts - `006-contracts.sql` - Escrow contracts table - `006-dispute-enhancements.sql` - Dispute enhancements - `006-rate-limits.sql` - Rate limiting table +- `009-reviews-schema.sql` - Reviews table for client/freelancer ratings +- `010-freelancer-discovery-indexes.sql` - GIN/B-tree indexes that power GET /api/freelancers (task #121) ### Security Tables - `007-fail-safe.sql` - Critical operations table for fail-safe system