From bf13fb09b7564ee52485d1aeeef5d789894f98af Mon Sep 17 00:00:00 2001 From: Evelyn Lawrence Date: Tue, 28 Jul 2026 12:54:52 +0100 Subject: [PATCH] feat(reputation): implement Reputation Query API with leaderboards and analytics - Add ReputationRecord and ReputationEvent entities with TypeORM - Implement ReputationService with retrieval, history, leaderboards, and search - Add ReputationController with full REST API endpoints - Integrate Redis caching via ReputationCache - Add DTOs with class-validator and Swagger decorators - Include unit tests for ReputationService - Register ReputationModule in AppModule Closes #275 --- src/app.module.ts | 2 + src/reputation/dto/query-reputation.dto.ts | 40 +++ src/reputation/entities/reputation.entity.ts | 91 ++++++ src/reputation/reputation.cache.ts | 129 ++++++++ src/reputation/reputation.controller.ts | 134 +++++++++ src/reputation/reputation.module.ts | 18 ++ src/reputation/reputation.service.spec.ts | 118 ++++++++ src/reputation/reputation.service.ts | 294 +++++++++++++++++++ 8 files changed, 826 insertions(+) create mode 100644 src/reputation/dto/query-reputation.dto.ts create mode 100644 src/reputation/entities/reputation.entity.ts create mode 100644 src/reputation/reputation.cache.ts create mode 100644 src/reputation/reputation.controller.ts create mode 100644 src/reputation/reputation.module.ts create mode 100644 src/reputation/reputation.service.spec.ts create mode 100644 src/reputation/reputation.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 125f2d7..fade0a4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,7 @@ import { LoggingInterceptor } from './logger/logging.interceptor'; import { AuthModule } from './auth/auth.module'; import { GlobalAuthGuard } from './auth/global-auth.guard'; import { MetricsModule } from './metrics/metrics.module'; +import { ReputationModule } from './reputation/reputation.module'; // In-memory storage for development (no Redis needed) class ThrottlerMemoryStorage { @@ -283,6 +284,7 @@ async function createThrottlerStorage(configService: ConfigService): Promise; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} + +@Entity('reputation_events') +@Index(['walletAddress']) +@Index(['eventType']) +@Index(['walletAddress', 'eventType']) +@Index(['walletAddress', 'createdAt']) +export class ReputationEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar' }) + walletAddress: string; + + @Column({ + type: 'varchar', + default: ReputationEventType.VERIFICATION, + }) + eventType: ReputationEventType; + + @Column({ type: 'int', default: 0 }) + scoreChange: number; + + @Column({ type: 'int', default: 0 }) + scoreAfter: number; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'simple-json', default: {} }) + metadata: Record; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/reputation/reputation.cache.ts b/src/reputation/reputation.cache.ts new file mode 100644 index 0000000..38db766 --- /dev/null +++ b/src/reputation/reputation.cache.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { RedisService } from '../redis/redis.service'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class ReputationCache { + private readonly logger = new Logger(ReputationCache.name); + private readonly ttl: number; + + constructor( + private readonly redisService: RedisService, + private readonly configService: ConfigService, + ) { + this.ttl = this.configService.get('CACHE_REPUTATION_TTL', 3600); + } + + private getUserKey(wallet: string): string { + return `reputation:user:${wallet.toLowerCase()}`; + } + + private getLeaderboardKey(type: string): string { + return `reputation:leaderboard:${type}`; + } + + private getEventsKey(wallet: string): string { + return `reputation:events:${wallet.toLowerCase()}`; + } + + private getStatsKey(): string { + return 'reputation:stats'; + } + + async getUserReputation(wallet: string): Promise { + const data = await this.redisService.get(this.getUserKey(wallet)); + if (data) { + this.logger.debug(`Cache hit for reputation:user:${wallet}`); + try { + return JSON.parse(data); + } catch { + return null; + } + } + return null; + } + + async setUserReputation(wallet: string, reputation: any): Promise { + await this.redisService.set( + this.getUserKey(wallet), + JSON.stringify(reputation), + this.ttl, + ); + } + + async getLeaderboard(type: string): Promise { + const data = await this.redisService.get(this.getLeaderboardKey(type)); + if (data) { + this.logger.debug(`Cache hit for reputation:leaderboard:${type}`); + try { + return JSON.parse(data); + } catch { + return null; + } + } + return null; + } + + async setLeaderboard(type: string, leaderboard: any[]): Promise { + await this.redisService.set( + this.getLeaderboardKey(type), + JSON.stringify(leaderboard), + this.ttl, + ); + } + + async getEvents(wallet: string): Promise { + const data = await this.redisService.get(this.getEventsKey(wallet)); + if (data) { + this.logger.debug(`Cache hit for reputation:events:${wallet}`); + try { + return JSON.parse(data); + } catch { + return null; + } + } + return null; + } + + async setEvents(wallet: string, events: any[]): Promise { + await this.redisService.set( + this.getEventsKey(wallet), + JSON.stringify(events), + this.ttl, + ); + } + + async getStats(): Promise { + const data = await this.redisService.get(this.getStatsKey()); + if (data) { + this.logger.debug('Cache hit for reputation:stats'); + try { + return JSON.parse(data); + } catch { + return null; + } + } + return null; + } + + async setStats(stats: any): Promise { + await this.redisService.set( + this.getStatsKey(), + JSON.stringify(stats), + this.ttl, + ); + } + + async invalidateUser(wallet: string): Promise { + const promises = [ + this.redisService.del(this.getUserKey(wallet)), + this.redisService.del(this.getEventsKey(wallet)), + this.redisService.del(this.getLeaderboardKey('highest')), + this.redisService.del(this.getLeaderboardKey('fastest_growing')), + this.redisService.del(this.getLeaderboardKey('most_active')), + this.redisService.del(this.getStatsKey()), + ]; + await Promise.all(promises); + this.logger.debug(`Invalidated cache for reputation:user:${wallet}`); + } +} diff --git a/src/reputation/reputation.controller.ts b/src/reputation/reputation.controller.ts new file mode 100644 index 0000000..6d36eb1 --- /dev/null +++ b/src/reputation/reputation.controller.ts @@ -0,0 +1,134 @@ +import { + Controller, + Get, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { ReputationService } from './reputation.service'; +import { QueryReputationDto } from './dto/query-reputation.dto'; +import { ReputationEventType } from './entities/reputation.entity'; +import { OptionalJwtAuthGuard } from '../auth/optional-jwt-auth.guard'; + +@ApiTags('reputation') +@Controller('reputation') +export class ReputationController { + constructor(private readonly reputationService: ReputationService) {} + + // ─── Reputation Retrieval ───────────────────────────────────────────── + + @Get('user/:wallet') + @UseGuards(OptionalJwtAuthGuard) + @ApiOperation({ summary: 'Get reputation for a wallet address' }) + @ApiParam({ name: 'wallet', description: 'Wallet address' }) + @ApiResponse({ status: 200, description: 'Reputation record' }) + @ApiResponse({ status: 404, description: 'Reputation not found' }) + async getByWallet(@Param('wallet') wallet: string) { + return this.reputationService.findByWalletOrThrow(wallet); + } + + @Get('users') + @UseGuards(OptionalJwtAuthGuard) + @ApiOperation({ summary: 'Get reputation for multiple wallets' }) + @ApiQuery({ name: 'wallets', description: 'Comma-separated wallet addresses', type: String }) + @ApiResponse({ status: 200, description: 'List of reputation records' }) + async getMany(@Query('wallets') wallets: string) { + const walletList = wallets?.split(',').map((w) => w.trim()) ?? []; + return this.reputationService.findMany(walletList); + } + + @Get() + @UseGuards(OptionalJwtAuthGuard) + @ApiOperation({ summary: 'List all reputation records with filters' }) + @ApiQuery({ name: 'walletAddress', required: false, type: String }) + @ApiQuery({ name: 'minScore', required: false, type: Number }) + @ApiQuery({ name: 'maxScore', required: false, type: Number }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + @ApiQuery({ + name: 'sort', + required: false, + enum: ['highest', 'newest', 'most_active', 'highest_rewards'], + }) + @ApiResponse({ status: 200, description: 'Paginated reputation records' }) + async findAll( + @Query() query: QueryReputationDto, + ) { + return this.reputationService.findAll(query); + } + + // ─── Reputation History ─────────────────────────────────────────────── + + @Get('user/:wallet/events') + @UseGuards(OptionalJwtAuthGuard) + @ApiOperation({ summary: 'Get reputation events for a wallet' }) + @ApiParam({ name: 'wallet', description: 'Wallet address' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + @ApiQuery({ + name: 'eventType', + required: false, + enum: ReputationEventType, + }) + @ApiResponse({ status: 200, description: 'List of reputation events' }) + async getEvents( + @Param('wallet') wallet: string, + @Query('limit') limit?: number, + @Query('offset') offset?: number, + @Query('eventType') eventType?: ReputationEventType, + ) { + return this.reputationService.getEvents(wallet, { + limit: limit ? +limit : undefined, + offset: offset ? +offset : undefined, + eventType, + }); + } + + // ─── Leaderboards ───────────────────────────────────────────────────── + + @Get('leaderboard') + @UseGuards(OptionalJwtAuthGuard) + @ApiOperation({ summary: 'Get reputation leaderboard' }) + @ApiQuery({ + name: 'type', + required: false, + enum: ['highest', 'fastest_growing', 'most_active', 'highest_rewards'], + }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'Leaderboard entries' }) + async getLeaderboard( + @Query('type') type?: 'highest' | 'fastest_growing' | 'most_active' | 'highest_rewards', + @Query('limit') limit?: number, + ) { + return this.reputationService.getLeaderboard( + type ?? 'highest', + limit ? +limit : undefined, + ); + } + + // ─── Analytics ──────────────────────────────────────────────────────── + + @Get('stats') + @UseGuards(OptionalJwtAuthGuard) + @ApiOperation({ summary: 'Get reputation statistics' }) + @ApiResponse({ status: 200, description: 'Reputation stats' }) + async getStats() { + return this.reputationService.getStats(); + } + + // ─── Search ─────────────────────────────────────────────────────────── + + @Get('search') + @UseGuards(OptionalJwtAuthGuard) + @ApiOperation({ summary: 'Search reputation by wallet address' }) + @ApiQuery({ name: 'q', description: 'Search query', type: String }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'Search results' }) + async search( + @Query('q') q: string, + @Query('limit') limit?: number, + ) { + return this.reputationService.search(q, limit ? +limit : undefined); + } +} diff --git a/src/reputation/reputation.module.ts b/src/reputation/reputation.module.ts new file mode 100644 index 0000000..898bb44 --- /dev/null +++ b/src/reputation/reputation.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ReputationController } from './reputation.controller'; +import { ReputationService } from './reputation.service'; +import { ReputationCache } from './reputation.cache'; +import { ReputationRecord, ReputationEvent } from './entities/reputation.entity'; +import { CacheModule } from '../cache/cache.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ReputationRecord, ReputationEvent]), + CacheModule, + ], + controllers: [ReputationController], + providers: [ReputationService, ReputationCache], + exports: [ReputationService], +}) +export class ReputationModule {} diff --git a/src/reputation/reputation.service.spec.ts b/src/reputation/reputation.service.spec.ts new file mode 100644 index 0000000..1af6114 --- /dev/null +++ b/src/reputation/reputation.service.spec.ts @@ -0,0 +1,118 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReputationService } from './reputation.service'; +import { ReputationCache } from './reputation.cache'; +import { + ReputationRecord, + ReputationEvent, + ReputationEventType, +} from './entities/reputation.entity'; + +describe('ReputationService', () => { + let service: ReputationService; + let reputationRepo: jest.Mocked>; + let eventRepo: jest.Mocked>; + let cache: jest.Mocked; + + beforeEach(async () => { + const mockReputationRepo = { + findOne: jest.fn(), + find: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockEventRepo = { + find: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockCache = { + getUserReputation: jest.fn().mockResolvedValue(null), + setUserReputation: jest.fn(), + getLeaderboard: jest.fn().mockResolvedValue(null), + setLeaderboard: jest.fn(), + getEvents: jest.fn().mockResolvedValue(null), + setEvents: jest.fn(), + getStats: jest.fn().mockResolvedValue(null), + setStats: jest.fn(), + invalidateUser: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReputationService, + { + provide: getRepositoryToken(ReputationRecord), + useValue: mockReputationRepo, + }, + { + provide: getRepositoryToken(ReputationEvent), + useValue: mockEventRepo, + }, + { provide: ReputationCache, useValue: mockCache }, + ], + }).compile(); + + service = module.get(ReputationService); + reputationRepo = module.get(getRepositoryToken(ReputationRecord)); + eventRepo = module.get(getRepositoryToken(ReputationEvent)); + cache = module.get(ReputationCache); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findByWallet', () => { + it('should return reputation for a wallet', async () => { + const mockRecord = { + id: '1', + walletAddress: '0x123', + score: 100, + } as ReputationRecord; + reputationRepo.findOne.mockResolvedValue(mockRecord); + + const result = await service.findByWallet('0x123'); + expect(result).toEqual(mockRecord); + expect(cache.setUserReputation).toHaveBeenCalledWith('0x123', mockRecord); + }); + + it('should return null if not found', async () => { + reputationRepo.findOne.mockResolvedValue(null); + + const result = await service.findByWallet('nonexistent'); + expect(result).toBeNull(); + }); + }); + + describe('findByWalletOrThrow', () => { + it('should throw if not found', async () => { + reputationRepo.findOne.mockResolvedValue(null); + + await expect( + service.findByWalletOrThrow('nonexistent'), + ).rejects.toThrow('Reputation record not found'); + }); + }); + + describe('getLeaderboard', () => { + it('should return leaderboard entries', async () => { + const mockQueryBuilder = { + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([ + { walletAddress: '0x1', score: 100, verificationCount: 10, governanceParticipation: 5, rewardTotal: 50 }, + { walletAddress: '0x2', score: 80, verificationCount: 8, governanceParticipation: 3, rewardTotal: 30 }, + ]), + }; + reputationRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.getLeaderboard('highest', 10); + expect(result).toHaveLength(2); + expect(result[0].rank).toBe(1); + expect(result[0].walletAddress).toBe('0x1'); + }); + }); +}); diff --git a/src/reputation/reputation.service.ts b/src/reputation/reputation.service.ts new file mode 100644 index 0000000..b671086 --- /dev/null +++ b/src/reputation/reputation.service.ts @@ -0,0 +1,294 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + ReputationRecord, + ReputationEvent, + ReputationEventType, +} from './entities/reputation.entity'; +import { ReputationCache } from './reputation.cache'; + +export interface FindReputationsDto { + walletAddress?: string; + minScore?: number; + maxScore?: number; + page?: number; + pageSize?: number; + sort?: 'highest' | 'newest' | 'most_active' | 'highest_rewards'; +} + +export interface PaginatedReputations { + items: ReputationRecord[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface LeaderboardEntry { + rank: number; + walletAddress: string; + score: number; + verificationCount: number; + governanceParticipation: number; + rewardTotal: number; +} + +export interface ReputationStats { + totalUsers: number; + averageScore: number; + highestScore: number; + totalVerifications: number; + totalDisputes: number; +} + +@Injectable() +export class ReputationService { + private readonly logger = new Logger(ReputationService.name); + + constructor( + @InjectRepository(ReputationRecord) + private readonly reputationRepository: Repository, + @InjectRepository(ReputationEvent) + private readonly eventRepository: Repository, + private readonly reputationCache: ReputationCache, + ) {} + + // ─── Reputation Retrieval ───────────────────────────────────────────── + + async findByWallet(wallet: string): Promise { + const cached = await this.reputationCache.getUserReputation(wallet); + if (cached) return cached; + + const record = await this.reputationRepository.findOne({ + where: { walletAddress: wallet.toLowerCase() }, + }); + + if (record) { + await this.reputationCache.setUserReputation(wallet, record); + } + return record; + } + + async findByWalletOrThrow(wallet: string): Promise { + const record = await this.findByWallet(wallet); + if (!record) { + throw new NotFoundException( + `Reputation record not found for wallet ${wallet}`, + ); + } + return record; + } + + async findMany(wallets: string[]): Promise { + if (wallets.length === 0) return []; + + const records = await this.reputationRepository + .createQueryBuilder('r') + .where('r.walletAddress IN (:...wallets)', { + wallets: wallets.map((w) => w.toLowerCase()), + }) + .getMany(); + + return records; + } + + async findAll(dto: FindReputationsDto = {}): Promise { + const { + walletAddress, + minScore, + maxScore, + page = 1, + pageSize = 50, + sort = 'highest', + } = dto; + + if (pageSize < 1 || pageSize > 100) { + throw new BadRequestException('pageSize must be between 1 and 100'); + } + if (page < 1) { + throw new BadRequestException('page must be at least 1'); + } + + const qb = this.reputationRepository + .createQueryBuilder('r') + .skip((page - 1) * pageSize) + .take(pageSize); + + if (walletAddress) { + qb.andWhere('r.walletAddress = :walletAddress', { + walletAddress: walletAddress.toLowerCase(), + }); + } + if (minScore !== undefined) { + qb.andWhere('r.score >= :minScore', { minScore }); + } + if (maxScore !== undefined) { + qb.andWhere('r.score <= :maxScore', { maxScore }); + } + + switch (sort) { + case 'newest': + qb.orderBy('r.createdAt', 'DESC'); + break; + case 'most_active': + qb.orderBy('r.verificationCount', 'DESC'); + break; + case 'highest_rewards': + qb.orderBy('r.rewardTotal', 'DESC'); + break; + case 'highest': + default: + qb.orderBy('r.score', 'DESC'); + break; + } + + const [items, total] = await qb.getManyAndCount(); + const totalPages = Math.ceil(total / pageSize); + + return { items, total, page, pageSize, totalPages }; + } + + // ─── Reputation History ─────────────────────────────────────────────── + + async getEvents( + wallet: string, + options?: { limit?: number; offset?: number; eventType?: ReputationEventType }, + ): Promise { + const cached = await this.reputationCache.getEvents(wallet); + if (cached && !options?.eventType) return cached; + + const qb = this.eventRepository + .createQueryBuilder('e') + .where('e.walletAddress = :wallet', { wallet: wallet.toLowerCase() }) + .orderBy('e.createdAt', 'DESC'); + + if (options?.eventType) { + qb.andWhere('e.eventType = :eventType', { eventType: options.eventType }); + } + + if (options?.limit) { + qb.take(options.limit); + } + if (options?.offset) { + qb.skip(options.offset); + } + + const events = await qb.getMany(); + + if (!options?.eventType) { + await this.reputationCache.setEvents(wallet, events); + } + + return events; + } + + // ─── Leaderboards ───────────────────────────────────────────────────── + + async getLeaderboard( + type: 'highest' | 'fastest_growing' | 'most_active' | 'highest_rewards' = 'highest', + limit: number = 20, + ): Promise { + const cacheKey = `${type}:${limit}`; + const cached = await this.reputationCache.getLeaderboard(cacheKey); + if (cached) return cached; + + const qb = this.reputationRepository.createQueryBuilder('r'); + + switch (type) { + case 'fastest_growing': + qb.orderBy('r.updatedAt', 'DESC'); + break; + case 'most_active': + qb.orderBy('r.verificationCount', 'DESC'); + break; + case 'highest_rewards': + qb.orderBy('r.rewardTotal', 'DESC'); + break; + case 'highest': + default: + qb.orderBy('r.score', 'DESC'); + break; + } + + qb.take(Math.min(limit, 100)); + + const records = await qb.getMany(); + + const leaderboard: LeaderboardEntry[] = records.map((r, index) => ({ + rank: index + 1, + walletAddress: r.walletAddress, + score: r.score, + verificationCount: r.verificationCount, + governanceParticipation: r.governanceParticipation, + rewardTotal: r.rewardTotal, + })); + + await this.reputationCache.setLeaderboard(cacheKey, leaderboard); + return leaderboard; + } + + // ─── Analytics ──────────────────────────────────────────────────────── + + async getStats(): Promise { + const cached = await this.reputationCache.getStats(); + if (cached) return cached; + + const totalUsers = await this.reputationRepository.count(); + + const allRecords = await this.reputationRepository.find(); + const averageScore = + allRecords.length > 0 + ? allRecords.reduce((sum, r) => sum + r.score, 0) / allRecords.length + : 0; + const highestScore = + allRecords.length > 0 + ? Math.max(...allRecords.map((r) => r.score)) + : 0; + const totalVerifications = allRecords.reduce( + (sum, r) => sum + r.verificationCount, + 0, + ); + const totalDisputes = allRecords.reduce( + (sum, r) => sum + r.disputeCount, + 0, + ); + + const stats: ReputationStats = { + totalUsers, + averageScore: Number(averageScore.toFixed(2)), + highestScore, + totalVerifications, + totalDisputes, + }; + + await this.reputationCache.setStats(stats); + return stats; + } + + // ─── Search ─────────────────────────────────────────────────────────── + + async search( + query: string, + limit: number = 20, + ): Promise { + if (!query?.trim()) { + throw new BadRequestException('Search query is required'); + } + + const qb = this.reputationRepository + .createQueryBuilder('r') + .where('r.walletAddress LIKE :query', { + query: `%${query.toLowerCase()}%`, + }) + .orderBy('r.score', 'DESC') + .take(Math.min(limit, 100)); + + return qb.getMany(); + } +}