diff --git a/app/api/freelancer/reputation/route.ts b/app/api/freelancer/reputation/route.ts new file mode 100644 index 0000000..340fcf9 --- /dev/null +++ b/app/api/freelancer/reputation/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { + getFreelancerReputation, + getUserIdByWallet, +} from '@/lib/reputation' + +export const GET = withAuth(async (request: NextRequest, auth) => { + const userId = await getUserIdByWallet(auth.walletAddress) + if (userId === null) { + return NextResponse.json( + { error: 'Platform user not found for this wallet', code: 'USER_NOT_FOUND' }, + { status: 404 } + ) + } + + const forceRefresh = + request.nextUrl.searchParams.get('refresh') === '1' || + request.nextUrl.searchParams.get('refresh') === 'true' + + try { + const payload = await getFreelancerReputation(userId, { forceRefresh }) + return NextResponse.json(payload, { + status: 200, + headers: { + 'Cache-Control': 'private, no-store', + }, + }) + } catch { + return NextResponse.json( + { error: 'Unable to load reputation', code: 'REPUTATION_UNAVAILABLE' }, + { status: 503 } + ) + } +}) diff --git a/app/api/freelancers/[userId]/reputation/route.ts b/app/api/freelancers/[userId]/reputation/route.ts new file mode 100644 index 0000000..6ce80fd --- /dev/null +++ b/app/api/freelancers/[userId]/reputation/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + getFreelancerReputation, + userExists, +} from '@/lib/reputation' + +type RouteContext = { params: Promise<{ userId: string }> } + +export async function GET(_request: NextRequest, context: RouteContext) { + const { userId: rawId } = await context.params + const id = Number.parseInt(rawId, 10) + + if (!Number.isFinite(id) || id < 1) { + return NextResponse.json( + { error: 'Invalid user id', code: 'INVALID_USER_ID' }, + { status: 400 } + ) + } + + const exists = await userExists(id) + if (!exists) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 } + ) + } + + try { + const payload = await getFreelancerReputation(id) + return NextResponse.json(payload, { + status: 200, + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + }, + }) + } catch { + return NextResponse.json( + { error: 'Unable to load reputation', code: 'REPUTATION_UNAVAILABLE' }, + { status: 503 } + ) + } +} diff --git a/docs/reputation-api.md b/docs/reputation-api.md new file mode 100644 index 0000000..a93c5ea --- /dev/null +++ b/docs/reputation-api.md @@ -0,0 +1,71 @@ +# Reputation API — integration guide + +Backend reputation metrics are **pre-aggregated** in Postgres (`freelancer_reputation`) and refreshed when a snapshot is **missing or older than five minutes** (configurable in code via `REPUTATION_SNAPSHOT_MAX_AGE_MS`). This keeps reads cheap at scale while staying close to real-time job data. + +## Metrics definitions + +| Field | Meaning | +|--------|---------| +| **completionRate** | `jobsCompleted / jobsStarted`, capped 0–1. `jobsStarted` counts all jobs where the user is `freelancer_id` (any non-null assignment). | +| **disputeRate** | Share of those jobs that either have `status = 'disputed'` or at least one row in `disputes` for that `job_id`. | +| **totalVolume** | Sum of `budget` over jobs with `status = 'completed'`. Amounts follow whatever `currency` is on each job; if you mix currencies, treat this as informational or extend the API to group by currency. | +| **onTimeDeliveryPct** | Among completed jobs with both `deadline` and `completed_at`, the fraction where `completed_at <= deadline`. If none qualify, this is `null`. | +| **reputationScore** | Optional display score 0–100: `0.4 * completion + 0.35 * onTime + 0.25 * (1 - dispute)`, with `0.5` used for on-time when there is no on-time sample but the user has started jobs. `null` when `jobsStarted === 0`. | + +Rates and the score are **`null`** when denominators are zero (except `totalVolume`, which is `0`). + +## Endpoints + +### 1. Public profile — `GET /api/freelancers/{userId}/reputation` + +- **Path**: `userId` is the integer primary key from `users.id`. +- **Auth**: none. +- **Cache**: `Cache-Control: public, s-maxage=60, stale-while-revalidate=300`. +- **Errors**: `400` invalid id, `404` user missing, `503` database failure. + +**Example response** + +```json +{ + "userId": 42, + "metrics": { + "completionRate": 0.92, + "disputeRate": 0.04, + "totalVolume": "12500.00", + "onTimeDeliveryPct": 0.88, + "jobsStarted": 25, + "jobsCompleted": 23, + "jobsWithDispute": 1, + "completedWithDeadline": 20, + "onTimeDeliveries": 17 + }, + "reputationScore": 86.7, + "computedAt": "2026-03-24T12:00:00.000Z" +} +``` + +### 2. Authenticated freelancer — `GET /api/freelancer/reputation` + +- **Auth**: session / access cookie (same as `/api/auth/me`). +- **Resolution**: `users.wallet_address` must match the token’s wallet; returns that row’s reputation. +- **Query**: `?refresh=1` or `?refresh=true` forces a recomputation before responding (use sparingly). +- **Cache**: `Cache-Control: private, no-store`. +- **Errors**: `401` unauthenticated, `404` no `users` row for wallet, `503` database failure. + +## Database setup + +Run the migration after `001-create-tables.sql` / `002-auth-tables.sql`: + +```bash +# Example: pipe into psql or Neon's SQL editor +scripts/003-freelancer-reputation.sql +``` + +This adds `jobs.completed_at`, table `freelancer_reputation`, and indexes on `(freelancer_id, status)` and completed jobs to keep aggregations fast as data grows. + +## Frontend usage + +- **Profile pages**: call the public route with the profile’s numeric `userId`. +- **Dashboard “my reputation”**: call `/api/freelancer/reputation` with `credentials: 'include'` (see existing dashboard fetch patterns in `lib/freelancer-dashboard.ts`). + +After job completion flows (including the Stellar worker), ensure `completed_at` is set so **on-time delivery** stays accurate; the worker updates it when marking a job `completed` from escrow release. diff --git a/lib/reputation.ts b/lib/reputation.ts new file mode 100644 index 0000000..a5330f5 --- /dev/null +++ b/lib/reputation.ts @@ -0,0 +1,326 @@ +import { sql } from '@/lib/db' + +/** Maximum age of a cached row before metrics are recomputed from `jobs` / `disputes`. */ +export const REPUTATION_SNAPSHOT_MAX_AGE_MS = 5 * 60 * 1000 + +export interface ReputationMetrics { + /** Share of assigned jobs that reached `completed` (0–1). */ + completionRate: number | null + /** Share of assigned jobs that are disputed or have a `disputes` row (0–1). */ + disputeRate: number | null + /** Sum of `budget` for completed jobs (same numeric mix as stored per job). */ + totalVolume: number + /** Among completed jobs with both `deadline` and `completed_at`, share finished on/before deadline (0–1). */ + onTimeDeliveryPct: number | null + jobsStarted: number + jobsCompleted: number + jobsWithDispute: number + completedWithDeadline: number + onTimeDeliveries: number +} + +export interface FreelancerReputationPayload { + userId: number + metrics: ReputationMetrics + /** Optional blended score 0–100 for display; null if insufficient data. */ + reputationScore: number | null + computedAt: string +} + +interface AggregateRow { + jobs_started: number + jobs_completed: number + jobs_with_dispute: number + total_volume: string | number + completed_with_deadline: number + on_time_deliveries: number +} + +interface SnapshotRow { + user_id: number + jobs_started: number + jobs_completed: number + jobs_with_dispute: number + total_completed_volume: string | number + completed_with_deadline: number + on_time_deliveries: number + completion_rate: string | number | null + dispute_rate: string | number | null + on_time_delivery_rate: string | number | null + reputation_score: string | number | null + computed_at: string +} + +function num(v: string | number | null | undefined): number | null { + if (v === null || v === undefined) return null + const n = typeof v === 'number' ? v : Number(v) + return Number.isFinite(n) ? n : null +} + +function buildPayload( + userId: number, + row: { + jobs_started: number + jobs_completed: number + jobs_with_dispute: number + total_volume: string | number + completed_with_deadline: number + on_time_deliveries: number + completion_rate: number | null + dispute_rate: number | null + on_time_delivery_rate: number | null + reputation_score: number | null + computed_at: Date | string + } +): FreelancerReputationPayload { + const computedAt = + row.computed_at instanceof Date + ? row.computed_at.toISOString() + : row.computed_at + + return { + userId, + metrics: { + completionRate: row.completion_rate, + disputeRate: row.dispute_rate, + totalVolume: num(row.total_volume) ?? 0, + onTimeDeliveryPct: row.on_time_delivery_rate, + jobsStarted: row.jobs_started, + jobsCompleted: row.jobs_completed, + jobsWithDispute: row.jobs_with_dispute, + completedWithDeadline: row.completed_with_deadline, + onTimeDeliveries: row.on_time_deliveries, + }, + reputationScore: row.reputation_score, + computedAt, + } +} + +export function deriveRatesFromCounts(args: { + jobsStarted: number + jobsCompleted: number + jobsWithDispute: number + completedWithDeadline: number + onTimeDeliveries: number +}): { + completionRate: number | null + disputeRate: number | null + onTimeDeliveryPct: number | null + reputationScore: number | null +} { + const { jobsStarted, jobsCompleted, jobsWithDispute, completedWithDeadline, onTimeDeliveries } = + args + + const completionRate = + jobsStarted > 0 ? Math.min(1, Math.max(0, jobsCompleted / jobsStarted)) : null + const disputeRate = + jobsStarted > 0 ? Math.min(1, Math.max(0, jobsWithDispute / jobsStarted)) : null + const onTimeDeliveryPct = + completedWithDeadline > 0 + ? Math.min(1, Math.max(0, onTimeDeliveries / completedWithDeadline)) + : null + + let reputationScore: number | null = null + if (jobsStarted > 0 && completionRate !== null && disputeRate !== null) { + const onTimeComponent = onTimeDeliveryPct ?? 0.5 + const raw = + 100 * (0.4 * completionRate + 0.35 * onTimeComponent + 0.25 * (1 - disputeRate)) + reputationScore = Math.round(Math.min(100, Math.max(0, raw)) * 10) / 10 + } + + return { completionRate, disputeRate, onTimeDeliveryPct, reputationScore } +} + +async function aggregateForFreelancer(userId: number): Promise { + const rows = await sql` + SELECT + COUNT(*)::int AS jobs_started, + COUNT(*) FILTER (WHERE j.status = 'completed')::int AS jobs_completed, + COUNT(*) FILTER ( + WHERE j.status = 'disputed' + OR EXISTS (SELECT 1 FROM disputes d WHERE d.job_id = j.id) + )::int AS jobs_with_dispute, + COALESCE( + SUM(j.budget) FILTER (WHERE j.status = 'completed'), + 0 + )::numeric AS total_volume, + COUNT(*) FILTER ( + WHERE j.status = 'completed' + AND j.deadline IS NOT NULL + AND j.completed_at IS NOT NULL + )::int AS completed_with_deadline, + COUNT(*) FILTER ( + WHERE j.status = 'completed' + AND j.deadline IS NOT NULL + AND j.completed_at IS NOT NULL + AND j.completed_at <= j.deadline + )::int AS on_time_deliveries + FROM jobs j + WHERE j.freelancer_id = ${userId} + ` + + return ( + rows[0] ?? { + jobs_started: 0, + jobs_completed: 0, + jobs_with_dispute: 0, + total_volume: 0, + completed_with_deadline: 0, + on_time_deliveries: 0, + } + ) +} + +async function upsertSnapshot(userId: number, agg: AggregateRow): Promise { + const totalVolume = num(agg.total_volume) ?? 0 + const { completionRate, disputeRate, onTimeDeliveryPct, reputationScore } = + deriveRatesFromCounts({ + jobsStarted: agg.jobs_started, + jobsCompleted: agg.jobs_completed, + jobsWithDispute: agg.jobs_with_dispute, + completedWithDeadline: agg.completed_with_deadline, + onTimeDeliveries: agg.on_time_deliveries, + }) + + await sql` + INSERT INTO freelancer_reputation ( + user_id, + jobs_started, + jobs_completed, + jobs_with_dispute, + total_completed_volume, + completed_with_deadline, + on_time_deliveries, + completion_rate, + dispute_rate, + on_time_delivery_rate, + reputation_score, + computed_at + ) + VALUES ( + ${userId}, + ${agg.jobs_started}, + ${agg.jobs_completed}, + ${agg.jobs_with_dispute}, + ${totalVolume}, + ${agg.completed_with_deadline}, + ${agg.on_time_deliveries}, + ${completionRate}, + ${disputeRate}, + ${onTimeDeliveryPct}, + ${reputationScore}, + NOW() + ) + ON CONFLICT (user_id) DO UPDATE SET + jobs_started = EXCLUDED.jobs_started, + jobs_completed = EXCLUDED.jobs_completed, + jobs_with_dispute = EXCLUDED.jobs_with_dispute, + total_completed_volume = EXCLUDED.total_completed_volume, + completed_with_deadline = EXCLUDED.completed_with_deadline, + on_time_deliveries = EXCLUDED.on_time_deliveries, + completion_rate = EXCLUDED.completion_rate, + dispute_rate = EXCLUDED.dispute_rate, + on_time_delivery_rate = EXCLUDED.on_time_delivery_rate, + reputation_score = EXCLUDED.reputation_score, + computed_at = EXCLUDED.computed_at + ` +} + +async function readSnapshot(userId: number): Promise { + const rows = await sql` + SELECT + user_id, + jobs_started, + jobs_completed, + jobs_with_dispute, + total_completed_volume, + completed_with_deadline, + on_time_deliveries, + completion_rate, + dispute_rate, + on_time_delivery_rate, + reputation_score, + computed_at + FROM freelancer_reputation + WHERE user_id = ${userId} + LIMIT 1 + ` + return rows[0] ?? null +} + +function snapshotIsFresh(computedAt: string, maxAgeMs: number): boolean { + const t = new Date(computedAt).getTime() + if (!Number.isFinite(t)) return false + return Date.now() - t < maxAgeMs +} + +/** Recomputes metrics from source tables and refreshes the snapshot row. */ +export async function refreshFreelancerReputation(userId: number): Promise { + const agg = await aggregateForFreelancer(userId) + await upsertSnapshot(userId, agg) + const snap = await readSnapshot(userId) + if (!snap) { + throw new Error('Reputation snapshot missing after upsert') + } + + return buildPayload(userId, { + jobs_started: snap.jobs_started, + jobs_completed: snap.jobs_completed, + jobs_with_dispute: snap.jobs_with_dispute, + total_volume: snap.total_completed_volume, + completed_with_deadline: snap.completed_with_deadline, + on_time_deliveries: snap.on_time_deliveries, + completion_rate: num(snap.completion_rate), + dispute_rate: num(snap.dispute_rate), + on_time_delivery_rate: num(snap.on_time_delivery_rate), + reputation_score: num(snap.reputation_score), + computed_at: snap.computed_at, + }) +} + +/** + * Returns cached reputation when fresh; otherwise recomputes once per TTL window. + * Reads are O(1) against `freelancer_reputation`; recomputation uses one aggregation query. + */ +export async function getFreelancerReputation( + userId: number, + options?: { maxAgeMs?: number; forceRefresh?: boolean } +): Promise { + const maxAgeMs = options?.maxAgeMs ?? REPUTATION_SNAPSHOT_MAX_AGE_MS + const forceRefresh = options?.forceRefresh ?? false + + if (!forceRefresh) { + const snap = await readSnapshot(userId) + if (snap && snapshotIsFresh(snap.computed_at, maxAgeMs)) { + return buildPayload(userId, { + jobs_started: snap.jobs_started, + jobs_completed: snap.jobs_completed, + jobs_with_dispute: snap.jobs_with_dispute, + total_volume: snap.total_completed_volume, + completed_with_deadline: snap.completed_with_deadline, + on_time_deliveries: snap.on_time_deliveries, + completion_rate: num(snap.completion_rate), + dispute_rate: num(snap.dispute_rate), + on_time_delivery_rate: num(snap.on_time_delivery_rate), + reputation_score: num(snap.reputation_score), + computed_at: snap.computed_at, + }) + } + } + + return refreshFreelancerReputation(userId) +} + +export async function getUserIdByWallet(walletAddress: string): Promise { + const rows = await sql<{ id: number }[]>` + SELECT id FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 + ` + return rows[0]?.id ?? null +} + +export async function userExists(userId: number): Promise { + const rows = await sql<{ id: number }[]>` + SELECT id FROM users WHERE id = ${userId} LIMIT 1 + ` + return rows.length > 0 +} diff --git a/scripts/003-freelancer-reputation.sql b/scripts/003-freelancer-reputation.sql new file mode 100644 index 0000000..035d1e5 --- /dev/null +++ b/scripts/003-freelancer-reputation.sql @@ -0,0 +1,33 @@ +-- Freelancer reputation: denormalized metrics for fast reads + jobs completion timestamp + +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS completed_at TIMESTAMP; + +UPDATE jobs +SET completed_at = updated_at +WHERE status = 'completed' AND completed_at IS NULL; + +CREATE TABLE IF NOT EXISTS freelancer_reputation ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + jobs_started INTEGER NOT NULL DEFAULT 0, + jobs_completed INTEGER NOT NULL DEFAULT 0, + jobs_with_dispute INTEGER NOT NULL DEFAULT 0, + total_completed_volume NUMERIC(14, 2) NOT NULL DEFAULT 0, + completed_with_deadline INTEGER NOT NULL DEFAULT 0, + on_time_deliveries INTEGER NOT NULL DEFAULT 0, + completion_rate NUMERIC(7, 6), + dispute_rate NUMERIC(7, 6), + on_time_delivery_rate NUMERIC(7, 6), + reputation_score NUMERIC(6, 2), + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_freelancer_reputation_computed_at + ON freelancer_reputation (computed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_jobs_freelancer_status + ON jobs (freelancer_id, status) + WHERE freelancer_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_jobs_freelancer_completed + ON jobs (freelancer_id) + WHERE freelancer_id IS NOT NULL AND status = 'completed'; diff --git a/scripts/worker.ts b/scripts/worker.ts index 428bf02..8c164af 100644 --- a/scripts/worker.ts +++ b/scripts/worker.ts @@ -88,7 +88,10 @@ async function processPaymentEvent(record: any) { await sql` UPDATE jobs - SET escrow_status = 'released', status = 'completed', updated_at = CURRENT_TIMESTAMP + SET escrow_status = 'released', + status = 'completed', + completed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP WHERE id = ${jobId} AND escrow_status != 'released' `