Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
235 changes: 235 additions & 0 deletions apps/api/src/achievements/achievements.controller.ts
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")

Copy link
Copy Markdown
Collaborator

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 @RequirePermission decorator with proper permissions. Now everyone can do anything with the achievement through the API.

export class AchievementsController {
constructor(private readonly achievementsService: AchievementsService) {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
constructor(private readonly achievementsService: AchievementsService) {}
constructor(private readonly achievementsService: AchievementsService) {}

@Get()
async getAchievementsList(
@Query("is-enabled") isEnabled: boolean,
@Query("visibility") visibility: GamificationVisibility,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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);
}
}
17 changes: 17 additions & 0 deletions apps/api/src/achievements/achievements.module.ts
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 {}
88 changes: 88 additions & 0 deletions apps/api/src/achievements/achievements.repository.ts
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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's create a enum for it ({} as const) and derive type from 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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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");
}
}
}
}
}
}
Loading
Loading