feat: build gamification system core - #1800
Conversation
…ification handler, achievement notifications websocket emit, list of achievements in profile page
…nction in service
Pieselak
left a comment
There was a problem hiding this comment.
Overall it looks decent, but needs some minor corrections
| .delete(achievements) | ||
| .where(eq(achievements.id, achievementId)) | ||
| .returning({ id: achievements.id }); | ||
| if (!deletedAchievement) throw new BadRequestException("common.error"); |
| .returning(); | ||
|
|
||
| if (!updatedAchievement) { | ||
| throw new BadRequestException("error"); |
There was a problem hiding this comment.
Why throw BadRequestException here? I think NotFoundException will be better here. Also use a valid translation key
| .where(eq(achievements.id, achievementId)); | ||
|
|
||
| if (!achievement) { | ||
| throw new BadRequestException("error"); |
There was a problem hiding this comment.
Why throw BadRequestException here? I think NotFoundException will be better here. Also use a valid translation key
| id: achievements.id, | ||
| key: achievements.key, | ||
| }); | ||
| if (!createdAchievement) throw new BadRequestException("error"); |
There was a problem hiding this comment.
You can delete this check as it's useless (also exception type is incorrect)
| .returning({ | ||
| id: achievementLevels.id, | ||
| }); | ||
| if (!updatedLevel) throw new BadRequestException("error"); |
There was a problem hiding this comment.
Why throw BadRequestException here? I think NotFoundException will be better here. Also use a valid translation key
| type: "post" | "update", | ||
| levelNumber?: number, | ||
| ) { | ||
| if (!threshold) throw new BadRequestException("common.error"); |
There was a problem hiding this comment.
Use a valid translation key
| .where(eq(userProgress.userId, currentUser.userId)); | ||
|
|
||
| if (!progress) { | ||
| throw new BadRequestException("common.error"); |
There was a problem hiding this comment.
Why throw BadRequestException here? I think NotFoundException will be better here. Also use a valid translation key
| export function resetSocket(): void { | ||
| if (!socket) return; | ||
|
|
||
| socket.disconnect(); | ||
| socket.connect(); | ||
| } |
There was a problem hiding this comment.
Maybe use a different name for this function, maybe reconnectSocket. The current name might be confusing
|
|
||
| <span className="text-sm text-neutral-600"> | ||
| {level.earnedAt | ||
| ? new Date(level.earnedAt).toLocaleString("pl-PL").slice(0, -3) |
There was a problem hiding this comment.
Maybe use Intl.DateTimeFormat with user lang code instead?
|
|
||
| {achievement.currentLevel.earnedAt && ( | ||
| <span className="text-xs text-neutral-500"> | ||
| {new Date(achievement.currentLevel.earnedAt).toLocaleDateString()} |
| const hasHistory = achievement.history.length > 1; | ||
|
|
||
| return ( | ||
| <button |
There was a problem hiding this comment.
Maybe use component instead?
…d, change achievementsCarousel into more ui-component
mountain-bit
left a comment
There was a problem hiding this comment.
Pretty good overall! I only have one suggestion: avoid hardcoding locale-specific values
| "justify-between flex w-full max-w-[720px] flex-col gap-y-6 rounded-b-lg rounded-t-2xl bg-white p-6 drop-shadow"; | ||
|
|
||
| function formatDate(date: string, options: Intl.DateTimeFormatOptions): string { | ||
| return new Intl.DateTimeFormat("pl-PL", options).format(new Date(date)); |
There was a problem hiding this comment.
pl-PL is hardcoded here. Could we use the application's current locale to support all languages?
…ding. Change achievements avatar for new. Add missing gamification errors translation
its-gabo
left a comment
There was a problem hiding this comment.
Good job, it needs some things to be worked on
| export const createTranslationSchema = Type.Object({ | ||
| key: Type.String(), | ||
| }); |
There was a problem hiding this comment.
I'm not sure about this the name of the columns. It's kinda deceiving. Let's rename it to something like title.
|
|
||
| @Controller("achievements") | ||
| export class AchievementsController { | ||
| constructor(private readonly achievementsService: AchievementsService) {} |
There was a problem hiding this comment.
| constructor(private readonly achievementsService: AchievementsService) {} | |
| constructor(private readonly achievementsService: AchievementsService) {} | |
| UpdateAchievementLevel, | ||
| } from "./schema/updateAchievementLevel.schema"; | ||
|
|
||
| @Controller("achievements") |
There was a problem hiding this comment.
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.
| @Get() | ||
| async getAchievementsList( | ||
| @Query("is-enabled") isEnabled: boolean, | ||
| @Query("visibility") visibility: GamificationVisibility, |
There was a problem hiding this comment.
I would derive it from user permissions rather than through query param - someone can manually change the query
| async validateThreshold( | ||
| achievementId: UUIDType, | ||
| threshold: number | undefined, | ||
| type: "post" | "update", |
There was a problem hiding this comment.
Let's create a enum for it ({} as const) and derive type from it
| if (!event.resourceType) throw new Error("common.error.somethingWentWrong"); | ||
| await this.tenantRunner.runWithTenant(event.tenantId, async () => { | ||
| this.logger.log(`Processing gamification event: ${event.actionType}`); | ||
| await new Promise((resolve) => setTimeout(resolve, 500)); |
There was a problem hiding this comment.
Why is the additional timeout here?
|
|
||
| tenantId, | ||
|
|
||
| key: jsonb("key").$type<LocalizedText>().default({}).notNull(), |
|
|
||
| const TOAST_LIMIT = 1; | ||
| const TOAST_REMOVE_DELAY = 1000000; | ||
| const TOAST_REMOVE_DELAY = 10000; |
| function formatDate(date: string, options: Intl.DateTimeFormatOptions, language: string): string { | ||
| return new Intl.DateTimeFormat(language, options).format(new Date(date)); | ||
| } | ||
|
|
||
| function groupAchievements(achievements: UserAchievement[]): GroupedAchievement[] { | ||
| const achievementsMap = new Map<string, UserAchievement[]>(); | ||
|
|
||
| achievements.forEach((achievement) => { | ||
| const existingAchievements = achievementsMap.get(achievement.achievementId) ?? []; | ||
| existingAchievements.push(achievement); | ||
| achievementsMap.set(achievement.achievementId, existingAchievements); | ||
| }); |
There was a problem hiding this comment.
Let's extract the utils to separate file
| canViewExtendedProfile, | ||
| ); | ||
|
|
||
| const { data: achievements } = useUserAchievements(language); |
There was a problem hiding this comment.
There will be issue when a user will go to students profile - he will see his own achievements rather than the student's

Issue(s)
1722
Overview
Implementation of gamification core.
System of rewarding users for activities in platform
Gamification worker uses activityLogs to listen to new events, and after that checks new user's achievements. Every achievement could have many levels. Every level of achievement could have different xpReward. User's xp has 2 categories: spendable and lifetime. Lifetime couldn't be dicreased.
Type of achievements is defined based on resourceType and actionType from activityLogs (e.g. "user" and "login")
If user receives new achievement, websocket emits message to the front-end and user receives notification.
Every user's achievements are in his profile page.
Business Value
Automatic, consistent recognition of learning activity increases learner motivation and engagement, giving users clear, immediate feedback on their progress instead of relying on passive course completion alone.
Screenshots / Video