diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index 2351a820..943fdafa 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -12,6 +12,8 @@ import { AnalyticsService } from './analytics.service'; import { DashboardKpisDto } from './dto/dashboard-kpis.dto'; import { MarketAnalyticsDto } from './dto/market-analytics.dto'; import { MarketHistoryResponseDto } from './dto/market-history.dto'; +import { UserTrendsDto } from './dto/user-trends.dto'; +import { CategoryAnalyticsResponseDto } from './dto/category-analytics.dto'; @ApiTags('Analytics') @Controller('analytics') @@ -63,4 +65,33 @@ export class AnalyticsController { ): Promise { return this.analyticsService.getMarketHistory(id); } + + @Get('users/:address/trends') + @Public() + @ApiOperation({ summary: 'Get user performance trends over time' }) + @ApiResponse({ + status: 200, + description: + 'User trends including accuracy, volume, profit/loss, and category performance', + type: UserTrendsDto, + }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getUserTrends( + @Param('address') address: string, + ): Promise { + return this.analyticsService.getUserTrends(address); + } + + @Get('categories') + @Public() + @ApiOperation({ summary: 'Get category analytics and statistics' }) + @ApiResponse({ + status: 200, + description: + 'Category analytics including market counts, volume, participants, and trending status', + type: CategoryAnalyticsResponseDto, + }) + async getCategoryAnalytics(): Promise { + return this.analyticsService.getCategoryAnalytics(); + } } diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts index 87af4253..00a38a32 100644 --- a/backend/src/analytics/analytics.service.ts +++ b/backend/src/analytics/analytics.service.ts @@ -13,6 +13,15 @@ import { OutcomeDistributionDto, } from './dto/market-analytics.dto'; import { MarketHistoryResponseDto } from './dto/market-history.dto'; +import { + UserTrendsDto, + TrendDataPointDto, + CategoryPerformanceDto, +} from './dto/user-trends.dto'; +import { + CategoryStatsDto, + CategoryAnalyticsResponseDto, +} from './dto/category-analytics.dto'; /** Tier thresholds: Bronze < 200, Silver < 500, Gold < 1000, Platinum ≥ 1000 */ export function predictorTierFromReputation(reputationScore: number): string { @@ -255,4 +264,214 @@ export class AnalyticsService { await this.marketHistoryRepository.save(snapshot); } + + /** + * Get user performance trends over time + */ + async getUserTrends(address: string): Promise { + const user = await this.usersRepository.findOne({ + where: { stellar_address: address }, + }); + + if (!user) { + throw new NotFoundException(`User with address ${address} not found`); + } + + const predictions = await this.predictionsRepository.find({ + where: { user: { id: user.id } }, + relations: ['market'], + order: { submitted_at: 'ASC' }, + }); + + const accuracyTrend = this.computeAccuracyTrend(predictions); + const volumeTrend = this.computeVolumeTrend(predictions); + const profitLossTrend = this.computeProfitLossTrend(predictions); + const categoryPerformance = this.computeCategoryPerformance(predictions); + + const bestCategory = categoryPerformance.reduce((best, current) => + current.accuracy_rate > (best?.accuracy_rate ?? 0) ? current : best, + ); + + const worstCategory = categoryPerformance.reduce((worst, current) => + current.accuracy_rate < (worst?.accuracy_rate ?? 100) ? current : worst, + ); + + return { + address, + accuracy_trend: accuracyTrend, + prediction_volume_trend: volumeTrend, + profit_loss_trend: profitLossTrend, + category_performance: categoryPerformance, + best_category: bestCategory || null, + worst_category: worstCategory || null, + }; + } + + private computeAccuracyTrend(predictions: Prediction[]): TrendDataPointDto[] { + const trend: TrendDataPointDto[] = []; + let correct = 0; + let total = 0; + + predictions.forEach((p) => { + if (p.market?.is_resolved) { + total++; + if (p.market.resolved_outcome === p.chosen_outcome) { + correct++; + } + trend.push({ + timestamp: p.submitted_at, + value: total > 0 ? Math.round((correct / total) * 10000) / 100 : 0, + }); + } + }); + + return trend; + } + + private computeVolumeTrend(predictions: Prediction[]): TrendDataPointDto[] { + const trend: TrendDataPointDto[] = []; + let count = 0; + + predictions.forEach((p) => { + count++; + trend.push({ + timestamp: p.submitted_at, + value: count, + }); + }); + + return trend; + } + + private computeProfitLossTrend( + predictions: Prediction[], + ): TrendDataPointDto[] { + const trend: TrendDataPointDto[] = []; + let cumulativePnL = 0n; + + predictions.forEach((p) => { + if (p.market?.is_resolved) { + const stake = BigInt(p.stake_amount_stroops || 0); + const payout = BigInt(p.payout_amount_stroops || 0); + cumulativePnL += payout - stake; + + trend.push({ + timestamp: p.submitted_at, + value: Number(cumulativePnL), + }); + } + }); + + return trend; + } + + private computeCategoryPerformance( + predictions: Prediction[], + ): CategoryPerformanceDto[] { + const categoryMap = new Map< + string, + { correct: number; total: number; pnl: bigint } + >(); + + predictions.forEach((p) => { + const category = p.market?.category || 'Unknown'; + const current = categoryMap.get(category) || { + correct: 0, + total: 0, + pnl: 0n, + }; + + if (p.market?.is_resolved) { + current.total++; + if (p.market.resolved_outcome === p.chosen_outcome) { + current.correct++; + } + const stake = BigInt(p.stake_amount_stroops || 0); + const payout = BigInt(p.payout_amount_stroops || 0); + current.pnl += payout - stake; + } + + categoryMap.set(category, current); + }); + + return Array.from(categoryMap.entries()).map(([category, stats]) => ({ + category, + accuracy_rate: + stats.total > 0 + ? Math.round((stats.correct / stats.total) * 10000) / 100 + : 0, + prediction_count: stats.total, + profit_loss_stroops: stats.pnl.toString(), + })); + } + + /** + * Get category analytics with trending calculation + */ + async getCategoryAnalytics(): Promise { + const markets = await this.marketsRepository.find(); + + const categoryMap = new Map< + string, + { + total: number; + active: number; + volume: bigint; + participants: number[]; + } + >(); + + markets.forEach((market) => { + const category = market.category || 'Unknown'; + const current = categoryMap.get(category) || { + total: 0, + active: 0, + volume: 0n, + participants: [], + }; + + current.total++; + if (!market.is_resolved && !market.is_cancelled) { + current.active++; + } + current.volume += BigInt(market.total_pool_stroops || 0); + current.participants.push(market.participant_count); + + categoryMap.set(category, current); + }); + + const categories: CategoryStatsDto[] = Array.from( + categoryMap.entries(), + ).map(([name, stats]) => { + const avgParticipants = + stats.participants.length > 0 + ? Math.round( + stats.participants.reduce((a, b) => a + b, 0) / + stats.participants.length, + ) + : 0; + + const trending = this.isCategoryTrending(stats.active, stats.total); + + return { + name, + total_markets: stats.total, + active_markets: stats.active, + total_volume_stroops: stats.volume.toString(), + avg_participants: avgParticipants, + trending, + }; + }); + + return { + categories: categories.sort((a, b) => b.total_markets - a.total_markets), + generated_at: new Date(), + }; + } + + private isCategoryTrending(active: number, total: number): boolean { + if (total === 0) return false; + const activeRatio = active / total; + return activeRatio > 0.5; + } } diff --git a/backend/src/analytics/dto/category-analytics.dto.ts b/backend/src/analytics/dto/category-analytics.dto.ts new file mode 100644 index 00000000..67128d5b --- /dev/null +++ b/backend/src/analytics/dto/category-analytics.dto.ts @@ -0,0 +1,13 @@ +export class CategoryStatsDto { + name: string; + total_markets: number; + active_markets: number; + total_volume_stroops: string; + avg_participants: number; + trending: boolean; +} + +export class CategoryAnalyticsResponseDto { + categories: CategoryStatsDto[]; + generated_at: Date; +} diff --git a/backend/src/analytics/dto/user-trends.dto.ts b/backend/src/analytics/dto/user-trends.dto.ts new file mode 100644 index 00000000..03de5bb8 --- /dev/null +++ b/backend/src/analytics/dto/user-trends.dto.ts @@ -0,0 +1,21 @@ +export class TrendDataPointDto { + timestamp: Date; + value: number; +} + +export class CategoryPerformanceDto { + category: string; + accuracy_rate: number; + prediction_count: number; + profit_loss_stroops: string; +} + +export class UserTrendsDto { + address: string; + accuracy_trend: TrendDataPointDto[]; + prediction_volume_trend: TrendDataPointDto[]; + profit_loss_trend: TrendDataPointDto[]; + category_performance: CategoryPerformanceDto[]; + best_category: CategoryPerformanceDto | null; + worst_category: CategoryPerformanceDto | null; +} diff --git a/backend/src/migrations/1775300000000-CreateUserPreferencesTable.ts b/backend/src/migrations/1775300000000-CreateUserPreferencesTable.ts new file mode 100644 index 00000000..256e5034 --- /dev/null +++ b/backend/src/migrations/1775300000000-CreateUserPreferencesTable.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserPreferencesTable1775300000000 implements MigrationInterface { + name = 'CreateUserPreferencesTable1775300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_preferences" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" uuid NOT NULL, "email_notifications" boolean NOT NULL DEFAULT true, "market_resolution_notifications" boolean NOT NULL DEFAULT true, "competition_notifications" boolean NOT NULL DEFAULT true, "leaderboard_notifications" boolean NOT NULL DEFAULT true, "marketing_emails" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "REL_user_preferences_user_id" UNIQUE ("user_id"), CONSTRAINT "PK_user_preferences_id" PRIMARY KEY ("id"), CONSTRAINT "FK_user_preferences_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE)`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_user_preferences_user_id" ON "user_preferences" ("user_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_user_preferences_user_id"`, + ); + await queryRunner.query(`DROP TABLE "user_preferences"`); + } +} diff --git a/backend/src/migrations/1775310000000-CreateUserFollowsTable.ts b/backend/src/migrations/1775310000000-CreateUserFollowsTable.ts new file mode 100644 index 00000000..b685cf23 --- /dev/null +++ b/backend/src/migrations/1775310000000-CreateUserFollowsTable.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserFollowsTable1775310000000 implements MigrationInterface { + name = 'CreateUserFollowsTable1775310000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_follows" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "follower_id" uuid NOT NULL, "following_id" uuid NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_user_follows_follower_following" UNIQUE ("follower_id", "following_id"), CONSTRAINT "PK_user_follows_id" PRIMARY KEY ("id"), CONSTRAINT "FK_user_follows_follower_id" FOREIGN KEY ("follower_id") REFERENCES "users"("id") ON DELETE CASCADE, CONSTRAINT "FK_user_follows_following_id" FOREIGN KEY ("following_id") REFERENCES "users"("id") ON DELETE CASCADE)`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_user_follows_follower_id" ON "user_follows" ("follower_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_user_follows_following_id" ON "user_follows" ("following_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_user_follows_following_id"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_user_follows_follower_id"`, + ); + await queryRunner.query(`DROP TABLE "user_follows"`); + } +} diff --git a/backend/src/users/dto/user-follow.dto.ts b/backend/src/users/dto/user-follow.dto.ts new file mode 100644 index 00000000..9e6550d0 --- /dev/null +++ b/backend/src/users/dto/user-follow.dto.ts @@ -0,0 +1,40 @@ +import { IsNumber, IsOptional, Min } from 'class-validator'; + +export class PaginationDto { + @IsOptional() + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number = 20; +} + +export class UserFollowResponseDto { + id: string; + stellar_address: string; + username: string | null; + avatar_url: string | null; + reputation_score: number; +} + +export class FollowersListDto { + data: UserFollowResponseDto[]; + total: number; + page: number; + limit: number; +} + +export class FollowingListDto { + data: UserFollowResponseDto[]; + total: number; + page: number; + limit: number; +} + +export class FollowActionResponseDto { + success: boolean; + message: string; +} diff --git a/backend/src/users/dto/user-preferences.dto.ts b/backend/src/users/dto/user-preferences.dto.ts new file mode 100644 index 00000000..9c1c8480 --- /dev/null +++ b/backend/src/users/dto/user-preferences.dto.ts @@ -0,0 +1,34 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateUserPreferencesDto { + @IsOptional() + @IsBoolean() + email_notifications?: boolean; + + @IsOptional() + @IsBoolean() + market_resolution_notifications?: boolean; + + @IsOptional() + @IsBoolean() + competition_notifications?: boolean; + + @IsOptional() + @IsBoolean() + leaderboard_notifications?: boolean; + + @IsOptional() + @IsBoolean() + marketing_emails?: boolean; +} + +export class UserPreferencesResponseDto { + id: string; + email_notifications: boolean; + market_resolution_notifications: boolean; + competition_notifications: boolean; + leaderboard_notifications: boolean; + marketing_emails: boolean; + created_at: Date; + updated_at: Date; +} diff --git a/backend/src/users/entities/user-follow.entity.ts b/backend/src/users/entities/user-follow.entity.ts new file mode 100644 index 00000000..23ba34a6 --- /dev/null +++ b/backend/src/users/entities/user-follow.entity.ts @@ -0,0 +1,34 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Index, + Unique, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('user_follows') +@Unique(['follower_id', 'following_id']) +@Index(['follower_id']) +@Index(['following_id']) +export class UserFollow { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + follower_id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + follower: User; + + @Column() + following_id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + following: User; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/users/entities/user-preferences.entity.ts b/backend/src/users/entities/user-preferences.entity.ts new file mode 100644 index 00000000..addf0e6d --- /dev/null +++ b/backend/src/users/entities/user-preferences.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('user_preferences') +export class UserPreferences { + @PrimaryGeneratedColumn('uuid') + id: string; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ default: true }) + email_notifications: boolean; + + @Column({ default: true }) + market_resolution_notifications: boolean; + + @Column({ default: true }) + competition_notifications: boolean; + + @Column({ default: true }) + leaderboard_notifications: boolean; + + @Column({ default: false }) + marketing_emails: boolean; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 8e2fff01..d934cc63 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -2,6 +2,8 @@ import { Controller, Get, Patch, + Post, + Delete, Param, Body, Query, @@ -16,6 +18,16 @@ import { UsersService } from './users.service'; import { PublicUserDto } from './dto/public-user.dto'; import { UserResponseDto } from './dto/user-response.dto'; import { UpdateUserDto } from './dto/update-user.dto'; +import { + UpdateUserPreferencesDto, + UserPreferencesResponseDto, +} from './dto/user-preferences.dto'; +import { + PaginationDto, + FollowersListDto, + FollowingListDto, + FollowActionResponseDto, +} from './dto/user-follow.dto'; import { User } from './entities/user.entity'; import { ListUserPredictionsDto, @@ -128,4 +140,93 @@ export class UsersController { async exportData(@CurrentUser() user: User) { return await this.usersService.exportUserData(user.id); } + + @Patch('me/preferences') + @UsePipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false }), + ) + @ApiOperation({ summary: 'Update user notification preferences' }) + @ApiResponse({ + status: 200, + description: 'Preferences updated successfully', + type: UserPreferencesResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async updatePreferences( + @CurrentUser() user: User, + @Body() dto: UpdateUserPreferencesDto, + ): Promise { + return this.usersService.updatePreferences(user.id, dto); + } + + @Post(':address/follow') + @ApiOperation({ summary: 'Follow a user' }) + @ApiResponse({ + status: 200, + description: 'User followed successfully', + type: FollowActionResponseDto, + }) + @ApiResponse({ status: 400, description: 'Bad request' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'User not found' }) + async followUser( + @CurrentUser() user: User, + @Param('address') address: string, + ): Promise { + return this.usersService.followUser(user.id, address); + } + + @Delete(':address/unfollow') + @ApiOperation({ summary: 'Unfollow a user' }) + @ApiResponse({ + status: 200, + description: 'User unfollowed successfully', + type: FollowActionResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Follow relationship not found' }) + async unfollowUser( + @CurrentUser() user: User, + @Param('address') address: string, + ): Promise { + return this.usersService.unfollowUser(user.id, address); + } + + @Get(':address/followers') + @Public() + @UsePipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false }), + ) + @ApiOperation({ summary: 'Get followers of a user' }) + @ApiResponse({ + status: 200, + description: 'Paginated followers list', + type: FollowersListDto, + }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getFollowers( + @Param('address') address: string, + @Query() query: PaginationDto, + ): Promise { + return this.usersService.getFollowers(address, query); + } + + @Get(':address/following') + @Public() + @UsePipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false }), + ) + @ApiOperation({ summary: 'Get users that a user is following' }) + @ApiResponse({ + status: 200, + description: 'Paginated following list', + type: FollowingListDto, + }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getFollowing( + @Param('address') address: string, + @Query() query: PaginationDto, + ): Promise { + return this.usersService.getFollowing(address, query); + } } diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 7a6b91c4..ea634908 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; +import { UserPreferences } from './entities/user-preferences.entity'; +import { UserFollow } from './entities/user-follow.entity'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { Prediction } from '../predictions/entities/prediction.entity'; @@ -12,6 +14,8 @@ import { Notification } from '../notifications/entities/notification.entity'; imports: [ TypeOrmModule.forFeature([ User, + UserPreferences, + UserFollow, Prediction, CompetitionParticipant, Market, diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 6dfb04df..1359a08d 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -4,6 +4,8 @@ import { NotFoundException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { UsersService } from './users.service'; import { User } from './entities/user.entity'; +import { UserPreferences } from './entities/user-preferences.entity'; +import { UserFollow } from './entities/user-follow.entity'; import { Prediction } from '../predictions/entities/prediction.entity'; import { Market } from '../markets/entities/market.entity'; import { Notification } from '../notifications/entities/notification.entity'; @@ -56,6 +58,21 @@ describe('UsersService', () => { find: jest.fn(), }, }, + { + provide: getRepositoryToken(UserPreferences), + useValue: { + findOneBy: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: getRepositoryToken(UserFollow), + useValue: { + find: jest.fn(), + delete: jest.fn(), + save: jest.fn(), + }, + }, { provide: getRepositoryToken(Prediction), useValue: { diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index f185288c..a0ff1f19 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Prediction } from '../predictions/entities/prediction.entity'; @@ -9,9 +13,21 @@ import { PublicUserPredictionItem, } from './dto/list-user-predictions.dto'; import { User } from './entities/user.entity'; +import { UserPreferences } from './entities/user-preferences.entity'; +import { UserFollow } from './entities/user-follow.entity'; import { Market } from '../markets/entities/market.entity'; import { Notification } from '../notifications/entities/notification.entity'; import { UpdateUserDto } from './dto/update-user.dto'; +import { + UpdateUserPreferencesDto, + UserPreferencesResponseDto, +} from './dto/user-preferences.dto'; +import { + PaginationDto, + UserFollowResponseDto, + FollowersListDto, + FollowingListDto, +} from './dto/user-follow.dto'; import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; import { @@ -31,6 +47,10 @@ export class UsersService { constructor( @InjectRepository(User) private readonly usersRepository: Repository, + @InjectRepository(UserPreferences) + private readonly preferencesRepository: Repository, + @InjectRepository(UserFollow) + private readonly followRepository: Repository, @InjectRepository(Prediction) private readonly predictionsRepository: Repository, @InjectRepository(Market) @@ -297,4 +317,157 @@ export class UsersService { exported_at: new Date().toISOString(), }; } + + async getOrCreatePreferences(userId: string): Promise { + let prefs = await this.preferencesRepository.findOne({ + where: { userId }, + }); + + if (!prefs) { + prefs = this.preferencesRepository.create({ userId }); + prefs = await this.preferencesRepository.save(prefs); + } + + return prefs; + } + + async updatePreferences( + userId: string, + dto: UpdateUserPreferencesDto, + ): Promise { + const prefs = await this.getOrCreatePreferences(userId); + + if (dto.email_notifications !== undefined) { + prefs.email_notifications = dto.email_notifications; + } + if (dto.market_resolution_notifications !== undefined) { + prefs.market_resolution_notifications = + dto.market_resolution_notifications; + } + if (dto.competition_notifications !== undefined) { + prefs.competition_notifications = dto.competition_notifications; + } + if (dto.leaderboard_notifications !== undefined) { + prefs.leaderboard_notifications = dto.leaderboard_notifications; + } + if (dto.marketing_emails !== undefined) { + prefs.marketing_emails = dto.marketing_emails; + } + + const updated = await this.preferencesRepository.save(prefs); + + return { + id: updated.id, + email_notifications: updated.email_notifications, + market_resolution_notifications: updated.market_resolution_notifications, + competition_notifications: updated.competition_notifications, + leaderboard_notifications: updated.leaderboard_notifications, + marketing_emails: updated.marketing_emails, + created_at: updated.created_at, + updated_at: updated.updated_at, + }; + } + + async followUser( + followerId: string, + followingAddress: string, + ): Promise<{ success: boolean; message: string }> { + const follower = await this.findById(followerId); + const following = await this.findByAddress(followingAddress); + + if (follower.id === following.id) { + throw new BadRequestException('Cannot follow yourself'); + } + + const existing = await this.followRepository.findOne({ + where: { follower_id: followerId, following_id: following.id }, + }); + + if (existing) { + throw new BadRequestException('Already following this user'); + } + + await this.followRepository.save({ + follower_id: followerId, + following_id: following.id, + }); + + return { success: true, message: 'User followed successfully' }; + } + + async unfollowUser( + followerId: string, + followingAddress: string, + ): Promise<{ success: boolean; message: string }> { + const following = await this.findByAddress(followingAddress); + + const result = await this.followRepository.delete({ + follower_id: followerId, + following_id: following.id, + }); + + if (result.affected === 0) { + throw new NotFoundException('Follow relationship not found'); + } + + return { success: true, message: 'User unfollowed successfully' }; + } + + async getFollowers( + address: string, + dto: PaginationDto, + ): Promise { + const user = await this.findByAddress(address); + const page = dto.page ?? 1; + const limit = Math.min(dto.limit ?? 20, 50); + const skip = (page - 1) * limit; + + const [followers, total] = await this.followRepository + .createQueryBuilder('follow') + .leftJoinAndSelect('follow.follower', 'follower') + .where('follow.following_id = :userId', { userId: user.id }) + .orderBy('follow.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + const data = followers.map((f) => this.mapUserToFollowResponse(f.follower)); + + return { data, total, page, limit }; + } + + async getFollowing( + address: string, + dto: PaginationDto, + ): Promise { + const user = await this.findByAddress(address); + const page = dto.page ?? 1; + const limit = Math.min(dto.limit ?? 20, 50); + const skip = (page - 1) * limit; + + const [following, total] = await this.followRepository + .createQueryBuilder('follow') + .leftJoinAndSelect('follow.following', 'following') + .where('follow.follower_id = :userId', { userId: user.id }) + .orderBy('follow.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + const data = following.map((f) => + this.mapUserToFollowResponse(f.following), + ); + + return { data, total, page, limit }; + } + + private mapUserToFollowResponse(user: User): UserFollowResponseDto { + return { + id: user.id, + stellar_address: user.stellar_address, + username: user.username, + avatar_url: user.avatar_url, + reputation_score: user.reputation_score, + }; + } }