Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions apps/access-api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -592,18 +592,46 @@ model Approval {
}

model ContributionScore {
id String @id @default(uuid())
walletId String
id String @id @default(uuid())
walletId String
communityId String
totalScore Int @default(0)
breakdown Json?
updatedAt DateTime @updatedAt
totalScore Int @default(0)
breakdown Json?
updatedAt DateTime @updatedAt

events ContributionEvent[]

@@unique([walletId, communityId])
@@index([communityId])
@@index([totalScore])
}

// Per-recompute audit log. Each row captures one full recomputation cycle
// so scores are traceable back to the exact signal contributions and the
// triggering event. The latest row for a (walletId, communityId) pair is
// the current authoritative snapshot; older rows form an append-only
// history useful for debugging, dispute resolution, and trend analysis.
model ContributionEvent {
id String @id @default(uuid())
walletId String
communityId String
totalScore Int
breakdown Json
explanations Json?
// The outbox event that triggered this recomputation (nullable for
// manual/admin recomputes or backfills).
triggerEventId String?
contributionScoreId String?
contributionScore ContributionScore? @relation(fields: [contributionScoreId], references: [id])
createdAt DateTime @default(now())

@@index([walletId, communityId])
@@index([communityId])
@@index([createdAt])
@@index([triggerEventId])
@@index([contributionScoreId])
}

// --- Webhook Delivery (outbox handler) ---

// A community's subscription to outbox events, delivered as signed HTTP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ describe('createContributionScoreHandler', () => {
});

expect(mockRecompute).toHaveBeenCalledWith(
expect.anything,
expect.anything(),
'0xabc123',
'community-1',
'evt-1',
);
});

Expand Down
2 changes: 1 addition & 1 deletion apps/access-api/src/handlers/contributionScoreHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function createContributionScoreHandler(

try {
const db = await getPrisma();
await recomputeAndPersist(db, wallet, communityId);
await recomputeAndPersist(db, wallet, communityId, event.id);
} catch (err: any) {
// Log but don't throw — the score will be recomputed on the next
// relevant event, and we don't want to stall the outbox worker.
Expand Down
53 changes: 53 additions & 0 deletions apps/access-api/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getAuditTracesByTxHash,
getAuditTracesByWallet,
} from "./services/auditTraceService";
import { getScore, getScoreHistory } from "./services/contributionService";
import {
notFound,
validationError,
Expand Down Expand Up @@ -78,6 +79,7 @@ import {
updateResourceSchema,
archiveResourceSchema,
listResourcesSchema,
getContributionScoreSchema,
AccessCheckBody,
} from "./schemas";
import {
Expand Down Expand Up @@ -1638,6 +1640,57 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
}
});

// --- Contribution Score Routes ---

// GET /v1/communities/:communityId/members/:wallet/score
app.get(
'/v1/communities/:communityId/members/:wallet/score',
{ schema: getContributionScoreSchema },
async (request: FastifyRequest, reply: FastifyReply) => {
const { communityId, wallet } = request.params as {
communityId: string;
wallet: string;
};

if (!wallet || !/^0x[0-9a-fA-F]{40}$/.test(wallet)) {
return reply
.status(400)
.send(
validationErrorWithReason(
'INVALID_WALLET',
'Invalid wallet format',
),
);
}

const community = await prisma.community.findUnique({
where: { id: communityId },
});
if (!community) {
return reply
.status(404)
.send(notFound('Community not found'));
}

const score = await getScore(prisma, wallet, communityId);
if (!score) {
return reply
.status(404)
.send(notFound('No contribution score found for this member'));
}

const history = await getScoreHistory(prisma, wallet, communityId, 10);

return reply.status(200).send({
wallet,
communityId,
totalScore: score.total,
breakdown: score.breakdown,
history,
});
},
);

// --- Constitutional Rule Set Management Routes ---

// POST /v1/communities/:communityId/constitutional-rulesets — Create a new versioned constitutional rule set
Expand Down
56 changes: 56 additions & 0 deletions apps/access-api/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2431,3 +2431,59 @@ export const decideSuspensionAppealSchema = {
},
},
} as const;

// ---------------------------------------------------------------------------
// GET /v1/communities/:communityId/members/:wallet/score
// ---------------------------------------------------------------------------

export const getContributionScoreSchema = {
summary: "Get contribution score for a member",
tags: ["Contribution Scoring"],
params: {
type: "object",
required: ["communityId", "wallet"],
properties: {
communityId: { type: "string", description: "Community identifier" },
wallet: walletAddressSchema,
},
},
response: {
200: {
description: "Contribution score for the member",
type: "object",
required: ["wallet", "communityId", "totalScore", "breakdown"],
properties: {
wallet: walletAddressSchema,
communityId: { type: "string" },
totalScore: { type: "number", description: "Aggregated contribution score" },
breakdown: {
type: "object",
additionalProperties: { type: "number" },
description: "Per-signal point breakdown",
},
history: {
type: "array",
description: "Recent recomputation history (most recent first)",
items: {
type: "object",
required: ["totalScore", "breakdown", "createdAt"],
properties: {
totalScore: { type: "number" },
breakdown: {
type: "object",
additionalProperties: { type: "number" },
},
explanations: {
type: "object",
additionalProperties: { type: "string" },
},
triggerEventId: { type: "string", nullable: true },
createdAt: { type: "string", format: "date-time" },
},
},
},
},
},
404: { description: "Member not found", ...errorSchema },
},
} as const;
112 changes: 112 additions & 0 deletions apps/access-api/src/services/contributionScoreRoute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { getScore, getScoreHistory } from './contributionService';

