Skip to content

feat: build gamification system core - #1800

Open
AdamStajer07 wants to merge 18 commits into
stagingfrom
as_feat_1722_build_gamification_system_core
Open

feat: build gamification system core#1800
AdamStajer07 wants to merge 18 commits into
stagingfrom
as_feat_1722_build_gamification_system_core

Conversation

@AdamStajer07

Copy link
Copy Markdown
Collaborator

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

Zrzut ekranu 2026-07-22 125213 Zrzut ekranu 2026-07-22 125651 Zrzut ekranu 2026-07-22 125752

@AdamStajer07 AdamStajer07 self-assigned this Jul 22, 2026

@Pieselak Pieselak left a comment

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.

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");

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.

Same as above

.returning();

if (!updatedAchievement) {
throw new BadRequestException("error");

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.

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");

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.

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");

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.

You can delete this check as it's useless (also exception type is incorrect)

.returning({
id: achievementLevels.id,
});
if (!updatedLevel) throw new BadRequestException("error");

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.

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");

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.

Use a valid translation key

.where(eq(userProgress.userId, currentUser.userId));

if (!progress) {
throw new BadRequestException("common.error");

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.

Why throw BadRequestException here? I think NotFoundException will be better here. Also use a valid translation key

Comment thread apps/web/app/api/socket.ts Outdated
Comment on lines +39 to +44
export function resetSocket(): void {
if (!socket) return;

socket.disconnect();
socket.connect();
}

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.

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)

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.

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()}

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.

Same as above

@AdamStajer07
AdamStajer07 removed the request for review from its-gabo July 22, 2026 12:40

@mountain-bit mountain-bit left a comment

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.

This looks solid.

const hasHistory = achievement.history.length > 1;

return (
<button

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.

Maybe use component instead?

@pkrzysiekk pkrzysiekk left a comment

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.

Looks great to me

…d, change achievementsCarousel into more ui-component

@mountain-bit mountain-bit left a comment

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.

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));

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.

pl-PL is hardcoded here. Could we use the application's current locale to support all languages?

@AdamStajer07

Copy link
Copy Markdown
Collaborator Author

Changing default trophy Icon for own SVG Achievement icon
image

@AdamStajer07
AdamStajer07 requested a review from its-gabo July 23, 2026 13:29
…ding. Change achievements avatar for new. Add missing gamification errors translation

@its-gabo its-gabo left a comment

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.

Good job, it needs some things to be worked on

Comment on lines +5 to +7
export const createTranslationSchema = Type.Object({
key: Type.String(),
});

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'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) {}

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) {}

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.

@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

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

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));

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.

Why is the additional timeout here?

Comment thread apps/api/src/storage/schema/index.ts Outdated

tenantId,

key: jsonb("key").$type<LocalizedText>().default({}).notNull(),

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'd change it to title

Comment thread apps/web/app/components/ui/use-toast.ts Outdated

const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
const TOAST_REMOVE_DELAY = 10000;

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.

Why was this changed?

Comment on lines +48 to +59
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);
});

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 extract the utils to separate file

canViewExtendedProfile,
);

const { data: achievements } = useUserAchievements(language);

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.

There will be issue when a user will go to students profile - he will see his own achievements rather than the student's

@mountain-bit mountain-bit left a comment

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.

Looks good to me. The only thing left is to resolve the conflicts.

@pkrzysiekk pkrzysiekk left a comment

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.

Changes look good

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants