diff --git a/__tests__/quests.ts b/__tests__/quests.ts index 7ebf654328..b07ff0edb7 100644 --- a/__tests__/quests.ts +++ b/__tests__/quests.ts @@ -8,6 +8,9 @@ import { type GraphQLTestingState, } from './helpers'; import { + Achievement, + AchievementEventType, + AchievementType, Quest, QuestEventType, QuestReward, @@ -17,6 +20,7 @@ import { User, } from '../src/entity'; import { + UserAchievement, UserQuest, UserQuestProfile, UserQuestStatus, @@ -121,6 +125,8 @@ beforeEach(async () => { loggedUser = null; isPlus = false; + await con.createQueryBuilder().delete().from(UserAchievement).execute(); + await con.createQueryBuilder().delete().from(Achievement).execute(); await con.createQueryBuilder().delete().from(UserQuest).execute(); await con.createQueryBuilder().delete().from(UserQuestProfile).execute(); await con.createQueryBuilder().delete().from(QuestRotation).execute(); @@ -327,4 +333,168 @@ describe('claimQuestReward mutation', () => { expect(res.errors).toHaveLength(1); expect(res.errors?.[0]?.message).toBe('Quest is not completed yet'); }); + + it('should increment achievement progress on claim', async () => { + const now = new Date(); + loggedUser = questUserId; + + const achievementId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + await con.getRepository(Achievement).save({ + id: achievementId, + name: 'Quest enthusiast test', + description: 'Complete and claim 10 quests', + image: '', + type: AchievementType.Milestone, + eventType: AchievementEventType.QuestClaim, + criteria: { targetCount: 10 }, + points: 5, + }); + + await seedQuest({ + userQuestStatus: UserQuestStatus.Completed, + periodStart: new Date(now.getTime() - 60 * 60 * 1000), + periodEnd: new Date(now.getTime() + 60 * 60 * 1000), + }); + + const res = await client.mutate(CLAIM_QUEST_REWARD_MUTATION, { + variables: { userQuestId }, + }); + + expect(res.errors).toBeUndefined(); + + const userAchievement = await con + .getRepository(UserAchievement) + .findOneBy({ achievementId, userId: questUserId }); + + expect(userAchievement).not.toBeNull(); + expect(userAchievement?.progress).toBe(1); + expect(userAchievement?.unlockedAt).toBeNull(); + }); + + it('should unlock achievement when target reached', async () => { + const now = new Date(); + loggedUser = questUserId; + + const achievementId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + await con.getRepository(Achievement).save({ + id: achievementId, + name: 'Quest enthusiast test', + description: 'Complete and claim 1 quest', + image: '', + type: AchievementType.Milestone, + eventType: AchievementEventType.QuestClaim, + criteria: { targetCount: 1 }, + points: 5, + }); + + await seedQuest({ + userQuestStatus: UserQuestStatus.Completed, + periodStart: new Date(now.getTime() - 60 * 60 * 1000), + periodEnd: new Date(now.getTime() + 60 * 60 * 1000), + }); + + const res = await client.mutate(CLAIM_QUEST_REWARD_MUTATION, { + variables: { userQuestId }, + }); + + expect(res.errors).toBeUndefined(); + + const userAchievement = await con + .getRepository(UserAchievement) + .findOneBy({ achievementId, userId: questUserId }); + + expect(userAchievement).not.toBeNull(); + expect(userAchievement?.progress).toBeGreaterThanOrEqual(1); + expect(userAchievement?.unlockedAt).not.toBeNull(); + }); + + it('should accumulate progress across multiple claims', async () => { + const now = new Date(); + loggedUser = questUserId; + + const achievementId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + await con.getRepository(Achievement).save({ + id: achievementId, + name: 'Quest enthusiast test', + description: 'Complete and claim 10 quests', + image: '', + type: AchievementType.Milestone, + eventType: AchievementEventType.QuestClaim, + criteria: { targetCount: 10 }, + points: 5, + }); + + const secondQuestId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab'; + const secondRotationId = 'dddddddd-dddd-4ddd-8ddd-ddddddddddde'; + const secondUserQuestId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeed'; + + await seedQuest({ + userQuestStatus: UserQuestStatus.Completed, + periodStart: new Date(now.getTime() - 60 * 60 * 1000), + periodEnd: new Date(now.getTime() + 60 * 60 * 1000), + }); + + await client.mutate(CLAIM_QUEST_REWARD_MUTATION, { + variables: { userQuestId }, + }); + + await saveFixtures(con, Quest, [ + { + id: secondQuestId, + name: 'Second quest', + description: 'Write 2 comments', + type: QuestType.Daily, + eventType: QuestEventType.CommentCreate, + criteria: { targetCount: 2 }, + active: true, + }, + ]); + + await saveFixtures(con, QuestReward, [ + { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2', + questId: secondQuestId, + type: QuestRewardType.XP, + amount: 10, + metadata: {}, + }, + ]); + + await saveFixtures(con, QuestRotation, [ + { + id: secondRotationId, + questId: secondQuestId, + type: QuestType.Daily, + plusOnly: false, + slot: 2, + periodStart: new Date(now.getTime() - 60 * 60 * 1000), + periodEnd: new Date(now.getTime() + 60 * 60 * 1000), + }, + ]); + + await saveFixtures(con, UserQuest, [ + { + id: secondUserQuestId, + rotationId: secondRotationId, + userId: questUserId, + progress: 2, + status: UserQuestStatus.Completed, + completedAt: new Date(), + claimedAt: null, + }, + ]); + + const res = await client.mutate(CLAIM_QUEST_REWARD_MUTATION, { + variables: { userQuestId: secondUserQuestId }, + }); + + expect(res.errors).toBeUndefined(); + + const userAchievement = await con + .getRepository(UserAchievement) + .findOneBy({ achievementId, userId: questUserId }); + + expect(userAchievement).not.toBeNull(); + expect(userAchievement?.progress).toBe(2); + }); }); diff --git a/src/common/achievement/retroactive.ts b/src/common/achievement/retroactive.ts index 63bb089226..24ad747dce 100644 --- a/src/common/achievement/retroactive.ts +++ b/src/common/achievement/retroactive.ts @@ -513,6 +513,18 @@ const handleSharePostsClicked: RetroactiveHandler = async (con, userIds) => { return toProgressMap(rows); }; +const handleQuestClaim: RetroactiveHandler = async (con, userIds) => { + const rows = await con.query( + `SELECT "userId", COUNT(*)::int AS count + FROM user_quest + WHERE "userId" = ANY($1) AND status = 'claimed' + GROUP BY "userId"`, + [userIds], + ); + + return toProgressMap(rows); +}; + const handlers: Partial> = { [AchievementEventType.ProfileImageUpdate]: handleProfileImageUpdate, [AchievementEventType.ProfileCoverUpdate]: handleProfileCoverUpdate, @@ -561,6 +573,7 @@ const handlers: Partial> = { [AchievementEventType.PostImpressions]: handlePostImpressions, [AchievementEventType.ShareClickMilestone]: handleShareClickMilestone, [AchievementEventType.SharePostsClicked]: handleSharePostsClicked, + [AchievementEventType.QuestClaim]: handleQuestClaim, }; export const syncUsersRetroactiveAchievements = async ({ diff --git a/src/entity/Achievement.ts b/src/entity/Achievement.ts index 1b6c4ebe97..18acab4c07 100644 --- a/src/entity/Achievement.ts +++ b/src/entity/Achievement.ts @@ -58,6 +58,7 @@ export enum AchievementEventType { ShareClick = 'share_click', ShareClickMilestone = 'share_click_milestone', SharePostsClicked = 'share_posts_clicked', + QuestClaim = 'quest_claim', } export interface AchievementCriteria { diff --git a/src/migration/1772800000000-QuestClaimAchievements.ts b/src/migration/1772800000000-QuestClaimAchievements.ts new file mode 100644 index 0000000000..c1b0d7baf4 --- /dev/null +++ b/src/migration/1772800000000-QuestClaimAchievements.ts @@ -0,0 +1,58 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class QuestClaimAchievements1772800000000 implements MigrationInterface { + name = 'QuestClaimAchievements1772800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + INSERT INTO "achievement" ( + "name", + "description", + "image", + "type", + "eventType", + "criteria", + "points" + ) + VALUES + ( + 'Bright eyed adventurer', + 'Complete 10 quests', + 'https://media.daily.dev/image/upload/s--HMBjgs_T--/q_auto/v1773743113/achievements/bright_eyed_adventurer', + 'milestone', + 'quest_claim', + '{"targetCount": 10}', + 5 + ), + ( + 'On the path', + 'Complete 50 quests', + 'https://media.daily.dev/image/upload/s--iwqvFWLT--/q_auto/v1773743172/achievements/on_the_path', + 'milestone', + 'quest_claim', + '{"targetCount": 50}', + 10 + ), + ( + 'Hero', + 'Complete 100 quests', + 'https://media.daily.dev/image/upload/s--5WqXv9y7--/q_auto/v1773743176/achievements/heros_quest', + 'milestone', + 'quest_claim', + '{"targetCount": 100}', + 25 + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + DELETE FROM "achievement" + WHERE "name" IN ( + 'Quest enthusiast', + 'Quest conqueror', + 'Quest legend' + ) + `); + } +} diff --git a/src/schema/quests.ts b/src/schema/quests.ts index ba8f465c89..4c44811195 100644 --- a/src/schema/quests.ts +++ b/src/schema/quests.ts @@ -24,6 +24,11 @@ import { UserTransactionProcessor, UserTransactionStatus, } from '../entity/user/UserTransaction'; + +import { + AchievementEventType, + checkAchievementProgress, +} from '../common/achievement'; import { NotFoundError } from '../errors'; import { redisPubSub } from '../redis'; @@ -540,6 +545,13 @@ export const resolvers: IResolvers = { updatedAt: now, }); + await checkAchievementProgress( + ctx.con, + ctx.log, + ctx.userId, + AchievementEventType.QuestClaim, + ); + return dashboard; }, },