From bee6592a6c633eba96bc626939ddc082e3ea20c9 Mon Sep 17 00:00:00 2001 From: AmarTrebinjac Date: Mon, 2 Feb 2026 22:58:24 +0000 Subject: [PATCH 1/5] feat: add achievement system for user gamification Implements a comprehensive achievement system including: - Achievement and UserAchievement entities with database migrations - GraphQL schema for querying user achievements - Achievement progress tracking via CDC workers for various events (posts, comments, upvotes, profile updates, squads, etc.) - Notification system integration for achievement unlocks - PubSub event for achievement-unlocked notifications Co-Authored-By: Claude Opus 4.5 --- .infra/common.ts | 4 + src/common/achievement/index.ts | 313 ++++++++++++++++++ src/common/typedPubsub.ts | 4 + src/entity/Achievement.ts | 77 +++++ src/entity/index.ts | 1 + src/entity/notifications/NotificationV2.ts | 3 +- src/entity/user/UserAchievement.ts | 61 ++++ src/entity/user/index.ts | 1 + src/graphql.ts | 3 + .../1770072115384-AchievementSystem.ts | 122 +++++++ .../1770072146923-SeedAchievements.ts | 92 +++++ src/notifications/builder.ts | 7 + src/notifications/common.ts | 5 + src/notifications/generate.ts | 16 + src/notifications/types.ts | 7 + src/schema/achievements.ts | 280 ++++++++++++++++ src/workers/cdc/primary.ts | 223 +++++++++++++ src/workers/newNotificationV2Mail.ts | 4 + .../achievementUnlockedNotification.ts | 28 ++ src/workers/notifications/index.ts | 2 + 20 files changed, 1252 insertions(+), 1 deletion(-) create mode 100644 src/common/achievement/index.ts create mode 100644 src/entity/Achievement.ts create mode 100644 src/entity/user/UserAchievement.ts create mode 100644 src/migration/1770072115384-AchievementSystem.ts create mode 100644 src/migration/1770072146923-SeedAchievements.ts create mode 100644 src/schema/achievements.ts create mode 100644 src/workers/notifications/achievementUnlockedNotification.ts diff --git a/.infra/common.ts b/.infra/common.ts index d9852919f3..0238fe9836 100644 --- a/.infra/common.ts +++ b/.infra/common.ts @@ -484,6 +484,10 @@ export const workers: Worker[] = [ topic: 'api.v1.feedback-updated', subscription: 'api.feedback-updated-slack', }, + { + topic: 'api.v1.achievement-unlocked', + subscription: 'api.achievement-unlocked-notification', + }, ]; export const personalizedDigestWorkers: Worker[] = [ diff --git a/src/common/achievement/index.ts b/src/common/achievement/index.ts new file mode 100644 index 0000000000..06285e0abf --- /dev/null +++ b/src/common/achievement/index.ts @@ -0,0 +1,313 @@ +import { DataSource } from 'typeorm'; +import { FastifyBaseLogger } from 'fastify'; +import { + Achievement, + AchievementEventType, + AchievementType, +} from '../../entity/Achievement'; +import { UserAchievement } from '../../entity/user/UserAchievement'; +import { triggerTypedEvent } from '../typedPubsub'; + +export { + AchievementEventType, + AchievementType, +} from '../../entity/Achievement'; + +/** + * Get achievements by eventType - fast indexed lookup + */ +export async function getAchievementsByEventType( + con: DataSource, + eventType: AchievementEventType, +): Promise { + return con.getRepository(Achievement).find({ + where: { eventType }, + order: { criteria: { targetCount: 'ASC' } }, + }); +} + +/** + * Get or create a user achievement record + */ +export async function getOrCreateUserAchievement( + con: DataSource, + userId: string, + achievementId: string, +): Promise { + const repo = con.getRepository(UserAchievement); + + let userAchievement = await repo.findOne({ + where: { userId, achievementId }, + }); + + if (!userAchievement) { + userAchievement = repo.create({ + userId, + achievementId, + progress: 0, + unlockedAt: null, + }); + await repo.save(userAchievement); + } + + return userAchievement; +} + +/** + * Update user achievement progress and check if it should be unlocked + * Returns true if the achievement was newly unlocked + */ +export async function updateUserAchievementProgress( + con: DataSource, + logger: FastifyBaseLogger, + userId: string, + achievementId: string, + progress: number, + targetCount: number, +): Promise { + const repo = con.getRepository(UserAchievement); + + const userAchievement = await getOrCreateUserAchievement( + con, + userId, + achievementId, + ); + + // Already unlocked, no need to update + if (userAchievement.unlockedAt) { + return false; + } + + const shouldUnlock = progress >= targetCount; + const updateData: Partial = { + progress, + updatedAt: new Date(), + }; + + if (shouldUnlock) { + updateData.unlockedAt = new Date(); + } + + await repo.update({ achievementId, userId }, updateData); + + return shouldUnlock; +} + +/** + * Increment user achievement progress by a given amount + * Returns true if the achievement was newly unlocked + */ +export async function incrementUserAchievementProgress( + con: DataSource, + logger: FastifyBaseLogger, + userId: string, + achievementId: string, + targetCount: number, + incrementBy: number = 1, +): Promise { + const userAchievement = await getOrCreateUserAchievement( + con, + userId, + achievementId, + ); + + // Already unlocked, no need to update + if (userAchievement.unlockedAt) { + return false; + } + + const newProgress = userAchievement.progress + incrementBy; + return updateUserAchievementProgress( + con, + logger, + userId, + achievementId, + newProgress, + targetCount, + ); +} + +/** + * Evaluates and updates achievements for instant type (one-time actions) + */ +async function evaluateInstantAchievement( + con: DataSource, + logger: FastifyBaseLogger, + userId: string, + achievements: Achievement[], +): Promise { + for (const achievement of achievements) { + const targetCount = achievement.criteria.targetCount ?? 1; + const wasUnlocked = await updateUserAchievementProgress( + con, + logger, + userId, + achievement.id, + 1, // Instant achievements are always complete with 1 action + targetCount, + ); + + if (wasUnlocked) { + logger.info( + { achievementId: achievement.id, userId, name: achievement.name }, + 'Achievement unlocked', + ); + await triggerTypedEvent(logger, 'api.v1.achievement-unlocked', { + achievementId: achievement.id, + userId, + }); + } + } +} + +/** + * Evaluates and updates achievements for milestone type (counting actions) + */ +async function evaluateMilestoneAchievement( + con: DataSource, + logger: FastifyBaseLogger, + userId: string, + achievements: Achievement[], + incrementBy: number = 1, +): Promise { + for (const achievement of achievements) { + const targetCount = achievement.criteria.targetCount ?? 1; + const wasUnlocked = await incrementUserAchievementProgress( + con, + logger, + userId, + achievement.id, + targetCount, + incrementBy, + ); + + if (wasUnlocked) { + logger.info( + { achievementId: achievement.id, userId, name: achievement.name }, + 'Achievement unlocked', + ); + await triggerTypedEvent(logger, 'api.v1.achievement-unlocked', { + achievementId: achievement.id, + userId, + }); + } + } +} + +/** + * Evaluates reputation-based achievements (absolute value comparison) + */ +async function evaluateReputationAchievement( + con: DataSource, + logger: FastifyBaseLogger, + userId: string, + achievements: Achievement[], + currentReputation: number, +): Promise { + for (const achievement of achievements) { + const targetCount = achievement.criteria.targetCount ?? 1; + const wasUnlocked = await updateUserAchievementProgress( + con, + logger, + userId, + achievement.id, + currentReputation, + targetCount, + ); + + if (wasUnlocked) { + logger.info( + { achievementId: achievement.id, userId, name: achievement.name }, + 'Achievement unlocked', + ); + await triggerTypedEvent(logger, 'api.v1.achievement-unlocked', { + achievementId: achievement.id, + userId, + }); + } + } +} + +/** + * Evaluator type for achievement progress checking + */ +type AchievementEvaluator = ( + con: DataSource, + logger: FastifyBaseLogger, + userId: string, + achievements: Achievement[], + currentValue?: number, +) => Promise; + +/** + * Get the appropriate evaluator based on achievement type + */ +function getEvaluator(type: AchievementType): AchievementEvaluator { + switch (type) { + case AchievementType.Instant: + return evaluateInstantAchievement; + case AchievementType.Milestone: + return evaluateMilestoneAchievement; + default: + // Default to milestone for future types + return evaluateMilestoneAchievement; + } +} + +/** + * Core function to check and update achievement progress + * Uses eventType to efficiently find relevant achievements + */ +export async function checkAchievementProgress( + con: DataSource, + logger: FastifyBaseLogger, + userId: string, + eventType: AchievementEventType, + currentValue?: number, +): Promise { + try { + // Get all achievements for this event type + const achievements = await getAchievementsByEventType(con, eventType); + + if (achievements.length === 0) { + return; + } + + // Special handling for reputation achievements (use absolute value) + if (eventType === AchievementEventType.ReputationGain) { + await evaluateReputationAchievement( + con, + logger, + userId, + achievements, + currentValue ?? 0, + ); + return; + } + + // Group achievements by type for proper evaluation + const achievementsByType = achievements.reduce( + (acc, achievement) => { + const type = achievement.type as AchievementType; + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(achievement); + return acc; + }, + {} as Record, + ); + + // Evaluate each group with the appropriate evaluator + for (const [type, typeAchievements] of Object.entries(achievementsByType)) { + const evaluator = getEvaluator(type as AchievementType); + await evaluator(con, logger, userId, typeAchievements, currentValue); + } + } catch (error) { + logger.error( + { error, userId, eventType }, + 'Error checking achievement progress', + ); + // Don't throw - achievement failures shouldn't block the main operation + } +} diff --git a/src/common/typedPubsub.ts b/src/common/typedPubsub.ts index 49b4463cb8..00bbe1f457 100644 --- a/src/common/typedPubsub.ts +++ b/src/common/typedPubsub.ts @@ -268,6 +268,10 @@ export type PubSubSchema = { 'api.v1.feedback-updated': { feedbackId: string; }; + 'api.v1.achievement-unlocked': { + achievementId: string; + userId: string; + }; }; export async function triggerTypedEvent( diff --git a/src/entity/Achievement.ts b/src/entity/Achievement.ts new file mode 100644 index 0000000000..3dc73090ba --- /dev/null +++ b/src/entity/Achievement.ts @@ -0,0 +1,77 @@ +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum AchievementType { + Instant = 'instant', // One-time action (e.g., update profile picture) + Streak = 'streak', // Consecutive actions (future use) + Milestone = 'milestone', // Cumulative count (e.g., upvote 10 posts) + Multipart = 'multipart', // Multiple criteria (future use) +} + +export enum AchievementEventType { + PostUpvote = 'post_upvote', + CommentUpvote = 'comment_upvote', + BookmarkPost = 'bookmark_post', + ProfileImageUpdate = 'profile_image_update', + ProfileCoverUpdate = 'profile_cover_update', + ProfileLocationUpdate = 'profile_location_update', + ExperienceWork = 'experience_work', + ExperienceEducation = 'experience_education', + ExperienceOpenSource = 'experience_opensource', + ExperienceProject = 'experience_project', + ExperienceVolunteering = 'experience_volunteering', + ExperienceSkill = 'experience_skill', + HotTakeCreate = 'hot_take_create', + PostShare = 'post_share', + PostFreeform = 'post_freeform', + SquadJoin = 'squad_join', + SquadCreate = 'squad_create', + BriefRead = 'brief_read', + ReputationGain = 'reputation_gain', +} + +export interface AchievementCriteria { + targetCount?: number; // For milestone achievements + metadata?: Record; // Additional criteria data +} + +@Entity() +@Index('IDX_achievement_eventType') +@Index('IDX_achievement_type') +export class Achievement { + @PrimaryGeneratedColumn('uuid', { + primaryKeyConstraintName: 'PK_achievement_id', + }) + id: string; + + @Column({ default: () => 'now()' }) + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ type: 'text', unique: true }) + name: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'text' }) + image: string; + + @Column({ type: 'text' }) + @Index('IDX_achievement_type_value') + type: AchievementType; + + @Column({ type: 'text' }) + @Index('IDX_achievement_eventType_value') + eventType: AchievementEventType; + + @Column({ type: 'jsonb', default: {} }) + criteria: AchievementCriteria; +} diff --git a/src/entity/index.ts b/src/entity/index.ts index cf1b5572ce..192085f4b6 100644 --- a/src/entity/index.ts +++ b/src/entity/index.ts @@ -39,3 +39,4 @@ export * from './Organization'; export * from './campaign'; export * from './PersonalAccessToken'; export * from './Feedback'; +export * from './Achievement'; diff --git a/src/entity/notifications/NotificationV2.ts b/src/entity/notifications/NotificationV2.ts index d3e0251944..453ed224c6 100644 --- a/src/entity/notifications/NotificationV2.ts +++ b/src/entity/notifications/NotificationV2.ts @@ -18,7 +18,8 @@ export type NotificationReferenceType = | 'campaign' | 'user' | 'opportunity' - | 'feedback'; + | 'feedback' + | 'achievement'; @Entity() @Index('ID_notification_v2_reference', ['referenceId', 'referenceType']) diff --git a/src/entity/user/UserAchievement.ts b/src/entity/user/UserAchievement.ts new file mode 100644 index 0000000000..7b751024bd --- /dev/null +++ b/src/entity/user/UserAchievement.ts @@ -0,0 +1,61 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import type { User } from './User'; +import type { Achievement } from '../Achievement'; + +@Entity() +@Index('IDX_user_achievement_userId', ['userId']) +@Index('IDX_user_achievement_unlockedAt', ['unlockedAt']) +@Index('IDX_user_achievement_userId_unlockedAt', ['userId', 'unlockedAt']) +export class UserAchievement { + @PrimaryColumn({ + type: 'uuid', + primaryKeyConstraintName: 'PK_user_achievement', + }) + achievementId: string; + + @PrimaryColumn({ + length: 36, + primaryKeyConstraintName: 'PK_user_achievement', + }) + userId: string; + + @Column({ default: () => 'now()' }) + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ type: 'integer', default: 0 }) + progress: number; + + @Column({ type: 'timestamp', nullable: true }) + unlockedAt: Date | null; + + @ManyToOne('Achievement', { + lazy: true, + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'achievementId', + foreignKeyConstraintName: 'FK_user_achievement_achievement_id', + }) + achievement: Promise; + + @ManyToOne('User', { + lazy: true, + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'userId', + foreignKeyConstraintName: 'FK_user_achievement_user_id', + }) + user: Promise; +} diff --git a/src/entity/user/index.ts b/src/entity/user/index.ts index 7b4eee8bbf..ec343cd238 100644 --- a/src/entity/user/index.ts +++ b/src/entity/user/index.ts @@ -15,3 +15,4 @@ export * from './HotTake'; export * from './UserHotTake'; export * from './UserWorkspacePhoto'; export * from './UserGear'; +export * from './UserAchievement'; diff --git a/src/graphql.ts b/src/graphql.ts index 647ef4b564..534feee83c 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -40,6 +40,7 @@ import * as gear from './schema/gear'; import * as userWorkspacePhoto from './schema/userWorkspacePhoto'; import * as personalAccessTokens from './schema/personalAccessTokens'; import * as feedback from './schema/feedback'; +import * as achievements from './schema/achievements'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { rateLimitTypeDefs, @@ -96,6 +97,7 @@ export const schema = urlDirective.transformer( userWorkspacePhoto.typeDefs, personalAccessTokens.typeDefs, feedback.typeDefs, + achievements.typeDefs, ], resolvers: merge( common.resolvers, @@ -135,6 +137,7 @@ export const schema = urlDirective.transformer( userWorkspacePhoto.resolvers, personalAccessTokens.resolvers, feedback.resolvers, + achievements.resolvers, ), }), ), diff --git a/src/migration/1770072115384-AchievementSystem.ts b/src/migration/1770072115384-AchievementSystem.ts new file mode 100644 index 0000000000..44b407bd8a --- /dev/null +++ b/src/migration/1770072115384-AchievementSystem.ts @@ -0,0 +1,122 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AchievementSystem1770072115384 implements MigrationInterface { + name = 'AchievementSystem1770072115384'; + + public async up(queryRunner: QueryRunner): Promise { + // Create achievement_type enum + await queryRunner.query(` + CREATE TYPE "achievement_type_enum" AS ENUM ( + 'instant', + 'streak', + 'milestone', + 'multipart' + ) + `); + + // Create achievement_event_type enum + await queryRunner.query(` + CREATE TYPE "achievement_event_type_enum" AS ENUM ( + 'post_upvote', + 'comment_upvote', + 'bookmark_post', + 'profile_image_update', + 'profile_cover_update', + 'profile_location_update', + 'experience_work', + 'experience_education', + 'experience_opensource', + 'experience_project', + 'experience_volunteering', + 'experience_skill', + 'hot_take_create', + 'post_share', + 'post_freeform', + 'squad_join', + 'squad_create', + 'brief_read', + 'reputation_gain' + ) + `); + + // Create achievement table + await queryRunner.query(` + CREATE TABLE "achievement" ( + "id" uuid DEFAULT uuid_generate_v4() NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + "name" text NOT NULL, + "description" text NOT NULL, + "image" text NOT NULL, + "type" text NOT NULL, + "eventType" text NOT NULL, + "criteria" jsonb NOT NULL DEFAULT '{}', + CONSTRAINT "PK_achievement_id" PRIMARY KEY ("id"), + CONSTRAINT "UQ_achievement_name" UNIQUE ("name") + ) + `); + + // Create indexes on achievement table + await queryRunner.query(`CREATE INDEX "IDX_achievement_eventType" ON "achievement" ("eventType")`); + await queryRunner.query(`CREATE INDEX "IDX_achievement_type" ON "achievement" ("type")`); + + // Create user_achievement table + await queryRunner.query(` + CREATE TABLE "user_achievement" ( + "achievementId" uuid NOT NULL, + "userId" character varying(36) NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + "progress" integer NOT NULL DEFAULT 0, + "unlockedAt" TIMESTAMP, + CONSTRAINT "PK_user_achievement" PRIMARY KEY ("achievementId", "userId") + ) + `); + + // Create indexes on user_achievement table + await queryRunner.query(`CREATE INDEX "IDX_user_achievement_userId" ON "user_achievement" ("userId")`); + await queryRunner.query(`CREATE INDEX "IDX_user_achievement_unlockedAt" ON "user_achievement" ("unlockedAt")`); + await queryRunner.query(`CREATE INDEX "IDX_user_achievement_userId_unlockedAt" ON "user_achievement" ("userId", "unlockedAt")`); + + // Add foreign key constraints + await queryRunner.query(` + ALTER TABLE "user_achievement" + ADD CONSTRAINT "FK_user_achievement_achievement_id" + FOREIGN KEY ("achievementId") + REFERENCES "achievement"("id") + ON DELETE CASCADE + `); + + await queryRunner.query(` + ALTER TABLE "user_achievement" + ADD CONSTRAINT "FK_user_achievement_user_id" + FOREIGN KEY ("userId") + REFERENCES "user"("id") + ON DELETE CASCADE + `); + + // Set REPLICA IDENTITY FULL for CDC support + await queryRunner.query(`ALTER TABLE "user_achievement" REPLICA IDENTITY FULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop foreign key constraints + await queryRunner.query(`ALTER TABLE "user_achievement" DROP CONSTRAINT "FK_user_achievement_user_id"`); + await queryRunner.query(`ALTER TABLE "user_achievement" DROP CONSTRAINT "FK_user_achievement_achievement_id"`); + + // Drop indexes + await queryRunner.query(`DROP INDEX "IDX_user_achievement_userId_unlockedAt"`); + await queryRunner.query(`DROP INDEX "IDX_user_achievement_unlockedAt"`); + await queryRunner.query(`DROP INDEX "IDX_user_achievement_userId"`); + await queryRunner.query(`DROP INDEX "IDX_achievement_type"`); + await queryRunner.query(`DROP INDEX "IDX_achievement_eventType"`); + + // Drop tables + await queryRunner.query(`DROP TABLE "user_achievement"`); + await queryRunner.query(`DROP TABLE "achievement"`); + + // Drop enums + await queryRunner.query(`DROP TYPE "achievement_event_type_enum"`); + await queryRunner.query(`DROP TYPE "achievement_type_enum"`); + } +} diff --git a/src/migration/1770072146923-SeedAchievements.ts b/src/migration/1770072146923-SeedAchievements.ts new file mode 100644 index 0000000000..389d510c7f --- /dev/null +++ b/src/migration/1770072146923-SeedAchievements.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedAchievements1770072146923 implements MigrationInterface { + name = 'SeedAchievements1770072146923'; + + public async up(queryRunner: QueryRunner): Promise { + // Insert achievements in order by category + // Placeholder image URL - should be updated with actual achievement icons + const placeholderImage = 'https://media.daily.dev/image/upload/s--placeholder--/achievements/'; + + await queryRunner.query(` + INSERT INTO "achievement" ("name", "description", "image", "type", "eventType", "criteria") + VALUES + -- Post upvote achievements + ('Readit!', 'Upvote 10 posts', '${placeholderImage}readit.png', 'milestone', 'post_upvote', '{"targetCount": 10}'), + ('Everyone gets an upvote!', 'Upvote 50 posts', '${placeholderImage}everyone-upvote.png', 'milestone', 'post_upvote', '{"targetCount": 50}'), + ('Upvote economy', 'Upvote 100 posts', '${placeholderImage}upvote-economy.png', 'milestone', 'post_upvote', '{"targetCount": 100}'), + + -- Comment upvote achievements + ('Well said!', 'Upvote 10 comments', '${placeholderImage}well-said.png', 'milestone', 'comment_upvote', '{"targetCount": 10}'), + ('User feedback', 'Upvote 50 comments', '${placeholderImage}user-feedback.png', 'milestone', 'comment_upvote', '{"targetCount": 50}'), + + -- Bookmark achievements + ('Definitely gonna read it', 'Bookmark 1 post', '${placeholderImage}bookmark.png', 'milestone', 'bookmark_post', '{"targetCount": 1}'), + + -- Profile achievements + ('Hello, World!', 'Update your profile picture', '${placeholderImage}hello-world.png', 'instant', 'profile_image_update', '{"targetCount": 1}'), + ('Cover art', 'Update your cover picture', '${placeholderImage}cover-art.png', 'instant', 'profile_cover_update', '{"targetCount": 1}'), + ('Hello, is it me you''re looking for?', 'Update your profile location', '${placeholderImage}location.png', 'instant', 'profile_location_update', '{"targetCount": 1}'), + + -- Experience achievements + ('Workaholic', 'Add a work experience', '${placeholderImage}workaholic.png', 'instant', 'experience_work', '{"targetCount": 1}'), + ('Scholar', 'Add an education experience', '${placeholderImage}scholar.png', 'instant', 'experience_education', '{"targetCount": 1}'), + ('Open Sourcerer', 'Add an open source experience', '${placeholderImage}open-sourcerer.png', 'instant', 'experience_opensource', '{"targetCount": 1}'), + ('Under new management', 'Add a project experience', '${placeholderImage}project.png', 'instant', 'experience_project', '{"targetCount": 1}'), + ('Gentle soul', 'Add a volunteering experience', '${placeholderImage}gentle-soul.png', 'instant', 'experience_volunteering', '{"targetCount": 1}'), + ('The right tool for the job', 'Add 3 skills to your profile', '${placeholderImage}skills.png', 'milestone', 'experience_skill', '{"targetCount": 3}'), + + -- Hot takes achievements + ('It''s getting hot in here!', 'Add 3 hot takes to your profile', '${placeholderImage}hot-takes.png', 'milestone', 'hot_take_create', '{"targetCount": 3}'), + + -- Post creation achievements + ('Town crier', 'Share a link (post)', '${placeholderImage}town-crier.png', 'instant', 'post_share', '{"targetCount": 1}'), + ('Free for all', 'Create a freeform post', '${placeholderImage}freeform.png', 'instant', 'post_freeform', '{"targetCount": 1}'), + + -- Squad achievements + ('Squad up', 'Join a squad', '${placeholderImage}squad-up.png', 'instant', 'squad_join', '{"targetCount": 1}'), + ('Team player', 'Join 5 squads', '${placeholderImage}team-player.png', 'milestone', 'squad_join', '{"targetCount": 5}'), + ('Hop on dailydev', 'Create your own squad', '${placeholderImage}squad-create.png', 'instant', 'squad_create', '{"targetCount": 1}'), + + -- Brief achievements + ('Debriefed', 'Read 5 briefs', '${placeholderImage}debriefed.png', 'milestone', 'brief_read', '{"targetCount": 5}'), + + -- Reputation achievements + ('You''re him!', 'Gain 500 reputation', '${placeholderImage}youre-him.png', 'milestone', 'reputation_gain', '{"targetCount": 500}'), + ('In the big league', 'Gain 10000 reputation', '${placeholderImage}big-league.png', 'milestone', 'reputation_gain', '{"targetCount": 10000}') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Delete all seeded achievements + await queryRunner.query(` + DELETE FROM "achievement" + WHERE "name" IN ( + 'Readit!', + 'Everyone gets an upvote!', + 'Upvote economy', + 'Well said!', + 'User feedback', + 'Definitely gonna read it', + 'Hello, World!', + 'Cover art', + 'Hello, is it me you''re looking for?', + 'Workaholic', + 'Scholar', + 'Open Sourcerer', + 'Under new management', + 'Gentle soul', + 'The right tool for the job', + 'It''s getting hot in here!', + 'Town crier', + 'Free for all', + 'Squad up', + 'Team player', + 'Hop on dailydev', + 'Debriefed', + 'You''re him!', + 'In the big league' + ) + `); + } +} diff --git a/src/notifications/builder.ts b/src/notifications/builder.ts index 0ff22555b1..b28e38a9df 100644 --- a/src/notifications/builder.ts +++ b/src/notifications/builder.ts @@ -233,6 +233,13 @@ export class NotificationBuilder { }); } + referenceAchievement(achievementId: string): NotificationBuilder { + return this.enrichNotification({ + referenceId: achievementId, + referenceType: 'achievement', + }); + } + icon(icon: NotificationIcon): NotificationBuilder { return this.enrichNotification({ icon }); } diff --git a/src/notifications/common.ts b/src/notifications/common.ts index 45bccc0f04..f1fd7ee5e6 100644 --- a/src/notifications/common.ts +++ b/src/notifications/common.ts @@ -84,6 +84,7 @@ export enum NotificationType { RecruiterExternalPayment = 'recruiter_external_payment', ExperienceCompanyEnriched = 'experience_company_enriched', FeedbackResolved = 'feedback_resolved', + AchievementUnlocked = 'achievement_unlocked', } export enum NotificationPreferenceType { @@ -299,6 +300,10 @@ export const DEFAULT_NOTIFICATION_SETTINGS: UserNotificationFlags = { email: NotificationPreferenceStatus.Subscribed, inApp: NotificationPreferenceStatus.Subscribed, }, + [NotificationType.AchievementUnlocked]: { + email: NotificationPreferenceStatus.Subscribed, + inApp: NotificationPreferenceStatus.Subscribed, + }, }; export const commentReplyNotificationTypes = [ diff --git a/src/notifications/generate.ts b/src/notifications/generate.ts index a62970516d..281d2fe04a 100644 --- a/src/notifications/generate.ts +++ b/src/notifications/generate.ts @@ -38,6 +38,7 @@ import { type NotificationExperienceCompanyEnrichedContext, type NotificationRecruiterExternalPaymentContext, type NotificationFeedbackResolvedContext, + type NotificationAchievementContext, } from './types'; import { UPVOTE_TITLES } from '../workers/notifications/utils'; import { checkHasMention } from '../common/markdown'; @@ -239,6 +240,8 @@ export const notificationTitleMap: Record< `Your job opportunity ${ctx.opportunityTitle} has been paid for!`, feedback_resolved: () => `Your feedback has been resolved. Thank you for helping us improve!`, + achievement_unlocked: (ctx: NotificationAchievementContext) => + `Achievement unlocked! ${ctx.achievementName}`, }; export const generateNotificationMap: Record< @@ -699,4 +702,17 @@ export const generateNotificationMap: Record< .targetUrl(process.env.COMMENTS_PREFIX) .uniqueKey(ctx.feedbackId); }, + achievement_unlocked: ( + builder: NotificationBuilder, + ctx: NotificationAchievementContext, + ) => { + return builder + .icon(NotificationIcon.Bell) + .description(ctx.achievementDescription) + .referenceAchievement(ctx.achievementId) + .targetUrl( + `${process.env.COMMENTS_PREFIX}/${ctx.userIds[0]}/achievements`, + ) + .uniqueKey(ctx.achievementId); + }, }; diff --git a/src/notifications/types.ts b/src/notifications/types.ts index b84c3ba01a..3cb4c96e93 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -210,6 +210,13 @@ export type NotificationFeedbackResolvedContext = NotificationBaseContext & { feedbackDescription: string; }; +export type NotificationAchievementContext = NotificationBaseContext & { + achievementId: string; + achievementName: string; + achievementDescription: string; + achievementImage: string; +}; + declare module 'fs' { interface ReadStream { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/schema/achievements.ts b/src/schema/achievements.ts new file mode 100644 index 0000000000..053625a41f --- /dev/null +++ b/src/schema/achievements.ts @@ -0,0 +1,280 @@ +import { IResolvers } from '@graphql-tools/utils'; +import { BaseContext, type AuthContext } from '../Context'; +import { traceResolvers } from './trace'; +import { + Achievement, + AchievementType, + AchievementEventType, +} from '../entity/Achievement'; +import { UserAchievement } from '../entity/user/UserAchievement'; +import { ForbiddenError } from 'apollo-server-errors'; + +export type GQLAchievement = { + id: string; + name: string; + description: string; + image: string; + type: AchievementType; + eventType: AchievementEventType; + criteria: { + targetCount?: number; + }; + createdAt: Date; +}; + +export type GQLUserAchievement = { + achievement: GQLAchievement | Promise; + progress: number; + unlockedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +export type GQLUserAchievementStats = { + totalAchievements: number; + unlockedCount: number; + lockedCount: number; +}; + +export const typeDefs = /* GraphQL */ ` + """ + Achievement type determines how progress is tracked + """ + enum AchievementType { + """ + One-time action (e.g., first profile picture) + """ + instant + """ + Requires consecutive days (e.g., 7-day streak) + """ + streak + """ + Cumulative count (e.g., upvote 100 posts) + """ + milestone + """ + Multiple sub-achievements to unlock + """ + multipart + } + + """ + Achievement criteria for unlocking + """ + type AchievementCriteria { + """ + Target count required to unlock (for milestone type) + """ + targetCount: Int + } + + """ + An achievement definition + """ + type Achievement { + """ + Unique achievement ID + """ + id: ID! + """ + Display name of the achievement + """ + name: String! + """ + Description of how to unlock + """ + description: String! + """ + URL to achievement badge/icon image + """ + image: String! + """ + Type of achievement (instant, streak, milestone, multipart) + """ + type: AchievementType! + """ + Criteria required to unlock this achievement + """ + criteria: AchievementCriteria! + """ + When the achievement was created + """ + createdAt: DateTime! + } + + """ + A user's progress on an achievement + """ + type UserAchievement { + """ + The achievement definition + """ + achievement: Achievement! + """ + Current progress towards unlocking (for milestone types) + """ + progress: Int! + """ + When the achievement was unlocked (null if not yet unlocked) + """ + unlockedAt: DateTime + """ + When the user started tracking this achievement + """ + createdAt: DateTime! + """ + When the progress was last updated + """ + updatedAt: DateTime! + } + + """ + Statistics about a user's achievements + """ + type UserAchievementStats { + """ + Total number of achievements available + """ + totalAchievements: Int! + """ + Number of achievements the user has unlocked + """ + unlockedCount: Int! + """ + Number of achievements not yet unlocked + """ + lockedCount: Int! + } + + extend type Query { + """ + Get all available achievements + """ + achievements: [Achievement!]! @cacheControl(maxAge: 3600) + + """ + Get a user's achievements with progress + """ + userAchievements( + """ + User ID to get achievements for (defaults to current user) + """ + userId: ID + ): [UserAchievement!]! @auth + + """ + Get achievement statistics for a user + """ + userAchievementStats( + """ + User ID to get stats for (defaults to current user) + """ + userId: ID + ): UserAchievementStats! @auth + } +`; + +export const resolvers: IResolvers = traceResolvers< + unknown, + BaseContext +>({ + Query: { + achievements: async (_, __, ctx): Promise => { + const achievements = await ctx.con.getRepository(Achievement).find({ + order: { createdAt: 'ASC' }, + }); + + return achievements; + }, + userAchievements: async ( + _, + args: { userId?: string }, + ctx: AuthContext, + ): Promise => { + const userId = args.userId || ctx.userId; + + if (!userId) { + throw new ForbiddenError('User not authenticated'); + } + + // If viewing another user's achievements, ensure we only return unlocked ones + const isOwnProfile = userId === ctx.userId; + + // Get all achievements + const achievements = await ctx.con.getRepository(Achievement).find({ + order: { createdAt: 'ASC' }, + }); + + // Get user's progress on achievements + const userAchievements = await ctx.con + .getRepository(UserAchievement) + .find({ + where: { userId }, + relations: ['achievement'], + }); + + const userAchievementMap = new Map( + userAchievements.map((ua) => [ua.achievementId, ua]), + ); + + // Return all achievements with user progress + const results: GQLUserAchievement[] = []; + + for (const achievement of achievements) { + const userAchievement = userAchievementMap.get(achievement.id); + + // For other users, only show unlocked achievements + if (!isOwnProfile && !userAchievement?.unlockedAt) { + continue; + } + + results.push({ + achievement: { + id: achievement.id, + name: achievement.name, + description: achievement.description, + image: achievement.image, + type: achievement.type, + eventType: achievement.eventType, + criteria: achievement.criteria, + createdAt: achievement.createdAt, + }, + progress: userAchievement?.progress ?? 0, + unlockedAt: userAchievement?.unlockedAt ?? null, + createdAt: userAchievement?.createdAt ?? new Date(), + updatedAt: userAchievement?.updatedAt ?? new Date(), + }); + } + + return results; + }, + userAchievementStats: async ( + _, + args: { userId?: string }, + ctx: AuthContext, + ): Promise => { + const userId = args.userId || ctx.userId; + + if (!userId) { + throw new ForbiddenError('User not authenticated'); + } + + const [totalAchievements, unlockedCount] = await Promise.all([ + ctx.con.getRepository(Achievement).count(), + ctx.con + .getRepository(UserAchievement) + .createQueryBuilder('ua') + .where('ua.userId = :userId', { userId }) + .andWhere('ua.unlockedAt IS NOT NULL') + .getCount(), + ]); + + return { + totalAchievements, + unlockedCount, + lockedCount: totalAchievements - unlockedCount, + }; + }, + }, +}); diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index bdd5de307b..3e13ebc857 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -30,6 +30,7 @@ import { SourceFeed, SourceMember, SourceRequest, + SourceType, SourceUser, SQUAD_IMAGE_PLACEHOLDER, SquadPublicRequest, @@ -44,6 +45,8 @@ import { UserTopReader, Feedback, } from '../../entity'; +import { HotTake } from '../../entity/user/HotTake'; +import { UserExperienceSkill } from '../../entity/user/experiences/UserExperienceSkill'; import { Campaign, CampaignType } from '../../entity/campaign/Campaign'; import { messageToJson, Worker } from '../worker'; import { @@ -165,6 +168,11 @@ import { enrichCompanyForExperience } from '../../common/companyEnrichment'; import { Company } from '../../entity/Company'; import { OpportunityUser } from '../../entity/opportunities/user/OpportunityUser'; import { OpportunityUserType } from '../../entity/opportunities/types'; +import { SourceMemberRoles } from '../../roles'; +import { + checkAchievementProgress, + AchievementEventType, +} from '../../common/achievement'; const convertUserToChangeObject = (user: User): ChangeObject => ({ ...user, @@ -339,6 +347,15 @@ const onPostVoteChange = async ( }, vote: data.payload.after!.vote, }); + // Check achievement progress for post upvotes + if (data.payload.after!.vote === UserVote.Up) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.PostUpvote, + ); + } break; case 'u': await handleVoteUpdated({ @@ -358,6 +375,18 @@ const onPostVoteChange = async ( }, voteBefore: data.payload.before!.vote, }); + // Check achievement progress when vote changes to upvote + if ( + data.payload.after!.vote === UserVote.Up && + data.payload.before!.vote !== UserVote.Up + ) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.PostUpvote, + ); + } break; case 'd': await handleVoteDeleted({ @@ -393,6 +422,15 @@ const onCommentVoteChange = async ( }, vote: data.payload.after!.vote, }); + // Check achievement progress for comment upvotes + if (data.payload.after!.vote === UserVote.Up) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.CommentUpvote, + ); + } break; case 'u': await handleVoteUpdated({ @@ -412,6 +450,18 @@ const onCommentVoteChange = async ( }, voteBefore: data.payload.before!.vote, }); + // Check achievement progress when vote changes to upvote + if ( + data.payload.after!.vote === UserVote.Up && + data.payload.before!.vote !== UserVote.Up + ) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.CommentUpvote, + ); + } break; case 'd': await handleVoteDeleted({ @@ -501,6 +551,14 @@ const onUserChange = async ( data.payload.before!, data.payload.after!, ); + // Check reputation-based achievements + await checkAchievementProgress( + con, + logger, + data.payload.after!.id, + AchievementEventType.ReputationGain, + data.payload.after!.reputation, + ); } if ( data.payload.before!.infoConfirmed && @@ -517,6 +575,41 @@ const onUserChange = async ( await notifyUserReadmeUpdated(logger, data.payload.after!); } + // Check profile update achievements + if ( + isChanged(data.payload.before!, data.payload.after!, 'image') && + data.payload.after!.image + ) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.id, + AchievementEventType.ProfileImageUpdate, + ); + } + if ( + isChanged(data.payload.before!, data.payload.after!, 'cover') && + data.payload.after!.cover + ) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.id, + AchievementEventType.ProfileCoverUpdate, + ); + } + if ( + isChanged(data.payload.before!, data.payload.after!, 'locationId') && + data.payload.after!.locationId + ) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.id, + AchievementEventType.ProfileLocationUpdate, + ); + } + if ( isChanged(data.payload.before!, data.payload.after!, [ 'name', @@ -571,6 +664,28 @@ const onPostChange = async ( if (isFreeformPostLongEnough(freeform)) { await notifyFreeformContentRequested(logger, freeform); } + // Check freeform post achievement + if (data.payload.after!.authorId) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.authorId, + AchievementEventType.PostFreeform, + ); + } + } + // Check share post achievement + if ( + data.payload.after!.type === PostType.Share && + data.payload.after!.authorId && + data.payload.after!.visible + ) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.authorId, + AchievementEventType.PostShare, + ); } if (data.payload.after!.type === PostType.Poll) { const poll = data as ChangeMessage; @@ -976,6 +1091,36 @@ const onSourceMemberChange = async ( ) => { if (data.payload.op === 'c') { await notifyMemberJoinedSource(logger, data.payload.after!); + + // Check squad achievements when user joins a source + const sourceMember = data.payload.after!; + + // Check if the source is a squad (not a regular source) + const source = await con + .getRepository(Source) + .findOneBy({ id: sourceMember.sourceId }); + + if (source && source.type === SourceType.Squad) { + // Squad join achievement (for members who join) + if (sourceMember.role === SourceMemberRoles.Member) { + await checkAchievementProgress( + con, + logger, + sourceMember.userId, + AchievementEventType.SquadJoin, + ); + } + + // Squad create achievement (for admins who are the first member) + if (sourceMember.role === SourceMemberRoles.Admin) { + await checkAchievementProgress( + con, + logger, + sourceMember.userId, + AchievementEventType.SquadCreate, + ); + } + } } if (data.payload.op === 'u') { if (data.payload.before!.role !== data.payload.after!.role) { @@ -1261,6 +1406,16 @@ const onBookmarkChange = async ( if (data.payload.after?.remindAt) { runReminderWorkflow(getParams('after')); } + + // Check bookmark achievement on create + if (data.payload.op === 'c') { + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.BookmarkPost, + ); + } }; const onContentPreferenceChange = async ( @@ -1599,6 +1754,28 @@ const onUserExperienceChange = async ( ) => { const experience = data.payload.after; + // Check experience achievements on create + if (data.payload.op === 'c' && experience) { + const eventTypeMap: Record< + UserExperienceType, + AchievementEventType | null + > = { + [UserExperienceType.Work]: AchievementEventType.ExperienceWork, + [UserExperienceType.Education]: AchievementEventType.ExperienceEducation, + [UserExperienceType.OpenSource]: + AchievementEventType.ExperienceOpenSource, + [UserExperienceType.Project]: AchievementEventType.ExperienceProject, + [UserExperienceType.Volunteering]: + AchievementEventType.ExperienceVolunteering, + [UserExperienceType.Certification]: null, // No achievement for certification + }; + + const eventType = eventTypeMap[experience.type]; + if (eventType) { + await checkAchievementProgress(con, logger, experience.userId, eventType); + } + } + if ( !experience || ![UserExperienceType.Work, UserExperienceType.Education].includes( @@ -1743,6 +1920,46 @@ const onFeedbackChange = async ( } }; +const onHotTakeChange = async ( + con: DataSource, + logger: FastifyBaseLogger, + data: ChangeMessage, +) => { + // Check hot take achievement on create + if (data.payload.op === 'c') { + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.HotTakeCreate, + ); + } +}; + +const onUserExperienceSkillChange = async ( + con: DataSource, + logger: FastifyBaseLogger, + data: ChangeMessage, +) => { + // Check skill achievement on create + if (data.payload.op === 'c') { + // UserExperienceSkill doesn't have userId directly, we need to get it from the experience + const experienceId = data.payload.after!.experienceId; + const experience = await con + .getRepository(UserExperience) + .findOneBy({ id: experienceId }); + + if (experience) { + await checkAchievementProgress( + con, + logger, + experience.userId, + AchievementEventType.ExperienceSkill, + ); + } + } +}; + const worker: Worker = { subscription: 'api-cdc', maxMessages: parseInt(process.env.CDC_WORKER_MAX_MESSAGES) || undefined, @@ -1880,6 +2097,12 @@ const worker: Worker = { case getTableName(con, Feedback): await onFeedbackChange(con, logger, data); break; + case getTableName(con, HotTake): + await onHotTakeChange(con, logger, data); + break; + case getTableName(con, UserExperienceSkill): + await onUserExperienceSkillChange(con, logger, data); + break; } } catch (err) { logger.error( diff --git a/src/workers/newNotificationV2Mail.ts b/src/workers/newNotificationV2Mail.ts index c3eaa20477..24deafe599 100644 --- a/src/workers/newNotificationV2Mail.ts +++ b/src/workers/newNotificationV2Mail.ts @@ -133,6 +133,7 @@ export const notificationToTemplateId: Record = { experience_company_enriched: '', recruiter_external_payment: '91', feedback_resolved: '', + achievement_unlocked: '', // No email for achievement unlocks }; type TemplateData = Record & { @@ -1262,6 +1263,9 @@ const notificationToTemplateData: Record = { feedback_resolved: async () => { return null; }, + achievement_unlocked: async () => { + return null; // No email for achievement unlocks + }, }; const formatTemplateDate = (data: T): T => { diff --git a/src/workers/notifications/achievementUnlockedNotification.ts b/src/workers/notifications/achievementUnlockedNotification.ts new file mode 100644 index 0000000000..7422a6e085 --- /dev/null +++ b/src/workers/notifications/achievementUnlockedNotification.ts @@ -0,0 +1,28 @@ +import { TypedNotificationWorker } from '../worker'; +import { NotificationAchievementContext } from '../../notifications'; +import { NotificationType } from '../../notifications/common'; +import { Achievement } from '../../entity/Achievement'; + +export const achievementUnlockedNotification: TypedNotificationWorker<'api.v1.achievement-unlocked'> = + { + subscription: 'api.achievement-unlocked-notification', + handler: async ({ achievementId, userId }, con) => { + const achievement = await con + .getRepository(Achievement) + .findOneBy({ id: achievementId }); + + if (!achievement) { + return; + } + + const ctx: NotificationAchievementContext = { + userIds: [userId], + achievementId: achievement.id, + achievementName: achievement.name, + achievementDescription: achievement.description, + achievementImage: achievement.image, + }; + + return [{ type: NotificationType.AchievementUnlocked, ctx }]; + }, + }; diff --git a/src/workers/notifications/index.ts b/src/workers/notifications/index.ts index 0a642fb4ff..43acc6101e 100644 --- a/src/workers/notifications/index.ts +++ b/src/workers/notifications/index.ts @@ -42,6 +42,7 @@ import { recruiterNewCandidateNotification } from './recruiterNewCandidateNotifi import { recruiterOpportunityLiveNotification } from './recruiterOpportunityLiveNotification'; import { experienceCompanyEnrichedNotification } from './experienceCompanyEnrichedNotification'; import { recruiterExternalPaymentNotification } from './recruiterExternalPaymentNotification'; +import { achievementUnlockedNotification } from './achievementUnlockedNotification'; export function notificationWorkerToWorker( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -130,6 +131,7 @@ const notificationWorkers: TypedNotificationWorker[] = [ recruiterOpportunityLiveNotification, experienceCompanyEnrichedNotification, recruiterExternalPaymentNotification, + achievementUnlockedNotification, ]; export const workers = [...notificationWorkers.map(notificationWorkerToWorker)]; From d44a1748f3ca12a69eb1db3a7e33da7d478f04d7 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 3 Feb 2026 20:30:08 +0100 Subject: [PATCH 2/5] fix non working achis --- .../1770072115384-AchievementSystem.ts | 35 ----- src/workers/cdc/primary.ts | 120 +++++++++++------- 2 files changed, 71 insertions(+), 84 deletions(-) diff --git a/src/migration/1770072115384-AchievementSystem.ts b/src/migration/1770072115384-AchievementSystem.ts index 44b407bd8a..1f15d0fa58 100644 --- a/src/migration/1770072115384-AchievementSystem.ts +++ b/src/migration/1770072115384-AchievementSystem.ts @@ -4,41 +4,6 @@ export class AchievementSystem1770072115384 implements MigrationInterface { name = 'AchievementSystem1770072115384'; public async up(queryRunner: QueryRunner): Promise { - // Create achievement_type enum - await queryRunner.query(` - CREATE TYPE "achievement_type_enum" AS ENUM ( - 'instant', - 'streak', - 'milestone', - 'multipart' - ) - `); - - // Create achievement_event_type enum - await queryRunner.query(` - CREATE TYPE "achievement_event_type_enum" AS ENUM ( - 'post_upvote', - 'comment_upvote', - 'bookmark_post', - 'profile_image_update', - 'profile_cover_update', - 'profile_location_update', - 'experience_work', - 'experience_education', - 'experience_opensource', - 'experience_project', - 'experience_volunteering', - 'experience_skill', - 'hot_take_create', - 'post_share', - 'post_freeform', - 'squad_join', - 'squad_create', - 'brief_read', - 'reputation_gain' - ) - `); - // Create achievement table await queryRunner.query(` CREATE TABLE "achievement" ( diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 3e13ebc857..0ae356598d 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -46,7 +46,7 @@ import { Feedback, } from '../../entity'; import { HotTake } from '../../entity/user/HotTake'; -import { UserExperienceSkill } from '../../entity/user/experiences/UserExperienceSkill'; +import { UserStack } from '../../entity/user/UserStack'; import { Campaign, CampaignType } from '../../entity/campaign/Campaign'; import { messageToJson, Worker } from '../worker'; import { @@ -347,14 +347,22 @@ const onPostVoteChange = async ( }, vote: data.payload.after!.vote, }); - // Check achievement progress for post upvotes + // Check achievement progress for post upvotes (exclude self-upvotes) if (data.payload.after!.vote === UserVote.Up) { - await checkAchievementProgress( - con, - logger, - data.payload.after!.userId, - AchievementEventType.PostUpvote, - ); + const post = await con.getRepository(Post).findOne({ + where: { id: data.payload.after!.postId }, + select: ['id', 'authorId', 'scoutId'], + }); + + const voterId = data.payload.after!.userId; + if (post && post.authorId !== voterId && post.scoutId !== voterId) { + await checkAchievementProgress( + con, + logger, + voterId, + AchievementEventType.PostUpvote, + ); + } } break; case 'u': @@ -375,17 +383,25 @@ const onPostVoteChange = async ( }, voteBefore: data.payload.before!.vote, }); - // Check achievement progress when vote changes to upvote + // Check achievement progress when vote changes to upvote (exclude self-upvotes) if ( data.payload.after!.vote === UserVote.Up && data.payload.before!.vote !== UserVote.Up ) { - await checkAchievementProgress( - con, - logger, - data.payload.after!.userId, - AchievementEventType.PostUpvote, - ); + const post = await con.getRepository(Post).findOne({ + where: { id: data.payload.after!.postId }, + select: ['id', 'authorId', 'scoutId'], + }); + + const voterId = data.payload.after!.userId; + if (post && post.authorId !== voterId && post.scoutId !== voterId) { + await checkAchievementProgress( + con, + logger, + voterId, + AchievementEventType.PostUpvote, + ); + } } break; case 'd': @@ -422,14 +438,22 @@ const onCommentVoteChange = async ( }, vote: data.payload.after!.vote, }); - // Check achievement progress for comment upvotes + // Check achievement progress for comment upvotes (exclude self-upvotes) if (data.payload.after!.vote === UserVote.Up) { - await checkAchievementProgress( - con, - logger, - data.payload.after!.userId, - AchievementEventType.CommentUpvote, - ); + const comment = await con.getRepository(Comment).findOne({ + where: { id: data.payload.after!.commentId }, + select: ['id', 'userId'], + }); + + const voterId = data.payload.after!.userId; + if (comment && comment.userId !== voterId) { + await checkAchievementProgress( + con, + logger, + voterId, + AchievementEventType.CommentUpvote, + ); + } } break; case 'u': @@ -450,17 +474,25 @@ const onCommentVoteChange = async ( }, voteBefore: data.payload.before!.vote, }); - // Check achievement progress when vote changes to upvote + // Check achievement progress when vote changes to upvote (exclude self-upvotes) if ( data.payload.after!.vote === UserVote.Up && data.payload.before!.vote !== UserVote.Up ) { - await checkAchievementProgress( - con, - logger, - data.payload.after!.userId, - AchievementEventType.CommentUpvote, - ); + const comment = await con.getRepository(Comment).findOne({ + where: { id: data.payload.after!.commentId }, + select: ['id', 'userId'], + }); + + const voterId = data.payload.after!.userId; + if (comment && comment.userId !== voterId) { + await checkAchievementProgress( + con, + logger, + voterId, + AchievementEventType.CommentUpvote, + ); + } } break; case 'd': @@ -1925,7 +1957,6 @@ const onHotTakeChange = async ( logger: FastifyBaseLogger, data: ChangeMessage, ) => { - // Check hot take achievement on create if (data.payload.op === 'c') { await checkAchievementProgress( con, @@ -1936,27 +1967,18 @@ const onHotTakeChange = async ( } }; -const onUserExperienceSkillChange = async ( +const onUserStackChange = async ( con: DataSource, logger: FastifyBaseLogger, - data: ChangeMessage, + data: ChangeMessage, ) => { - // Check skill achievement on create if (data.payload.op === 'c') { - // UserExperienceSkill doesn't have userId directly, we need to get it from the experience - const experienceId = data.payload.after!.experienceId; - const experience = await con - .getRepository(UserExperience) - .findOneBy({ id: experienceId }); - - if (experience) { - await checkAchievementProgress( - con, - logger, - experience.userId, - AchievementEventType.ExperienceSkill, - ); - } + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.ExperienceSkill, + ); } }; @@ -2100,8 +2122,8 @@ const worker: Worker = { case getTableName(con, HotTake): await onHotTakeChange(con, logger, data); break; - case getTableName(con, UserExperienceSkill): - await onUserExperienceSkillChange(con, logger, data); + case getTableName(con, UserStack): + await onUserStackChange(con, logger, data); break; } } catch (err) { From 68af720e9d5d30fa37002a5ffec14fca9fccda1f Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Thu, 5 Feb 2026 11:04:54 +0100 Subject: [PATCH 3/5] add more achievements and infra --- .infra/application.properties | 2 +- src/common/achievement/index.ts | 17 +- src/entity/Achievement.ts | 21 + .../1770072115384-AchievementSystem.ts | 1 + .../1770072146923-SeedAchievements.ts | 180 +++++---- src/workers/cdc/primary.ts | 362 +++++++++++++++++- 6 files changed, 484 insertions(+), 99 deletions(-) diff --git a/.infra/application.properties b/.infra/application.properties index 488efd77d5..29ee404237 100644 --- a/.infra/application.properties +++ b/.infra/application.properties @@ -8,7 +8,7 @@ debezium.source.database.user=%database_user% debezium.source.database.password=%database_pass% debezium.source.database.dbname=%database_dbname% debezium.source.database.server.name=api -debezium.source.table.include.list=public.comment,public.user_comment,public.comment_mention,public.source_request,public.post,public.user,public.post_report,public.source_feed,public.settings,public.reputation_event,public.submission,public.user_state,public.notification_v2,public.source_member,public.feature,public.source,public.post_mention,public.content_image,public.comment_report,public.user_post,public.banner,public.post_relation,public.marketing_cta,public.squad_public_request,public.user_streak,public.bookmark,public.user_company,public.source_report,public.user_top_reader,public.source_post_moderation,public.user_report,public.user_transaction,public.content_preference,public.campaign,public.opportunity_match,public.opportunity,public.organization,public.user_candidate_preference,public.user_experience,public.feedback +debezium.source.table.include.list=public.comment,public.user_comment,public.comment_mention,public.source_request,public.post,public.user,public.post_report,public.source_feed,public.settings,public.reputation_event,public.submission,public.user_state,public.notification_v2,public.source_member,public.feature,public.source,public.post_mention,public.content_image,public.comment_report,public.user_post,public.banner,public.post_relation,public.marketing_cta,public.squad_public_request,public.user_streak,public.bookmark,public.user_company,public.source_report,public.user_top_reader,public.source_post_moderation,public.user_report,public.user_transaction,public.content_preference,public.campaign,public.opportunity_match,public.opportunity,public.organization,public.user_candidate_preference,public.user_experience,public.feedback,public.hot_take,public.user_stack debezium.source.column.exclude.list=public.post.tsv,public.post.placeholder,public.source.flags,public.user_top_reader.image debezium.source.skip.messages.without.change=true debezium.source.plugin.name=pgoutput diff --git a/src/common/achievement/index.ts b/src/common/achievement/index.ts index 06285e0abf..193cc1ea05 100644 --- a/src/common/achievement/index.ts +++ b/src/common/achievement/index.ts @@ -195,14 +195,14 @@ async function evaluateMilestoneAchievement( } /** - * Evaluates reputation-based achievements (absolute value comparison) + * Evaluates achievements using absolute value comparison (reputation, streaks, etc.) */ -async function evaluateReputationAchievement( +async function evaluateAbsoluteValueAchievement( con: DataSource, logger: FastifyBaseLogger, userId: string, achievements: Achievement[], - currentReputation: number, + currentValue: number, ): Promise { for (const achievement of achievements) { const targetCount = achievement.criteria.targetCount ?? 1; @@ -211,7 +211,7 @@ async function evaluateReputationAchievement( logger, userId, achievement.id, - currentReputation, + currentValue, targetCount, ); @@ -273,9 +273,12 @@ export async function checkAchievementProgress( return; } - // Special handling for reputation achievements (use absolute value) - if (eventType === AchievementEventType.ReputationGain) { - await evaluateReputationAchievement( + const absoluteValueEventTypes: AchievementEventType[] = [ + AchievementEventType.ReputationGain, + ]; + + if (absoluteValueEventTypes.includes(eventType)) { + await evaluateAbsoluteValueAchievement( con, logger, userId, diff --git a/src/entity/Achievement.ts b/src/entity/Achievement.ts index 3dc73090ba..dff1f0d1f2 100644 --- a/src/entity/Achievement.ts +++ b/src/entity/Achievement.ts @@ -33,6 +33,24 @@ export enum AchievementEventType { SquadCreate = 'squad_create', BriefRead = 'brief_read', ReputationGain = 'reputation_gain', + ExperienceCertification = 'experience_certification', + CVUpload = 'cv_upload', + CommentCreate = 'comment_create', + FeedCreate = 'feed_create', + BookmarkListCreate = 'bookmark_list_create', + PostBoost = 'post_boost', + UpvoteReceived = 'upvote_received', + AwardReceived = 'award_received', + UserFollow = 'user_follow', + FollowerGain = 'follower_gain', + PlusSubscribe = 'plus_subscribe', + SubscriptionAnniversary = 'subscription_anniversary', + TopReaderBadge = 'top_reader_badge', + ReadingStreak = 'reading_streak', + ProfileComplete = 'profile_complete', + ShareClick = 'share_click', + ShareClickMilestone = 'share_click_milestone', + SharePostsClicked = 'share_posts_clicked', } export interface AchievementCriteria { @@ -74,4 +92,7 @@ export class Achievement { @Column({ type: 'jsonb', default: {} }) criteria: AchievementCriteria; + + @Column({ type: 'smallint', default: 5 }) + points: number; } diff --git a/src/migration/1770072115384-AchievementSystem.ts b/src/migration/1770072115384-AchievementSystem.ts index 1f15d0fa58..77f858bbd9 100644 --- a/src/migration/1770072115384-AchievementSystem.ts +++ b/src/migration/1770072115384-AchievementSystem.ts @@ -16,6 +16,7 @@ export class AchievementSystem1770072115384 implements MigrationInterface { "type" text NOT NULL, "eventType" text NOT NULL, "criteria" jsonb NOT NULL DEFAULT '{}', + "points" smallint NOT NULL DEFAULT 5, CONSTRAINT "PK_achievement_id" PRIMARY KEY ("id"), CONSTRAINT "UQ_achievement_name" UNIQUE ("name") ) diff --git a/src/migration/1770072146923-SeedAchievements.ts b/src/migration/1770072146923-SeedAchievements.ts index 389d510c7f..310eb3f9ca 100644 --- a/src/migration/1770072146923-SeedAchievements.ts +++ b/src/migration/1770072146923-SeedAchievements.ts @@ -4,89 +4,109 @@ export class SeedAchievements1770072146923 implements MigrationInterface { name = 'SeedAchievements1770072146923'; public async up(queryRunner: QueryRunner): Promise { - // Insert achievements in order by category - // Placeholder image URL - should be updated with actual achievement icons - const placeholderImage = 'https://media.daily.dev/image/upload/s--placeholder--/achievements/'; - await queryRunner.query(` - INSERT INTO "achievement" ("name", "description", "image", "type", "eventType", "criteria") - VALUES - -- Post upvote achievements - ('Readit!', 'Upvote 10 posts', '${placeholderImage}readit.png', 'milestone', 'post_upvote', '{"targetCount": 10}'), - ('Everyone gets an upvote!', 'Upvote 50 posts', '${placeholderImage}everyone-upvote.png', 'milestone', 'post_upvote', '{"targetCount": 50}'), - ('Upvote economy', 'Upvote 100 posts', '${placeholderImage}upvote-economy.png', 'milestone', 'post_upvote', '{"targetCount": 100}'), - - -- Comment upvote achievements - ('Well said!', 'Upvote 10 comments', '${placeholderImage}well-said.png', 'milestone', 'comment_upvote', '{"targetCount": 10}'), - ('User feedback', 'Upvote 50 comments', '${placeholderImage}user-feedback.png', 'milestone', 'comment_upvote', '{"targetCount": 50}'), - - -- Bookmark achievements - ('Definitely gonna read it', 'Bookmark 1 post', '${placeholderImage}bookmark.png', 'milestone', 'bookmark_post', '{"targetCount": 1}'), - - -- Profile achievements - ('Hello, World!', 'Update your profile picture', '${placeholderImage}hello-world.png', 'instant', 'profile_image_update', '{"targetCount": 1}'), - ('Cover art', 'Update your cover picture', '${placeholderImage}cover-art.png', 'instant', 'profile_cover_update', '{"targetCount": 1}'), - ('Hello, is it me you''re looking for?', 'Update your profile location', '${placeholderImage}location.png', 'instant', 'profile_location_update', '{"targetCount": 1}'), - - -- Experience achievements - ('Workaholic', 'Add a work experience', '${placeholderImage}workaholic.png', 'instant', 'experience_work', '{"targetCount": 1}'), - ('Scholar', 'Add an education experience', '${placeholderImage}scholar.png', 'instant', 'experience_education', '{"targetCount": 1}'), - ('Open Sourcerer', 'Add an open source experience', '${placeholderImage}open-sourcerer.png', 'instant', 'experience_opensource', '{"targetCount": 1}'), - ('Under new management', 'Add a project experience', '${placeholderImage}project.png', 'instant', 'experience_project', '{"targetCount": 1}'), - ('Gentle soul', 'Add a volunteering experience', '${placeholderImage}gentle-soul.png', 'instant', 'experience_volunteering', '{"targetCount": 1}'), - ('The right tool for the job', 'Add 3 skills to your profile', '${placeholderImage}skills.png', 'milestone', 'experience_skill', '{"targetCount": 3}'), - - -- Hot takes achievements - ('It''s getting hot in here!', 'Add 3 hot takes to your profile', '${placeholderImage}hot-takes.png', 'milestone', 'hot_take_create', '{"targetCount": 3}'), - - -- Post creation achievements - ('Town crier', 'Share a link (post)', '${placeholderImage}town-crier.png', 'instant', 'post_share', '{"targetCount": 1}'), - ('Free for all', 'Create a freeform post', '${placeholderImage}freeform.png', 'instant', 'post_freeform', '{"targetCount": 1}'), - - -- Squad achievements - ('Squad up', 'Join a squad', '${placeholderImage}squad-up.png', 'instant', 'squad_join', '{"targetCount": 1}'), - ('Team player', 'Join 5 squads', '${placeholderImage}team-player.png', 'milestone', 'squad_join', '{"targetCount": 5}'), - ('Hop on dailydev', 'Create your own squad', '${placeholderImage}squad-create.png', 'instant', 'squad_create', '{"targetCount": 1}'), - - -- Brief achievements - ('Debriefed', 'Read 5 briefs', '${placeholderImage}debriefed.png', 'milestone', 'brief_read', '{"targetCount": 5}'), - - -- Reputation achievements - ('You''re him!', 'Gain 500 reputation', '${placeholderImage}youre-him.png', 'milestone', 'reputation_gain', '{"targetCount": 500}'), - ('In the big league', 'Gain 10000 reputation', '${placeholderImage}big-league.png', 'milestone', 'reputation_gain', '{"targetCount": 10000}') - `); + INSERT INTO "achievement" ("name", "description", "image", "type", "eventType", "criteria", "points") + VALUES + -- Post upvote achievements (giving upvotes) + ('Readit!', 'Upvote 10 posts', 'https://daily-now-res.cloudinary.com/image/upload/v1770222920/achievements/readit.png', 'milestone', 'post_upvote', '{"targetCount": 10}', 10), + ('Everyone gets an upvote!', 'Upvote 50 posts', 'https://daily-now-res.cloudinary.com/image/upload/v1770222884/achievements/Everyone_gets_an_upvote.png', 'milestone', 'post_upvote', '{"targetCount": 50}', 15), + ('Upvote economy', 'Upvote 100 posts', 'https://daily-now-res.cloudinary.com/image/upload/v1770222941/achievements/Upvote_economy.png', 'milestone', 'post_upvote', '{"targetCount": 100}', 25), + + -- Comment upvote achievements (giving upvotes) + ('Well said!', 'Upvote 10 comments', 'https://daily-now-res.cloudinary.com/image/upload/v1770222883/achievements/hello_there.png', 'milestone', 'comment_upvote', '{"targetCount": 10}', 10), + ('User feedback', 'Upvote 50 comments', 'https://daily-now-res.cloudinary.com/image/upload/v1770222937/achievements/User_feedback.png', 'milestone', 'comment_upvote', '{"targetCount": 50}', 15), + + -- Bookmark achievements + ('Definitely gonna read it', 'Bookmark 1 post', 'https://daily-now-res.cloudinary.com/image/upload/v1770222883/achievements/Definitely_gonna_read_it.png', 'milestone', 'bookmark_post', '{"targetCount": 1}', 5), + + -- Profile achievements + ('Hello, World!', 'Update your profile picture', 'https://daily-now-res.cloudinary.com/image/upload/v1770222916/achievements/Hello_World.png', 'instant', 'profile_image_update', '{"targetCount": 1}', 5), + ('Cover art', 'Update your cover picture', 'https://daily-now-res.cloudinary.com/image/upload/v1770222884/achievements/Cover_art.png', 'instant', 'profile_cover_update', '{"targetCount": 1}', 5), + ('Hello, is it me you''re looking for?', 'Update your profile location', 'https://daily-now-res.cloudinary.com/image/upload/v1770222884/achievements/Hello_is_it_me_you_re_looking_for.png', 'instant', 'profile_location_update', '{"targetCount": 1}', 5), + ('All about me', 'Complete your profile 100%', 'https://daily-now-res.cloudinary.com/image/upload/v1770222887/achievements/All_about_me.png', 'instant', 'profile_complete', '{"targetCount": 1}', 15), + + -- Experience achievements + ('Workaholic', 'Add a work experience', 'https://daily-now-res.cloudinary.com/image/upload/v1770222937/achievements/Workaholic.png', 'instant', 'experience_work', '{"targetCount": 1}', 5), + ('Scholar', 'Add an education experience', 'https://daily-now-res.cloudinary.com/image/upload/v1770222917/achievements/Scholar.png', 'instant', 'experience_education', '{"targetCount": 1}', 5), + ('Open Sourcerer', 'Add an open source experience', 'https://daily-now-res.cloudinary.com/image/upload/v1770222919/achievements/Open_Sourcerer.png', 'instant', 'experience_opensource', '{"targetCount": 1}', 5), + ('Under new management', 'Add a project experience', 'https://daily-now-res.cloudinary.com/image/upload/v1770222936/achievements/Under_new_management.png', 'instant', 'experience_project', '{"targetCount": 1}', 5), + ('Gentle soul', 'Add a volunteering experience', 'https://daily-now-res.cloudinary.com/image/upload/v1770222887/achievements/Gentle_soul.png', 'instant', 'experience_volunteering', '{"targetCount": 1}', 5), + ('Certifiably certified', 'Add a certification to your profile', 'https://daily-now-res.cloudinary.com/image/upload/v1770222884/achievements/Certifiably_Certified.png', 'instant', 'experience_certification', '{"targetCount": 1}', 5), + ('The right tool for the job', 'Add 3 skills to your profile', 'https://daily-now-res.cloudinary.com/image/upload/v1770222932/achievements/The_right_tool_for_the_job.png', 'milestone', 'experience_skill', '{"targetCount": 3}', 10), + + -- Hot takes achievements + ('It''s getting hot in here!', 'Add 3 hot takes to your profile', 'https://daily-now-res.cloudinary.com/image/upload/v1770222926/achievements/It_s_getting_hot_in_here.png', 'milestone', 'hot_take_create', '{"targetCount": 3}', 10), + + -- Post creation achievements + ('Town crier', 'Share a link (post)', 'https://daily-now-res.cloudinary.com/image/upload/v1770222937/achievements/Town_crier.png', 'instant', 'post_share', '{"targetCount": 1}', 10), + ('Free for all', 'Create a freeform post', 'https://daily-now-res.cloudinary.com/image/upload/v1770222888/achievements/free_for_all.png', 'instant', 'post_freeform', '{"targetCount": 1}', 10), + + -- Squad achievements + ('Squad up', 'Join a squad', 'https://daily-now-res.cloudinary.com/image/upload/v1770222916/achievements/Squad_up.png', 'instant', 'squad_join', '{"targetCount": 1}', 5), + ('Team player', 'Join 5 squads', 'https://daily-now-res.cloudinary.com/image/upload/v1770222931/achievements/Team_player.png', 'milestone', 'squad_join', '{"targetCount": 5}', 20), + ('Hop on dailydev', 'Create your own squad', 'https://daily-now-res.cloudinary.com/image/upload/v1770222927/achievements/Hop_on_dailydev.png', 'instant', 'squad_create', '{"targetCount": 1}', 20), + + -- Brief achievements + ('Debriefed', 'Read 5 briefs', 'https://daily-now-res.cloudinary.com/image/upload/v1770222884/achievements/Debriefed.png', 'milestone', 'brief_read', '{"targetCount": 5}', 15), + + -- Reputation achievements + ('You''re him!', 'Gain 500 reputation', 'https://daily-now-res.cloudinary.com/image/upload/v1770222931/achievements/Youre_him.png', 'milestone', 'reputation_gain', '{"targetCount": 500}', 40), + ('In the big league', 'Gain 10000 reputation', 'https://daily-now-res.cloudinary.com/image/upload/v1770222928/achievements/In_the_big_league.png', 'milestone', 'reputation_gain', '{"targetCount": 10000}', 50), + + -- Comment creation achievements + ('Got something to say', 'Write your first comment', 'https://daily-now-res.cloudinary.com/image/upload/v1770222890/achievements/Got_something_to_say.png', 'milestone', 'comment_create', '{"targetCount": 1}', 5), + ('Well, actually...', 'Write 50 comments', 'https://daily-now-res.cloudinary.com/image/upload/v1770232584/achievements/Well_actually.png', 'milestone', 'comment_create', '{"targetCount": 50}', 20), + ('Senator', 'Write 100 comments', 'https://daily-now-res.cloudinary.com/image/upload/v1770233009/achievements/Well_Spoken.png', 'milestone', 'comment_create', '{"targetCount": 100}', 25), + + -- Top reader achievements + ('Professional reader', 'Earn your first top reader badge', 'https://daily-now-res.cloudinary.com/image/upload/v1770224907/achievements/Professional_reader.png', 'milestone', 'top_reader_badge', '{"targetCount": 1}', 30), + ('Touch grass', 'Earn 10 top reader badges', 'https://daily-now-res.cloudinary.com/image/upload/v1770222937/achievements/Touch_grass.png', 'milestone', 'top_reader_badge', '{"targetCount": 10}', 40), + + -- Reading streak achievements + ('Just getting started', 'Reach a 10-day reading streak', 'https://daily-now-res.cloudinary.com/image/upload/v1770222919/achievements/Just_getting_started.png', 'milestone', 'reading_streak', '{"targetCount": 10}', 15), + ('Committed', 'Reach a 50-day reading streak', 'https://daily-now-res.cloudinary.com/image/upload/v1770222887/achievements/Comitted.png', 'milestone', 'reading_streak', '{"targetCount": 50}', 30), + ('I took "daily dev" literally', 'Reach a 365-day reading streak', 'https://daily-now-res.cloudinary.com/image/upload/v1770224984/achievements/I_took_dailydev_literally.png', 'milestone', 'reading_streak', '{"targetCount": 365}', 50), + + -- Custom feed achievement + ('Power user', 'Create a custom feed', 'https://daily-now-res.cloudinary.com/image/upload/v1770222920/achievements/Power_user.png', 'instant', 'feed_create', '{"targetCount": 1}', 10), + + -- Bookmark folder achievement + ('Organized', 'Create a bookmark folder', 'https://daily-now-res.cloudinary.com/image/upload/v1770222923/achievements/Organized.png', 'instant', 'bookmark_list_create', '{"targetCount": 1}', 10), + + -- CV upload achievement + ('Curriculum Vitae', 'Upload your CV', 'https://daily-now-res.cloudinary.com/image/upload/v1770222886/achievements/Curriculum_Vitae.png', 'instant', 'cv_upload', '{"targetCount": 1}', 10), + + -- Post boost achievement + ('Boosted', 'Boost a post', 'https://daily-now-res.cloudinary.com/image/upload/v1770222884/achievements/Boosted.png', 'instant', 'post_boost', '{"targetCount": 1}', 20), + + -- Upvote received achievements (receiving upvotes) + ('Good stuff, buddy!', 'Receive your first upvote', 'https://daily-now-res.cloudinary.com/image/upload/v1770222888/achievements/Good_stuff_buddy.png', 'milestone', 'upvote_received', '{"targetCount": 1}', 5), + ('You''re the cool kid!', 'Receive 100 upvotes', 'https://daily-now-res.cloudinary.com/image/upload/v1770222932/achievements/You_re_the_cool_kid.png', 'milestone', 'upvote_received', '{"targetCount": 100}', 25), + ('True chad', 'Receive 1000 upvotes', 'https://daily-now-res.cloudinary.com/image/upload/v1770222934/achievements/True_chad.png', 'milestone', 'upvote_received', '{"targetCount": 1000}', 40), + + -- Award received achievement + ('Not a Nobel prize, but...', 'Receive an award', 'https://daily-now-res.cloudinary.com/image/upload/v1770231401/achievements/Not_a_nobel_prize_but.png', 'instant', 'award_received', '{"targetCount": 1}', 50), + + -- User follow achievements + ('Acolyte', 'Follow another user', 'https://daily-now-res.cloudinary.com/image/upload/v1770222885/achievements/Acolyte.png', 'instant', 'user_follow', '{"targetCount": 1}', 5), + ('Sheeple', 'Follow 10 users', 'https://daily-now-res.cloudinary.com/image/upload/v1770222927/achievements/Sheeple.png', 'milestone', 'user_follow', '{"targetCount": 10}', 20), + + -- Follower gain achievements + ('Shepherd', 'Gain 10 followers', 'https://daily-now-res.cloudinary.com/image/upload/v1770222926/achievements/Shepherd.png', 'milestone', 'follower_gain', '{"targetCount": 10}', 15), + ('Prophet', 'Gain 100 followers', 'https://daily-now-res.cloudinary.com/image/upload/v1770222920/achievements/Prophet.png', 'milestone', 'follower_gain', '{"targetCount": 100}', 30), + + -- Plus subscription achievements + ('1UP', 'Subscribe to Plus', 'https://daily-now-res.cloudinary.com/image/upload/v1770222884/achievements/1UP.png', 'instant', 'plus_subscribe', '{"targetCount": 1}', 25), + ('Loyalist', 'Stay subscribed to Plus for 12 months', 'https://daily-now-res.cloudinary.com/image/upload/v1770222925/achievements/Loyalist.png', 'milestone', 'subscription_anniversary', '{"targetCount": 12}', 40), + + -- Share click achievements + ('Check it out', 'Get someone to click your shared link', 'https://daily-now-res.cloudinary.com/image/upload/v1770222886/achievements/Check_it_out.png', 'instant', 'share_click', '{"targetCount": 1}', 10), + ('Hot Topic', 'Get 100 clicks on a shared link', 'https://daily-now-res.cloudinary.com/image/upload/v1770222917/achievements/Hot_Topic.png', 'milestone', 'share_click_milestone', '{"targetCount": 100}', 30), + ('Curator', 'Have 10 different shared links clicked', 'https://daily-now-res.cloudinary.com/image/upload/v1770222887/achievements/Curator.png', 'milestone', 'share_posts_clicked', '{"targetCount": 10}', 25) + `); } public async down(queryRunner: QueryRunner): Promise { - // Delete all seeded achievements - await queryRunner.query(` - DELETE FROM "achievement" - WHERE "name" IN ( - 'Readit!', - 'Everyone gets an upvote!', - 'Upvote economy', - 'Well said!', - 'User feedback', - 'Definitely gonna read it', - 'Hello, World!', - 'Cover art', - 'Hello, is it me you''re looking for?', - 'Workaholic', - 'Scholar', - 'Open Sourcerer', - 'Under new management', - 'Gentle soul', - 'The right tool for the job', - 'It''s getting hot in here!', - 'Town crier', - 'Free for all', - 'Squad up', - 'Team player', - 'Hop on dailydev', - 'Debriefed', - 'You''re him!', - 'In the big league' - ) - `); + await queryRunner.query(`DELETE FROM "achievement"`); } } diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 0ae356598d..45c76da395 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -45,8 +45,11 @@ import { UserTopReader, Feedback, } from '../../entity'; +import { BookmarkList } from '../../entity/BookmarkList'; import { HotTake } from '../../entity/user/HotTake'; import { UserStack } from '../../entity/user/UserStack'; +import { SharePost } from '../../entity/posts/SharePost'; +import { PostAnalytics } from '../../entity/posts/PostAnalytics'; import { Campaign, CampaignType } from '../../entity/campaign/Campaign'; import { messageToJson, Worker } from '../worker'; import { @@ -116,7 +119,8 @@ import { runEntityReminderWorkflow, runReminderWorkflow, } from '../../temporal/notifications/utils'; -import { addDays, nextMonday, nextTuesday } from 'date-fns'; +import { addDays, differenceInMonths, nextMonday, nextTuesday } from 'date-fns'; +import { hasPlusStatusChanged } from '../../paddle'; import { postReportReasonsMap, reportCommentReasonsMap, @@ -137,6 +141,7 @@ import { UserReport } from '../../entity/UserReport'; import { UserTransaction, UserTransactionStatus, + UserTransactionType, } from '../../entity/user/UserTransaction'; import { checkUserCoresAccess } from '../../common/user'; import { ContentPreference } from '../../entity/contentPreference/ContentPreference'; @@ -198,6 +203,58 @@ const isFreeformPostChangeLongEnough = ( (freeform.payload.after!.content?.length || 0), ) >= FREEFORM_POST_MINIMUM_CHANGE_LENGTH; +/** + * Check if user has completed their profile (5 criteria) and award achievement + */ +const checkProfileCompletionAchievement = async ( + con: DataSource, + logger: FastifyBaseLogger, + user: ChangeObject, +): Promise => { + // Profile completion criteria: + // 1. Profile image + // 2. Bio (headline) + // 3. Experience level + // 4. Work experience + // 5. Education experience + const hasProfileImage = !!user.image && user.image !== ''; + const hasHeadline = !!user.bio && String(user.bio).trim() !== ''; + const hasExperienceLevel = !!user.experienceLevel; + + // Only proceed if basic profile fields are complete + if (!hasProfileImage || !hasHeadline || !hasExperienceLevel) { + return; + } + + // Check for work and education experiences + const experienceResult = await con + .getRepository(UserExperience) + .createQueryBuilder('ue') + .select([ + `MAX(CASE WHEN ue.type = :workType THEN 1 ELSE 0 END) as "hasWork"`, + `MAX(CASE WHEN ue.type = :educationType THEN 1 ELSE 0 END) as "hasEducation"`, + ]) + .where('ue.userId = :userId', { userId: user.id }) + .setParameters({ + workType: UserExperienceType.Work, + educationType: UserExperienceType.Education, + }) + .getRawOne(); + + const hasWork = experienceResult?.hasWork == 1; + const hasEducation = experienceResult?.hasEducation == 1; + + // All 5 criteria must be met + if (hasWork && hasEducation) { + await checkAchievementProgress( + con, + logger, + user.id, + AchievementEventType.ProfileComplete, + ); + } +}; + const isCollectionUpdated = ( collection: ChangeMessage, ): boolean => @@ -356,12 +413,22 @@ const onPostVoteChange = async ( const voterId = data.payload.after!.userId; if (post && post.authorId !== voterId && post.scoutId !== voterId) { + // Achievement for the voter (giving upvote) await checkAchievementProgress( con, logger, voterId, AchievementEventType.PostUpvote, ); + // Achievement for the post author (receiving upvote) + if (post.authorId) { + await checkAchievementProgress( + con, + logger, + post.authorId, + AchievementEventType.UpvoteReceived, + ); + } } } break; @@ -395,12 +462,22 @@ const onPostVoteChange = async ( const voterId = data.payload.after!.userId; if (post && post.authorId !== voterId && post.scoutId !== voterId) { + // Achievement for the voter (giving upvote) await checkAchievementProgress( con, logger, voterId, AchievementEventType.PostUpvote, ); + // Achievement for the post author (receiving upvote) + if (post.authorId) { + await checkAchievementProgress( + con, + logger, + post.authorId, + AchievementEventType.UpvoteReceived, + ); + } } } break; @@ -447,12 +524,20 @@ const onCommentVoteChange = async ( const voterId = data.payload.after!.userId; if (comment && comment.userId !== voterId) { + // Achievement for the voter (giving upvote) await checkAchievementProgress( con, logger, voterId, AchievementEventType.CommentUpvote, ); + // Achievement for the comment author (receiving upvote) + await checkAchievementProgress( + con, + logger, + comment.userId, + AchievementEventType.UpvoteReceived, + ); } } break; @@ -486,12 +571,20 @@ const onCommentVoteChange = async ( const voterId = data.payload.after!.userId; if (comment && comment.userId !== voterId) { + // Achievement for the voter (giving upvote) await checkAchievementProgress( con, logger, voterId, AchievementEventType.CommentUpvote, ); + // Achievement for the comment author (receiving upvote) + await checkAchievementProgress( + con, + logger, + comment.userId, + AchievementEventType.UpvoteReceived, + ); } } break; @@ -554,6 +647,13 @@ const onCommentChange = async ( data.payload.after!.contentHtml, ); } + // Check comment creation achievement + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.CommentCreate, + ); } else if (data.payload.op === 'u') { if (data.payload.before!.contentHtml !== data.payload.after!.contentHtml) { await notifyCommentEdited(logger, data.payload.after!); @@ -658,6 +758,41 @@ const onUserChange = async ( }, ); } + + // Check Plus subscription achievement + const beforeFlags = JSON.parse( + (data.payload.before!.subscriptionFlags as unknown as string) || '{}', + ); + const afterFlags = JSON.parse( + (data.payload.after!.subscriptionFlags as unknown as string) || '{}', + ); + const plusStatus = hasPlusStatusChanged(afterFlags, beforeFlags); + if (plusStatus.statusChanged && plusStatus.isPlus) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.id, + AchievementEventType.PlusSubscribe, + ); + } + + // Check subscription anniversary achievement + if (plusStatus.isPlus && afterFlags.createdAt) { + const createdAt = new Date(afterFlags.createdAt); + const monthsSubscribed = differenceInMonths(new Date(), createdAt); + if (monthsSubscribed >= 12) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.id, + AchievementEventType.SubscriptionAnniversary, + monthsSubscribed, + ); + } + } + + // Check profile completion achievement + await checkProfileCompletionAchievement(con, logger, data.payload.after!); } if (data.payload.op === 'd') { await triggerTypedEvent(logger, 'user-deleted', { @@ -1063,6 +1198,17 @@ const onFeedChange = async ( ) => { if (data.payload.op === 'c') { await updateAlerts(con, data.payload.after!.userId, { myFeed: 'created' }); + + // Check custom feed achievement - feed id differs from userId means custom feed + const feed = data.payload.after!; + if (feed.id !== feed.userId) { + await checkAchievementProgress( + con, + logger, + feed.userId, + AchievementEventType.FeedCreate, + ); + } } }; @@ -1338,6 +1484,18 @@ const onUserStreakChange = async ( await triggerTypedEvent(logger, 'api.v1.user-streak-updated', { streak: data.payload.after!, }); + + // Check reading streak achievements when streak increases + const currentStreak = data.payload.after!.currentStreak; + if (currentStreak > (data.payload.before?.currentStreak ?? 0)) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.ReadingStreak, + currentStreak, + ); + } } }; @@ -1389,7 +1547,7 @@ const onUserCompanyCompanyChange = async ( }; const onUserTopReaderChange = async ( - _: DataSource, + con: DataSource, logger: FastifyBaseLogger, data: ChangeMessage, ) => { @@ -1399,10 +1557,23 @@ const onUserTopReaderChange = async ( await triggerTypedEvent(logger, 'api.v1.user-top-reader', { userTopReader: data.payload.after!, }); + + // Check top reader badge achievement - count total badges + const userId = data.payload.after!.userId; + const badgeCount = await con + .getRepository(UserTopReader) + .count({ where: { userId } }); + await checkAchievementProgress( + con, + logger, + userId, + AchievementEventType.TopReaderBadge, + badgeCount, + ); }; const onUserTransactionChange = async ( - _: DataSource, + con: DataSource, logger: FastifyBaseLogger, data: ChangeMessage, ) => { @@ -1417,6 +1588,37 @@ const onUserTransactionChange = async ( await triggerTypedEvent(logger, 'api.v1.user-transaction', { transaction: data.payload.after!, }); + + const transaction = data.payload.after!; + + // Check post boost achievement (sender boosted a post) + if ( + transaction.referenceType === UserTransactionType.PostBoost && + transaction.senderId + ) { + await checkAchievementProgress( + con, + logger, + transaction.senderId, + AchievementEventType.PostBoost, + ); + } + + // Check award received achievement (receiver got an award) + // Awards are Post or Comment types where receiver is different from sender + if ( + (transaction.referenceType === UserTransactionType.Post || + transaction.referenceType === UserTransactionType.Comment) && + transaction.senderId && + transaction.receiverId !== transaction.senderId + ) { + await checkAchievementProgress( + con, + logger, + transaction.receiverId, + AchievementEventType.AwardReceived, + ); + } } }; @@ -1451,7 +1653,7 @@ const onBookmarkChange = async ( }; const onContentPreferenceChange = async ( - _: DataSource, + con: DataSource, logger: FastifyBaseLogger, data: ChangeMessage, ) => { @@ -1470,6 +1672,33 @@ const onContentPreferenceChange = async ( await triggerTypedEvent(logger, 'api.v1.user-follow', { payload: contentPreferenceUser, }); + + // Achievement for the follower (user who follows) + await checkAchievementProgress( + con, + logger, + contentPreferenceUser.userId, + AchievementEventType.UserFollow, + ); + + // Achievement for the followed user (gaining a follower) + // Count total followers to pass as absolute value + const followerCount = await con + .getRepository(ContentPreference) + .count({ + where: { + referenceId: contentPreferenceUser.referenceId, + type: ContentPreferenceType.User, + status: ContentPreferenceStatus.Follow, + }, + }); + await checkAchievementProgress( + con, + logger, + contentPreferenceUser.referenceId, + AchievementEventType.FollowerGain, + followerCount, + ); } break; } @@ -1566,6 +1795,21 @@ const onUserCandidatePreferenceChange = async ( logger, userId: data.payload.after!.userId, }); + + // Check CV upload achievement - cv field has a url when uploaded + const cv = data.payload.after!.cv as { url?: string } | undefined; + const previousCv = data.payload.before?.cv as { url?: string } | undefined; + const hasCvUrl = cv?.url; + const hadPreviousCvUrl = previousCv?.url; + + if (hasCvUrl && !hadPreviousCvUrl) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.CVUpload, + ); + } }; const onOpportunityChange = async ( @@ -1788,10 +2032,7 @@ const onUserExperienceChange = async ( // Check experience achievements on create if (data.payload.op === 'c' && experience) { - const eventTypeMap: Record< - UserExperienceType, - AchievementEventType | null - > = { + const eventTypeMap: Record = { [UserExperienceType.Work]: AchievementEventType.ExperienceWork, [UserExperienceType.Education]: AchievementEventType.ExperienceEducation, [UserExperienceType.OpenSource]: @@ -1799,12 +2040,25 @@ const onUserExperienceChange = async ( [UserExperienceType.Project]: AchievementEventType.ExperienceProject, [UserExperienceType.Volunteering]: AchievementEventType.ExperienceVolunteering, - [UserExperienceType.Certification]: null, // No achievement for certification + [UserExperienceType.Certification]: + AchievementEventType.ExperienceCertification, }; const eventType = eventTypeMap[experience.type]; - if (eventType) { - await checkAchievementProgress(con, logger, experience.userId, eventType); + await checkAchievementProgress(con, logger, experience.userId, eventType); + + // Check profile completion when work/education is added + if ( + experience.type === UserExperienceType.Work || + experience.type === UserExperienceType.Education + ) { + const user = await con.getRepository(User).findOneBy({ + id: experience.userId, + }); + if (user) { + const userChangeObject = convertUserToChangeObject(user); + await checkProfileCompletionAchievement(con, logger, userChangeObject); + } } } @@ -1982,6 +2236,86 @@ const onUserStackChange = async ( } }; +const onBookmarkListChange = async ( + con: DataSource, + logger: FastifyBaseLogger, + data: ChangeMessage, +) => { + if (data.payload.op === 'c') { + await checkAchievementProgress( + con, + logger, + data.payload.after!.userId, + AchievementEventType.BookmarkListCreate, + ); + } +}; + +const onPostAnalyticsChange = async ( + con: DataSource, + logger: FastifyBaseLogger, + data: ChangeMessage, +) => { + if (data.payload.op !== 'u') { + return; + } + + const postId = data.payload.after!.id; + const clicks = data.payload.after!.clicks; + const prevClicks = data.payload.before?.clicks ?? 0; + + // Skip if clicks haven't increased + if (clicks <= prevClicks) { + return; + } + + // Check if this is a SharePost + const sharePost = await con.getRepository(SharePost).findOne({ + where: { id: postId }, + select: ['id', 'authorId'], + }); + + if (!sharePost || !sharePost.authorId) { + return; + } + + // First click achievement (when clicks goes from 0 to 1+) + if (prevClicks === 0 && clicks > 0) { + await checkAchievementProgress( + con, + logger, + sharePost.authorId, + AchievementEventType.ShareClick, + ); + } + + // Milestone achievement (100 clicks on single share) + await checkAchievementProgress( + con, + logger, + sharePost.authorId, + AchievementEventType.ShareClickMilestone, + clicks, + ); + + // Count unique share posts with clicks > 0 for Curator achievement + const postsWithClicks = await con + .getRepository(SharePost) + .createQueryBuilder('sp') + .innerJoin(PostAnalytics, 'pa', 'pa.id = sp.id') + .where('sp.authorId = :userId', { userId: sharePost.authorId }) + .andWhere('pa.clicks > 0') + .getCount(); + + await checkAchievementProgress( + con, + logger, + sharePost.authorId, + AchievementEventType.SharePostsClicked, + postsWithClicks, + ); +}; + const worker: Worker = { subscription: 'api-cdc', maxMessages: parseInt(process.env.CDC_WORKER_MAX_MESSAGES) || undefined, @@ -2125,6 +2459,12 @@ const worker: Worker = { case getTableName(con, UserStack): await onUserStackChange(con, logger, data); break; + case getTableName(con, BookmarkList): + await onBookmarkListChange(con, logger, data); + break; + case getTableName(con, PostAnalytics): + await onPostAnalyticsChange(con, logger, data); + break; } } catch (err) { logger.error( From 6cf2cff6e24d0ff07b9c7a77235a48a48a5e1956 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Thu, 5 Feb 2026 19:47:17 +0100 Subject: [PATCH 4/5] add brief condition --- __tests__/workers/newView.ts | 111 +++++++++++++++++++++++++++++++++++ src/schema/achievements.ts | 6 ++ src/workers/newView.ts | 43 +++++++++++++- 3 files changed, 158 insertions(+), 2 deletions(-) diff --git a/__tests__/workers/newView.ts b/__tests__/workers/newView.ts index 3a48e483a1..d7d597cad0 100644 --- a/__tests__/workers/newView.ts +++ b/__tests__/workers/newView.ts @@ -6,8 +6,13 @@ import { } from '../helpers'; import { postsFixture } from '../fixture/post'; import { + Achievement, + AchievementEventType, + AchievementType, Alerts, ArticlePost, + BRIEFING_SOURCE, + PostType, Source, User, UserStreak, @@ -15,6 +20,8 @@ import { UserStreakActionType, View, } from '../../src/entity'; +import { BriefPost } from '../../src/entity/posts/BriefPost'; +import { UserAchievement } from '../../src/entity/user/UserAchievement'; import { sourcesFixture } from '../fixture/source'; import { usersFixture } from '../fixture/user'; import { DataSource, IsNull, Not } from 'typeorm'; @@ -602,3 +609,107 @@ describe('reading streaks', () => { }); }); }); + +describe('brief read achievement', () => { + const briefAchievementId = 'debriefed-achievement-id'; + + beforeEach(async () => { + // Seed the brief read achievement + await con.getRepository(Achievement).save({ + id: briefAchievementId, + name: 'Debriefed', + description: 'Read 5 briefs', + image: 'https://example.com/achievement.png', + type: AchievementType.Milestone, + eventType: AchievementEventType.BriefRead, + criteria: { targetCount: 5 }, + points: 15, + }); + + // Create the briefing source if it doesn't exist + await con.getRepository(Source).save({ + id: BRIEFING_SOURCE, + name: 'Briefing', + handle: 'briefing', + private: true, + }); + + // Create a brief post + await con.getRepository(BriefPost).save({ + id: 'brief-1', + shortId: 'brief1', + sourceId: BRIEFING_SOURCE, + authorId: 'u1', + title: 'Test Brief', + type: PostType.Brief, + private: true, + visible: true, + }); + }); + + it('should increment BriefRead achievement progress when viewing a BriefPost', async () => { + await expectSuccessfulBackground(worker, { + postId: 'brief-1', + userId: 'u2', + referer: 'referer', + timestamp: new Date().toISOString(), + }); + + const userAchievement = await con.getRepository(UserAchievement).findOne({ + where: { userId: 'u2', achievementId: briefAchievementId }, + }); + + expect(userAchievement).not.toBeNull(); + expect(userAchievement?.progress).toBe(1); + expect(userAchievement?.unlockedAt).toBeNull(); + }); + + it('should unlock BriefRead achievement after reading 5 briefs', async () => { + // Create 5 brief posts + for (let i = 2; i <= 5; i++) { + await con.getRepository(BriefPost).save({ + id: `brief-${i}`, + shortId: `brief${i}`, + sourceId: BRIEFING_SOURCE, + authorId: 'u1', + title: `Test Brief ${i}`, + type: PostType.Brief, + private: true, + visible: true, + }); + } + + // View all 5 briefs + for (let i = 1; i <= 5; i++) { + await expectSuccessfulBackground(worker, { + postId: `brief-${i}`, + userId: 'u2', + referer: 'referer', + timestamp: new Date().toISOString(), + }); + } + + const userAchievement = await con.getRepository(UserAchievement).findOne({ + where: { userId: 'u2', achievementId: briefAchievementId }, + }); + + expect(userAchievement).not.toBeNull(); + expect(userAchievement?.progress).toBe(5); + expect(userAchievement?.unlockedAt).not.toBeNull(); + }); + + it('should NOT trigger BriefRead achievement when viewing an article post', async () => { + await expectSuccessfulBackground(worker, { + postId: 'p1', + userId: 'u1', + referer: 'referer', + timestamp: new Date().toISOString(), + }); + + const userAchievement = await con.getRepository(UserAchievement).findOne({ + where: { userId: 'u1', achievementId: briefAchievementId }, + }); + + expect(userAchievement).toBeNull(); + }); +}); diff --git a/src/schema/achievements.ts b/src/schema/achievements.ts index 053625a41f..f5f874e2c9 100644 --- a/src/schema/achievements.ts +++ b/src/schema/achievements.ts @@ -19,6 +19,7 @@ export type GQLAchievement = { criteria: { targetCount?: number; }; + points: number; createdAt: Date; }; @@ -98,6 +99,10 @@ export const typeDefs = /* GraphQL */ ` """ criteria: AchievementCriteria! """ + Points awarded for unlocking this achievement + """ + points: Int! + """ When the achievement was created """ createdAt: DateTime! @@ -238,6 +243,7 @@ export const resolvers: IResolvers = traceResolvers< type: achievement.type, eventType: achievement.eventType, criteria: achievement.criteria, + points: achievement.points, createdAt: achievement.createdAt, }, progress: userAchievement?.progress ?? 0, diff --git a/src/workers/newView.ts b/src/workers/newView.ts index 01555dd392..e69dc0da70 100644 --- a/src/workers/newView.ts +++ b/src/workers/newView.ts @@ -1,11 +1,45 @@ -import { DeepPartial, EntityManager } from 'typeorm'; -import { Alerts, User, UserStreak, View } from '../entity'; +import { DataSource, DeepPartial, EntityManager } from 'typeorm'; +import { Alerts, Post, PostType, User, UserStreak, View } from '../entity'; import { messageToJson, Worker } from './worker'; import { TypeORMQueryFailedError, TypeOrmError } from '../errors'; import { isFibonacci } from '../common/fibonacci'; import { generateStorageKey, StorageKey, StorageTopic } from '../config'; import { deleteRedisKey } from '../redis'; import { logger } from '../logger'; +import { + AchievementEventType, + checkAchievementProgress, +} from '../common/achievement'; +import { FastifyBaseLogger } from 'fastify'; + +const checkBriefReadAchievement = async ( + con: DataSource, + logger: FastifyBaseLogger, + postId: string, + userId: string, +): Promise => { + try { + const post = await con.getRepository(Post).findOne({ + where: { id: postId }, + select: ['id', 'type'], + }); + + if (post?.type === PostType.Brief) { + await checkAchievementProgress( + con, + logger, + userId, + AchievementEventType.BriefRead, + ); + } + } catch (err) { + logger.error( + { postId, userId, err }, + 'failed to check brief read achievement', + ); + // Don't throw - achievement failures shouldn't block the main operation + } +}; interface ShouldIncrement { currentStreak: number; @@ -204,6 +238,11 @@ const worker: Worker = { throw err; } }); + + // Check BriefRead achievement outside the transaction + if (didSave && data.userId && data.postId) { + await checkBriefReadAchievement(con, logger, data.postId, data.userId); + } }, }; From fc86fae0ef2a3fb2f69f5378b815a7089e463a7e Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Fri, 6 Feb 2026 00:25:17 +0100 Subject: [PATCH 5/5] add achievements for awarding --- src/entity/Achievement.ts | 1 + ...770327354229-SeedAwardGivenAchievements.ts | 24 +++++++++++++++++++ src/workers/cdc/primary.ts | 16 +++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 src/migration/1770327354229-SeedAwardGivenAchievements.ts diff --git a/src/entity/Achievement.ts b/src/entity/Achievement.ts index dff1f0d1f2..6323d95732 100644 --- a/src/entity/Achievement.ts +++ b/src/entity/Achievement.ts @@ -41,6 +41,7 @@ export enum AchievementEventType { PostBoost = 'post_boost', UpvoteReceived = 'upvote_received', AwardReceived = 'award_received', + AwardGiven = 'award_given', UserFollow = 'user_follow', FollowerGain = 'follower_gain', PlusSubscribe = 'plus_subscribe', diff --git a/src/migration/1770327354229-SeedAwardGivenAchievements.ts b/src/migration/1770327354229-SeedAwardGivenAchievements.ts new file mode 100644 index 0000000000..dd1f6e78f7 --- /dev/null +++ b/src/migration/1770327354229-SeedAwardGivenAchievements.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedAwardGivenAchievements1770327354229 + implements MigrationInterface +{ + name = 'SeedAwardGivenAchievements1770327354229'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO "achievement" ("name", "description", "image", "type", "eventType", "criteria", "points") + VALUES + -- Award given achievements (giving awards) + ('Altruistic', 'Give your first award', '', 'milestone', 'award_given', '{"targetCount": 1}', 10), + ('The giver', 'Give 5 awards', '', 'milestone', 'award_given', '{"targetCount": 5}', 20), + ('The head of the committee', 'Give 10 awards', '', 'milestone', 'award_given', '{"targetCount": 10}', 30) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "achievement" WHERE "eventType" = 'award_given'`, + ); + } +} diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 45c76da395..8d65645889 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -1619,6 +1619,22 @@ const onUserTransactionChange = async ( AchievementEventType.AwardReceived, ); } + + // Check award given achievement (sender gave an award) + // Awards are Post or Comment types where receiver is different from sender + if ( + (transaction.referenceType === UserTransactionType.Post || + transaction.referenceType === UserTransactionType.Comment) && + transaction.senderId && + transaction.receiverId !== transaction.senderId + ) { + await checkAchievementProgress( + con, + logger, + transaction.senderId, + AchievementEventType.AwardGiven, + ); + } } };