-
Notifications
You must be signed in to change notification settings - Fork 27
feat: build gamification system core #1800
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from 10 commits
2423e54
e174acf
e1ee6c1
f83eca0
8727116
2391fc1
5e16304
bcad913
fd71c42
db03094
b8c1008
3111c46
090615d
423acda
6f88003
96d5aa8
539887c
1e36e25
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,235 @@ | ||||||||
| import { | ||||||||
| Body, | ||||||||
| Controller, | ||||||||
| Delete, | ||||||||
| Get, | ||||||||
| Param, | ||||||||
| ParseUUIDPipe, | ||||||||
| Patch, | ||||||||
| Post, | ||||||||
| Query, | ||||||||
| } from "@nestjs/common"; | ||||||||
| import { GamificationVisibility, SupportedLanguages } from "@repo/shared"; | ||||||||
| import { Validate } from "nestjs-typebox"; | ||||||||
|
|
||||||||
| import { BaseResponse, baseResponse, UUIDSchema, UUIDType } from "src/common"; | ||||||||
| import { CurrentUser } from "src/common/decorators/user.decorator"; | ||||||||
| import { CurrentUserType } from "src/common/types/current-user.type"; | ||||||||
|
|
||||||||
| import { AchievementsService } from "./achievements.service"; | ||||||||
| import { | ||||||||
| achievementsLanguageSchema, | ||||||||
| GetUserAchievementsSchema, | ||||||||
| } from "./schema/achievements.schema"; | ||||||||
| import { CreateAchievement, createAchievementSchema } from "./schema/createAchievement.schema"; | ||||||||
| import { | ||||||||
| CreateAchievementLevel, | ||||||||
| createAchievementLevelSchema, | ||||||||
| } from "./schema/createAchievementLevel.schema"; | ||||||||
| import { CreateTranslation, createTranslationSchema } from "./schema/createTranslation.schema"; | ||||||||
| import { UpdateAchievement, updateAchievementSchema } from "./schema/updateAchievement.schema"; | ||||||||
| import { | ||||||||
| LevelNumberParam, | ||||||||
| levelNumberParamSchema, | ||||||||
| UpdateAchievementLevel, | ||||||||
| } from "./schema/updateAchievementLevel.schema"; | ||||||||
|
|
||||||||
| @Controller("achievements") | ||||||||
| export class AchievementsController { | ||||||||
| constructor(private readonly achievementsService: AchievementsService) {} | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| @Get() | ||||||||
| async getAchievementsList( | ||||||||
| @Query("is-enabled") isEnabled: boolean, | ||||||||
| @Query("visibility") visibility: GamificationVisibility, | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would derive it from user permissions rather than through query param - someone can manually change the query |
||||||||
| @Query("trigger-event-type") triggerEventType: string, | ||||||||
| ) { | ||||||||
| return await this.achievementsService.getAchievementsList( | ||||||||
| isEnabled, | ||||||||
| visibility, | ||||||||
| triggerEventType, | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| @Get("user-achievements") | ||||||||
| @Validate({ | ||||||||
| response: baseResponse(GetUserAchievementsSchema), | ||||||||
| request: [ | ||||||||
| { | ||||||||
| type: "query", | ||||||||
| name: "language", | ||||||||
| schema: achievementsLanguageSchema, | ||||||||
| }, | ||||||||
| ], | ||||||||
| }) | ||||||||
| async getUserAchievements( | ||||||||
| @Query("language") language: SupportedLanguages, | ||||||||
| @CurrentUser() currentUser: CurrentUserType, | ||||||||
| ) { | ||||||||
| return new BaseResponse( | ||||||||
| await this.achievementsService.getUserAchievements(currentUser, language), | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| @Post() | ||||||||
| @Validate({ | ||||||||
| request: [ | ||||||||
| { | ||||||||
| type: "body", | ||||||||
| schema: createAchievementSchema, | ||||||||
| }, | ||||||||
| ], | ||||||||
| }) | ||||||||
| async createAchievement(@Body() createAchievementBody: CreateAchievement) { | ||||||||
| await this.achievementsService.createAchievement(createAchievementBody); | ||||||||
| } | ||||||||
|
|
||||||||
| @Patch(":id") | ||||||||
| @Validate({ | ||||||||
| request: [ | ||||||||
| { type: "param", name: "id", schema: UUIDSchema }, | ||||||||
| { | ||||||||
| type: "body", | ||||||||
| schema: updateAchievementSchema, | ||||||||
| }, | ||||||||
| ], | ||||||||
| }) | ||||||||
| async updateAchievement( | ||||||||
| @Param("id") id: UUIDType, | ||||||||
| @Body() updateAchievementBody: UpdateAchievement, | ||||||||
| ) { | ||||||||
| return await this.achievementsService.updateAchievement(id, updateAchievementBody); | ||||||||
| } | ||||||||
|
|
||||||||
| @Delete(":id") | ||||||||
| @Validate({ | ||||||||
| request: [{ type: "param", name: "id", schema: UUIDSchema }], | ||||||||
| }) | ||||||||
| async deleteAchievement(@Param("id") id: UUIDType) { | ||||||||
| await this.achievementsService.deleteAchievement(id); | ||||||||
| } | ||||||||
|
|
||||||||
| @Get("levels/:achievementId") | ||||||||
| @Validate({ | ||||||||
| request: [{ type: "param", name: "achievementId", schema: UUIDSchema }], | ||||||||
| }) | ||||||||
| async getAchievementAllLevels(@Param("achievementId") achievementId: UUIDType) { | ||||||||
| return await this.achievementsService.getAchievementLevels(achievementId); | ||||||||
| } | ||||||||
|
|
||||||||
| @Get("levels/:achievementId/:levelId") | ||||||||
| @Validate({ | ||||||||
| request: [ | ||||||||
| { type: "param", name: "achievementId", schema: UUIDSchema }, | ||||||||
| { type: "param", name: "levelId", schema: levelNumberParamSchema }, | ||||||||
| ], | ||||||||
| }) | ||||||||
| async getAchievementLevel( | ||||||||
| @Param("achievementId") achievementId: UUIDType, | ||||||||
| @Param("levelId") levelNumber?: LevelNumberParam, | ||||||||
| ) { | ||||||||
| return await this.achievementsService.getAchievementLevels(achievementId, levelNumber); | ||||||||
| } | ||||||||
|
|
||||||||
| @Post("levels/:achievementId") | ||||||||
| @Validate({ | ||||||||
| request: [ | ||||||||
| { | ||||||||
| type: "param", | ||||||||
| name: "achievementId", | ||||||||
| schema: UUIDSchema, | ||||||||
| }, | ||||||||
| { | ||||||||
| type: "body", | ||||||||
| schema: createAchievementLevelSchema, | ||||||||
| }, | ||||||||
| ], | ||||||||
| }) | ||||||||
| async createAchievementLevel( | ||||||||
| @Param("achievementId") achievementId: UUIDType, | ||||||||
| @Body() achievementLevelBody: CreateAchievementLevel, | ||||||||
| ) { | ||||||||
| await this.achievementsService.createAchievementLevel(achievementLevelBody, achievementId); | ||||||||
| } | ||||||||
|
|
||||||||
| @Patch("levels/:achievementId/:levelNumber") | ||||||||
| @Validate({ | ||||||||
| request: [ | ||||||||
| { | ||||||||
| type: "param", | ||||||||
| name: "achievementId", | ||||||||
| schema: UUIDSchema, | ||||||||
| }, | ||||||||
| { | ||||||||
| type: "param", | ||||||||
| name: "levelNumber", | ||||||||
| schema: levelNumberParamSchema, | ||||||||
| }, | ||||||||
| { | ||||||||
| type: "body", | ||||||||
| schema: createAchievementLevelSchema, | ||||||||
| }, | ||||||||
| ], | ||||||||
| }) | ||||||||
| async updateAchievementLevel( | ||||||||
| @Param("achievementId") achievementId: UUIDType, | ||||||||
| @Param("levelNumber") levelNumber: number, | ||||||||
| @Body() updateAchievementLevelBody: UpdateAchievementLevel, | ||||||||
| ) { | ||||||||
| await this.achievementsService.updateAchievementLevel( | ||||||||
| updateAchievementLevelBody, | ||||||||
| achievementId, | ||||||||
| levelNumber, | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| @Delete("levels/:achievementId") | ||||||||
| async deleteAchievementLevel(@Param("achievementId") achievementId: UUIDType) { | ||||||||
| await this.achievementsService.deleteAchievemntLevel(achievementId); | ||||||||
| } | ||||||||
|
|
||||||||
| @Post(":achievementId/translation") | ||||||||
| @Validate({ | ||||||||
| request: [ | ||||||||
| { | ||||||||
| type: "param", | ||||||||
| name: "achievementId", | ||||||||
| schema: UUIDSchema, | ||||||||
| }, | ||||||||
| { | ||||||||
| type: "query", | ||||||||
| name: "language", | ||||||||
| schema: achievementsLanguageSchema, | ||||||||
| }, | ||||||||
| { | ||||||||
| type: "body", | ||||||||
| schema: createTranslationSchema, | ||||||||
| }, | ||||||||
| ], | ||||||||
| }) | ||||||||
| async createTranslation( | ||||||||
| @Param("achievementId") id: UUIDType, | ||||||||
| @Query("language") language: SupportedLanguages, | ||||||||
| @Body() translationBody: CreateTranslation, | ||||||||
| ) { | ||||||||
| await this.achievementsService.createTranslation(id, language, translationBody.key); | ||||||||
| } | ||||||||
|
|
||||||||
| @Get(":id") | ||||||||
| @Validate({ | ||||||||
| request: [ | ||||||||
| { | ||||||||
| type: "param", | ||||||||
| name: "id", | ||||||||
| schema: UUIDSchema, | ||||||||
| }, | ||||||||
| { | ||||||||
| type: "query", | ||||||||
| name: "language", | ||||||||
| schema: achievementsLanguageSchema, | ||||||||
| }, | ||||||||
| ], | ||||||||
| }) | ||||||||
| async getAchievement(@Param("id", ParseUUIDPipe) id: UUIDType) { | ||||||||
| return await this.achievementsService.getAchievement(id); | ||||||||
| } | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { Module } from "@nestjs/common"; | ||
|
|
||
| import { GamificationRepository } from "src/gamification/gamification.repository"; | ||
| import { LocalizationModule } from "src/localization/localization.module"; | ||
| import { SettingsModule } from "src/settings/settings.module"; | ||
|
|
||
| import { AchievementsController } from "./achievements.controller"; | ||
| import { AchievementsRepository } from "./achievements.repository"; | ||
| import { AchievementsService } from "./achievements.service"; | ||
|
|
||
| @Module({ | ||
| exports: [AchievementsModule], | ||
| imports: [SettingsModule, LocalizationModule], | ||
| controllers: [AchievementsController], | ||
| providers: [AchievementsService, AchievementsRepository, GamificationRepository], | ||
| }) | ||
| export class AchievementsModule {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { Injectable, Inject, BadRequestException } from "@nestjs/common"; | ||
| import { eq, desc } from "drizzle-orm"; | ||
|
|
||
| import { DatabasePg } from "src/common"; | ||
| import { DB } from "src/storage/db/db.providers"; | ||
| import { achievementLevels, achievements } from "src/storage/schema"; | ||
|
|
||
| import type { GamificationVisibility } from "@repo/shared"; | ||
| import type { UUIDType } from "src/common"; | ||
|
|
||
| @Injectable() | ||
| export class AchievementsRepository { | ||
| constructor(@Inject(DB) private readonly db: DatabasePg) {} | ||
| async getActualLevelNumber(achievementId: UUIDType) { | ||
| const [highestLevel] = await this.db | ||
| .select({ | ||
| levelNumber: achievementLevels.levelNumber, | ||
| }) | ||
| .from(achievementLevels) | ||
| .where(eq(achievementLevels.achievementId, achievementId)) | ||
| .orderBy(desc(achievementLevels.levelNumber)) | ||
| .limit(1); | ||
|
|
||
| return highestLevel == undefined ? 0 : highestLevel.levelNumber; | ||
| } | ||
|
|
||
| async getAchievementsConditions( | ||
| isEnabled?: boolean, | ||
| visibility?: GamificationVisibility, | ||
| triggerEventType?: string, | ||
| ) { | ||
| const conditions = []; | ||
|
|
||
| if (isEnabled !== undefined) { | ||
| conditions.push(eq(achievements.isEnabled, isEnabled)); | ||
| } | ||
|
|
||
| if (visibility) { | ||
| conditions.push(eq(achievements.visibility, visibility)); | ||
| } | ||
|
|
||
| if (triggerEventType) { | ||
| conditions.push(eq(achievements.triggerEventType, triggerEventType)); | ||
| } | ||
|
|
||
| return conditions; | ||
| } | ||
|
|
||
| async validateThreshold( | ||
| achievementId: UUIDType, | ||
| threshold: number | undefined, | ||
| type: "post" | "update", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's create a enum for it ( |
||
| levelNumber?: number, | ||
| ) { | ||
| if (!threshold) | ||
| throw new BadRequestException("gamification.errors.wrongAchievementLevelThreshold"); | ||
| const levels = await this.db | ||
| .select() | ||
| .from(achievementLevels) | ||
| .where(eq(achievementLevels.achievementId, achievementId)) | ||
| .orderBy(desc(achievementLevels.levelNumber)); | ||
| if (levels.length > 0) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logic should live in service rather than in repository. Repository is only for database queries
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also we can use guard clauses pattern here, so it's easier to read what is going on |
||
| if (type == "post") { | ||
| if (levels[0].threshold >= threshold) { | ||
| throw new BadRequestException("gamification.errors.wrongAchievementLevelThreshold"); | ||
| } | ||
| } else if (type == "update") { | ||
| const actualLevel = levels.findIndex((level) => level.levelNumber == levelNumber); | ||
| if (actualLevel == 0) { | ||
| if (levels[actualLevel + 1].threshold >= threshold) { | ||
| throw new BadRequestException("gamification.errors.wrongAchievementLevelThreshold"); | ||
| } | ||
| } else if (actualLevel == levels.length - 1) { | ||
| if (levels[actualLevel - 1].threshold <= threshold) { | ||
| throw new BadRequestException("gamification.errors.wrongAchievementLevelThreshold"); | ||
| } | ||
| } else { | ||
| if ( | ||
| levels[actualLevel - 1].threshold <= threshold || | ||
| levels[actualLevel + 1].threshold >= threshold | ||
| ) { | ||
| throw new BadRequestException("gamification.errors.wrongAchievementLevelThreshold"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the entire file we're missing permission checks.
We'll need to use
@RequirePermissiondecorator with proper permissions. Now everyone can do anything with the achievement through the API.