diff --git a/__tests__/workers/cdc/primary.ts b/__tests__/workers/cdc/primary.ts index 801d51a409..190b9ef0ab 100644 --- a/__tests__/workers/cdc/primary.ts +++ b/__tests__/workers/cdc/primary.ts @@ -8343,6 +8343,150 @@ describe('post analytics achievement progress', () => { }); }); +describe('share click achievement progress', () => { + let sharePostsClickedAchievementId: string; + let shareClickMilestoneAchievementId: string; + let authorId: string; + + const postIds = ['share-click-1', 'share-click-2', 'share-click-3']; + + const emitClickChange = ( + postId: string, + beforeClicks: number, + afterClicks: number, + ) => + expectSuccessfulBackground( + worker, + mockChangeMessage({ + before: { + id: postId, + impressions: 0, + impressionsAds: 0, + clicks: beforeClicks, + }, + after: { + id: postId, + impressions: 0, + impressionsAds: 0, + clicks: afterClicks, + }, + op: 'u', + table: 'post_analytics', + }), + ); + + beforeEach(async () => { + sharePostsClickedAchievementId = randomUUID(); + shareClickMilestoneAchievementId = randomUUID(); + authorId = randomUUID(); + + await saveFixtures(con, User, [ + { + id: authorId, + bio: null, + name: 'Share Click Author', + image: 'https://daily.dev/share-click-author.jpg', + email: `share-click-author-${authorId}@daily.dev`, + createdAt: new Date(), + username: `share${authorId.slice(0, 8)}`, + infoConfirmed: true, + }, + ]); + await saveFixtures(con, Source, sourcesFixture); + + await con.getRepository(Achievement).save([ + { + id: sharePostsClickedAchievementId, + name: 'Share Posts Clicked Achievement', + description: 'Get clicks on 10 distinct share posts', + image: '', + type: AchievementType.Milestone, + eventType: AchievementEventType.SharePostsClicked, + criteria: { targetCount: 10 }, + points: 10, + }, + { + id: shareClickMilestoneAchievementId, + name: 'Share Click Milestone Achievement', + description: 'Reach 100 clicks on a share post', + image: '', + type: AchievementType.Milestone, + eventType: AchievementEventType.ShareClickMilestone, + criteria: { targetCount: 100 }, + points: 10, + }, + ]); + + await con.getRepository(Post).save( + postIds.map((id, index) => ({ + id, + shortId: id, + title: `Share post ${index + 1}`, + authorId, + sourceId: 'a', + type: PostType.Share, + sharedPostId: null, + })), + ); + + await con.getRepository(PostAnalytics).save( + postIds.map((id) => ({ + id, + impressions: 0, + impressionsAds: 0, + clicks: 0, + })), + ); + }); + + it('should set SharePostsClicked progress to the count of share posts with clicks (not accumulate)', async () => { + await con + .getRepository(PostAnalytics) + .update({ id: postIds[0] }, { clicks: 1 }); + await emitClickChange(postIds[0], 0, 1); + + await con + .getRepository(PostAnalytics) + .update({ id: postIds[1] }, { clicks: 1 }); + await emitClickChange(postIds[1], 0, 1); + + await con + .getRepository(PostAnalytics) + .update({ id: postIds[2] }, { clicks: 1 }); + await emitClickChange(postIds[2], 0, 1); + + const userAchievement = await con.getRepository(UserAchievement).findOneBy({ + achievementId: sharePostsClickedAchievementId, + userId: authorId, + }); + + expect(userAchievement).not.toBeNull(); + expect(userAchievement!.progress).toEqual(3); + expect(userAchievement!.unlockedAt).toBeNull(); + }); + + it('should set ShareClickMilestone progress to the user-level max clicks across share posts', async () => { + await con + .getRepository(PostAnalytics) + .update({ id: postIds[0] }, { clicks: 50 }); + await emitClickChange(postIds[0], 0, 50); + + await con + .getRepository(PostAnalytics) + .update({ id: postIds[1] }, { clicks: 10 }); + await emitClickChange(postIds[1], 0, 10); + + const userAchievement = await con.getRepository(UserAchievement).findOneBy({ + achievementId: shareClickMilestoneAchievementId, + userId: authorId, + }); + + expect(userAchievement).not.toBeNull(); + expect(userAchievement!.progress).toEqual(50); + expect(userAchievement!.unlockedAt).toBeNull(); + }); +}); + describe('user experience change', () => { type ObjectType = UserExperience; let experienceId: string; diff --git a/src/common/achievement/index.ts b/src/common/achievement/index.ts index 2d6c3718c5..9fb2667896 100644 --- a/src/common/achievement/index.ts +++ b/src/common/achievement/index.ts @@ -274,6 +274,8 @@ export async function checkAchievementProgress( AchievementEventType.ReadingStreak, AchievementEventType.CoresSpent, AchievementEventType.PostImpressions, + AchievementEventType.ShareClickMilestone, + AchievementEventType.SharePostsClicked, ]; if (absoluteValueEventTypes.includes(eventType)) { diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 48d2d50182..96b569410b 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -2743,22 +2743,29 @@ const onPostAnalyticsChange = async ( ); } + const stats = await con + .getRepository(SharePost) + .createQueryBuilder('sp') + .innerJoin(PostAnalytics, 'pa', 'pa.id = sp.id') + .where('sp.authorId = :userId', { userId: sharePost.authorId }) + .select('COALESCE(MAX(pa.clicks), 0)::int', 'maxClicks') + .addSelect( + 'COUNT(*) FILTER (WHERE pa.clicks > 0)::int', + 'postsWithClicks', + ) + .getRawOne<{ maxClicks: number; postsWithClicks: number }>(); + + const maxClicks = stats?.maxClicks ?? 0; + const postsWithClicks = stats?.postsWithClicks ?? 0; + await checkAchievementProgress( con, logger, sharePost.authorId, AchievementEventType.ShareClickMilestone, - clicks, + maxClicks, ); - 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,