diff --git a/README.md b/README.md index 6c12926..02f85aa 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,13 @@ Future work may introduce an alternative CI/CD pipeline (e.g., a different provi See the Soroban escrow deployment guide for build and deploy steps, example CLI calls, and integration notes: [docs/soroban-escrow-deployment.md](docs/soroban-escrow-deployment.md) +--- + +## ๐Ÿ“ก API Documentation + +- [Public Freelancer Profile API](docs/public-freelancer-profile-api.md) โ€” unauthenticated endpoints for freelancer profiles, completed contracts, reviews, and reputation scores +- [Reputation API](docs/reputation-api.md) โ€” raw delivery metrics and how they are aggregated + ## ๐Ÿ“„ License diff --git a/__tests__/api/public-freelancer-profile.test.ts b/__tests__/api/public-freelancer-profile.test.ts new file mode 100644 index 0000000..7cb8196 --- /dev/null +++ b/__tests__/api/public-freelancer-profile.test.ts @@ -0,0 +1,687 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest, NextResponse } from 'next/server' + +import { GET as getProfile } from '@/app/api/public/freelancers/[id]/route' +import { GET as getContracts } from '@/app/api/public/freelancers/[id]/contracts/route' +import { GET as getReviews } from '@/app/api/public/freelancers/[id]/reviews/route' +import { GET as getReputation } from '@/app/api/public/freelancers/[id]/reputation/route' + +vi.mock('@/lib/db', () => ({ + sql: vi.fn(), +})) + +vi.mock('@/lib/security/rateLimit', () => ({ + enforceRateLimit: vi.fn().mockResolvedValue(null), + buildRateLimitKey: vi.fn().mockReturnValue('test-key'), +})) + +vi.mock('@/lib/reputation', () => ({ + getFreelancerReputation: vi.fn(), +})) + +import { sql } from '@/lib/db' +import { getFreelancerReputation } from '@/lib/reputation' +import { enforceRateLimit } from '@/lib/security/rateLimit' +import { + PUBLIC_MAX_LIMIT, + SENSITIVE_FIELDS, + computeReputationScore, + parseFreelancerId, + parsePagination, + parseVerifiedFilter, + PublicProfileError, + type ReputationComponentKey, +} from '@/lib/publicFreelancerProfile' + +type SqlMock = ReturnType + +function queueSql(...responses: unknown[]) { + const mock = sql as unknown as SqlMock + for (const response of responses) { + mock.mockResolvedValueOnce(response) + } +} + +function makeRequest(url = 'http://localhost/api/public/freelancers/7'): NextRequest { + return new NextRequest(new Request(url)) +} + +function routeContext(id: string) { + return { params: Promise.resolve({ id }) } +} + +/** A freelancer row as returned by the profile query. */ +function profileRow(overrides: Record = {}) { + return [ + { + id: 7, + username: 'ada', + bio: 'Solidity and Soroban contracts.', + skills: ['rust', 'soroban'], + user_type: 'freelancer', + wallet_address: 'GABC123', + rating: '4.50', + total_jobs_completed: 12, + created_at: new Date('2026-01-05T10:00:00.000Z'), + review_count: 4, + average_rating: '4.2500', + active_engagements: 0, + completed_contracts: 9, + ...overrides, + }, + ] +} + +const baseMetrics = { + completionRate: 0.9, + disputeRate: 0.1, + totalVolume: 5000, + onTimeDeliveryPct: 0.8, + jobsStarted: 10, + jobsCompleted: 9, + jobsWithDispute: 1, + completedWithDeadline: 5, + onTimeDeliveries: 4, +} + +beforeEach(() => { + vi.clearAllMocks() + ;(enforceRateLimit as unknown as SqlMock).mockResolvedValue(null) + ;(getFreelancerReputation as unknown as SqlMock).mockResolvedValue({ + userId: 7, + metrics: baseMetrics, + reputationScore: 88, + computedAt: '2026-07-26T00:00:00.000Z', + }) +}) + +describe('GET /api/public/freelancers/[id]', () => { + it.each(['abc', '0', '-3', '1.5', '7abc', ''])( + 'rejects id %j with 400 INVALID_FREELANCER_ID', + async (rawId) => { + const response = await getProfile(makeRequest(), routeContext(rawId)) + expect(response.status).toBe(400) + expect((await response.json()).code).toBe('INVALID_FREELANCER_ID') + expect(sql).not.toHaveBeenCalled() + }, + ) + + it('returns the profile in a single query', async () => { + queueSql(profileRow()) + + const response = await getProfile(makeRequest(), routeContext('7')) + expect(response.status).toBe(200) + expect(sql).toHaveBeenCalledTimes(1) + + const { data } = await response.json() + expect(data).toEqual({ + id: 7, + username: 'ada', + bio: 'Solidity and Soroban contracts.', + skills: ['rust', 'soroban'], + userType: 'freelancer', + walletAddress: 'GABC123', + availability: { status: 'available', activeEngagements: 0 }, + ratings: { average: 4.25, reviewCount: 4, storedRating: 4.5 }, + stats: { completedContracts: 9, completedJobs: 12 }, + memberSince: '2026-01-05T10:00:00.000Z', + }) + }) + + it('never leaks sensitive columns even when the row carries them', async () => { + queueSql( + profileRow({ + email: 'ada@example.com', + password_hash: 'argon2-hash', + last_login_at: '2026-07-20T00:00:00Z', + }), + ) + + const response = await getProfile(makeRequest(), routeContext('7')) + const body = await response.text() + + expect(body).not.toContain('ada@example.com') + expect(body).not.toContain('argon2-hash') + expect(body).not.toContain('last_login_at') + }) + + it('reports busy when the freelancer has active engagements', async () => { + queueSql(profileRow({ active_engagements: 2 })) + + const response = await getProfile(makeRequest(), routeContext('7')) + const { data } = await response.json() + expect(data.availability).toEqual({ status: 'busy', activeEngagements: 2 }) + }) + + it('reports a null average rating when there are no reviews', async () => { + queueSql(profileRow({ review_count: 0, average_rating: null })) + + const response = await getProfile(makeRequest(), routeContext('7')) + const { data } = await response.json() + expect(data.ratings).toEqual({ average: null, reviewCount: 0, storedRating: 4.5 }) + }) + + it('returns 404 for an unknown or client-only account', async () => { + queueSql([]) + + const response = await getProfile(makeRequest(), routeContext('7')) + expect(response.status).toBe(404) + expect((await response.json()).code).toBe('FREELANCER_NOT_FOUND') + }) + + it('returns 503 when the query fails', async () => { + ;(sql as unknown as SqlMock).mockRejectedValueOnce(new Error('connection lost')) + + const response = await getProfile(makeRequest(), routeContext('7')) + expect(response.status).toBe(503) + expect((await response.json()).code).toBe('PROFILE_UNAVAILABLE') + }) + + it('is cacheable at the edge', async () => { + queueSql(profileRow()) + + const response = await getProfile(makeRequest(), routeContext('7')) + expect(response.headers.get('Cache-Control')).toBe( + 'public, s-maxage=60, stale-while-revalidate=300', + ) + }) + + it('passes a rate-limit rejection straight through', async () => { + ;(enforceRateLimit as unknown as SqlMock).mockResolvedValueOnce( + NextResponse.json({ error: 'Too many requests', code: 'RATE_LIMITED' }, { status: 429 }), + ) + + const response = await getProfile(makeRequest(), routeContext('7')) + expect(response.status).toBe(429) + expect(sql).not.toHaveBeenCalled() + }) +}) + +describe('GET /api/public/freelancers/[id]/contracts', () => { + const contractRow = { + id: 31, + job_id: 12, + job_title: 'Escrow audit', + total_amount: '1500.000000', + currency: 'XLM', + contract_address: 'CDEF456', + contract_tx_hash: 'abc123', + completed_at: new Date('2026-06-01T12:00:00.000Z'), + created_at: new Date('2026-05-01T12:00:00.000Z'), + total_count: '3', + } + + it('returns completed contracts with pagination meta', async () => { + queueSql([{ '?column?': 1 }], [contractRow]) + + const response = await getContracts( + makeRequest('http://localhost/api/public/freelancers/7/contracts?page=1&limit=1'), + routeContext('7'), + ) + expect(response.status).toBe(200) + + const { data, meta } = await response.json() + expect(data).toEqual([ + { + id: 31, + jobId: 12, + jobTitle: 'Escrow audit', + totalAmount: '1500.000000', + currency: 'XLM', + contractAddress: 'CDEF456', + contractTxHash: 'abc123', + completedAt: '2026-06-01T12:00:00.000Z', + createdAt: '2026-05-01T12:00:00.000Z', + }, + ]) + expect(meta).toEqual({ + page: 1, + limit: 1, + totalCount: 3, + totalPages: 3, + hasNextPage: true, + }) + }) + + it('withholds contract terms and the client identity', async () => { + queueSql( + [{ '?column?': 1 }], + [{ ...contractRow, terms: 'Confidential retainer terms', client_id: 99 }], + ) + + const response = await getContracts( + makeRequest('http://localhost/api/public/freelancers/7/contracts'), + routeContext('7'), + ) + const body = await response.text() + + expect(body).not.toContain('Confidential retainer terms') + expect(body).not.toContain('client') + }) + + it('returns an empty page with zero totals', async () => { + queueSql([{ '?column?': 1 }], []) + + const response = await getContracts( + makeRequest('http://localhost/api/public/freelancers/7/contracts?page=4'), + routeContext('7'), + ) + const { data, meta } = await response.json() + + expect(data).toEqual([]) + expect(meta).toEqual({ + page: 4, + limit: 20, + totalCount: 0, + totalPages: 0, + hasNextPage: false, + }) + }) + + it('returns 404 without running the list query when the freelancer is unknown', async () => { + queueSql([]) + + const response = await getContracts( + makeRequest('http://localhost/api/public/freelancers/7/contracts'), + routeContext('7'), + ) + expect(response.status).toBe(404) + expect(sql).toHaveBeenCalledTimes(1) + }) + + it('rejects a bad page before touching the database', async () => { + const response = await getContracts( + makeRequest('http://localhost/api/public/freelancers/7/contracts?page=0'), + routeContext('7'), + ) + expect(response.status).toBe(400) + expect((await response.json()).code).toBe('INVALID_PAGINATION') + expect(sql).not.toHaveBeenCalled() + }) + + it('returns 503 when the list query fails', async () => { + queueSql([{ '?column?': 1 }]) + ;(sql as unknown as SqlMock).mockRejectedValueOnce(new Error('timeout')) + + const response = await getContracts( + makeRequest('http://localhost/api/public/freelancers/7/contracts'), + routeContext('7'), + ) + expect(response.status).toBe(503) + expect((await response.json()).code).toBe('CONTRACTS_UNAVAILABLE') + }) +}) + +describe('GET /api/public/freelancers/[id]/reviews', () => { + const reviewRow = { + id: 5, + contract_id: 31, + rating: 5, + comment: 'Shipped early.', + verified: true, + reviewer_username: 'grace', + created_at: new Date('2026-06-02T09:00:00.000Z'), + total_count: '2', + average_rating: '4.5000', + verified_count: '1', + } + + it('returns reviews with a summary computed over the full set', async () => { + queueSql([{ '?column?': 1 }], [reviewRow]) + + const response = await getReviews( + makeRequest('http://localhost/api/public/freelancers/7/reviews?limit=1'), + routeContext('7'), + ) + expect(response.status).toBe(200) + + const { data, meta } = await response.json() + expect(data).toEqual([ + { + id: 5, + contractId: 31, + rating: 5, + comment: 'Shipped early.', + verified: true, + reviewer: { username: 'grace' }, + createdAt: '2026-06-02T09:00:00.000Z', + }, + ]) + // totalCount is 2 while the page holds 1 โ€” the window aggregates cover the + // whole filtered set, not the page. + expect(meta.totalCount).toBe(2) + expect(meta.summary).toEqual({ averageRating: 4.5, verifiedCount: 1 }) + expect(meta.filters).toEqual({ verified: null }) + }) + + it('exposes the reviewer username but not their id', async () => { + queueSql([{ '?column?': 1 }], [{ ...reviewRow, reviewer_id: 4242 }]) + + const response = await getReviews( + makeRequest('http://localhost/api/public/freelancers/7/reviews'), + routeContext('7'), + ) + const body = await response.text() + + expect(body).toContain('grace') + expect(body).not.toContain('4242') + }) + + it('records the verified filter in meta', async () => { + queueSql([{ '?column?': 1 }], [reviewRow]) + + const response = await getReviews( + makeRequest('http://localhost/api/public/freelancers/7/reviews?verified=true'), + routeContext('7'), + ) + const { meta } = await response.json() + expect(meta.filters).toEqual({ verified: true }) + }) + + it('rejects a non-boolean verified filter', async () => { + const response = await getReviews( + makeRequest('http://localhost/api/public/freelancers/7/reviews?verified=maybe'), + routeContext('7'), + ) + expect(response.status).toBe(400) + expect((await response.json()).code).toBe('INVALID_VERIFIED_FILTER') + expect(sql).not.toHaveBeenCalled() + }) + + it('summarises an empty result as null, not zero', async () => { + queueSql([{ '?column?': 1 }], []) + + const response = await getReviews( + makeRequest('http://localhost/api/public/freelancers/7/reviews'), + routeContext('7'), + ) + const { meta } = await response.json() + expect(meta.summary).toEqual({ averageRating: null, verifiedCount: 0 }) + }) + + it('returns 404 for an unknown freelancer', async () => { + queueSql([]) + + const response = await getReviews( + makeRequest('http://localhost/api/public/freelancers/7/reviews'), + routeContext('7'), + ) + expect(response.status).toBe(404) + }) +}) + +describe('GET /api/public/freelancers/[id]/reputation', () => { + const sourceRow = [ + { + completed_contracts: 9, + review_count: 4, + verified_review_count: 3, + average_rating: '4.2500', + }, + ] + + it('blends delivery metrics with client reviews', async () => { + queueSql([{ '?column?': 1 }], sourceRow) + + const response = await getReputation(makeRequest(), routeContext('7')) + expect(response.status).toBe(200) + + const { data } = await response.json() + // 0.3*0.9 + 0.25*0.8 + 0.2*0.9 + 0.25*0.8125 = 0.853125 โ†’ 85.3 + expect(data.score).toBe(85.3) + expect(data.freelancerId).toBe(7) + expect(data.sources).toEqual({ + completedContracts: 9, + reviewCount: 4, + verifiedReviewCount: 3, + averageRating: 4.25, + metrics: baseMetrics, + }) + expect(data.computedAt).toBe('2026-07-26T00:00:00.000Z') + }) + + it('scores on delivery history alone when there are no reviews', async () => { + queueSql( + [{ '?column?': 1 }], + [{ completed_contracts: 9, review_count: 0, verified_review_count: 0, average_rating: null }], + ) + + const response = await getReputation(makeRequest(), routeContext('7')) + const { data } = await response.json() + + const clientRating = data.components.find( + (c: { key: ReputationComponentKey }) => c.key === 'clientRating', + ) + expect(clientRating).toEqual({ + key: 'clientRating', + value: null, + weight: 0.25, + appliedWeight: 0, + }) + // Remaining 0.75 of weight is redistributed: + // (0.3*0.9 + 0.25*0.8 + 0.2*0.9) / 0.75 = 0.8666โ€ฆ โ†’ 86.7 + expect(data.score).toBe(86.7) + }) + + it('returns 503 when reputation cannot be computed', async () => { + queueSql([{ '?column?': 1 }]) + ;(getFreelancerReputation as unknown as SqlMock).mockRejectedValueOnce( + new Error('snapshot missing'), + ) + ;(sql as unknown as SqlMock).mockResolvedValueOnce(sourceRow) + + const response = await getReputation(makeRequest(), routeContext('7')) + expect(response.status).toBe(503) + expect((await response.json()).code).toBe('REPUTATION_UNAVAILABLE') + }) + + it('returns 404 for an unknown freelancer', async () => { + queueSql([]) + + const response = await getReputation(makeRequest(), routeContext('7')) + expect(response.status).toBe(404) + expect(getFreelancerReputation).not.toHaveBeenCalled() + }) +}) + +describe('sensitive field guard', () => { + /** + * Every sensitive column, injected into each endpoint's rows with a + * recognisable value. Because the handlers copy fields explicitly rather than + * spreading rows, none of these may reach the response โ€” this test fails if a + * future change starts spreading a row. + */ + const contamination = { + email: 'leak-email@example.com', + password_hash: 'leak-password-hash', + terms: 'leak-contract-terms', + client_id: 987654, + escrow_contract_id: 'leak-escrow-id', + reviewer_id: 4242, + } + + const columnNames = SENSITIVE_FIELDS.map((field) => field.split('.')[1]) + + function expectClean(body: string) { + for (const value of Object.values(contamination)) { + expect(body).not.toContain(String(value)) + } + for (const column of columnNames) { + expect(body).not.toContain(column) + } + } + + it('keeps sensitive columns out of the profile response', async () => { + queueSql(profileRow(contamination)) + + const response = await getProfile(makeRequest(), routeContext('7')) + expectClean(await response.text()) + }) + + it('keeps sensitive columns out of the contracts response', async () => { + queueSql( + [{ '?column?': 1 }], + [ + { + id: 31, + job_id: 12, + job_title: 'Escrow audit', + total_amount: '1500.000000', + currency: 'XLM', + contract_address: 'CDEF456', + contract_tx_hash: 'abc123', + completed_at: new Date('2026-06-01T12:00:00.000Z'), + created_at: new Date('2026-05-01T12:00:00.000Z'), + total_count: '1', + ...contamination, + }, + ], + ) + + const response = await getContracts( + makeRequest('http://localhost/api/public/freelancers/7/contracts'), + routeContext('7'), + ) + expectClean(await response.text()) + }) + + it('keeps sensitive columns out of the reviews response', async () => { + queueSql( + [{ '?column?': 1 }], + [ + { + id: 5, + contract_id: 31, + rating: 5, + comment: 'Shipped early.', + verified: true, + reviewer_username: 'grace', + created_at: new Date('2026-06-02T09:00:00.000Z'), + total_count: '1', + average_rating: '5.0000', + verified_count: '1', + ...contamination, + }, + ], + ) + + const response = await getReviews( + makeRequest('http://localhost/api/public/freelancers/7/reviews'), + routeContext('7'), + ) + expectClean(await response.text()) + }) +}) + +describe('computeReputationScore', () => { + const emptyMetrics = { + ...baseMetrics, + completionRate: null, + disputeRate: null, + onTimeDeliveryPct: null, + jobsStarted: 0, + jobsCompleted: 0, + jobsWithDispute: 0, + completedWithDeadline: 0, + onTimeDeliveries: 0, + } + + it('returns a null score with no signal at all', () => { + const { score, components } = computeReputationScore({ + metrics: emptyMetrics, + averageRating: null, + reviewCount: 0, + }) + + expect(score).toBeNull() + expect(components.every((c) => c.value === null && c.appliedWeight === 0)).toBe(true) + }) + + it('scores on reviews alone when there is no job history', () => { + const { score, components } = computeReputationScore({ + metrics: emptyMetrics, + averageRating: 5, + reviewCount: 3, + }) + + // Sole available component absorbs the full weight. + expect(score).toBe(100) + expect(components.find((c) => c.key === 'clientRating')?.appliedWeight).toBe(1) + }) + + it('maps the 1-5 review scale onto 0-1', () => { + const { components } = computeReputationScore({ + metrics: emptyMetrics, + averageRating: 3, + reviewCount: 1, + }) + + expect(components.find((c) => c.key === 'clientRating')?.value).toBe(0.5) + }) + + it('turns the dispute rate into a dispute-free component', () => { + const { components } = computeReputationScore({ + metrics: { ...emptyMetrics, disputeRate: 0.25 }, + averageRating: null, + reviewCount: 0, + }) + + expect(components.find((c) => c.key === 'disputeFree')?.value).toBe(0.75) + }) + + it('clamps out-of-range inputs instead of producing a score above 100', () => { + const { score } = computeReputationScore({ + metrics: { ...emptyMetrics, completionRate: 1.4 }, + averageRating: null, + reviewCount: 0, + }) + + expect(score).toBe(100) + }) +}) + +describe('parameter parsing', () => { + it('accepts a plain positive integer id', () => { + expect(parseFreelancerId('42')).toBe(42) + }) + + it.each(['0', '-1', '1.5', '4abc', ' ', '', '007', '+7'])( + 'rejects id %j', + (raw) => { + expect(parseFreelancerId(raw)).toBeNull() + }, + ) + + it('defaults pagination when absent', () => { + expect(parsePagination(new URLSearchParams())).toEqual({ page: 1, limit: 20 }) + }) + + it('caps limit at the ceiling instead of erroring', () => { + expect(parsePagination(new URLSearchParams('limit=5000')).limit).toBe( + PUBLIC_MAX_LIMIT, + ) + }) + + it.each(['page=0', 'limit=0', 'page=-2', 'limit=abc'])( + 'rejects %s', + (query) => { + expect(() => parsePagination(new URLSearchParams(query))).toThrow( + PublicProfileError, + ) + }, + ) + + it.each([ + ['true', true], + ['1', true], + ['false', false], + ['0', false], + ['TRUE', true], + ])('parses verified=%s', (raw, expected) => { + expect(parseVerifiedFilter(new URLSearchParams(`verified=${raw}`))).toBe(expected) + }) + + it('treats an absent verified filter as no filter', () => { + expect(parseVerifiedFilter(new URLSearchParams())).toBeNull() + }) +}) diff --git a/app/api/public/freelancers/[id]/contracts/route.ts b/app/api/public/freelancers/[id]/contracts/route.ts new file mode 100644 index 0000000..4a07f9f --- /dev/null +++ b/app/api/public/freelancers/[id]/contracts/route.ts @@ -0,0 +1,78 @@ +import { NextRequest } from 'next/server' + +import { + PUBLIC_RATE_LIMIT, + PUBLIC_RATE_LIMIT_WINDOW_MS, + PublicProfileError, + buildPaginationMeta, + freelancerNotFound, + listCompletedContracts, + parseFreelancerId, + parsePagination, + publicError, + publicJson, + publicProfileErrorResponse, + publicFreelancerExists, +} from '@/lib/publicFreelancerProfile' +import { buildRateLimitKey, enforceRateLimit } from '@/lib/security/rateLimit' + +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ id: string }> } + +/** + * GET /api/public/freelancers/[id]/contracts + * + * Paginated list of contracts the freelancer completed, newest first. Contract + * `terms` and the client's identity are withheld; the on-chain address and + * deployment tx hash are included so consumers can verify the work themselves. + * + * Query: `?page=1&limit=20` (limit capped at 100). + * + * Responses: + * 200 { data, meta } + * 400 INVALID_FREELANCER_ID | INVALID_PAGINATION + * 404 FREELANCER_NOT_FOUND + * 429 RATE_LIMITED + * 503 CONTRACTS_UNAVAILABLE + */ +export async function GET(request: NextRequest, context: RouteContext) { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey(request, 'public:freelancers:contracts'), + limit: PUBLIC_RATE_LIMIT, + windowMs: PUBLIC_RATE_LIMIT_WINDOW_MS, + }) + if (limited) return limited + + const { id: rawId } = await context.params + const freelancerId = parseFreelancerId(rawId) + if (freelancerId === null) { + return publicError( + 400, + 'INVALID_FREELANCER_ID', + 'Freelancer id must be a positive integer', + ) + } + + let pagination + try { + pagination = parsePagination(request.nextUrl.searchParams) + } catch (error) { + if (error instanceof PublicProfileError) return publicProfileErrorResponse(error) + throw error + } + + try { + if (!(await publicFreelancerExists(freelancerId))) return freelancerNotFound() + + const { items, totalCount } = await listCompletedContracts(freelancerId, pagination) + return publicJson(items, buildPaginationMeta(pagination, totalCount)) + } catch (error) { + console.error(`[GET /api/public/freelancers/${freelancerId}/contracts]`, error) + return publicError( + 503, + 'CONTRACTS_UNAVAILABLE', + 'Unable to load completed contracts', + ) + } +} diff --git a/app/api/public/freelancers/[id]/reputation/route.ts b/app/api/public/freelancers/[id]/reputation/route.ts new file mode 100644 index 0000000..498e2e2 --- /dev/null +++ b/app/api/public/freelancers/[id]/reputation/route.ts @@ -0,0 +1,62 @@ +import { NextRequest } from 'next/server' + +import { + PUBLIC_RATE_LIMIT, + PUBLIC_RATE_LIMIT_WINDOW_MS, + freelancerNotFound, + getPublicReputation, + parseFreelancerId, + publicError, + publicJson, + publicFreelancerExists, +} from '@/lib/publicFreelancerProfile' +import { buildRateLimitKey, enforceRateLimit } from '@/lib/security/rateLimit' + +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ id: string }> } + +/** + * GET /api/public/freelancers/[id]/reputation + * + * Aggregated reputation: a 0โ€“100 score blended from completed contracts, + * client reviews and delivery metrics, plus the per-component breakdown and the + * raw inputs behind it. Delivery metrics come from the pre-aggregated + * `freelancer_reputation` snapshot (refreshed at most once per five minutes), + * so this stays cheap under load. + * + * Responses: + * 200 { data } + * 400 INVALID_FREELANCER_ID + * 404 FREELANCER_NOT_FOUND + * 429 RATE_LIMITED + * 503 REPUTATION_UNAVAILABLE + */ +export async function GET(request: NextRequest, context: RouteContext) { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey(request, 'public:freelancers:reputation'), + limit: PUBLIC_RATE_LIMIT, + windowMs: PUBLIC_RATE_LIMIT_WINDOW_MS, + }) + if (limited) return limited + + const { id: rawId } = await context.params + const freelancerId = parseFreelancerId(rawId) + if (freelancerId === null) { + return publicError( + 400, + 'INVALID_FREELANCER_ID', + 'Freelancer id must be a positive integer', + ) + } + + try { + if (!(await publicFreelancerExists(freelancerId))) return freelancerNotFound() + + const reputation = await getPublicReputation(freelancerId) + return publicJson(reputation) + } catch (error) { + console.error(`[GET /api/public/freelancers/${freelancerId}/reputation]`, error) + return publicError(503, 'REPUTATION_UNAVAILABLE', 'Unable to load reputation') + } +} diff --git a/app/api/public/freelancers/[id]/reviews/route.ts b/app/api/public/freelancers/[id]/reviews/route.ts new file mode 100644 index 0000000..ca125df --- /dev/null +++ b/app/api/public/freelancers/[id]/reviews/route.ts @@ -0,0 +1,85 @@ +import { NextRequest } from 'next/server' + +import { + PUBLIC_RATE_LIMIT, + PUBLIC_RATE_LIMIT_WINDOW_MS, + PublicProfileError, + buildPaginationMeta, + freelancerNotFound, + listReviews, + parseFreelancerId, + parsePagination, + parseVerifiedFilter, + publicError, + publicJson, + publicProfileErrorResponse, + publicFreelancerExists, +} from '@/lib/publicFreelancerProfile' +import { buildRateLimitKey, enforceRateLimit } from '@/lib/security/rateLimit' + +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ id: string }> } + +/** + * GET /api/public/freelancers/[id]/reviews + * + * Paginated client reviews, newest first. Reviewers are identified by public + * username only. `meta.summary` covers the whole filtered set, not just the + * current page. + * + * Query: `?page=1&limit=20&verified=true` (limit capped at 100). + * + * Responses: + * 200 { data, meta } + * 400 INVALID_FREELANCER_ID | INVALID_PAGINATION | INVALID_VERIFIED_FILTER + * 404 FREELANCER_NOT_FOUND + * 429 RATE_LIMITED + * 503 REVIEWS_UNAVAILABLE + */ +export async function GET(request: NextRequest, context: RouteContext) { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey(request, 'public:freelancers:reviews'), + limit: PUBLIC_RATE_LIMIT, + windowMs: PUBLIC_RATE_LIMIT_WINDOW_MS, + }) + if (limited) return limited + + const { id: rawId } = await context.params + const freelancerId = parseFreelancerId(rawId) + if (freelancerId === null) { + return publicError( + 400, + 'INVALID_FREELANCER_ID', + 'Freelancer id must be a positive integer', + ) + } + + let pagination + let verified: boolean | null + try { + pagination = parsePagination(request.nextUrl.searchParams) + verified = parseVerifiedFilter(request.nextUrl.searchParams) + } catch (error) { + if (error instanceof PublicProfileError) return publicProfileErrorResponse(error) + throw error + } + + try { + if (!(await publicFreelancerExists(freelancerId))) return freelancerNotFound() + + const { items, totalCount, summary } = await listReviews(freelancerId, { + ...pagination, + verified, + }) + + return publicJson(items, { + ...buildPaginationMeta(pagination, totalCount), + filters: { verified }, + summary, + }) + } catch (error) { + console.error(`[GET /api/public/freelancers/${freelancerId}/reviews]`, error) + return publicError(503, 'REVIEWS_UNAVAILABLE', 'Unable to load reviews') + } +} diff --git a/app/api/public/freelancers/[id]/route.ts b/app/api/public/freelancers/[id]/route.ts new file mode 100644 index 0000000..c3617fe --- /dev/null +++ b/app/api/public/freelancers/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest } from 'next/server' + +import { + PUBLIC_RATE_LIMIT, + PUBLIC_RATE_LIMIT_WINDOW_MS, + freelancerNotFound, + getPublicProfile, + parseFreelancerId, + publicError, + publicJson, +} from '@/lib/publicFreelancerProfile' +import { buildRateLimitKey, enforceRateLimit } from '@/lib/security/rateLimit' + +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ id: string }> } + +/** + * GET /api/public/freelancers/[id] + * + * Public, unauthenticated freelancer profile: identity, bio, skills, derived + * availability, rating summary and work-history counts. Sensitive columns are + * never selected โ€” see `SENSITIVE_FIELDS` in `lib/publicFreelancerProfile.ts`. + * + * Responses: + * 200 { data } + * 400 INVALID_FREELANCER_ID + * 404 FREELANCER_NOT_FOUND (unknown id, or a client-only account) + * 429 RATE_LIMITED + * 503 PROFILE_UNAVAILABLE + */ +export async function GET(request: NextRequest, context: RouteContext) { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey(request, 'public:freelancers:profile'), + limit: PUBLIC_RATE_LIMIT, + windowMs: PUBLIC_RATE_LIMIT_WINDOW_MS, + }) + if (limited) return limited + + const { id: rawId } = await context.params + const freelancerId = parseFreelancerId(rawId) + if (freelancerId === null) { + return publicError( + 400, + 'INVALID_FREELANCER_ID', + 'Freelancer id must be a positive integer', + ) + } + + try { + const profile = await getPublicProfile(freelancerId) + if (!profile) return freelancerNotFound() + return publicJson(profile) + } catch (error) { + console.error(`[GET /api/public/freelancers/${freelancerId}]`, error) + return publicError(503, 'PROFILE_UNAVAILABLE', 'Unable to load profile') + } +} diff --git a/docs/public-freelancer-profile-api.md b/docs/public-freelancer-profile-api.md new file mode 100644 index 0000000..123eecc --- /dev/null +++ b/docs/public-freelancer-profile-api.md @@ -0,0 +1,355 @@ +# Public Freelancer Profile API + +Read-only, unauthenticated endpoints that let external consumers and clients pull a freelancer's public record: profile, completed contracts, reviews, and an aggregated reputation score. + +- **Base path**: `/api/public/freelancers/{id}` +- **`id`**: the integer primary key from `users.id` +- **Methods**: `GET` only +- **Auth**: none โ€” these endpoints are deliberately public and expose public data only +- **Rate limit**: 120 requests/minute per IP, per endpoint +- **Cache**: `Cache-Control: public, s-maxage=60, stale-while-revalidate=300` on every `200` + +Implementation lives in [`lib/publicFreelancerProfile.ts`](../lib/publicFreelancerProfile.ts); supporting indexes in [`scripts/011-public-profile-indexes.sql`](../scripts/011-public-profile-indexes.sql). + +--- + +## Response conventions + +Every endpoint answers with the same envelope, so a client can handle all four the same way. + +**Success โ€” single resource** + +```json +{ "data": { } } +``` + +**Success โ€” collection** + +```json +{ "data": [], "meta": { "page": 1, "limit": 20, "totalCount": 0, "totalPages": 0, "hasNextPage": false } } +``` + +**Error** + +```json +{ "error": "Freelancer not found", "code": "FREELANCER_NOT_FOUND" } +``` + +`error` is a human-readable sentence and may be reworded at any time; **branch on `code`, never on `error`**. + +Other conventions: + +| Rule | Detail | +|------|--------| +| **Timestamps** | ISO 8601 UTC (`2026-06-01T12:00:00.000Z`). | +| **Money** | Decimal **strings** (`"1500.000000"`), never floats โ€” `total_amount` is `DECIMAL(18,6)` and JSON numbers would lose precision. | +| **Ratings** | Numbers on the 1โ€“5 review scale, rounded to 2dp. `null` means "no data", never `0`. | +| **Absent data** | `null`. Aggregate *counts* are `0`. | +| **Unknown query params** | Ignored. | +| **Pagination** | `?page=` (1-based) and `?limit=`. Default `limit` 20, ceiling 100 โ€” a larger value is capped, not rejected. `page`/`limit` below 1 or non-numeric return `400 INVALID_PAGINATION`. | + +### Common error codes + +| Status | Code | Meaning | +|--------|------|---------| +| `400` | `INVALID_FREELANCER_ID` | `id` is not a positive integer. | +| `400` | `INVALID_PAGINATION` | `page` or `limit` is not an integer โ‰ฅ 1. | +| `400` | `INVALID_VERIFIED_FILTER` | `?verified=` is not `true`/`false`/`1`/`0`. | +| `404` | `FREELANCER_NOT_FOUND` | No such user, **or** the user is a client-only account. | +| `429` | `RATE_LIMITED` | Rate limit exceeded; see the `Retry-After` and `X-RateLimit-*` headers. | +| `503` | `*_UNAVAILABLE` | The database call failed. Safe to retry with backoff. | + +--- + +## What is and is not exposed + +The acceptance criterion is "only public data is exposed". Rows are never spread into a response โ€” each endpoint copies fields explicitly, so a column added to `users`, `contracts`, or `reviews` later cannot leak by accident. + +**Withheld** (tracked as `SENSITIVE_FIELDS` in the helper module): + +| Field | Why | +|-------|-----| +| `users.email` | Private contact detail. | +| `contracts.terms` | Private agreement between client and freelancer. | +| `contracts.client_id` and any client identity | The client never opted into being listed on someone else's public profile. | +| `jobs.escrow_contract_id`, escrow amounts/status | Escrow wiring, not profile data. | +| `reviews.reviewer_id` | Replaced by the reviewer's public `username`. | + +**Deliberately included** โ€” `walletAddress`. The Stellar address is the freelancer's on-chain identity: it is already public on the ledger, is what makes escrow payments independently verifiable, and is already returned by the existing `GET /api/freelancers` listing. Excluding it here would be inconsistent without adding any privacy. + +**Visibility rule**: a user is only visible when `user_type IN ('freelancer', 'both')`. A client-only account returns `404` โ€” the same response as a non-existent id, so these endpoints cannot be used to enumerate which ids exist. + +--- + +## 1. Get Profile + +``` +GET /api/public/freelancers/{id} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | number | `users.id`. | +| `username` | string | Public display handle. | +| `bio` | string \| null | | +| `skills` | string[] | Empty array when unset. | +| `userType` | string | `freelancer` or `both`. | +| `walletAddress` | string | Stellar address. | +| `availability.status` | `available` \| `busy` | **Derived** โ€” see below. | +| `availability.activeEngagements` | number | Jobs in `assigned`, `in_progress`, or `in_review`. | +| `ratings.average` | number \| null | Mean of all reviews, 1โ€“5. `null` with no reviews. | +| `ratings.reviewCount` | number | Total reviews received. | +| `ratings.storedRating` | number \| null | Denormalised `users.rating`, kept for parity with `GET /api/freelancers`. May lag `ratings.average`; prefer `average`. | +| `stats.completedContracts` | number | Contracts in `completed` state. | +| `stats.completedJobs` | number | Denormalised `users.total_jobs_completed`. | +| `memberSince` | string | Account creation timestamp. | + +> **On `availability`**: the schema has no availability column, so it is derived from in-flight work rather than self-reported โ€” `busy` when the freelancer has at least one active job, otherwise `available`. If explicit, freelancer-set availability is added later, this field can keep its shape and change its source. + +**Example** + +```bash +curl https://taskchain.example/api/public/freelancers/7 +``` + +```json +{ + "data": { + "id": 7, + "username": "ada", + "bio": "Soroban contracts and escrow audits.", + "skills": ["rust", "soroban"], + "userType": "freelancer", + "walletAddress": "GABC...123", + "availability": { "status": "available", "activeEngagements": 0 }, + "ratings": { "average": 4.25, "reviewCount": 4, "storedRating": 4.5 }, + "stats": { "completedContracts": 9, "completedJobs": 12 }, + "memberSince": "2026-01-05T10:00:00.000Z" + } +} +``` + +Errors: `400 INVALID_FREELANCER_ID`, `404 FREELANCER_NOT_FOUND`, `429 RATE_LIMITED`, `503 PROFILE_UNAVAILABLE`. + +--- + +## 2. Get Completed Contracts + +``` +GET /api/public/freelancers/{id}/contracts?page=1&limit=20 +``` + +Contracts in `completed` state where the user is the freelancer, newest completion first. Ties break on descending contract id, so paging is stable. + +| Field | Type | Notes | +|-------|------|-------| +| `id` | number | `contracts.id`. | +| `jobId` | number | | +| `jobTitle` | string | | +| `totalAmount` | string | Decimal string. | +| `currency` | string | e.g. `XLM`. | +| `contractAddress` | string \| null | Soroban address; `null` until deployed on-chain. | +| `contractTxHash` | string \| null | Deployment tx hash, for independent verification. | +| `completedAt` | string \| null | `jobs.completed_at`, falling back to `contracts.updated_at`. | +| `createdAt` | string | | + +**Example** + +```bash +curl "https://taskchain.example/api/public/freelancers/7/contracts?page=1&limit=2" +``` + +```json +{ + "data": [ + { + "id": 31, + "jobId": 12, + "jobTitle": "Escrow audit", + "totalAmount": "1500.000000", + "currency": "XLM", + "contractAddress": "CDEF...456", + "contractTxHash": "abc123", + "completedAt": "2026-06-01T12:00:00.000Z", + "createdAt": "2026-05-01T12:00:00.000Z" + } + ], + "meta": { "page": 1, "limit": 2, "totalCount": 9, "totalPages": 5, "hasNextPage": true } +} +``` + +Errors: `400 INVALID_FREELANCER_ID`, `400 INVALID_PAGINATION`, `404 FREELANCER_NOT_FOUND`, `429 RATE_LIMITED`, `503 CONTRACTS_UNAVAILABLE`. + +--- + +## 3. Get Reviews + +``` +GET /api/public/freelancers/{id}/reviews?page=1&limit=20&verified=true +``` + +Client reviews, newest first. + +**Query parameters** + +| Param | Values | Default | Notes | +|-------|--------|---------|-------| +| `page` | integer โ‰ฅ 1 | `1` | | +| `limit` | integer 1โ€“100 | `20` | Capped at 100. | +| `verified` | `true` \| `false` \| `1` \| `0` | unset | Unset returns both. | + +**Item fields** + +| Field | Type | Notes | +|-------|------|-------| +| `id` | number | | +| `contractId` | number | The contract reviewed (one review per contract). | +| `rating` | number | Integer 1โ€“5. | +| `comment` | string \| null | | +| `verified` | boolean | True when the review was left on a contract that reached `completed`. | +| `reviewer.username` | string | Public handle only. | +| `createdAt` | string | | + +`meta` carries the pagination block plus: + +| Field | Notes | +|-------|-------| +| `filters.verified` | The filter that was applied (`null` = none). | +| `summary.averageRating` | Mean rating across the **whole filtered set**, not the current page. `null` when empty. | +| `summary.verifiedCount` | Verified reviews in the whole filtered set. | + +**Example** + +```bash +curl "https://taskchain.example/api/public/freelancers/7/reviews?limit=1&verified=true" +``` + +```json +{ + "data": [ + { + "id": 5, + "contractId": 31, + "rating": 5, + "comment": "Shipped early, clear communication.", + "verified": true, + "reviewer": { "username": "grace" }, + "createdAt": "2026-06-02T09:00:00.000Z" + } + ], + "meta": { + "page": 1, "limit": 1, "totalCount": 3, "totalPages": 3, "hasNextPage": true, + "filters": { "verified": true }, + "summary": { "averageRating": 4.67, "verifiedCount": 3 } + } +} +``` + +Errors: `400 INVALID_FREELANCER_ID`, `400 INVALID_PAGINATION`, `400 INVALID_VERIFIED_FILTER`, `404 FREELANCER_NOT_FOUND`, `429 RATE_LIMITED`, `503 REVIEWS_UNAVAILABLE`. + +--- + +## 4. Get Reputation Score + +``` +GET /api/public/freelancers/{id}/reputation +``` + +A single 0โ€“100 score blended from completed contracts, client reviews, and platform delivery metrics โ€” plus the breakdown behind it, so consumers can show *why* rather than treating the number as a black box. + +### Formula + +| Component | Base weight | Normalised input (0โ€“1) | +|-----------|-------------|------------------------| +| `completion` | 0.30 | `completionRate` โ€” completed รท started jobs. | +| `onTime` | 0.25 | `onTimeDeliveryPct` โ€” completed on/before deadline, among jobs that had one. | +| `disputeFree` | 0.20 | `1 - disputeRate`. | +| `clientRating` | 0.25 | `(averageRating - 1) / 4` โ€” maps the 1โ€“5 review scale onto 0โ€“1. | + +``` +score = 100 ร— ฮฃ (value ร— weight รท ฮฃ available weights) +``` + +**Components with no data are dropped and their weight is redistributed proportionally across the rest.** A freelancer with no reviews yet is therefore scored on delivery history alone rather than penalised for a missing signal, and one whose jobs never had deadlines is not penalised for the absent on-time sample. `appliedWeight` reports the weight actually used (`0` when the component was dropped). Inputs are clamped to 0โ€“1, and `score` is rounded to 1dp. + +`score` is `null` only when there is no signal at all โ€” no jobs started and no reviews. + +### Response fields + +| Field | Type | Notes | +|-------|------|-------| +| `freelancerId` | number | | +| `score` | number \| null | 0โ€“100, 1dp. | +| `components[]` | array | `{ key, value, weight, appliedWeight }` for all four components, including dropped ones (`value: null`). | +| `sources.completedContracts` | number | Contracts in `completed` state. | +| `sources.reviewCount` | number | | +| `sources.verifiedReviewCount` | number | | +| `sources.averageRating` | number \| null | 1โ€“5. | +| `sources.metrics` | object | Raw delivery metrics โ€” same shape as [`docs/reputation-api.md`](reputation-api.md). | +| `computedAt` | string | When the underlying metrics snapshot was computed. | + +**Example** + +```bash +curl https://taskchain.example/api/public/freelancers/7/reputation +``` + +```json +{ + "data": { + "freelancerId": 7, + "score": 85.3, + "components": [ + { "key": "completion", "value": 0.9, "weight": 0.3, "appliedWeight": 0.3 }, + { "key": "onTime", "value": 0.8, "weight": 0.25, "appliedWeight": 0.25 }, + { "key": "disputeFree", "value": 0.9, "weight": 0.2, "appliedWeight": 0.2 }, + { "key": "clientRating", "value": 0.8125, "weight": 0.25, "appliedWeight": 0.25 } + ], + "sources": { + "completedContracts": 9, + "reviewCount": 4, + "verifiedReviewCount": 3, + "averageRating": 4.25, + "metrics": { + "completionRate": 0.9, + "disputeRate": 0.1, + "totalVolume": 5000, + "onTimeDeliveryPct": 0.8, + "jobsStarted": 10, + "jobsCompleted": 9, + "jobsWithDispute": 1, + "completedWithDeadline": 5, + "onTimeDeliveries": 4 + } + }, + "computedAt": "2026-07-26T00:00:00.000Z" + } +} +``` + +Errors: `400 INVALID_FREELANCER_ID`, `404 FREELANCER_NOT_FOUND`, `429 RATE_LIMITED`, `503 REPUTATION_UNAVAILABLE`. + +> **Relationship to the existing reputation endpoints.** `GET /api/freelancers/{userId}/reputation` returns the raw delivery snapshot and its own `reputationScore`, which does **not** consider reviews. This endpoint is the composite, review-aware public score. Both are unauthenticated; neither replaces the other. + +--- + +## Performance + +The acceptance criterion is "endpoints handle large datasets without performance degradation". How that is met: + +1. **One database round-trip per endpoint** (two for the sub-resources, which first confirm the freelancer exists so an unknown id returns `404` rather than an empty page). Profile aggregates ride along as scalar sub-selects instead of separate queries. +2. **Total row counts come from `COUNT(*) OVER()`** in the same statement as the page. Window functions are evaluated *before* `LIMIT`, so the count and the review `summary` cover the whole filtered set while still costing one query. +3. **Reputation reads a pre-aggregated snapshot.** Delivery metrics come from `freelancer_reputation`, recomputed at most once every five minutes (`REPUTATION_SNAPSHOT_MAX_AGE_MS`) rather than on every request. +4. **Partial and composite indexes** keep every read on a narrow slice โ€” see `scripts/011-public-profile-indexes.sql`. The completed-contract and active-job indexes are partial, so they stay small as historical volume grows. +5. **`limit` is capped at 100**, so no caller can request an unbounded page. +6. **Edge-cacheable for 60s** with a 300s stale-while-revalidate window, plus a 120 req/min per-IP rate limit as a backstop. + +### Database setup + +Apply the index migration after `scripts/010-freelancer-discovery-indexes.sql`: + +```bash +psql "$DATABASE_URL" -f scripts/011-public-profile-indexes.sql +``` + +All statements use `CREATE INDEX IF NOT EXISTS` and are safe to re-run. The endpoints work without them โ€” the indexes are what keep them fast at scale. diff --git a/lib/publicFreelancerProfile.ts b/lib/publicFreelancerProfile.ts new file mode 100644 index 0000000..d55a30f --- /dev/null +++ b/lib/publicFreelancerProfile.ts @@ -0,0 +1,740 @@ +/** + * Public Freelancer Profile API helper. + * + * Backs the read-only, unauthenticated endpoints under `/api/public/freelancers/[id]`: + * - GET /api/public/freelancers/[id] โ†’ profile + * - GET /api/public/freelancers/[id]/contracts โ†’ completed contracts + * - GET /api/public/freelancers/[id]/reviews โ†’ client reviews + * - GET /api/public/freelancers/[id]/reputation โ†’ aggregated reputation score + * + * Design rules for everything in this module: + * + * 1. **Public data only.** Rows are never spread into responses. Every field is + * copied explicitly by a `map*` function, so a column added to `users`, + * `contracts`, or `reviews` later cannot leak by accident. See + * `SENSITIVE_FIELDS` for what is deliberately withheld. + * 2. **One round-trip per endpoint.** Aggregates ride along as scalar + * sub-selects, and list endpoints get their total via `COUNT(*) OVER()` + * (window functions run before `LIMIT`, so the count covers the whole + * filtered set, not just the page). + * 3. **Consistent envelope.** Every response is `{ data, meta }` and every + * error is `{ error, code }` โ€” see `publicJson` / `publicError`. + * + * Supporting indexes live in `scripts/011-public-profile-indexes.sql`. + */ + +import { NextResponse } from 'next/server' + +import { sql } from '@/lib/db' +import { getFreelancerReputation, type ReputationMetrics } from '@/lib/reputation' + +/** + * Columns that exist on the underlying tables and are intentionally *not* + * exposed by these endpoints. Kept as a list so the docs and the code cannot + * drift apart. + * + * users.email โ€” contact detail, private + * contracts.terms โ€” private agreement between client and freelancer + * contracts.client_id โ€” the client never opted into a public listing + * jobs.escrow_contract_id โ€” escrow wiring, not profile data + * reviews.reviewer_id โ€” replaced by the reviewer's public username + */ +export const SENSITIVE_FIELDS = [ + 'users.email', + 'contracts.terms', + 'contracts.client_id', + 'jobs.escrow_contract_id', + 'reviews.reviewer_id', +] as const + +/** `Cache-Control` used by every public profile endpoint. */ +export const PUBLIC_CACHE_CONTROL = 'public, s-maxage=60, stale-while-revalidate=300' + +/** Requests per minute allowed per IP, per endpoint. */ +export const PUBLIC_RATE_LIMIT = 120 +export const PUBLIC_RATE_LIMIT_WINDOW_MS = 60_000 + +export const PUBLIC_DEFAULT_PAGE = 1 +export const PUBLIC_DEFAULT_LIMIT = 20 +export const PUBLIC_MAX_LIMIT = 100 + +/** + * Job states that mean the freelancer is currently engaged. Used to derive + * `availability`, which the `users` table does not store. + */ +export const ACTIVE_JOB_STATUSES = ['assigned', 'in_progress', 'in_review'] as const + +/** Contract state that qualifies a contract as publicly listable work history. */ +export const COMPLETED_CONTRACT_STATUS = 'completed' + +// ---------- Public response types ------------------------------------------ + +export type AvailabilityStatus = 'available' | 'busy' + +export interface PublicFreelancerProfile { + id: number + username: string + bio: string | null + skills: string[] + /** `'freelancer'` or `'both'` โ€” clients-only accounts are not exposed here. */ + userType: string + /** + * Stellar account address. Public by nature (it is the on-chain identity used + * to verify escrow payments), and already exposed by `GET /api/freelancers`. + */ + walletAddress: string + availability: { + status: AvailabilityStatus + /** Jobs currently assigned / in progress / in review. */ + activeEngagements: number + } + ratings: { + /** Mean of every review's rating, 1โ€“5, rounded to 2dp. `null` with no reviews. */ + average: number | null + reviewCount: number + /** Denormalised `users.rating`, kept for parity with `GET /api/freelancers`. */ + storedRating: number | null + } + stats: { + /** Contracts in `completed` state where this user is the freelancer. */ + completedContracts: number + /** Denormalised `users.total_jobs_completed`. */ + completedJobs: number + } + memberSince: string +} + +export interface PublicCompletedContract { + id: number + jobId: number + jobTitle: string + /** Decimal string โ€” money is never converted to a float. */ + totalAmount: string + currency: string + /** Soroban contract address, `null` until the contract is deployed on-chain. */ + contractAddress: string | null + /** Deployment transaction hash, for independent on-chain verification. */ + contractTxHash: string | null + /** `jobs.completed_at`, falling back to `contracts.updated_at`. */ + completedAt: string | null + createdAt: string +} + +export interface PublicReview { + id: number + contractId: number + rating: number + comment: string | null + /** True when the review was left on a contract that reached `completed`. */ + verified: boolean + reviewer: { + username: string + } + createdAt: string +} + +export interface PublicReviewSummary { + /** Mean rating across the filtered set, 1โ€“5, rounded to 2dp. */ + averageRating: number | null + verifiedCount: number +} + +export type ReputationComponentKey = + | 'completion' + | 'onTime' + | 'disputeFree' + | 'clientRating' + +export interface ReputationComponent { + key: ReputationComponentKey + /** Normalised 0โ€“1 input. `null` when there is no data for this component. */ + value: number | null + /** Base weight before redistribution. */ + weight: number + /** Weight actually applied; `0` when `value` is `null`. */ + appliedWeight: number +} + +export interface PublicReputation { + freelancerId: number + /** Blended 0โ€“100 score, or `null` when the freelancer has no history at all. */ + score: number | null + components: ReputationComponent[] + sources: { + completedContracts: number + reviewCount: number + verifiedReviewCount: number + averageRating: number | null + metrics: ReputationMetrics + } + computedAt: string +} + +export interface PaginationMeta { + page: number + limit: number + totalCount: number + totalPages: number + hasNextPage: boolean +} + +export interface PaginationParams { + page: number + limit: number +} + +export interface PaginatedResult { + items: T[] + totalCount: number +} + +/** + * Base weights for the reputation blend. Components without data are dropped + * and their weight is redistributed proportionally across the rest, so a + * freelancer with no reviews is scored on delivery history alone rather than + * being penalised for a missing signal. + */ +export const REPUTATION_WEIGHTS: Record = { + completion: 0.3, + onTime: 0.25, + disputeFree: 0.2, + clientRating: 0.25, +} + +export class PublicProfileError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly status: number = 400, + ) { + super(message) + this.name = 'PublicProfileError' + } +} + +// ---------- Row shapes ----------------------------------------------------- + +interface ProfileRow { + id: number + username: string + bio: string | null + skills: string[] | null + user_type: string + wallet_address: string + rating: string | number | null + total_jobs_completed: number | null + created_at: Date | string + review_count: number + average_rating: string | number | null + active_engagements: number + completed_contracts: number +} + +interface CompletedContractRow { + id: number + job_id: number + job_title: string + total_amount: string | number + currency: string + contract_address: string | null + contract_tx_hash: string | null + completed_at: Date | string | null + created_at: Date | string + total_count: string | number +} + +interface ReviewRow { + id: number + contract_id: number + rating: number + comment: string | null + verified: boolean | null + reviewer_username: string | null + created_at: Date | string + total_count: string | number + average_rating: string | number | null + verified_count: string | number +} + +interface ReputationSourceRow { + completed_contracts: number + review_count: number + verified_review_count: number + average_rating: string | number | null +} + +// ---------- Coercion helpers ----------------------------------------------- + +function toNumber(value: string | number | null | undefined): number | null { + if (value === null || value === undefined) return null + const parsed = typeof value === 'number' ? value : Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +function toCount(value: string | number | null | undefined): number { + const parsed = toNumber(value) + return parsed === null ? 0 : Math.trunc(parsed) +} + +function toIso(value: Date | string | null): string | null { + if (value === null) return null + if (value instanceof Date) return value.toISOString() + // Postgres `TIMESTAMP` columns come back without a zone over the HTTP driver; + // parse and re-serialise so every timestamp we emit is unambiguous UTC. + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString() +} + +function roundTo(value: number, decimals: number): number { + const factor = 10 ** decimals + return Math.round(value * factor) / factor +} + +/** Clamps a rating to the 1โ€“5 review scale, or returns `null` when unrated. */ +function toRating(value: string | number | null | undefined): number | null { + const parsed = toNumber(value) + if (parsed === null || parsed <= 0) return null + return roundTo(Math.min(5, parsed), 2) +} + +// ---------- Row โ†’ response mapping ----------------------------------------- + +export function mapProfileRow(row: ProfileRow): PublicFreelancerProfile { + const activeEngagements = toCount(row.active_engagements) + + return { + id: row.id, + username: row.username, + bio: row.bio ?? null, + skills: Array.isArray(row.skills) ? row.skills : [], + userType: row.user_type, + walletAddress: row.wallet_address, + availability: { + status: activeEngagements > 0 ? 'busy' : 'available', + activeEngagements, + }, + ratings: { + average: toRating(row.average_rating), + reviewCount: toCount(row.review_count), + storedRating: toRating(row.rating), + }, + stats: { + completedContracts: toCount(row.completed_contracts), + completedJobs: toCount(row.total_jobs_completed), + }, + memberSince: toIso(row.created_at) ?? '', + } +} + +export function mapCompletedContractRow( + row: CompletedContractRow, +): PublicCompletedContract { + return { + id: row.id, + jobId: row.job_id, + jobTitle: row.job_title, + totalAmount: String(row.total_amount), + currency: row.currency, + contractAddress: row.contract_address ?? null, + contractTxHash: row.contract_tx_hash ?? null, + completedAt: toIso(row.completed_at), + createdAt: toIso(row.created_at) ?? '', + } +} + +export function mapReviewRow(row: ReviewRow): PublicReview { + return { + id: row.id, + contractId: row.contract_id, + rating: toCount(row.rating), + comment: row.comment ?? null, + verified: row.verified === true, + reviewer: { + // A deleted reviewer cascades the review away, so this is defensive only. + username: row.reviewer_username ?? 'unknown', + }, + createdAt: toIso(row.created_at) ?? '', + } +} + +// ---------- Query parameter parsing ---------------------------------------- + +/** Validates a path id. Returns `null` when it is not a usable `users.id`. */ +export function parseFreelancerId(raw: string): number | null { + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1) return null + // Reject "12abc" / "1.5": parseInt would silently accept the prefix. + if (String(parsed) !== raw.trim()) return null + return parsed +} + +/** + * Parses `?page=` and `?limit=`. Out-of-range values are rejected rather than + * silently clamped so that integrators notice a bad request, except for + * `limit` above the ceiling, which is capped at `PUBLIC_MAX_LIMIT`. + */ +export function parsePagination(searchParams: URLSearchParams): PaginationParams { + const page = parsePositiveInt(searchParams.get('page'), PUBLIC_DEFAULT_PAGE, 'page') + const limit = parsePositiveInt(searchParams.get('limit'), PUBLIC_DEFAULT_LIMIT, 'limit') + return { page, limit: Math.min(limit, PUBLIC_MAX_LIMIT) } +} + +function parsePositiveInt(raw: string | null, fallback: number, field: string): number { + if (raw === null || raw === '') return fallback + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new PublicProfileError( + 'INVALID_PAGINATION', + `${field} must be an integer greater than or equal to 1`, + ) + } + return parsed +} + +/** Parses the `?verified=` review filter. Absent means "no filter". */ +export function parseVerifiedFilter(searchParams: URLSearchParams): boolean | null { + const raw = searchParams.get('verified') + if (raw === null || raw === '') return null + const normalized = raw.toLowerCase() + if (normalized === 'true' || normalized === '1') return true + if (normalized === 'false' || normalized === '0') return false + throw new PublicProfileError( + 'INVALID_VERIFIED_FILTER', + 'verified must be one of: true, false', + ) +} + +export function buildPaginationMeta( + params: PaginationParams, + totalCount: number, +): PaginationMeta { + const totalPages = totalCount === 0 ? 0 : Math.ceil(totalCount / params.limit) + return { + page: params.page, + limit: params.limit, + totalCount, + totalPages, + hasNextPage: params.page < totalPages, + } +} + +// ---------- Response helpers ------------------------------------------------ + +/** Wraps a payload in the standard `{ data, meta }` envelope. */ +export function publicJson(data: T, meta?: object): NextResponse { + return NextResponse.json( + meta === undefined ? { data } : { data, meta }, + { status: 200, headers: { 'Cache-Control': PUBLIC_CACHE_CONTROL } }, + ) +} + +/** Wraps an error in the standard `{ error, code }` envelope. Never cached. */ +export function publicError( + status: number, + code: string, + message: string, +): NextResponse { + return NextResponse.json( + { error: message, code }, + { status, headers: { 'Cache-Control': 'no-store' } }, + ) +} + +/** 404 used by every endpoint so a missing and a client-only user look alike. */ +export function freelancerNotFound(): NextResponse { + return publicError(404, 'FREELANCER_NOT_FOUND', 'Freelancer not found') +} + +/** Maps a thrown `PublicProfileError` onto the error envelope. */ +export function publicProfileErrorResponse(error: PublicProfileError): NextResponse { + return publicError(error.status, error.code, error.message) +} + +// ---------- Queries --------------------------------------------------------- + +/** + * Loads the public profile. Aggregates are scalar sub-selects so the whole + * profile is one round-trip; each is backed by an index on `freelancer_id`. + * + * Returns `null` when the id does not exist *or* belongs to a client-only + * account โ€” callers must not distinguish the two in their response. + */ +export async function getPublicProfile( + freelancerId: number, +): Promise { + const rows = (await sql` + SELECT + u.id, + u.username, + u.bio, + u.skills, + u.user_type, + u.wallet_address, + u.rating, + u.total_jobs_completed, + u.created_at, + ( + SELECT COUNT(*)::int + FROM reviews r + WHERE r.freelancer_id = u.id + ) AS review_count, + ( + SELECT AVG(r.rating)::numeric + FROM reviews r + WHERE r.freelancer_id = u.id + ) AS average_rating, + ( + SELECT COUNT(*)::int + FROM jobs j + WHERE j.freelancer_id = u.id + AND j.status IN ('assigned', 'in_progress', 'in_review') + ) AS active_engagements, + ( + SELECT COUNT(*)::int + FROM contracts c + WHERE c.freelancer_id = u.id + AND c.status = 'completed' + ) AS completed_contracts + FROM users u + WHERE u.id = ${freelancerId} + AND u.user_type IN ('freelancer', 'both') + LIMIT 1 + `) as ProfileRow[] + + const row = rows[0] + return row ? mapProfileRow(row) : null +} + +/** + * Existence check for the sub-resource endpoints, so `/reviews` on an unknown + * id answers 404 instead of an empty page. Same visibility rule as + * `getPublicProfile`: client-only accounts are treated as absent. + */ +export async function publicFreelancerExists(freelancerId: number): Promise { + const rows = (await sql` + SELECT 1 + FROM users u + WHERE u.id = ${freelancerId} + AND u.user_type IN ('freelancer', 'both') + LIMIT 1 + `) as unknown[] + return rows.length > 0 +} + +/** + * Lists completed contracts, newest first. `COUNT(*) OVER()` gives the total in + * the same round-trip; ordering ties break on `c.id` so paging is stable. + */ +export async function listCompletedContracts( + freelancerId: number, + params: PaginationParams, +): Promise> { + const offset = (params.page - 1) * params.limit + + const rows = (await sql` + SELECT + c.id, + c.job_id, + j.title AS job_title, + c.total_amount, + c.currency, + c.contract_address, + c.contract_tx_hash, + COALESCE(j.completed_at, c.updated_at) AS completed_at, + c.created_at, + COUNT(*) OVER() AS total_count + FROM contracts c + JOIN jobs j ON j.id = c.job_id + WHERE c.freelancer_id = ${freelancerId} + AND c.status = 'completed' + ORDER BY COALESCE(j.completed_at, c.updated_at) DESC NULLS LAST, c.id DESC + LIMIT ${params.limit} + OFFSET ${offset} + `) as CompletedContractRow[] + + return { + items: rows.map(mapCompletedContractRow), + totalCount: rows.length > 0 ? toCount(rows[0].total_count) : 0, + } +} + +/** + * Lists reviews, newest first, with the summary computed over the *whole* + * filtered set: window functions are evaluated before `LIMIT`, so + * `AVG(...) OVER()` is not the page average. + * + * `verified` is applied with the parameterised `NULL`-guard pattern used + * elsewhere in the repo, keeping this to a single prepared statement. + */ +export async function listReviews( + freelancerId: number, + params: PaginationParams & { verified: boolean | null }, +): Promise & { summary: PublicReviewSummary }> { + const offset = (params.page - 1) * params.limit + const verified = params.verified + + const rows = (await sql` + SELECT + rv.id, + rv.contract_id, + rv.rating, + rv.comment, + rv.verified, + ru.username AS reviewer_username, + rv.created_at, + COUNT(*) OVER() AS total_count, + (AVG(rv.rating) OVER())::numeric AS average_rating, + COUNT(*) FILTER (WHERE COALESCE(rv.verified, FALSE)) OVER() AS verified_count + FROM reviews rv + LEFT JOIN users ru ON ru.id = rv.reviewer_id + WHERE rv.freelancer_id = ${freelancerId} + AND ( + ${verified}::boolean IS NULL + OR COALESCE(rv.verified, FALSE) = ${verified}::boolean + ) + ORDER BY rv.created_at DESC, rv.id DESC + LIMIT ${params.limit} + OFFSET ${offset} + `) as ReviewRow[] + + const first = rows[0] + + return { + items: rows.map(mapReviewRow), + totalCount: first ? toCount(first.total_count) : 0, + summary: { + averageRating: first ? toRating(first.average_rating) : null, + verifiedCount: first ? toCount(first.verified_count) : 0, + }, + } +} + +/** + * Blends delivery metrics with client reviews into a single 0โ€“100 score. + * + * Components with no data are dropped and their weight is redistributed across + * the remaining ones, so a freelancer with no reviews yet is scored on delivery + * history rather than penalised for the gap. `null` when there is no signal at + * all. `components` is returned so consumers can show the breakdown instead of + * treating the number as a black box. + */ +export function computeReputationScore(input: { + metrics: ReputationMetrics + averageRating: number | null + reviewCount: number +}): { score: number | null; components: ReputationComponent[] } { + const { metrics, averageRating, reviewCount } = input + + const rawValues: Record = { + completion: metrics.completionRate, + onTime: metrics.onTimeDeliveryPct, + disputeFree: metrics.disputeRate === null ? null : 1 - metrics.disputeRate, + // Map the 1โ€“5 review scale onto 0โ€“1. + clientRating: + reviewCount > 0 && averageRating !== null ? (averageRating - 1) / 4 : null, + } + + const keys = Object.keys(REPUTATION_WEIGHTS) as ReputationComponentKey[] + const availableWeight = keys.reduce( + (sum, key) => (rawValues[key] === null ? sum : sum + REPUTATION_WEIGHTS[key]), + 0, + ) + + const components: ReputationComponent[] = keys.map((key) => { + const value = rawValues[key] + const clamped = value === null ? null : Math.min(1, Math.max(0, value)) + return { + key, + value: clamped === null ? null : roundTo(clamped, 4), + weight: REPUTATION_WEIGHTS[key], + appliedWeight: + clamped === null || availableWeight === 0 + ? 0 + : roundTo(REPUTATION_WEIGHTS[key] / availableWeight, 4), + } + }) + + if (availableWeight === 0) { + return { score: null, components } + } + + const weighted = keys.reduce((sum, key) => { + const value = rawValues[key] + if (value === null) return sum + const clamped = Math.min(1, Math.max(0, value)) + return sum + clamped * (REPUTATION_WEIGHTS[key] / availableWeight) + }, 0) + + return { + score: roundTo(Math.min(100, Math.max(0, weighted * 100)), 1), + components, + } +} + +/** Completed-contract and review aggregates, all in one round-trip. */ +async function fetchReputationSources( + freelancerId: number, +): Promise { + return (await sql` + SELECT + ( + SELECT COUNT(*)::int + FROM contracts c + WHERE c.freelancer_id = ${freelancerId} + AND c.status = 'completed' + ) AS completed_contracts, + ( + SELECT COUNT(*)::int + FROM reviews r + WHERE r.freelancer_id = ${freelancerId} + ) AS review_count, + ( + SELECT COUNT(*)::int + FROM reviews r + WHERE r.freelancer_id = ${freelancerId} + AND r.verified + ) AS verified_review_count, + ( + SELECT AVG(r.rating)::numeric + FROM reviews r + WHERE r.freelancer_id = ${freelancerId} + ) AS average_rating + `) as ReputationSourceRow[] +} + +/** + * Assembles the public reputation payload: cached delivery metrics from + * `lib/reputation` plus contract and review aggregates fetched in one query. + */ +export async function getPublicReputation( + freelancerId: number, +): Promise { + const [reputation, sourceRows] = await Promise.all([ + getFreelancerReputation(freelancerId), + fetchReputationSources(freelancerId), + ]) + + const sourceRow = sourceRows[0] + const reviewCount = toCount(sourceRow?.review_count) + const averageRating = toRating(sourceRow?.average_rating) + + const { score, components } = computeReputationScore({ + metrics: reputation.metrics, + averageRating, + reviewCount, + }) + + return { + freelancerId, + score, + components, + sources: { + completedContracts: toCount(sourceRow?.completed_contracts), + reviewCount, + verifiedReviewCount: toCount(sourceRow?.verified_review_count), + averageRating, + metrics: reputation.metrics, + }, + computedAt: reputation.computedAt, + } +} diff --git a/scripts/011-public-profile-indexes.sql b/scripts/011-public-profile-indexes.sql new file mode 100644 index 0000000..743aec3 --- /dev/null +++ b/scripts/011-public-profile-indexes.sql @@ -0,0 +1,43 @@ +-- 011-public-profile-indexes.sql +-- +-- Performance indexes for the public freelancer profile API +-- (TaskChain issue #154), served from /api/public/freelancers/[id]: +-- * profile โ€” review count + AVG(rating), active-job count, completed-contract count +-- * /contracts โ€” completed contracts, ordered by completion date +-- * /reviews โ€” reviews page + AVG(rating) window, optional `verified` filter +-- * /reputation โ€” completed-contract and review aggregates +-- +-- Every one of those reads is keyed on `freelancer_id`, so the goal here is to +-- keep each aggregate an index-only scan over a narrow slice instead of a +-- sequential scan that grows with the whole table. + +-- The /contracts listing filters on (freelancer_id, status = 'completed') and +-- the profile/reputation endpoints count the same slice. A partial index keeps +-- it small: only completed contracts are ever publicly listable. +CREATE INDEX IF NOT EXISTS idx_contracts_freelancer_completed + ON contracts (freelancer_id, updated_at DESC) + WHERE status = 'completed'; + +-- Reviews are always read newest-first for one freelancer. The composite index +-- serves both the ORDER BY and the COUNT/AVG window in a single scan. +-- (`idx_reviews_freelancer` from 009 covers only the equality predicate.) +CREATE INDEX IF NOT EXISTS idx_reviews_freelancer_created + ON reviews (freelancer_id, created_at DESC); + +-- Supports `?verified=true`, the filter external consumers are most likely to +-- apply, without scanning the freelancer's full review history. +CREATE INDEX IF NOT EXISTS idx_reviews_freelancer_verified + ON reviews (freelancer_id, created_at DESC) + WHERE verified; + +-- `availability` is derived from in-flight work, so the profile endpoint counts +-- jobs in the three active states. Partial index = only the active slice, which +-- stays small even as completed history accumulates. +CREATE INDEX IF NOT EXISTS idx_jobs_freelancer_active + ON jobs (freelancer_id) + WHERE status IN ('assigned', 'in_progress', 'in_review'); + +-- The /contracts ORDER BY reads jobs.completed_at for each contract row. +CREATE INDEX IF NOT EXISTS idx_jobs_completed_at + ON jobs (completed_at DESC NULLS LAST) + WHERE completed_at IS NOT NULL;