From c7638270c943497874b3a070d16e61688f999189 Mon Sep 17 00:00:00 2001 From: olowo Date: Mon, 24 Feb 2025 00:17:56 +0100 Subject: [PATCH] refac: implement user system --- backend/package.json | 2 +- .../achievement.controller.spec.ts | 18 ++++ .../src/achievement/achievement.controller.ts | 44 +++----- .../src/achievement/achievement.gateway.ts | 18 ++++ backend/src/achievement/achievement.module.ts | 15 ++- .../achievement/achievement.service.spec.ts | 18 ++++ .../src/achievement/achievement.service.ts | 101 ++++++++++++++++++ .../achievement/dto/create-achievement.dto.ts | 20 ++-- .../achievement/dto/update-achievement.dto.ts | 4 - .../entities/achievement.entity.ts | 32 ++++-- .../entities/user-achievement.entity.ts | 25 +++++ .../provider/achievement.service.ts | 42 -------- backend/src/app.module.ts | 1 + 13 files changed, 245 insertions(+), 95 deletions(-) create mode 100644 backend/src/achievement/achievement.controller.spec.ts create mode 100644 backend/src/achievement/achievement.gateway.ts create mode 100644 backend/src/achievement/achievement.service.spec.ts create mode 100644 backend/src/achievement/achievement.service.ts delete mode 100644 backend/src/achievement/dto/update-achievement.dto.ts create mode 100644 backend/src/achievement/entities/user-achievement.entity.ts delete mode 100644 backend/src/achievement/provider/achievement.service.ts diff --git a/backend/package.json b/backend/package.json index 17642dfa..5bc645b5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,8 +26,8 @@ "@nestjs/event-emitter": "^3.0.0", "@nestjs/jwt": "^11.0.0", "@nestjs/platform-express": "^11.0.5", - "@nestjs/schedule": "^5.0.1", "@nestjs/platform-socket.io": "^11.0.10", + "@nestjs/schedule": "^5.0.1", "@nestjs/swagger": "^11.0.3", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^11.0.10", diff --git a/backend/src/achievement/achievement.controller.spec.ts b/backend/src/achievement/achievement.controller.spec.ts new file mode 100644 index 00000000..40f7ead6 --- /dev/null +++ b/backend/src/achievement/achievement.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AchievementController } from './achievement.controller'; + +describe('AchievementController', () => { + let controller: AchievementController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AchievementController], + }).compile(); + + controller = module.get(AchievementController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/achievement/achievement.controller.ts b/backend/src/achievement/achievement.controller.ts index 5b533485..537d49a8 100644 --- a/backend/src/achievement/achievement.controller.ts +++ b/backend/src/achievement/achievement.controller.ts @@ -1,17 +1,12 @@ -import { - Controller, - Get, - Post, - Body, - Patch, - Param, - Delete, -} from '@nestjs/common'; -import { AchievementService } from './provider/achievement.service'; +// src/achievement/achievement.controller.ts +import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; +import { AchievementService } from './achievement.service'; import { CreateAchievementDto } from './dto/create-achievement.dto'; -import { UpdateAchievementDto } from './dto/update-achievement.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; @Controller('achievements') +@UseGuards(JwtAuthGuard) export class AchievementController { constructor(private readonly achievementService: AchievementService) {} @@ -20,26 +15,13 @@ export class AchievementController { return this.achievementService.create(createAchievementDto); } - @Get() - findAll() { - return this.achievementService.findAll(); + @Get('user') + getUserAchievements(@CurrentUser() userId: string) { + return this.achievementService.getUserAchievements(userId); } - @Get(':id') - findOne(@Param('id') id: string) { - return this.achievementService.findOne(id); + @Get('leaderboard') + getLeaderboard() { + return this.achievementService.getLeaderboard(); } - - @Patch(':id') - update( - @Param('id') id: string, - @Body() updateAchievementDto: UpdateAchievementDto, - ) { - return this.achievementService.update(id, updateAchievementDto); - } - - @Delete(':id') - remove(@Param('id') id: string) { - return this.achievementService.remove(id); - } -} +} \ No newline at end of file diff --git a/backend/src/achievement/achievement.gateway.ts b/backend/src/achievement/achievement.gateway.ts new file mode 100644 index 00000000..8f5f37d3 --- /dev/null +++ b/backend/src/achievement/achievement.gateway.ts @@ -0,0 +1,18 @@ +// src/achievement/achievement.gateway.ts +import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; +import { Server } from 'socket.io'; +import { OnEvent } from '@nestjs/event-emitter'; + +@WebSocketGateway() +export class AchievementGateway { + @WebSocketServer() + server: Server; + + @OnEvent('achievement.unlocked') + handleAchievementUnlocked(payload: any) { + this.server.to(payload.userId).emit('achievementUnlocked', { + achievement: payload.achievement, + unlockedAt: payload.unlockedAt, + }); + } +} \ No newline at end of file diff --git a/backend/src/achievement/achievement.module.ts b/backend/src/achievement/achievement.module.ts index 340634f6..cbe2d415 100644 --- a/backend/src/achievement/achievement.module.ts +++ b/backend/src/achievement/achievement.module.ts @@ -1,9 +1,18 @@ +// src/achievement/achievement.module.ts import { Module } from '@nestjs/common'; -import { AchievementService } from './provider/achievement.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AchievementController } from './achievement.controller'; +import { AchievementService } from './achievement.service'; +import { AchievementGateway } from './achievement.gateway'; +import { Achievement } from './entities/achievement.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; @Module({ + imports: [ + TypeOrmModule.forFeature([Achievement, UserAchievement]), + ], controllers: [AchievementController], - providers: [AchievementService], + providers: [AchievementService, AchievementGateway], + exports: [AchievementService], }) -export class AchievementModule {} +export class AchievementModule {} \ No newline at end of file diff --git a/backend/src/achievement/achievement.service.spec.ts b/backend/src/achievement/achievement.service.spec.ts new file mode 100644 index 00000000..22332805 --- /dev/null +++ b/backend/src/achievement/achievement.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AchievementService } from './achievement.service'; + +describe('AchievementService', () => { + let service: AchievementService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AchievementService], + }).compile(); + + service = module.get(AchievementService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/achievement/achievement.service.ts b/backend/src/achievement/achievement.service.ts new file mode 100644 index 00000000..5944184f --- /dev/null +++ b/backend/src/achievement/achievement.service.ts @@ -0,0 +1,101 @@ +// src/achievement/achievement.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Achievement } from './entities/achievement.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { CreateAchievementDto } from './dto/create-achievement.dto'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +@Injectable() +export class AchievementService { + constructor( + @InjectRepository(Achievement) + private achievementRepository: Repository, + @InjectRepository(UserAchievement) + private userAchievementRepository: Repository, + private eventEmitter: EventEmitter2, + ) {} + + async create(createAchievementDto: CreateAchievementDto): Promise { + const achievement = this.achievementRepository.create(createAchievementDto); + return this.achievementRepository.save(achievement); + } + + async trackProgress(userId: string, eventType: string, eventData: any): Promise { + const relevantAchievements = await this.achievementRepository.find({ + where: { + 'criteria.eventType': eventType, + }, + }); + + for (const achievement of relevantAchievements) { + const userAchievement = await this.getUserAchievement(userId, achievement.id); + if (userAchievement.isCompleted) continue; + + const newProgress = await this.calculateProgress(achievement, userAchievement, eventData); + userAchievement.progress = newProgress; + + if (newProgress >= 1) { + await this.unlockAchievement(userId, achievement.id); + } else { + await this.userAchievementRepository.save(userAchievement); + } + } + } + + async unlockAchievement(userId: string, achievementId: string): Promise { + const userAchievement = await this.getUserAchievement(userId, achievementId); + if (userAchievement.isCompleted) return; + + userAchievement.isCompleted = true; + userAchievement.progress = 1; + userAchievement.unlockedAt = new Date(); + await this.userAchievementRepository.save(userAchievement); + + const achievement = await this.achievementRepository.findOne(achievementId); + this.eventEmitter.emit('achievement.unlocked', { + userId, + achievement, + unlockedAt: userAchievement.unlockedAt, + }); + } + + private async getUserAchievement(userId: string, achievementId: string): Promise { + let userAchievement = await this.userAchievementRepository.findOne({ + where: { + user: { id: userId }, + achievement: { id: achievementId }, + }, + }); + + if (!userAchievement) { + userAchievement = this.userAchievementRepository.create({ + user: { id: userId }, + achievement: { id: achievementId }, + progress: 0, + }); + await this.userAchievementRepository.save(userAchievement); + } + + return userAchievement; + } + + private async calculateProgress( + achievement: Achievement, + userAchievement: UserAchievement, + eventData: any, + ): Promise { + // Implement progress calculation based on achievement criteria + // This is a simplified example + const currentProgress = userAchievement.progress; + const increment = this.evaluateCriteria(achievement.criteria, eventData); + return Math.min(1, currentProgress + increment); + } + + private evaluateCriteria(criteria: Record, eventData: any): number { + // Implement criteria evaluation logic + // This is a simplified example + return 0.1; // Return progress increment + } +} \ No newline at end of file diff --git a/backend/src/achievement/dto/create-achievement.dto.ts b/backend/src/achievement/dto/create-achievement.dto.ts index c896fbf0..f9e95efe 100644 --- a/backend/src/achievement/dto/create-achievement.dto.ts +++ b/backend/src/achievement/dto/create-achievement.dto.ts @@ -1,19 +1,25 @@ -import { IsString, IsInt, Min, IsObject, IsNotEmpty } from 'class-validator'; +// src/achievement/dto/create-achievement.dto.ts +import { IsString, IsEnum, IsNumber, IsObject, IsNotEmpty } from 'class-validator'; +import { AchievementCategory } from '../entities/achievement.entity'; export class CreateAchievementDto { @IsString() @IsNotEmpty() - name: string; + title: string; @IsString() @IsNotEmpty() description: string; - @IsInt() - @Min(0) - points: number; + @IsString() + icon: string; + + @IsEnum(AchievementCategory) + category: AchievementCategory; + + @IsNumber() + pointsValue: number; @IsObject() - @IsNotEmpty() criteria: Record; -} +} \ No newline at end of file diff --git a/backend/src/achievement/dto/update-achievement.dto.ts b/backend/src/achievement/dto/update-achievement.dto.ts deleted file mode 100644 index b52a5f6a..00000000 --- a/backend/src/achievement/dto/update-achievement.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateAchievementDto } from './create-achievement.dto'; - -export class UpdateAchievementDto extends PartialType(CreateAchievementDto) {} diff --git a/backend/src/achievement/entities/achievement.entity.ts b/backend/src/achievement/entities/achievement.entity.ts index 5d36651a..e3563a16 100644 --- a/backend/src/achievement/entities/achievement.entity.ts +++ b/backend/src/achievement/entities/achievement.entity.ts @@ -1,5 +1,15 @@ -import { User } from 'src/user/user.entity'; -// import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm'; +Achievement System Implementation + +// src/achievement/entities/achievement.entity.ts +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export enum AchievementCategory { + STREAK = 'STREAK', + SCORE = 'SCORE', + GENRE = 'GENRE', + SOCIAL = 'SOCIAL', + MISC = 'MISC', +} @Entity() export class Achievement { @@ -7,18 +17,26 @@ export class Achievement { id: string; @Column() - name: string; + title: string; @Column('text') description: string; @Column() - points: number; + icon: string; + + @Column({ type: 'enum', enum: AchievementCategory }) + category: AchievementCategory; + + @Column('int') + pointsValue: number; @Column('json') criteria: Record; - @ManyToMany(() => User, (user) => user.achievements) - @JoinTable() - unlockedBy: User[]; + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; } \ No newline at end of file diff --git a/backend/src/achievement/entities/user-achievement.entity.ts b/backend/src/achievement/entities/user-achievement.entity.ts new file mode 100644 index 00000000..2847dba4 --- /dev/null +++ b/backend/src/achievement/entities/user-achievement.entity.ts @@ -0,0 +1,25 @@ +// src/achievement/entities/user-achievement.entity.ts +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn } from 'typeorm'; +import { Achievement } from './achievement.entity'; +import { User } from '../../user/entities/user.entity'; + +@Entity() +export class UserAchievement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User) + user: User; + + @ManyToOne(() => Achievement) + achievement: Achievement; + + @Column('float') + progress: number; + + @Column({ default: false }) + isCompleted: boolean; + + @CreateDateColumn() + unlockedAt: Date; +} \ No newline at end of file diff --git a/backend/src/achievement/provider/achievement.service.ts b/backend/src/achievement/provider/achievement.service.ts deleted file mode 100644 index b417bb34..00000000 --- a/backend/src/achievement/provider/achievement.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Achievement } from '../entities/achievement.entity'; -import { CreateAchievementDto } from '../dto/create-achievement.dto'; -import { UpdateAchievementDto } from '../dto/update-achievement.dto'; - -@Injectable() -export class AchievementService { - constructor( - @InjectRepository(Achievement) - private readonly achievementRepository: Repository, - ) {} - - async create(createAchievementDto: CreateAchievementDto) { - const achievement = this.achievementRepository.create(createAchievementDto); - return this.achievementRepository.save(achievement); - } - - async findAll() { - return this.achievementRepository.find(); - } - - async findOne(id: string) { - const achievement = await this.achievementRepository.findOne({ - where: { id }, - }); - if (!achievement) throw new NotFoundException('Achievement not found'); - return achievement; - } - - async update(id: string, updateAchievementDto: UpdateAchievementDto) { - await this.findOne(id); - await this.achievementRepository.update(id, updateAchievementDto); - return this.findOne(id); - } - - async remove(id: string) { - const achievement = await this.findOne(id); - return this.achievementRepository.remove(achievement); - } -} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6ff113a0..91ffa5e5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -25,6 +25,7 @@ import { GameGateway } from './websocket-game comms/providers/gamegateway'; import { GameModule } from './websocket-game comms/game.module'; import { AchievementModule } from './achievement/achievement.module'; import { SocialModule } from './social/social.module'; +import { AchievementModule } from './achievement/achievement.module'; @Module({ imports: [