diff --git a/src/achievements/achievements.service.ts b/src/achievements/achievements.service.ts index 2ea15a1..e523f36 100644 --- a/src/achievements/achievements.service.ts +++ b/src/achievements/achievements.service.ts @@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Achievement } from './entities/achievement.entity'; import { PlayerAchievement } from './entities/player-achievement.entity'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventName } from '../events/events.enum'; @Injectable() export class AchievementsService { @@ -11,6 +13,7 @@ export class AchievementsService { private readonly achievementRepo: Repository, @InjectRepository(PlayerAchievement) private readonly playerAchRepo: Repository, + private readonly eventEmitter: EventEmitter2, ) {} async getAll(): Promise { @@ -47,7 +50,10 @@ export class AchievementsService { if (satisfied) { const pa = this.playerAchRepo.create({ userId, achievementId: ach.id }); await this.playerAchRepo.save(pa); - // Emit event for other modules (e.g., NFT minting) if needed – omitted here. + this.eventEmitter.emit(EventName.AchievementUnlocked, { + userId, + achievementId: ach.id, + }); } } } diff --git a/src/app.module.ts b/src/app.module.ts index 928dd74..282457f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { StreakModule } from './streak/streak.module'; +import { SocialModule } from './social/social.module'; @Module({ imports: [ @@ -64,6 +65,7 @@ import { StreakModule } from './streak/streak.module'; }), ScheduleModule.forRoot(), StreakModule, + SocialModule, ], controllers: [AppController], providers: [EventService], diff --git a/src/leaderboard/leaderboard.controller.ts b/src/leaderboard/leaderboard.controller.ts index f2f0589..84e8ea1 100644 --- a/src/leaderboard/leaderboard.controller.ts +++ b/src/leaderboard/leaderboard.controller.ts @@ -8,12 +8,27 @@ import { import { Request } from 'express'; import { LeaderboardService } from './leaderboard.service'; import { LeaderboardQueryDto, LeaderboardEntryDto } from './dto/leaderboard.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('leaderboard') @Controller('leaderboard') export class LeaderboardController { constructor(private readonly leaderboardService: LeaderboardService) {} + @Get('friends') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Get a leaderboard scoped to followed players' }) + @ApiResponse({ status: 200, description: 'Friends leaderboard entries', type: [LeaderboardEntryDto] }) + @ApiResponse({ status: 401, description: 'Unauthenticated' }) + async getFriendsLeaderboard(@Req() req: Request, @Query() query: LeaderboardQueryDto): Promise { + const userId = (req as any).user?.id; + if (!userId) { + throw new Error('User not authenticated'); + } + return this.leaderboardService.getFriendsLeaderboard(userId, query); + } + @Get() @ApiOperation({ summary: 'Get the global (or category-scoped) top rankings' }) @ApiResponse({ status: 200, description: 'Paginated leaderboard entries sorted by total score', type: [LeaderboardEntryDto] }) diff --git a/src/leaderboard/leaderboard.module.ts b/src/leaderboard/leaderboard.module.ts index e7cedbb..745f44f 100644 --- a/src/leaderboard/leaderboard.module.ts +++ b/src/leaderboard/leaderboard.module.ts @@ -3,9 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { LeaderboardController } from './leaderboard.controller'; import { LeaderboardService } from './leaderboard.service'; import { LeaderboardEntry } from './entities/leaderboard-entry.entity'; +import { SocialModule } from '../social/social.module'; @Module({ - imports: [TypeOrmModule.forFeature([LeaderboardEntry])], + imports: [ + TypeOrmModule.forFeature([LeaderboardEntry]), + SocialModule, + ], controllers: [LeaderboardController], providers: [LeaderboardService], exports: [LeaderboardService], diff --git a/src/leaderboard/leaderboard.service.ts b/src/leaderboard/leaderboard.service.ts index 58ae831..f6d33b2 100644 --- a/src/leaderboard/leaderboard.service.ts +++ b/src/leaderboard/leaderboard.service.ts @@ -3,14 +3,43 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, IsNull } from 'typeorm'; import { LeaderboardEntry } from './entities/leaderboard-entry.entity'; import { LeaderboardQueryDto, LeaderboardEntryDto } from './dto/leaderboard.dto'; +import { SocialService } from '../social/social.service'; @Injectable() export class LeaderboardService { constructor( @InjectRepository(LeaderboardEntry) private readonly repo: Repository, + private readonly socialService: SocialService, ) {} + async getFriendsLeaderboard(userId: string, query: LeaderboardQueryDto): Promise { + const following = await this.socialService.getFollowing(userId); + const followingIds = following.map(u => u.id); + if (followingIds.length === 0) { + return []; + } + const { limit = 20, page = 1, category } = query; + const qb = this.repo.createQueryBuilder('lb') + .where('lb.playerId IN (:...followingIds)', { followingIds }) + .orderBy('lb.totalScore', 'DESC') + .addOrderBy('lb.createdAt', 'ASC') + .skip((page - 1) * limit) + .take(limit); + if (category) { + qb.andWhere('lb.category = :category', { category }); + } else { + qb.andWhere('lb.category IS NULL'); + } + const entries = await qb.getMany(); + return entries.map((e, idx) => ({ + userId: e.playerId, + totalScore: e.totalScore, + category: e.category ?? undefined, + rank: (page - 1) * limit + idx + 1, + })); + } + /** Get top entries with pagination and optional category filter */ async getTop(query: LeaderboardQueryDto): Promise { const { limit = 20, page = 1, category } = query; diff --git a/src/notifications/notification.entity.ts b/src/notifications/notification.entity.ts index 841a336..23741c6 100644 --- a/src/notifications/notification.entity.ts +++ b/src/notifications/notification.entity.ts @@ -7,6 +7,7 @@ export enum NotificationType { REWARD_GRANTED = 'REWARD_GRANTED', NFT_MINTED = 'NFT_MINTED', DIFFICULTY_RECALIBRATED = 'DIFFICULTY_RECALIBRATED', + STREAK_RECORD_BROKEN = 'STREAK_RECORD_BROKEN', } @Entity() diff --git a/src/social/entities/follow.entity.ts b/src/social/entities/follow.entity.ts new file mode 100644 index 0000000..ed04709 --- /dev/null +++ b/src/social/entities/follow.entity.ts @@ -0,0 +1,26 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Unique } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('follows') +@Unique(['followerId', 'followingId']) +export class Follow { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + followerId!: string; + + @Column() + followingId!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'followerId' }) + follower!: User; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'followingId' }) + following!: User; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/src/social/social.controller.ts b/src/social/social.controller.ts new file mode 100644 index 0000000..17c1981 --- /dev/null +++ b/src/social/social.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Post, Delete, Get, Param, UseGuards, Request } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; +import { SocialService } from './social.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserDto } from '../users/dto/user.dto'; + +@ApiTags('social') +@ApiBearerAuth('access-token') +@UseGuards(JwtAuthGuard) +@Controller('social') +@ApiResponse({ status: 401, description: 'Unauthenticated' }) +export class SocialController { + constructor(private readonly socialService: SocialService) {} + + @Post('follow/:userId') + @ApiOperation({ summary: 'Follow another player' }) + @ApiParam({ name: 'userId', description: 'UUID of the user to follow' }) + @ApiResponse({ status: 201, description: 'Successfully followed the user' }) + @ApiResponse({ status: 400, description: 'Cannot follow self or duplicate follow' }) + @ApiResponse({ status: 404, description: 'User not found' }) + async follow(@Param('userId') userId: string, @Request() req) { + const followerId = req.user.id; + return this.socialService.follow(followerId, userId); + } + + @Delete('follow/:userId') + @ApiOperation({ summary: 'Unfollow a player' }) + @ApiParam({ name: 'userId', description: 'UUID of the user to unfollow' }) + @ApiResponse({ status: 200, description: 'Successfully unfollowed the user' }) + @ApiResponse({ status: 404, description: 'Follow relationship not found' }) + async unfollow(@Param('userId') userId: string, @Request() req) { + const followerId = req.user.id; + await this.socialService.unfollow(followerId, userId); + return { success: true }; + } + + @Get('following') + @ApiOperation({ summary: 'Get list of players the authenticated user follows' }) + @ApiResponse({ status: 200, description: 'List of followed players', type: [UserDto] }) + async getFollowing(@Request() req): Promise { + const userId = req.user.id; + return this.socialService.getFollowing(userId); + } + + @Get('followers') + @ApiOperation({ summary: 'Get list of players following the authenticated user' }) + @ApiResponse({ status: 200, description: 'List of followers', type: [UserDto] }) + async getFollowers(@Request() req): Promise { + const userId = req.user.id; + return this.socialService.getFollowers(userId); + } +} diff --git a/src/social/social.listener.ts b/src/social/social.listener.ts new file mode 100644 index 0000000..45811f5 --- /dev/null +++ b/src/social/social.listener.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SocialService } from './social.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { Achievement, Rarity } from '../achievements/entities/achievement.entity'; +import { User } from '../users/entities/user.entity'; +import { NotificationType } from '../notifications/notification.entity'; +import { EventName } from '../events/events.enum'; + +@Injectable() +export class SocialListener { + constructor( + private readonly socialService: SocialService, + private readonly notificationsService: NotificationsService, + @InjectRepository(Achievement) + private readonly achievementRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + @OnEvent(EventName.AchievementUnlocked) + async handleAchievementUnlocked(payload: { userId: string; achievementId: string }) { + const { userId, achievementId } = payload; + + const user = await this.userRepo.findOne({ where: { id: userId } }); + const achievement = await this.achievementRepo.findOne({ where: { id: achievementId } }); + + if (!user || !achievement) return; + + // Check if the achievement is "rare" (RARE or EPIC) + const isRare = achievement.rarity === Rarity.RARE || achievement.rarity === Rarity.EPIC; + if (!isRare) return; + + // Fetch followers + const followers = await this.socialService.getFollowers(userId); + + // Create notifications for followers + for (const follower of followers) { + await this.notificationsService.create({ + userId: follower.id, + type: NotificationType.ACHIEVEMENT_UNLOCKED, + message: `${user.username} unlocked a rare achievement: ${achievement.name}!`, + }); + } + } + + @OnEvent('streak.record-broken') + async handleStreakRecordBroken(payload: { userId: string; currentStreak: number }) { + const { userId, currentStreak } = payload; + + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) return; + + // Fetch followers + const followers = await this.socialService.getFollowers(userId); + + // Create notifications for followers + for (const follower of followers) { + await this.notificationsService.create({ + userId: follower.id, + type: NotificationType.STREAK_RECORD_BROKEN, + message: `${user.username} broke their streak record with a ${currentStreak}-day streak!`, + }); + } + } +} diff --git a/src/social/social.module.ts b/src/social/social.module.ts new file mode 100644 index 0000000..c431015 --- /dev/null +++ b/src/social/social.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Follow } from './entities/follow.entity'; +import { User } from '../users/entities/user.entity'; +import { Achievement } from '../achievements/entities/achievement.entity'; +import { SocialService } from './social.service'; +import { SocialController } from './social.controller'; +import { SocialListener } from './social.listener'; +import { NotificationsModule } from '../notifications/notifications.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Follow, User, Achievement]), + NotificationsModule, + ], + controllers: [SocialController], + providers: [SocialService, SocialListener], + exports: [SocialService], +}) +export class SocialModule {} diff --git a/src/social/social.service.ts b/src/social/social.service.ts new file mode 100644 index 0000000..357ca31 --- /dev/null +++ b/src/social/social.service.ts @@ -0,0 +1,72 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Follow } from './entities/follow.entity'; +import { User } from '../users/entities/user.entity'; +import { UserDto } from '../users/dto/user.dto'; + +@Injectable() +export class SocialService { + constructor( + @InjectRepository(Follow) + private readonly followRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + async follow(followerId: string, followingId: string): Promise { + if (followerId === followingId) { + throw new BadRequestException('Users cannot follow themselves'); + } + + // Check if the target user actually exists + const targetUser = await this.userRepo.findOne({ where: { id: followingId } }); + if (!targetUser) { + throw new NotFoundException(`User with ID ${followingId} not found`); + } + + // Check duplicate + const existing = await this.followRepo.findOne({ where: { followerId, followingId } }); + if (existing) { + throw new BadRequestException('You are already following this user'); + } + + const follow = this.followRepo.create({ followerId, followingId }); + return this.followRepo.save(follow); + } + + async unfollow(followerId: string, followingId: string): Promise { + const existing = await this.followRepo.findOne({ where: { followerId, followingId } }); + if (!existing) { + throw new NotFoundException('Follow relationship not found'); + } + await this.followRepo.remove(existing); + } + + async getFollowing(userId: string): Promise { + const follows = await this.followRepo.find({ + where: { followerId: userId }, + relations: { following: true }, + }); + return follows.map(f => this.mapToUserDto(f.following)); + } + + async getFollowers(userId: string): Promise { + const follows = await this.followRepo.find({ + where: { followingId: userId }, + relations: { follower: true }, + }); + return follows.map(f => this.mapToUserDto(f.follower)); + } + + private mapToUserDto(user: User): UserDto { + return { + id: user.id, + username: user.username, + email: user.email, + role: user.role, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + } +} diff --git a/src/social/tests/social.service.spec.ts b/src/social/tests/social.service.spec.ts new file mode 100644 index 0000000..574ef2a --- /dev/null +++ b/src/social/tests/social.service.spec.ts @@ -0,0 +1,193 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SocialService } from '../social.service'; +import { Follow } from '../entities/follow.entity'; +import { User, UserRole } from '../../users/entities/user.entity'; +import { LeaderboardService } from '../../leaderboard/leaderboard.service'; +import { LeaderboardEntry } from '../../leaderboard/entities/leaderboard-entry.entity'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +describe('Social & Friends Leaderboard Logic', () => { + let socialService: SocialService; + let leaderboardService: LeaderboardService; + let followRepo: Repository; + let userRepo: Repository; + let leaderboardRepo: Repository; + + const mockFollowRepo = { + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }; + + const mockUserRepo = { + findOne: jest.fn(), + }; + + const mockLeaderboardRepo = { + createQueryBuilder: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SocialService, + LeaderboardService, + { + provide: getRepositoryToken(Follow), + useValue: mockFollowRepo, + }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepo, + }, + { + provide: getRepositoryToken(LeaderboardEntry), + useValue: mockLeaderboardRepo, + }, + ], + }).compile(); + + socialService = module.get(SocialService); + leaderboardService = module.get(LeaderboardService); + followRepo = module.get>(getRepositoryToken(Follow)); + userRepo = module.get>(getRepositoryToken(User)); + leaderboardRepo = module.get>(getRepositoryToken(LeaderboardEntry)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('SocialService - follow', () => { + it('should throw BadRequestException if a user tries to follow themselves', async () => { + await expect(socialService.follow('user1', 'user1')).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if target user does not exist', async () => { + mockUserRepo.findOne.mockResolvedValue(null); + await expect(socialService.follow('user1', 'nonexistent')).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for duplicate follow relationship', async () => { + const targetUser = new User(); + targetUser.id = 'user2'; + mockUserRepo.findOne.mockResolvedValue(targetUser); + mockFollowRepo.findOne.mockResolvedValue(new Follow()); + + await expect(socialService.follow('user1', 'user2')).rejects.toThrow(BadRequestException); + }); + + it('should successfully save follow relationship when valid', async () => { + const targetUser = new User(); + targetUser.id = 'user2'; + const newFollow = { followerId: 'user1', followingId: 'user2' }; + + mockUserRepo.findOne.mockResolvedValue(targetUser); + mockFollowRepo.findOne.mockResolvedValue(null); + mockFollowRepo.create.mockReturnValue(newFollow); + mockFollowRepo.save.mockResolvedValue(newFollow); + + const result = await socialService.follow('user1', 'user2'); + expect(mockFollowRepo.create).toHaveBeenCalledWith({ followerId: 'user1', followingId: 'user2' }); + expect(mockFollowRepo.save).toHaveBeenCalledWith(newFollow); + expect(result).toEqual(newFollow); + }); + }); + + describe('SocialService - unfollow', () => { + it('should throw NotFoundException if follow relationship does not exist', async () => { + mockFollowRepo.findOne.mockResolvedValue(null); + await expect(socialService.unfollow('user1', 'user2')).rejects.toThrow(NotFoundException); + }); + + it('should successfully remove follow relationship if exists', async () => { + const existingFollow = new Follow(); + existingFollow.followerId = 'user1'; + existingFollow.followingId = 'user2'; + mockFollowRepo.findOne.mockResolvedValue(existingFollow); + mockFollowRepo.remove.mockResolvedValue(existingFollow); + + await socialService.unfollow('user1', 'user2'); + expect(mockFollowRepo.remove).toHaveBeenCalledWith(existingFollow); + }); + }); + + describe('SocialService - query followers & following', () => { + it('should map following relations to UserDtos', async () => { + const followedUser = new User(); + followedUser.id = 'user2'; + followedUser.username = 'alice'; + followedUser.email = 'alice@example.com'; + followedUser.role = UserRole.PLAYER; + + const followEdge = { following: followedUser }; + mockFollowRepo.find.mockResolvedValue([followEdge]); + + const result = await socialService.getFollowing('user1'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('user2'); + expect(result[0].username).toBe('alice'); + }); + + it('should map follower relations to UserDtos', async () => { + const followerUser = new User(); + followerUser.id = 'user2'; + followerUser.username = 'bob'; + followerUser.email = 'bob@example.com'; + followerUser.role = UserRole.PLAYER; + + const followEdge = { follower: followerUser }; + mockFollowRepo.find.mockResolvedValue([followEdge]); + + const result = await socialService.getFollowers('user1'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('user2'); + expect(result[0].username).toBe('bob'); + }); + }); + + describe('LeaderboardService - friends-scoped leaderboard', () => { + it('should return empty list if following list is empty', async () => { + jest.spyOn(socialService, 'getFollowing').mockResolvedValue([]); + const result = await leaderboardService.getFriendsLeaderboard('user1', {}); + expect(result).toEqual([]); + }); + + it('should query, rank relative to subset and return correct list', async () => { + const mockFollowingList = [ + { id: 'user2', username: 'alice', email: 'alice@example.com', role: UserRole.PLAYER, createdAt: new Date(), updatedAt: new Date() }, + { id: 'user3', username: 'bob', email: 'bob@example.com', role: UserRole.PLAYER, createdAt: new Date(), updatedAt: new Date() } + ]; + jest.spyOn(socialService, 'getFollowing').mockResolvedValue(mockFollowingList); + + const mockEntries = [ + { playerId: 'user2', totalScore: 300, category: null, createdAt: new Date() }, + { playerId: 'user3', totalScore: 100, category: null, createdAt: new Date() } + ]; + + const qbSelectMock = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(mockEntries), + }; + + mockLeaderboardRepo.createQueryBuilder.mockReturnValue(qbSelectMock); + + const result = await leaderboardService.getFriendsLeaderboard('user1', { category: undefined }); + expect(qbSelectMock.where).toHaveBeenCalledWith('lb.playerId IN (:...followingIds)', { followingIds: ['user2', 'user3'] }); + expect(result).toHaveLength(2); + expect(result[0].userId).toBe('user2'); + expect(result[0].rank).toBe(1); + expect(result[1].userId).toBe('user3'); + expect(result[1].rank).toBe(2); + }); + }); +}); diff --git a/src/streak/services/streak.service.ts b/src/streak/services/streak.service.ts index 4d12a2d..2bdedaa 100644 --- a/src/streak/services/streak.service.ts +++ b/src/streak/services/streak.service.ts @@ -4,12 +4,14 @@ import { Model, Types } from 'mongoose'; import { Streak } from '../schemas/streak.schema'; import { StreakEvents } from '../events/streak.events'; import { streakConfig } from '../config/streak.config'; +import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class StreakService { constructor( @InjectModel(Streak.name) private streakModel: Model, private readonly streakEvents: StreakEvents, + private readonly eventEmitter: EventEmitter2, ) {} private getUTCDateString(date: Date = new Date()): string { @@ -56,12 +58,21 @@ export class StreakService { streak.currentStreak = 1; } + const isNewRecord = streak.currentStreak > streak.longestStreak; + streak.lastActiveDate = now; await streak.save(); this.checkMilestones(streak); this.checkStellaRewards(streak); + if (isNewRecord) { + this.eventEmitter.emit('streak.record-broken', { + userId: userId, + currentStreak: streak.currentStreak, + }); + } + return streak; }