Skip to content

Commit f5d54b9

Browse files
Merge pull request #57 from robertocarlous/Feat/Reputation-Scoring-Engine
[Feature]: Reputation Scoring Engine
2 parents 6a7083c + 47139f7 commit f5d54b9

6 files changed

Lines changed: 511 additions & 1 deletion

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { withAuth } from '@/lib/auth/middleware'
3+
import {
4+
getFreelancerReputation,
5+
getUserIdByWallet,
6+
} from '@/lib/reputation'
7+
8+
export const GET = withAuth(async (request: NextRequest, auth) => {
9+
const userId = await getUserIdByWallet(auth.walletAddress)
10+
if (userId === null) {
11+
return NextResponse.json(
12+
{ error: 'Platform user not found for this wallet', code: 'USER_NOT_FOUND' },
13+
{ status: 404 }
14+
)
15+
}
16+
17+
const forceRefresh =
18+
request.nextUrl.searchParams.get('refresh') === '1' ||
19+
request.nextUrl.searchParams.get('refresh') === 'true'
20+
21+
try {
22+
const payload = await getFreelancerReputation(userId, { forceRefresh })
23+
return NextResponse.json(payload, {
24+
status: 200,
25+
headers: {
26+
'Cache-Control': 'private, no-store',
27+
},
28+
})
29+
} catch {
30+
return NextResponse.json(
31+
{ error: 'Unable to load reputation', code: 'REPUTATION_UNAVAILABLE' },
32+
{ status: 503 }
33+
)
34+
}
35+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import {
3+
getFreelancerReputation,
4+
userExists,
5+
} from '@/lib/reputation'
6+
7+
type RouteContext = { params: Promise<{ userId: string }> }
8+
9+
export async function GET(_request: NextRequest, context: RouteContext) {
10+
const { userId: rawId } = await context.params
11+
const id = Number.parseInt(rawId, 10)
12+
13+
if (!Number.isFinite(id) || id < 1) {
14+
return NextResponse.json(
15+
{ error: 'Invalid user id', code: 'INVALID_USER_ID' },
16+
{ status: 400 }
17+
)
18+
}
19+
20+
const exists = await userExists(id)
21+
if (!exists) {
22+
return NextResponse.json(
23+
{ error: 'User not found', code: 'USER_NOT_FOUND' },
24+
{ status: 404 }
25+
)
26+
}
27+
28+
try {
29+
const payload = await getFreelancerReputation(id)
30+
return NextResponse.json(payload, {
31+
status: 200,
32+
headers: {
33+
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
34+
},
35+
})
36+
} catch {
37+
return NextResponse.json(
38+
{ error: 'Unable to load reputation', code: 'REPUTATION_UNAVAILABLE' },
39+
{ status: 503 }
40+
)
41+
}
42+
}

docs/reputation-api.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Reputation API — integration guide
2+
3+
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.
4+
5+
## Metrics definitions
6+
7+
| Field | Meaning |
8+
|--------|---------|
9+
| **completionRate** | `jobsCompleted / jobsStarted`, capped 0–1. `jobsStarted` counts all jobs where the user is `freelancer_id` (any non-null assignment). |
10+
| **disputeRate** | Share of those jobs that either have `status = 'disputed'` or at least one row in `disputes` for that `job_id`. |
11+
| **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. |
12+
| **onTimeDeliveryPct** | Among completed jobs with both `deadline` and `completed_at`, the fraction where `completed_at <= deadline`. If none qualify, this is `null`. |
13+
| **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`. |
14+
15+
Rates and the score are **`null`** when denominators are zero (except `totalVolume`, which is `0`).
16+
17+
## Endpoints
18+
19+
### 1. Public profile — `GET /api/freelancers/{userId}/reputation`
20+
21+
- **Path**: `userId` is the integer primary key from `users.id`.
22+
- **Auth**: none.
23+
- **Cache**: `Cache-Control: public, s-maxage=60, stale-while-revalidate=300`.
24+
- **Errors**: `400` invalid id, `404` user missing, `503` database failure.
25+
26+
**Example response**
27+
28+
```json
29+
{
30+
"userId": 42,
31+
"metrics": {
32+
"completionRate": 0.92,
33+
"disputeRate": 0.04,
34+
"totalVolume": "12500.00",
35+
"onTimeDeliveryPct": 0.88,
36+
"jobsStarted": 25,
37+
"jobsCompleted": 23,
38+
"jobsWithDispute": 1,
39+
"completedWithDeadline": 20,
40+
"onTimeDeliveries": 17
41+
},
42+
"reputationScore": 86.7,
43+
"computedAt": "2026-03-24T12:00:00.000Z"
44+
}
45+
```
46+
47+
### 2. Authenticated freelancer — `GET /api/freelancer/reputation`
48+
49+
- **Auth**: session / access cookie (same as `/api/auth/me`).
50+
- **Resolution**: `users.wallet_address` must match the token’s wallet; returns that row’s reputation.
51+
- **Query**: `?refresh=1` or `?refresh=true` forces a recomputation before responding (use sparingly).
52+
- **Cache**: `Cache-Control: private, no-store`.
53+
- **Errors**: `401` unauthenticated, `404` no `users` row for wallet, `503` database failure.
54+
55+
## Database setup
56+
57+
Run the migration after `001-create-tables.sql` / `002-auth-tables.sql`:
58+
59+
```bash
60+
# Example: pipe into psql or Neon's SQL editor
61+
scripts/003-freelancer-reputation.sql
62+
```
63+
64+
This adds `jobs.completed_at`, table `freelancer_reputation`, and indexes on `(freelancer_id, status)` and completed jobs to keep aggregations fast as data grows.
65+
66+
## Frontend usage
67+
68+
- **Profile pages**: call the public route with the profile’s numeric `userId`.
69+
- **Dashboard “my reputation”**: call `/api/freelancer/reputation` with `credentials: 'include'` (see existing dashboard fetch patterns in `lib/freelancer-dashboard.ts`).
70+
71+
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.

0 commit comments

Comments
 (0)