jest.mock('./prisma', () => ({
getPrisma: jest.fn(),
}));

jest.mock('../lib/wallet', () => ({
normalizeWalletAddress: (v: string) => v.toLowerCase(),
}));

function createMockPrisma(overrides: Record<string, any> = {}) {
return {
community: {
findUnique: jest.fn().mockResolvedValue(
overrides.community ?? { id: 'community-1', name: 'Test Community' },
),
},
contributionScore: {
findUnique: jest.fn().mockResolvedValue(
overrides.score !== undefined
? overrides.score
: {
totalScore: 30,
breakdown: { tenure: 10, badge_count: 15, activity: 5 },
},
),
},
contributionEvent: {
findMany: jest.fn().mockResolvedValue(
overrides.events ?? [
{
totalScore: 30,
breakdown: { tenure: 10, badge_count: 15, activity: 5 },
explanations: { tenure: '10 weeks', badge_count: '3 badges', activity: '5 events' },
triggerEventId: 'evt-1',
createdAt: new Date('2026-07-28T12:00:00Z'),
},
],
),
},
} as any;
}

describe('contributionService — score retrieval', () => {
describe('getScore', () => {
it('should return persisted score', async () => {
const prisma = createMockPrisma();
const result = await getScore(prisma, '0xABC123', 'community-1');

expect(result).not.toBeNull();
expect(result!.total).toBe(30);
expect(result!.breakdown).toEqual({ tenure: 10, badge_count: 15, activity: 5 });
});

it('should return null when no score exists', async () => {
const prisma = createMockPrisma({ score: null });
const result = await getScore(prisma, '0xabc123', 'community-1');

expect(result).toBeNull();
});

it('should normalise wallet address', async () => {
const prisma = createMockPrisma();
await getScore(prisma, '0xABC123DEF', 'community-1');

expect(prisma.contributionScore.findUnique).toHaveBeenCalledWith({
where: {
walletId_communityId: {
walletId: '0xabc123def',
communityId: 'community-1',
},
},
});
});
});

describe('getScoreHistory', () => {
it('should return recent events', async () => {
const prisma = createMockPrisma();
const result = await getScoreHistory(prisma, '0xabc123', 'community-1');

expect(result).toHaveLength(1);
expect(result[0].totalScore).toBe(30);
expect(result[0].triggerEventId).toBe('evt-1');
});

it('should return empty array when no events exist', async () => {
const prisma = createMockPrisma({ events: [] });
const result = await getScoreHistory(prisma, '0xabc123', 'community-1');

expect(result).toEqual([]);
});

it('should respect limit parameter', async () => {
const prisma = createMockPrisma();
await getScoreHistory(prisma, '0xabc123', 'community-1', 5);

expect(prisma.contributionEvent.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 5 }),
);
});

it('should cap limit at 100', async () => {
const prisma = createMockPrisma();
await getScoreHistory(prisma, '0xabc123', 'community-1', 500);

expect(prisma.contributionEvent.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 100 }),
);
});
});
});
9 changes: 6 additions & 3 deletions apps/access-api/src/services/contributionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ function createMockPrisma(overrides: Record<string, any> = {}) {
return {
wallet: {
findUnique: jest.fn().mockResolvedValue(
overrides.wallet ?? { id: 'wallet-1', address: '0xabc123' },
'wallet' in overrides ? overrides.wallet : { id: 'wallet-1', address: '0xabc123' },
),
},
member: {
findFirst: jest.fn().mockResolvedValue(
overrides.member ?? {
'member' in overrides ? overrides.member : {
id: 'member-1',
walletId: 'wallet-1',
communityId: 'community-1',
Expand All @@ -23,14 +23,17 @@ function createMockPrisma(overrides: Record<string, any> = {}) {
count: jest.fn().mockResolvedValue(overrides.attendanceCount ?? 5),
},
contributionScore: {
upsert: jest.fn().mockResolvedValue({}),
upsert: jest.fn().mockResolvedValue({ id: 'score-1' }),
findUnique: jest.fn().mockResolvedValue(
overrides.score !== undefined ? overrides.score : {
totalScore: 22,
breakdown: { tenure: 10, badge_count: 12 },
},
),
},
contributionEvent: {
create: jest.fn().mockResolvedValue({}),
},
} as any;
}

Expand Down
Loading