diff --git a/__tests__/questProgress.ts b/__tests__/questProgress.ts index d0eb510e6f..858a7ee554 100644 --- a/__tests__/questProgress.ts +++ b/__tests__/questProgress.ts @@ -9,7 +9,10 @@ import { User, } from '../src/entity'; import { UserQuest, UserQuestStatus } from '../src/entity/user'; -import { checkQuestProgress } from '../src/common/quest'; +import { + checkQuestProgress, + syncMilestoneQuestProgress, +} from '../src/common/quest'; import { createMockLogger, saveFixtures } from './helpers'; const userId = '11111111-1111-4111-8111-111111111111'; @@ -17,11 +20,17 @@ const questIds = [ '22222222-2222-4222-8222-222222222222', '33333333-3333-4333-8333-333333333333', '44444444-4444-4444-8444-444444444444', + '88888888-8888-4888-8888-888888888881', + '88888888-8888-4888-8888-888888888882', + '88888888-8888-4888-8888-888888888883', ]; const rotationIds = [ '55555555-5555-4555-8555-555555555555', '66666666-6666-4666-8666-666666666666', '77777777-7777-4777-8777-777777777777', + '99999999-9999-4999-8999-999999999991', + '99999999-9999-4999-8999-999999999992', + '99999999-9999-4999-8999-999999999993', ]; let con: DataSource; @@ -196,6 +205,230 @@ describe('checkQuestProgress', () => { expect(userQuest.status).toBe(UserQuestStatus.InProgress); }); + it('should not advance quest completion milestone on completion alone, only on claim', async () => { + const now = new Date(); + const logger = createMockLogger(); + const periodStart = new Date(now.getTime() - 60 * 60 * 1000); + const periodEnd = new Date(now.getTime() + 60 * 60 * 1000); + const milestonePeriodStart = new Date('2026-03-25T00:00:00.000Z'); + const milestonePeriodEnd = new Date('9999-12-31T23:59:59.000Z'); + + await saveFixtures(con, Quest, [ + { + id: questIds[3], + name: 'Up and comer', + description: 'Claim 2 quests', + type: QuestType.Milestone, + eventType: QuestEventType.QuestComplete, + criteria: { targetCount: 2 }, + active: true, + }, + { + id: questIds[4], + name: 'Daily upvotes 1', + description: 'Upvote 1 post', + type: QuestType.Daily, + eventType: QuestEventType.PostUpvote, + criteria: { targetCount: 1 }, + active: true, + }, + { + id: questIds[5], + name: 'Daily upvotes 2', + description: 'Upvote 1 more post', + type: QuestType.Daily, + eventType: QuestEventType.PostUpvote, + criteria: { targetCount: 1 }, + active: true, + }, + ]); + + await saveFixtures(con, QuestRotation, [ + { + id: rotationIds[3], + questId: questIds[3], + type: QuestType.Milestone, + plusOnly: false, + slot: 1, + periodStart: milestonePeriodStart, + periodEnd: milestonePeriodEnd, + }, + { + id: rotationIds[4], + questId: questIds[4], + type: QuestType.Daily, + plusOnly: false, + slot: 1, + periodStart, + periodEnd, + }, + { + id: rotationIds[5], + questId: questIds[5], + type: QuestType.Daily, + plusOnly: false, + slot: 2, + periodStart, + periodEnd, + }, + ]); + + const didUpdate = await checkQuestProgress({ + con, + logger, + userId, + eventType: QuestEventType.PostUpvote, + incrementBy: 1, + now, + }); + + expect(didUpdate).toBe(true); + + const userQuests = await con.getRepository(UserQuest).find({ + where: { userId }, + order: { rotationId: 'ASC' }, + }); + + expect(userQuests).toHaveLength(3); + expect( + userQuests + .filter(({ rotationId }) => + [rotationIds[4], rotationIds[5]].includes(rotationId), + ) + .map(({ progress, status }) => ({ progress, status })), + ).toEqual([ + { + progress: 1, + status: UserQuestStatus.Completed, + }, + { + progress: 1, + status: UserQuestStatus.Completed, + }, + ]); + + const milestoneQuest = userQuests.find( + ({ rotationId }) => rotationId === rotationIds[3], + ); + expect(milestoneQuest).toMatchObject({ + progress: 0, + status: UserQuestStatus.InProgress, + }); + }); + + it('should advance quest completion milestone when quests are claimed', async () => { + const now = new Date(); + const logger = createMockLogger(); + const periodStart = new Date(now.getTime() - 60 * 60 * 1000); + const periodEnd = new Date(now.getTime() + 60 * 60 * 1000); + const milestonePeriodStart = new Date('2026-03-25T00:00:00.000Z'); + const milestonePeriodEnd = new Date('9999-12-31T23:59:59.000Z'); + + await saveFixtures(con, Quest, [ + { + id: questIds[3], + name: 'Up and comer', + description: 'Claim 2 quests', + type: QuestType.Milestone, + eventType: QuestEventType.QuestComplete, + criteria: { targetCount: 2 }, + active: true, + }, + { + id: questIds[4], + name: 'Daily upvotes 1', + description: 'Upvote 1 post', + type: QuestType.Daily, + eventType: QuestEventType.PostUpvote, + criteria: { targetCount: 1 }, + active: true, + }, + { + id: questIds[5], + name: 'Daily upvotes 2', + description: 'Upvote 1 more post', + type: QuestType.Daily, + eventType: QuestEventType.PostUpvote, + criteria: { targetCount: 1 }, + active: true, + }, + ]); + + await saveFixtures(con, QuestRotation, [ + { + id: rotationIds[3], + questId: questIds[3], + type: QuestType.Milestone, + plusOnly: false, + slot: 1, + periodStart: milestonePeriodStart, + periodEnd: milestonePeriodEnd, + }, + { + id: rotationIds[4], + questId: questIds[4], + type: QuestType.Daily, + plusOnly: false, + slot: 1, + periodStart, + periodEnd, + }, + { + id: rotationIds[5], + questId: questIds[5], + type: QuestType.Daily, + plusOnly: false, + slot: 2, + periodStart, + periodEnd, + }, + ]); + + // Complete the daily quests + await checkQuestProgress({ + con, + logger, + userId, + eventType: QuestEventType.PostUpvote, + incrementBy: 1, + now, + }); + + // Simulate claiming both daily quests + const dailyUserQuests = await con.getRepository(UserQuest).find({ + where: { + userId, + rotationId: In([rotationIds[4], rotationIds[5]]), + }, + }); + + for (const uq of dailyUserQuests) { + await con + .getRepository(UserQuest) + .update( + { id: uq.id }, + { status: UserQuestStatus.Claimed, claimedAt: now }, + ); + } + + // Sync milestones after claims + await syncMilestoneQuestProgress({ + con, + userId, + eventType: QuestEventType.QuestComplete, + now, + }); + + const milestoneQuest = await con.getRepository(UserQuest).findOneOrFail({ + where: { userId, rotationId: rotationIds[3] }, + }); + + expect(milestoneQuest).toMatchObject({ + progress: 2, + status: UserQuestStatus.Completed, + }); + }); + it('should not update claimed quests', async () => { const now = new Date(); const logger = createMockLogger(); diff --git a/__tests__/quests.ts b/__tests__/quests.ts index 07f7253d1a..b62d1acd98 100644 --- a/__tests__/quests.ts +++ b/__tests__/quests.ts @@ -11,13 +11,17 @@ import { } from './helpers'; import { Feedback, + Post, + PostType, Quest, QuestEventType, QuestReward, QuestRewardType, QuestRotation, QuestType, + Source, User, + View, } from '../src/entity'; import { UserQuest, @@ -28,6 +32,7 @@ import { HotTake } from '../src/entity/user/HotTake'; import appFunc from '../src'; import type { Context } from '../src/Context'; import { FastifyInstance } from 'fastify'; +import { createSource } from './fixture/source'; const CLAIM_QUEST_REWARD_MUTATION = ` mutation ClaimQuestReward($userQuestId: ID!) { @@ -63,6 +68,21 @@ mutation ClaimQuestReward($userQuestId: ID!) { } `; +const CLAIM_MILESTONE_QUEST_REWARD_MUTATION = ` +mutation ClaimQuestReward($userQuestId: ID!) { + claimQuestReward(userQuestId: $userQuestId) { + level { + totalXp + } + milestone { + userQuestId + status + claimable + } + } +} +`; + const CLAIM_QUEST_REWARD_WITH_STREAKS_MUTATION = ` mutation ClaimQuestReward($userQuestId: ID!) { claimQuestReward(userQuestId: $userQuestId) { @@ -115,6 +135,29 @@ query QuestDashboard { } `; +const MILESTONE_QUEST_DASHBOARD_QUERY = ` +query QuestDashboard { + questDashboard { + milestone { + userQuestId + progress + status + claimable + quest { + id + name + type + targetCount + } + rewards { + type + amount + } + } + } +} +`; + const TRACK_QUEST_EVENT_MUTATION = ` mutation TrackQuestEvent($eventType: ClientQuestEventType!) { trackQuestEvent(eventType: $eventType) { @@ -175,6 +218,8 @@ beforeEach(async () => { await con.createQueryBuilder().delete().from(Quest).execute(); await con.createQueryBuilder().delete().from(Feedback).execute(); await con.createQueryBuilder().delete().from(HotTake).execute(); + await con.getRepository(View).delete({ userId: questUserId }); + await con.getRepository(Post).delete({ authorId: questUserId }); await con.getRepository(User).delete({ id: questUserId }); }); @@ -348,6 +393,113 @@ const seedQuestCompletionHistory = async (daysAgo: number[]) => { ); }; +const seedHistoricalBriefReadMilestoneQuest = async () => { + const milestoneQuestId = randomUUID(); + const milestoneRotationId = randomUUID(); + const briefPostIds = [ + 'brief-milestone-1', + 'brief-milestone-2', + 'brief-milestone-3', + ]; + const timestamps = [ + new Date('2026-03-20T08:00:00.000Z'), + new Date('2026-03-21T08:00:00.000Z'), + new Date('2026-03-22T08:00:00.000Z'), + ]; + + await saveFixtures(con, User, [{ id: questUserId, reputation: 10 }]); + await saveFixtures(con, Source, [ + createSource('a', 'A', 'https://example.com/source-a.png'), + ]); + await saveFixtures(con, Quest, [ + { + id: milestoneQuestId, + name: 'Up to date', + description: 'Read 3 articles', + type: QuestType.Milestone, + eventType: QuestEventType.BriefRead, + criteria: { + targetCount: 3, + }, + active: true, + }, + ]); + await saveFixtures(con, QuestReward, [ + { + id: randomUUID(), + questId: milestoneQuestId, + type: QuestRewardType.XP, + amount: 1000, + metadata: {}, + }, + ]); + await saveFixtures(con, QuestRotation, [ + { + id: milestoneRotationId, + questId: milestoneQuestId, + type: QuestType.Milestone, + plusOnly: false, + slot: 1, + periodStart: new Date('2026-03-25T00:00:00.000Z'), + periodEnd: new Date('9999-12-31T23:59:59.000Z'), + }, + ]); + await saveFixtures(con, Post, [ + { + id: briefPostIds[0], + shortId: 'brief-mile-001', + title: 'Brief 1', + url: 'https://example.com/brief-1', + sourceId: 'a', + authorId: questUserId, + type: PostType.Brief, + visible: true, + }, + { + id: briefPostIds[1], + shortId: 'brief-mile-002', + title: 'Brief 2', + url: 'https://example.com/brief-2', + sourceId: 'a', + authorId: questUserId, + type: PostType.Brief, + visible: true, + }, + { + id: briefPostIds[2], + shortId: 'brief-mile-003', + title: 'Brief 3', + url: 'https://example.com/brief-3', + sourceId: 'a', + authorId: questUserId, + type: PostType.Brief, + visible: true, + }, + ]); + await saveFixtures(con, View, [ + { + postId: briefPostIds[0], + userId: questUserId, + timestamp: timestamps[0], + }, + { + postId: briefPostIds[1], + userId: questUserId, + timestamp: timestamps[1], + }, + { + postId: briefPostIds[2], + userId: questUserId, + timestamp: timestamps[2], + }, + ]); + + return { + milestoneQuestId, + milestoneRotationId, + }; +}; + describe('claimQuestReward mutation', () => { it('should bucket plus slot quests separately from the quest definition', async () => { const now = new Date(); @@ -499,6 +651,95 @@ describe('claimQuestReward mutation', () => { ); expect(res.errors?.[0]?.message).toContain('ClaimQuestRewardPayload'); }); + + it('should backfill milestone quests from historical brief reads on dashboard fetch', async () => { + loggedUser = questUserId; + + const { milestoneQuestId, milestoneRotationId } = + await seedHistoricalBriefReadMilestoneQuest(); + + expect( + await con.getRepository(UserQuest).findOneBy({ + userId: questUserId, + rotationId: milestoneRotationId, + }), + ).toBeNull(); + + const dashboardRes = await client.query(MILESTONE_QUEST_DASHBOARD_QUERY); + + expect(dashboardRes.errors).toBeUndefined(); + expect(dashboardRes.data.questDashboard.milestone).toHaveLength(1); + + const milestoneQuest = dashboardRes.data.questDashboard.milestone[0]; + + expect(milestoneQuest).toMatchObject({ + progress: 3, + status: UserQuestStatus.Completed, + claimable: true, + quest: { + id: milestoneQuestId, + name: 'Up to date', + type: QuestType.Milestone, + targetCount: 3, + }, + }); + expect(milestoneQuest.userQuestId).toBeTruthy(); + expect(milestoneQuest.rewards).toEqual([ + { + type: QuestRewardType.XP, + amount: 1000, + }, + ]); + + const storedMilestoneQuest = await con.getRepository(UserQuest).findOneBy({ + userId: questUserId, + rotationId: milestoneRotationId, + }); + + expect(storedMilestoneQuest).toMatchObject({ + id: milestoneQuest.userQuestId, + progress: 3, + status: UserQuestStatus.Completed, + }); + expect(storedMilestoneQuest?.completedAt).toBeTruthy(); + }); + + it('should claim a backfilled milestone quest reward', async () => { + loggedUser = questUserId; + + await seedHistoricalBriefReadMilestoneQuest(); + + const dashboardRes = await client.query(MILESTONE_QUEST_DASHBOARD_QUERY); + + expect(dashboardRes.errors).toBeUndefined(); + + const milestoneQuest = dashboardRes.data.questDashboard.milestone[0]; + + const claimRes = await client.mutate( + CLAIM_MILESTONE_QUEST_REWARD_MUTATION, + { + variables: { + userQuestId: milestoneQuest.userQuestId, + }, + }, + ); + + expect(claimRes.errors).toBeUndefined(); + expect(claimRes.data.claimQuestReward.level.totalXp).toBe(1000); + expect(claimRes.data.claimQuestReward.milestone).toEqual([ + { + userQuestId: milestoneQuest.userQuestId, + status: UserQuestStatus.Claimed, + claimable: false, + }, + ]); + + const profile = await con.getRepository(UserQuestProfile).findOneByOrFail({ + userId: questUserId, + }); + + expect(profile.totalXp).toBe(1000); + }); }); describe('questDashboard query', () => { diff --git a/src/common/quest/index.ts b/src/common/quest/index.ts index 9391c32090..cd0cb9b3d5 100644 --- a/src/common/quest/index.ts +++ b/src/common/quest/index.ts @@ -1,4 +1,5 @@ export * from './level'; +export * from './milestone'; export * from './progress'; export * from './rotation'; export * from './streak'; diff --git a/src/common/quest/milestone.ts b/src/common/quest/milestone.ts new file mode 100644 index 0000000000..19c4620454 --- /dev/null +++ b/src/common/quest/milestone.ts @@ -0,0 +1,308 @@ +import type { DataSource, EntityManager } from 'typeorm'; +import { In, LessThanOrEqual, MoreThan } from 'typeorm'; +import { Comment } from '../../entity/Comment'; +import { Quest, QuestEventType, QuestType } from '../../entity/Quest'; +import { QuestRotation } from '../../entity/QuestRotation'; +import { View } from '../../entity/View'; +import { ContentPreference } from '../../entity/contentPreference/ContentPreference'; +import { + ContentPreferenceStatus, + ContentPreferenceType, +} from '../../entity/contentPreference/types'; +import { Post } from '../../entity/posts/Post'; +import { User } from '../../entity/user/User'; +import { UserComment } from '../../entity/user/UserComment'; +import { UserPost } from '../../entity/user/UserPost'; +import { UserQuest, UserQuestStatus } from '../../entity/user/UserQuest'; +import { UserVote } from '../../types'; + +const claimedQuestStatuses = [UserQuestStatus.Claimed]; + +const rotatingQuestTypes = [QuestType.Daily, QuestType.Weekly]; + +const toSafeTargetCount = (targetCount?: number): number => + Math.max(1, Math.floor(targetCount ?? 1)); + +const toSafeProgress = (progress: number): number => + Math.max(0, Math.floor(progress)); + +const getFollowerGainCount = async ({ + con, + userId, +}: { + con: DataSource | EntityManager; + userId: string; +}): Promise => + con.getRepository(ContentPreference).count({ + where: { + referenceId: userId, + type: ContentPreferenceType.User, + status: In([ + ContentPreferenceStatus.Follow, + ContentPreferenceStatus.Subscribed, + ]), + }, + }); + +const getReferralCount = async ({ + con, + userId, +}: { + con: DataSource | EntityManager; + userId: string; +}): Promise => + con.getRepository(User).count({ + where: { + referralId: userId, + }, + }); + +const getArticleReadCount = async ({ + con, + userId, +}: { + con: DataSource | EntityManager; + userId: string; +}): Promise => { + const result = await con + .getRepository(View) + .createQueryBuilder('view') + .select('COUNT(DISTINCT view."postId")', 'count') + .where('view."userId" = :userId', { userId }) + .getRawOne<{ count: string | number | null }>(); + + return Number(result?.count) || 0; +}; + +const getQuestCompleteCount = async ({ + con, + userId, +}: { + con: DataSource | EntityManager; + userId: string; +}): Promise => { + const result = await con + .getRepository(UserQuest) + .createQueryBuilder('uq') + .innerJoin(QuestRotation, 'rotation', 'rotation.id = uq."rotationId"') + .select('COUNT(*)', 'count') + .where('uq."userId" = :userId', { userId }) + .andWhere('uq.status IN (:...statuses)', { + statuses: claimedQuestStatuses, + }) + .andWhere('rotation.type IN (:...types)', { + types: rotatingQuestTypes, + }) + .getRawOne<{ count: string | number | null }>(); + + return Number(result?.count) || 0; +}; + +const getUpvoteReceivedCount = async ({ + con, + userId, +}: { + con: DataSource | EntityManager; + userId: string; +}): Promise => { + const [postResult, commentResult] = await Promise.all([ + con + .getRepository(UserPost) + .createQueryBuilder('up') + .innerJoin(Post, 'post', 'post.id = up."postId"') + .select('COUNT(*)', 'count') + .where('post."authorId" = :userId', { userId }) + .andWhere('up.vote = :upvote', { upvote: UserVote.Up }) + .andWhere('up."userId" != post."authorId"') + .getRawOne<{ count: string | number | null }>(), + con + .getRepository(UserComment) + .createQueryBuilder('uc') + .innerJoin(Comment, 'comment', 'comment.id = uc."commentId"') + .select('COUNT(*)', 'count') + .where('comment."userId" = :userId', { userId }) + .andWhere('uc.vote = :upvote', { upvote: UserVote.Up }) + .andWhere('uc."userId" != comment."userId"') + .getRawOne<{ count: string | number | null }>(), + ]); + + return (Number(postResult?.count) || 0) + (Number(commentResult?.count) || 0); +}; + +const getMilestoneQuestCurrentValue = async ({ + con, + userId, + eventType, +}: { + con: DataSource | EntityManager; + userId: string; + eventType: QuestEventType; +}): Promise => { + switch (eventType) { + case QuestEventType.BriefRead: + return getArticleReadCount({ con, userId }); + case QuestEventType.FollowerGain: + return getFollowerGainCount({ con, userId }); + case QuestEventType.ReferralCount: + return getReferralCount({ con, userId }); + case QuestEventType.QuestComplete: + return getQuestCompleteCount({ con, userId }); + case QuestEventType.UpvoteReceived: + return getUpvoteReceivedCount({ con, userId }); + default: + return 0; + } +}; + +export const syncMilestoneQuestProgress = async ({ + con, + userId, + now = new Date(), + eventType, +}: { + con: DataSource | EntityManager; + userId: string; + now?: Date; + eventType?: QuestEventType; +}): Promise => { + const rotations = await con.getRepository(QuestRotation).find({ + where: { + type: QuestType.Milestone, + periodStart: LessThanOrEqual(now), + periodEnd: MoreThan(now), + }, + order: { + slot: 'ASC', + createdAt: 'ASC', + id: 'ASC', + }, + }); + + if (!rotations.length) { + return false; + } + + const quests = await con.getRepository(Quest).find({ + where: { + id: In(rotations.map(({ questId }) => questId)), + type: QuestType.Milestone, + active: true, + ...(eventType ? { eventType } : {}), + }, + order: { + createdAt: 'ASC', + id: 'ASC', + }, + }); + + if (!quests.length) { + return false; + } + + const rotationByQuestId = new Map( + rotations.map((rotation) => [rotation.questId, rotation]), + ); + const relevantRotationIds = quests + .map(({ id }) => rotationByQuestId.get(id)?.id) + .filter((rotationId): rotationId is string => !!rotationId); + + const existingUserQuests = relevantRotationIds.length + ? await con.getRepository(UserQuest).find({ + where: { + userId, + rotationId: In(relevantRotationIds), + }, + }) + : []; + + const existingQuestByRotationId = new Map( + existingUserQuests.map((userQuest) => [userQuest.rotationId, userQuest]), + ); + const currentValueByEventType = new Map(); + + let didUpdate = false; + + for (const quest of quests) { + const rotation = rotationByQuestId.get(quest.id); + if (!rotation) { + continue; + } + + if (!currentValueByEventType.has(quest.eventType)) { + currentValueByEventType.set( + quest.eventType, + await getMilestoneQuestCurrentValue({ + con, + userId, + eventType: quest.eventType, + }), + ); + } + + const currentValue = currentValueByEventType.get(quest.eventType) ?? 0; + const targetCount = toSafeTargetCount(quest.criteria?.targetCount); + const existingUserQuest = existingQuestByRotationId.get(rotation.id); + const terminalProgress = + existingUserQuest?.status === UserQuestStatus.Completed || + existingUserQuest?.status === UserQuestStatus.Claimed || + !!existingUserQuest?.completedAt || + !!existingUserQuest?.claimedAt + ? targetCount + : 0; + const nextProgress = Math.min( + targetCount, + Math.max( + terminalProgress, + toSafeProgress(existingUserQuest?.progress ?? 0), + toSafeProgress(currentValue), + ), + ); + const isCompleted = nextProgress >= targetCount; + const nextStatus = + existingUserQuest?.status === UserQuestStatus.Claimed + ? UserQuestStatus.Claimed + : isCompleted + ? UserQuestStatus.Completed + : UserQuestStatus.InProgress; + const nextCompletedAt = + existingUserQuest?.completedAt ?? (isCompleted ? now : null); + + if (existingUserQuest) { + const shouldUpdate = + existingUserQuest.progress !== nextProgress || + existingUserQuest.status !== nextStatus || + existingUserQuest.completedAt?.getTime() !== nextCompletedAt?.getTime(); + + if (!shouldUpdate) { + continue; + } + + await con.getRepository(UserQuest).update( + { + id: existingUserQuest.id, + }, + { + progress: nextProgress, + status: nextStatus, + completedAt: nextCompletedAt, + }, + ); + + didUpdate = true; + continue; + } + + await con.getRepository(UserQuest).insert({ + rotationId: rotation.id, + userId, + progress: nextProgress, + status: nextStatus, + completedAt: nextCompletedAt, + claimedAt: null, + }); + + didUpdate = true; + } + + return didUpdate; +}; diff --git a/src/common/quest/progress.ts b/src/common/quest/progress.ts index f4d018cefb..b05b7a45e2 100644 --- a/src/common/quest/progress.ts +++ b/src/common/quest/progress.ts @@ -10,12 +10,18 @@ import { redisPubSub } from '../../redis'; import { Quest, QuestEventType, QuestType } from '../../entity/Quest'; import { QuestRotation } from '../../entity/QuestRotation'; import { UserQuest, UserQuestStatus } from '../../entity/user/UserQuest'; +import { syncMilestoneQuestProgress } from './milestone'; type QuestTarget = { rotationId: string; targetCount: number; }; +type QuestProgressUpdateResult = { + didUpdate: boolean; + didComplete: boolean; +}; + type QuestUpdatePayload = { updatedAt: Date; }; @@ -51,6 +57,7 @@ const getQuestTargetsByEventType = async ({ }): Promise => { const rotations = await con.getRepository(QuestRotation).find({ where: { + type: In([QuestType.Daily, QuestType.Weekly]), periodStart: LessThanOrEqual(now), periodEnd: MoreThan(now), }, @@ -167,7 +174,7 @@ const updateExistingUserQuestProgress = async ({ target: QuestTarget; safeIncrement: number; now: Date; -}): Promise => { +}): Promise => { const boundedProgressExpression = 'least(:targetCount, greatest(0, "progress") + :safeIncrement)'; @@ -187,6 +194,7 @@ const updateExistingUserQuestProgress = async ({ terminalStatuses: TERMINAL_USER_QUEST_STATUSES, }) .andWhere('"progress" < :targetCount') + .returning(['status']) .setParameters({ targetCount: target.targetCount, safeIncrement, @@ -196,7 +204,17 @@ const updateExistingUserQuestProgress = async ({ }) .execute(); - return (updateResult.affected ?? 0) > 0; + const didUpdate = (updateResult.affected ?? 0) > 0; + + return { + didUpdate, + didComplete: + didUpdate && + updateResult.raw.some( + ({ status }: { status?: UserQuestStatus }) => + status === UserQuestStatus.Completed, + ), + }; }; const insertNewUserQuestProgress = async ({ @@ -211,7 +229,7 @@ const insertNewUserQuestProgress = async ({ target: QuestTarget; safeIncrement: number; now: Date; -}): Promise => { +}): Promise => { const progress = Math.min(target.targetCount, safeIncrement); const status = progress >= target.targetCount @@ -235,7 +253,12 @@ const insertNewUserQuestProgress = async ({ .orIgnore() .execute(); - return Boolean(insertResult.generatedMaps?.[0]?.id); + const didUpdate = Boolean(insertResult.generatedMaps?.[0]?.id); + + return { + didUpdate, + didComplete: didUpdate && status === UserQuestStatus.Completed, + }; }; export const checkQuestProgress = async ({ @@ -265,14 +288,11 @@ export const checkQuestProgress = async ({ now, }); - if (!targets.length) { - return false; - } - let didUpdate = false; + let didCompleteQuest = false; for (const target of targets) { - const didUpdateExisting = await updateExistingUserQuestProgress({ + const updateExistingResult = await updateExistingUserQuestProgress({ con, userId, target, @@ -280,12 +300,13 @@ export const checkQuestProgress = async ({ now, }); - if (didUpdateExisting) { + if (updateExistingResult.didUpdate) { didUpdate = true; + didCompleteQuest = didCompleteQuest || updateExistingResult.didComplete; continue; } - const didInsertNew = await insertNewUserQuestProgress({ + const insertNewResult = await insertNewUserQuestProgress({ con, userId, target, @@ -293,13 +314,14 @@ export const checkQuestProgress = async ({ now, }); - if (didInsertNew) { + if (insertNewResult.didUpdate) { didUpdate = true; + didCompleteQuest = didCompleteQuest || insertNewResult.didComplete; continue; } // A concurrent event can insert between update and insert; retrying update applies this increment. - const didUpdateAfterConflict = await updateExistingUserQuestProgress({ + const updateAfterConflictResult = await updateExistingUserQuestProgress({ con, userId, target, @@ -307,7 +329,30 @@ export const checkQuestProgress = async ({ now, }); - didUpdate = didUpdate || didUpdateAfterConflict; + didUpdate = didUpdate || updateAfterConflictResult.didUpdate; + didCompleteQuest = + didCompleteQuest || updateAfterConflictResult.didComplete; + } + + const didUpdateMilestones = await syncMilestoneQuestProgress({ + con, + userId, + eventType, + now, + }); + + didUpdate = didUpdate || didUpdateMilestones; + + if (didCompleteQuest && eventType !== QuestEventType.QuestComplete) { + const didUpdateQuestCompletionMilestones = + await syncMilestoneQuestProgress({ + con, + userId, + eventType: QuestEventType.QuestComplete, + now, + }); + + didUpdate = didUpdate || didUpdateQuestCompletionMilestones; } if (didUpdate) { diff --git a/src/common/quest/rotation.ts b/src/common/quest/rotation.ts index a6e3a80795..7569bb3a14 100644 --- a/src/common/quest/rotation.ts +++ b/src/common/quest/rotation.ts @@ -7,6 +7,7 @@ import { getQuestWindow } from './window'; const REQUIRED_REGULAR_QUESTS: Record = { [QuestType.Daily]: 2, [QuestType.Weekly]: 1, + [QuestType.Milestone]: 0, }; const REQUIRED_PLUS_QUESTS = 1; diff --git a/src/entity/Quest.ts b/src/entity/Quest.ts index ed1446eefb..976485c9f9 100644 --- a/src/entity/Quest.ts +++ b/src/entity/Quest.ts @@ -9,6 +9,7 @@ import { export enum QuestType { Daily = 'daily', Weekly = 'weekly', + Milestone = 'milestone', } export enum QuestEventType { @@ -30,6 +31,10 @@ export enum QuestEventType { VisitReadItLaterPage = 'visit_read_it_later_page', FeedbackSubmit = 'feedback_submit', SquadJoin = 'squad_join', + FollowerGain = 'follower_gain', + ReferralCount = 'referral_count', + QuestComplete = 'quest_complete', + UpvoteReceived = 'upvote_received', } export interface QuestCriteria { diff --git a/src/migration/1774300000000-AddMilestoneQuests.ts b/src/migration/1774300000000-AddMilestoneQuests.ts new file mode 100644 index 0000000000..a61a3bca02 --- /dev/null +++ b/src/migration/1774300000000-AddMilestoneQuests.ts @@ -0,0 +1,234 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const questIds = { + localCelebrity: '31c4f2aa-1358-4e21-8e77-1b5d02ae5001', + celebrity: '4d79a6f3-2469-41fd-98d2-2c6e13bf5002', + upToDate: '5e8ab704-357a-4dce-8a63-3d7f24cf5003', + livingEncyclopedia: '6f9bc815-468b-4f9a-9b74-4e8a35df5004', + wellConnected: '70acd926-579c-4c6f-8c85-5f9b46ef5005', + upAndComer: '81bde037-68ad-45f1-8d96-60ac57ff5006', + famedAdventurer: '92cef148-79be-4a82-8ea7-71bd680f5007', + upUpAndAway: 'a3df0259-8acf-4b13-8fb8-82ce791f5008', +} as const; + +const rotationIds = { + localCelebrity: 'b4e1036a-9be0-4ca4-90c9-93df8a2f5001', + celebrity: 'c5f2147b-acf1-4d35-91da-a4e09b3f5002', + upToDate: 'd603258c-bd02-4ec6-92eb-b5f1ac4f5003', + livingEncyclopedia: 'e714369d-ce13-4f57-93fc-c602bd5f5004', + wellConnected: 'f82547ae-df24-4088-940d-d713ce6f5005', + upAndComer: '093658bf-e035-4199-951e-e824df7f5006', + famedAdventurer: '1a4769c0-f146-42aa-962f-f935e08f5007', + upUpAndAway: '2b587ad1-0257-43bb-9740-0a46f19f5008', +} as const; + +const questIdValues = Object.values(questIds) + .map((id) => `'${id}'`) + .join(', '); + +const milestonePeriodStart = '2026-03-25 00:00:00'; +const milestonePeriodEnd = '9999-12-31 23:59:59'; + +export class AddMilestoneQuests1774300000000 implements MigrationInterface { + name = 'AddMilestoneQuests1774300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + INSERT INTO "quest" ( + "id", + "name", + "description", + "type", + "eventType", + "criteria", + "active" + ) + VALUES + ( + '${questIds.localCelebrity}', + 'Local celebrity', + 'Gain 100 followers', + 'milestone', + 'follower_gain', + '{"targetCount": 100}', + true + ), + ( + '${questIds.celebrity}', + 'Celebrity', + 'Gain 1,000 followers', + 'milestone', + 'follower_gain', + '{"targetCount": 1000}', + true + ), + ( + '${questIds.upToDate}', + 'Up to date', + 'Read 1,000 articles', + 'milestone', + 'brief_read', + '{"targetCount": 1000}', + true + ), + ( + '${questIds.livingEncyclopedia}', + 'Living encyclopedia', + 'Read 10,000 articles', + 'milestone', + 'brief_read', + '{"targetCount": 10000}', + true + ), + ( + '${questIds.wellConnected}', + 'Well connected', + 'Refer 100 users', + 'milestone', + 'referral_count', + '{"targetCount": 100}', + true + ), + ( + '${questIds.upAndComer}', + 'Up and comer', + 'Complete 100 quests', + 'milestone', + 'quest_complete', + '{"targetCount": 100}', + true + ), + ( + '${questIds.famedAdventurer}', + 'Famed adventurer', + 'Complete 1,000 quests', + 'milestone', + 'quest_complete', + '{"targetCount": 1000}', + true + ), + ( + '${questIds.upUpAndAway}', + 'Up, up and away', + 'Receive 1,000 upvotes', + 'milestone', + 'upvote_received', + '{"targetCount": 1000}', + true + ) + `); + + await queryRunner.query(/* sql */ ` + INSERT INTO "quest_rotation" ( + "id", + "questId", + "type", + "plusOnly", + "slot", + "periodStart", + "periodEnd" + ) + VALUES + ( + '${rotationIds.localCelebrity}', + '${questIds.localCelebrity}', + 'milestone', + false, + 1, + '${milestonePeriodStart}', + '${milestonePeriodEnd}' + ), + ( + '${rotationIds.celebrity}', + '${questIds.celebrity}', + 'milestone', + false, + 2, + '${milestonePeriodStart}', + '${milestonePeriodEnd}' + ), + ( + '${rotationIds.upToDate}', + '${questIds.upToDate}', + 'milestone', + false, + 3, + '${milestonePeriodStart}', + '${milestonePeriodEnd}' + ), + ( + '${rotationIds.livingEncyclopedia}', + '${questIds.livingEncyclopedia}', + 'milestone', + false, + 4, + '${milestonePeriodStart}', + '${milestonePeriodEnd}' + ), + ( + '${rotationIds.wellConnected}', + '${questIds.wellConnected}', + 'milestone', + false, + 5, + '${milestonePeriodStart}', + '${milestonePeriodEnd}' + ), + ( + '${rotationIds.upAndComer}', + '${questIds.upAndComer}', + 'milestone', + false, + 6, + '${milestonePeriodStart}', + '${milestonePeriodEnd}' + ), + ( + '${rotationIds.famedAdventurer}', + '${questIds.famedAdventurer}', + 'milestone', + false, + 7, + '${milestonePeriodStart}', + '${milestonePeriodEnd}' + ), + ( + '${rotationIds.upUpAndAway}', + '${questIds.upUpAndAway}', + 'milestone', + false, + 8, + '${milestonePeriodStart}', + '${milestonePeriodEnd}' + ) + `); + + await queryRunner.query(/* sql */ ` + INSERT INTO "quest_reward" ("questId", "type", "amount") + VALUES + ('${questIds.localCelebrity}', 'xp', 1000), + ('${questIds.localCelebrity}', 'cores', 500), + ('${questIds.celebrity}', 'xp', 5000), + ('${questIds.celebrity}', 'cores', 1000), + ('${questIds.upToDate}', 'xp', 1000), + ('${questIds.upToDate}', 'cores', 200), + ('${questIds.livingEncyclopedia}', 'xp', 5000), + ('${questIds.livingEncyclopedia}', 'cores', 500), + ('${questIds.wellConnected}', 'xp', 2000), + ('${questIds.wellConnected}', 'cores', 1000), + ('${questIds.upAndComer}', 'xp', 500), + ('${questIds.upAndComer}', 'cores', 50), + ('${questIds.famedAdventurer}', 'xp', 1000), + ('${questIds.famedAdventurer}', 'cores', 100), + ('${questIds.upUpAndAway}', 'xp', 1000), + ('${questIds.upUpAndAway}', 'cores', 100) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + DELETE FROM "quest" + WHERE "id" IN (${questIdValues}) + `); + } +} diff --git a/src/schema/quests.ts b/src/schema/quests.ts index 983f1c1aeb..5506fe14e4 100644 --- a/src/schema/quests.ts +++ b/src/schema/quests.ts @@ -10,6 +10,7 @@ import { getQuestLevelState, publishQuestUpdate, QUEST_ROTATION_UPDATE_CHANNEL, + syncMilestoneQuestProgress, } from '../common/quest'; import { transferCores } from '../common/njord'; import { systemUser } from '../common/utils'; @@ -59,11 +60,12 @@ type GQLQuestDashboard = { longestStreak: number; daily: GQLQuestBucket; weekly: GQLQuestBucket; + milestone: GQLUserQuest[]; }; type GQLClaimQuestRewardPayload = Pick< GQLQuestDashboard, - 'level' | 'daily' | 'weekly' + 'level' | 'daily' | 'weekly' | 'milestone' >; type GQLQuestUpdate = { @@ -153,6 +155,14 @@ const getCurrentUserQuestsByType = async ({ return []; } + if (type === QuestType.Milestone) { + await syncMilestoneQuestProgress({ + con, + userId, + now, + }); + } + const rotationIds = rotations.map(({ id }) => id); const questIds = [...new Set(rotations.map(({ questId }) => questId))]; @@ -260,38 +270,47 @@ const getQuestDashboard = async ({ isPlus: boolean; now: Date; }): Promise => { - const [profile, dailyQuests, weeklyQuests, streaks] = await Promise.all([ - con.getRepository(UserQuestProfile).findOne({ - where: { + const [profile, dailyQuests, weeklyQuests, milestoneQuests, streaks] = + await Promise.all([ + con.getRepository(UserQuestProfile).findOne({ + where: { + userId, + }, + }), + getCurrentUserQuestsByType({ + con, userId, - }, - }), - getCurrentUserQuestsByType({ - con, - userId, - type: QuestType.Daily, - isPlus, - now, - }), - getCurrentUserQuestsByType({ - con, - userId, - type: QuestType.Weekly, - isPlus, - now, - }), - getQuestStreaks({ - con, - userId, - now, - }), - ]); + type: QuestType.Daily, + isPlus, + now, + }), + getCurrentUserQuestsByType({ + con, + userId, + type: QuestType.Weekly, + isPlus, + now, + }), + getCurrentUserQuestsByType({ + con, + userId, + type: QuestType.Milestone, + isPlus, + now, + }), + getQuestStreaks({ + con, + userId, + now, + }), + ]); return { level: getQuestLevelState(profile?.totalXp ?? 0), ...streaks, daily: toQuestBucket(dailyQuests), weekly: toQuestBucket(weeklyQuests), + milestone: milestoneQuests, }; }; @@ -306,32 +325,41 @@ const getClaimQuestRewardPayload = async ({ isPlus: boolean; now: Date; }): Promise => { - const [profile, dailyQuests, weeklyQuests] = await Promise.all([ - con.getRepository(UserQuestProfile).findOne({ - where: { + const [profile, dailyQuests, weeklyQuests, milestoneQuests] = + await Promise.all([ + con.getRepository(UserQuestProfile).findOne({ + where: { + userId, + }, + }), + getCurrentUserQuestsByType({ + con, userId, - }, - }), - getCurrentUserQuestsByType({ - con, - userId, - type: QuestType.Daily, - isPlus, - now, - }), - getCurrentUserQuestsByType({ - con, - userId, - type: QuestType.Weekly, - isPlus, - now, - }), - ]); + type: QuestType.Daily, + isPlus, + now, + }), + getCurrentUserQuestsByType({ + con, + userId, + type: QuestType.Weekly, + isPlus, + now, + }), + getCurrentUserQuestsByType({ + con, + userId, + type: QuestType.Milestone, + isPlus, + now, + }), + ]); return { level: getQuestLevelState(profile?.totalXp ?? 0), daily: toQuestBucket(dailyQuests), weekly: toQuestBucket(weeklyQuests), + milestone: milestoneQuests, }; }; @@ -510,6 +538,7 @@ export const typeDefs = /* GraphQL */ ` enum QuestType { daily weekly + milestone } enum QuestStatus { @@ -569,12 +598,14 @@ export const typeDefs = /* GraphQL */ ` longestStreak: Int! daily: QuestBucket! weekly: QuestBucket! + milestone: [UserQuest!]! } type ClaimQuestRewardPayload { level: QuestLevel! daily: QuestBucket! weekly: QuestBucket! + milestone: [UserQuest!]! } type QuestUpdate { @@ -643,6 +674,13 @@ export const resolvers: IResolvers = { now, }); + await syncMilestoneQuestProgress({ + con, + userId: ctx.userId, + eventType: QuestEventType.QuestComplete, + now, + }); + return getClaimQuestRewardPayload({ con, userId: ctx.userId, diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 3d2498feb6..9528d214b3 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -478,6 +478,12 @@ const onPostVoteChange = async ( post.authorId, AchievementEventType.UpvoteReceived, ); + await checkQuestProgress({ + con, + logger, + userId: post.authorId, + eventType: QuestEventType.UpvoteReceived, + }); } } } @@ -530,6 +536,12 @@ const onPostVoteChange = async ( post.authorId, AchievementEventType.UpvoteReceived, ); + await checkQuestProgress({ + con, + logger, + userId: post.authorId, + eventType: QuestEventType.UpvoteReceived, + }); } } } @@ -594,6 +606,12 @@ const onCommentVoteChange = async ( comment.userId, AchievementEventType.UpvoteReceived, ); + await checkQuestProgress({ + con, + logger, + userId: comment.userId, + eventType: QuestEventType.UpvoteReceived, + }); } } break; @@ -644,6 +662,12 @@ const onCommentVoteChange = async ( comment.userId, AchievementEventType.UpvoteReceived, ); + await checkQuestProgress({ + con, + logger, + userId: comment.userId, + eventType: QuestEventType.UpvoteReceived, + }); } } break; @@ -750,6 +774,12 @@ const onUserChange = async ( AchievementEventType.ReferralCount, referralCount, ); + await checkQuestProgress({ + con, + logger, + userId: data.payload.after!.referralId, + eventType: QuestEventType.ReferralCount, + }); } } else if (data.payload.op === 'u') { await triggerTypedEvent(logger, 'user-updated', { @@ -882,6 +912,12 @@ const onUserChange = async ( AchievementEventType.ReferralCount, referralCount, ); + await checkQuestProgress({ + con, + logger, + userId: referralUserId, + eventType: QuestEventType.ReferralCount, + }); } } @@ -1861,6 +1897,12 @@ const onContentPreferenceChange = async ( AchievementEventType.FollowerGain, followerCount, ); + await checkQuestProgress({ + con, + logger, + userId: contentPreferenceUser.referenceId, + eventType: QuestEventType.FollowerGain, + }); } break; }