Skip to content

Commit a67db9f

Browse files
committed
feat: enhance DID/Reputation UI — dashboard, public profiles, credentials, receipts, badge
- Reputation Dashboard: auto-query own DID stats with share section (copyable URL + badge) - Public Reputation Profile: ?did= query param for shareable reputation links - Credential List: show all credentials on DID management page - Task Receipt History: show all receipts on DID management page - Reputation Badge: SVG badge endpoint at /api/reputation/badge/[did] (shields.io style) - New API endpoints: GET /api/reputation/credentials?did=X, GET /api/reputation/receipts?did=X - Tests for all new API endpoints (8 tests passing) - Fix pre-commit hook double-build OOM issue Bump to v0.5.7
1 parent 0553b9d commit a67db9f

File tree

11 files changed

+614
-43
lines changed

11 files changed

+614
-43
lines changed

hooks/pre-commit

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ if [ $? -ne 0 ]; then
1313
exit 1
1414
fi
1515

16-
# Run tests
16+
# Run tests (without rebuilding)
1717
echo "Running tests..."
18-
pnpm run pre-commit
18+
npx vitest run
1919
if [ $? -ne 0 ]; then
2020
echo "❌ Tests failed. Please fix failing tests before committing."
2121
exit 1

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "coinpayportal",
3-
"version": "0.5.6",
3+
"version": "0.5.7",
44
"private": true,
55
"type": "module",
66
"bin": {

packages/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/coinpay",
3-
"version": "0.5.6",
3+
"version": "0.5.7",
44
"description": "CoinPay SDK & CLI — Accept cryptocurrency payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet and swap support",
55
"type": "module",
66
"main": "./src/index.js",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { createClient } from '@supabase/supabase-js';
3+
import { computeReputation } from '@/lib/reputation/attestation-engine';
4+
import { isValidDid } from '@/lib/reputation/crypto';
5+
6+
const supabase = createClient(
7+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
8+
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
9+
);
10+
11+
function generateBadgeSvg(taskCount: number, acceptedRate: number, flagged: boolean): string {
12+
const label = 'CPR Score';
13+
const scoreText = taskCount === 0
14+
? 'no data'
15+
: `${(acceptedRate * 100).toFixed(0)}% · ${taskCount} tasks`;
16+
17+
const labelColor = '#555';
18+
const valueColor = flagged
19+
? '#e05d44'
20+
: acceptedRate >= 0.9
21+
? '#4c1'
22+
: acceptedRate >= 0.7
23+
? '#dfb317'
24+
: '#e05d44';
25+
26+
const labelWidth = label.length * 7 + 12;
27+
const valueWidth = scoreText.length * 6.5 + 12;
28+
const totalWidth = labelWidth + valueWidth;
29+
30+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${scoreText}">
31+
<title>${label}: ${scoreText}</title>
32+
<linearGradient id="s" x2="0" y2="100%">
33+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
34+
<stop offset="1" stop-opacity=".1"/>
35+
</linearGradient>
36+
<clipPath id="r">
37+
<rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
38+
</clipPath>
39+
<g clip-path="url(#r)">
40+
<rect width="${labelWidth}" height="20" fill="${labelColor}"/>
41+
<rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${valueColor}"/>
42+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
43+
</g>
44+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
45+
<text aria-hidden="true" x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${label}</text>
46+
<text x="${labelWidth / 2}" y="14">${label}</text>
47+
<text aria-hidden="true" x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${scoreText}</text>
48+
<text x="${labelWidth + valueWidth / 2}" y="14">${scoreText}</text>
49+
</g>
50+
</svg>`;
51+
}
52+
53+
export async function GET(
54+
request: NextRequest,
55+
{ params }: { params: Promise<{ did: string }> }
56+
) {
57+
try {
58+
const { did } = await params;
59+
const agentDid = decodeURIComponent(did);
60+
61+
if (!isValidDid(agentDid)) {
62+
const svg = generateBadgeSvg(0, 0, false);
63+
return new NextResponse(svg, {
64+
headers: {
65+
'Content-Type': 'image/svg+xml',
66+
'Cache-Control': 'public, max-age=300',
67+
},
68+
});
69+
}
70+
71+
const reputation = await computeReputation(supabase, agentDid);
72+
const allTime = reputation.windows.all_time;
73+
const svg = generateBadgeSvg(
74+
allTime.task_count,
75+
allTime.accepted_rate,
76+
reputation.anti_gaming.flagged
77+
);
78+
79+
return new NextResponse(svg, {
80+
headers: {
81+
'Content-Type': 'image/svg+xml',
82+
'Cache-Control': 'public, max-age=300',
83+
},
84+
});
85+
} catch (error) {
86+
console.error('Badge generation error:', error);
87+
const svg = generateBadgeSvg(0, 0, false);
88+
return new NextResponse(svg, {
89+
headers: { 'Content-Type': 'image/svg+xml' },
90+
status: 500,
91+
});
92+
}
93+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { GET } from './[did]/route';
3+
import { NextRequest } from 'next/server';
4+
5+
vi.mock('@supabase/supabase-js', () => ({
6+
createClient: () => ({}),
7+
}));
8+
9+
vi.mock('@/lib/reputation/attestation-engine', () => ({
10+
computeReputation: async () => ({
11+
agent_did: 'did:key:z6MkTest123',
12+
windows: {
13+
last_30_days: { task_count: 5, accepted_rate: 0.8, dispute_rate: 0.1 },
14+
last_90_days: { task_count: 15, accepted_rate: 0.9, dispute_rate: 0.05 },
15+
all_time: { task_count: 42, accepted_rate: 0.95, dispute_rate: 0.02 },
16+
},
17+
anti_gaming: { flagged: false, flags: [], adjusted_weight: 1 },
18+
}),
19+
}));
20+
21+
describe('GET /api/reputation/badge/[did]', () => {
22+
it('returns SVG for valid DID', async () => {
23+
const req = new NextRequest('http://localhost/api/reputation/badge/did%3Akey%3Az6MkTest123');
24+
const res = await GET(req, { params: Promise.resolve({ did: 'did%3Akey%3Az6MkTest123' }) });
25+
expect(res.status).toBe(200);
26+
expect(res.headers.get('content-type')).toBe('image/svg+xml');
27+
const svg = await res.text();
28+
expect(svg).toContain('<svg');
29+
expect(svg).toContain('95%');
30+
expect(svg).toContain('42 tasks');
31+
});
32+
33+
it('returns SVG with "no data" for invalid DID', async () => {
34+
const req = new NextRequest('http://localhost/api/reputation/badge/invalid');
35+
const res = await GET(req, { params: Promise.resolve({ did: 'invalid' }) });
36+
expect(res.status).toBe(200);
37+
const svg = await res.text();
38+
expect(svg).toContain('no data');
39+
});
40+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from './route';
3+
import { NextRequest } from 'next/server';
4+
5+
// Mock supabase
6+
vi.mock('@supabase/supabase-js', () => ({
7+
createClient: () => ({
8+
from: (table: string) => ({
9+
select: () => ({
10+
eq: (_col: string, _val: string) => ({
11+
order: () => ({
12+
data: [
13+
{
14+
id: 'cred-1',
15+
type: 'TaskCompletion',
16+
subject_did: 'did:key:z6MkTest123',
17+
issuer_did: 'did:key:z6MkIssuer456',
18+
claims: { score: 95 },
19+
created_at: '2025-01-01T00:00:00Z',
20+
revoked: false,
21+
},
22+
],
23+
error: null,
24+
}),
25+
}),
26+
}),
27+
}),
28+
}),
29+
}));
30+
31+
describe('GET /api/reputation/credentials', () => {
32+
it('returns 400 when no DID provided', async () => {
33+
const req = new NextRequest('http://localhost/api/reputation/credentials');
34+
const res = await GET(req);
35+
expect(res.status).toBe(400);
36+
const data = await res.json();
37+
expect(data.success).toBe(false);
38+
});
39+
40+
it('returns 400 for invalid DID format', async () => {
41+
const req = new NextRequest('http://localhost/api/reputation/credentials?did=invalid');
42+
const res = await GET(req);
43+
expect(res.status).toBe(400);
44+
});
45+
46+
it('returns credentials for valid DID', async () => {
47+
const req = new NextRequest('http://localhost/api/reputation/credentials?did=did:key:z6MkTest123');
48+
const res = await GET(req);
49+
expect(res.status).toBe(200);
50+
const data = await res.json();
51+
expect(data.success).toBe(true);
52+
expect(data.credentials).toHaveLength(1);
53+
expect(data.credentials[0].id).toBe('cred-1');
54+
});
55+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { createClient } from '@supabase/supabase-js';
3+
import { isValidDid } from '@/lib/reputation/crypto';
4+
5+
const supabase = createClient(
6+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
7+
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
8+
);
9+
10+
export async function GET(request: NextRequest) {
11+
try {
12+
const did = request.nextUrl.searchParams.get('did');
13+
14+
if (!did || !isValidDid(did)) {
15+
return NextResponse.json({ success: false, error: 'Valid DID parameter required' }, { status: 400 });
16+
}
17+
18+
const { data: credentials, error } = await supabase
19+
.from('reputation_credentials')
20+
.select('*')
21+
.eq('subject_did', did)
22+
.order('created_at', { ascending: false });
23+
24+
if (error) {
25+
console.error('Credentials fetch error:', error);
26+
return NextResponse.json({ success: false, error: 'Failed to fetch credentials' }, { status: 500 });
27+
}
28+
29+
return NextResponse.json({ success: true, credentials: credentials || [] });
30+
} catch (error) {
31+
console.error('Credentials fetch error:', error);
32+
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 });
33+
}
34+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { GET } from './route';
3+
import { NextRequest } from 'next/server';
4+
5+
vi.mock('@supabase/supabase-js', () => ({
6+
createClient: () => ({
7+
from: () => ({
8+
select: () => ({
9+
eq: () => ({
10+
order: () => ({
11+
data: [
12+
{
13+
id: 'receipt-1',
14+
agent_did: 'did:key:z6MkTest123',
15+
buyer_did: 'did:key:z6MkBuyer456',
16+
task_type: 'coding',
17+
amount: 100,
18+
currency: 'USD',
19+
status: 'accepted',
20+
created_at: '2025-01-01T00:00:00Z',
21+
},
22+
],
23+
error: null,
24+
}),
25+
}),
26+
}),
27+
}),
28+
}),
29+
}));
30+
31+
describe('GET /api/reputation/receipts', () => {
32+
it('returns 400 when no DID provided', async () => {
33+
const req = new NextRequest('http://localhost/api/reputation/receipts');
34+
const res = await GET(req);
35+
expect(res.status).toBe(400);
36+
});
37+
38+
it('returns 400 for invalid DID', async () => {
39+
const req = new NextRequest('http://localhost/api/reputation/receipts?did=bad');
40+
const res = await GET(req);
41+
expect(res.status).toBe(400);
42+
});
43+
44+
it('returns receipts for valid DID', async () => {
45+
const req = new NextRequest('http://localhost/api/reputation/receipts?did=did:key:z6MkTest123');
46+
const res = await GET(req);
47+
expect(res.status).toBe(200);
48+
const data = await res.json();
49+
expect(data.success).toBe(true);
50+
expect(data.receipts).toHaveLength(1);
51+
expect(data.receipts[0].status).toBe('accepted');
52+
});
53+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { createClient } from '@supabase/supabase-js';
3+
import { isValidDid } from '@/lib/reputation/crypto';
4+
5+
const supabase = createClient(
6+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
7+
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
8+
);
9+
10+
export async function GET(request: NextRequest) {
11+
try {
12+
const did = request.nextUrl.searchParams.get('did');
13+
14+
if (!did || !isValidDid(did)) {
15+
return NextResponse.json({ success: false, error: 'Valid DID parameter required' }, { status: 400 });
16+
}
17+
18+
const { data: receipts, error } = await supabase
19+
.from('task_receipts')
20+
.select('*')
21+
.eq('agent_did', did)
22+
.order('created_at', { ascending: false });
23+
24+
if (error) {
25+
console.error('Receipts fetch error:', error);
26+
return NextResponse.json({ success: false, error: 'Failed to fetch receipts' }, { status: 500 });
27+
}
28+
29+
return NextResponse.json({ success: true, receipts: receipts || [] });
30+
} catch (error) {
31+
console.error('Receipts fetch error:', error);
32+
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 });
33+
}
34+
}

0 commit comments

Comments
 (0)