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
8 changes: 7 additions & 1 deletion src/achievements/achievements.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -11,6 +13,7 @@ export class AchievementsService {
private readonly achievementRepo: Repository<Achievement>,
@InjectRepository(PlayerAchievement)
private readonly playerAchRepo: Repository<PlayerAchievement>,
private readonly eventEmitter: EventEmitter2,
) {}

async getAll(): Promise<Achievement[]> {
Expand Down Expand Up @@ -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,
});
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -64,6 +65,7 @@ import { StreakModule } from './streak/streak.module';
}),
ScheduleModule.forRoot(),
StreakModule,
SocialModule,
],
controllers: [AppController],
providers: [EventService],
Expand Down
15 changes: 15 additions & 0 deletions src/leaderboard/leaderboard.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LeaderboardEntryDto[]> {
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] })
Expand Down
6 changes: 5 additions & 1 deletion src/leaderboard/leaderboard.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
29 changes: 29 additions & 0 deletions src/leaderboard/leaderboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LeaderboardEntry>,
private readonly socialService: SocialService,
) {}

async getFriendsLeaderboard(userId: string, query: LeaderboardQueryDto): Promise<LeaderboardEntryDto[]> {
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<LeaderboardEntryDto[]> {
const { limit = 20, page = 1, category } = query;
Expand Down
1 change: 1 addition & 0 deletions src/notifications/notification.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
26 changes: 26 additions & 0 deletions src/social/entities/follow.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions src/social/social.controller.ts
Original file line number Diff line number Diff line change
@@ -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<UserDto[]> {
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<UserDto[]> {
const userId = req.user.id;
return this.socialService.getFollowers(userId);
}
}
68 changes: 68 additions & 0 deletions src/social/social.listener.ts
Original file line number Diff line number Diff line change
@@ -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<Achievement>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}

@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!`,
});
}
}
}
20 changes: 20 additions & 0 deletions src/social/social.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
72 changes: 72 additions & 0 deletions src/social/social.service.ts
Original file line number Diff line number Diff line change
@@ -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<Follow>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}

async follow(followerId: string, followingId: string): Promise<Follow> {
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<void> {
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<UserDto[]> {
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<UserDto[]> {
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,
};
}
}
Loading