diff --git a/__tests__/quests.ts b/__tests__/quests.ts index b62d1acd98..1516760679 100644 --- a/__tests__/quests.ts +++ b/__tests__/quests.ts @@ -96,6 +96,7 @@ query QuestDashboard { questDashboard { currentStreak longestStreak + hasNewQuestRotations daily { regular { rotationId @@ -126,6 +127,22 @@ query QuestDashboard { } `; +const QUEST_NEW_ROTATIONS_QUERY = ` +query QuestDashboard { + questDashboard { + hasNewQuestRotations + } +} +`; + +const MARK_QUEST_ROTATIONS_VIEWED_MUTATION = ` +mutation MarkQuestRotationsViewed { + markQuestRotationsViewed { + _ + } +} +`; + const QUEST_STREAK_QUERY = ` query QuestDashboard { questDashboard { @@ -743,6 +760,89 @@ describe('claimQuestReward mutation', () => { }); describe('questDashboard query', () => { + it('should expose when there are unseen active quest rotations', async () => { + const now = new Date(); + loggedUser = questUserId; + + await saveFixtures(con, User, [{ id: questUserId }]); + await saveFixtures(con, Quest, [ + { + id: questId, + name: 'Fresh quest', + description: 'New quest', + type: QuestType.Daily, + eventType: QuestEventType.PostUpvote, + criteria: { + targetCount: 1, + }, + active: true, + }, + ]); + await saveFixtures(con, QuestRotation, [ + { + id: questRotationId, + questId, + type: QuestType.Daily, + plusOnly: false, + slot: 1, + periodStart: new Date(now.getTime() - 60 * 60 * 1000), + periodEnd: new Date(now.getTime() + 60 * 60 * 1000), + }, + ]); + + const res = await client.query(QUEST_NEW_ROTATIONS_QUERY); + + expect(res.errors).toBeUndefined(); + expect(res.data.questDashboard.hasNewQuestRotations).toBe(true); + }); + + it('should clear new quest rotations after marking them viewed', async () => { + const now = new Date(); + loggedUser = questUserId; + + await saveFixtures(con, User, [{ id: questUserId }]); + await saveFixtures(con, Quest, [ + { + id: questId, + name: 'Fresh quest', + description: 'New quest', + type: QuestType.Daily, + eventType: QuestEventType.PostUpvote, + criteria: { + targetCount: 1, + }, + active: true, + }, + ]); + await saveFixtures(con, QuestRotation, [ + { + id: questRotationId, + questId, + type: QuestType.Daily, + plusOnly: false, + slot: 1, + periodStart: new Date(now.getTime() - 60 * 60 * 1000), + periodEnd: new Date(now.getTime() + 60 * 60 * 1000), + }, + ]); + + const markRes = await client.mutate(MARK_QUEST_ROTATIONS_VIEWED_MUTATION); + + expect(markRes.errors).toBeUndefined(); + expect(markRes.data.markQuestRotationsViewed._).toBe(true); + + const dashboardRes = await client.query(QUEST_NEW_ROTATIONS_QUERY); + + expect(dashboardRes.errors).toBeUndefined(); + expect(dashboardRes.data.questDashboard.hasNewQuestRotations).toBe(false); + + const profile = await con + .getRepository(UserQuestProfile) + .findOneByOrFail({ userId: questUserId }); + + expect(profile.lastViewedQuestRotationsAt).toBeTruthy(); + }); + it('should return the current quest streak when consecutive quest days end yesterday', async () => { loggedUser = questUserId; diff --git a/src/entity/user/UserQuestProfile.ts b/src/entity/user/UserQuestProfile.ts index 07bbe04eb3..a0f511af1d 100644 --- a/src/entity/user/UserQuestProfile.ts +++ b/src/entity/user/UserQuestProfile.ts @@ -25,6 +25,9 @@ export class UserQuestProfile { @Column({ type: 'integer', default: 0 }) totalXp: number; + @Column({ type: 'timestamp', nullable: true }) + lastViewedQuestRotationsAt: Date | null; + @ManyToOne('User', { lazy: true, onDelete: 'CASCADE', diff --git a/src/migration/1776100000000-QuestRotationSeenAt.ts b/src/migration/1776100000000-QuestRotationSeenAt.ts new file mode 100644 index 0000000000..d5347af1b5 --- /dev/null +++ b/src/migration/1776100000000-QuestRotationSeenAt.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class QuestRotationSeenAt1776100000000 implements MigrationInterface { + name = 'QuestRotationSeenAt1776100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_quest_profile" + ADD COLUMN "lastViewedQuestRotationsAt" TIMESTAMP + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_quest_profile" + DROP COLUMN "lastViewedQuestRotationsAt" + `); + } +} diff --git a/src/schema/quests.ts b/src/schema/quests.ts index 5506fe14e4..a9e53c9c99 100644 --- a/src/schema/quests.ts +++ b/src/schema/quests.ts @@ -58,6 +58,7 @@ type GQLQuestDashboard = { level: GQLQuestLevel; currentStreak: number; longestStreak: number; + hasNewQuestRotations: boolean; daily: GQLQuestBucket; weekly: GQLQuestBucket; milestone: GQLUserQuest[]; @@ -259,6 +260,30 @@ const getQuestStreaks = async ({ }; }; +const getLatestActiveQuestRotationCreatedAt = async ({ + con, + now, +}: { + con: EntityManager; + now: Date; +}): Promise => { + const result = await con + .getRepository(QuestRotation) + .createQueryBuilder('rotation') + .select('MAX(rotation."createdAt")', 'latestCreatedAt') + .where('rotation."periodStart" <= :now', { now }) + .andWhere('rotation."periodEnd" > :now', { now }) + .getRawOne<{ latestCreatedAt: Date | string | null }>(); + + if (!result?.latestCreatedAt) { + return null; + } + + const latestCreatedAt = new Date(result.latestCreatedAt); + + return Number.isNaN(latestCreatedAt.getTime()) ? null : latestCreatedAt; +}; + const getQuestDashboard = async ({ con, userId, @@ -270,50 +295,86 @@ const getQuestDashboard = async ({ isPlus: boolean; now: Date; }): Promise => { - const [profile, dailyQuests, weeklyQuests, milestoneQuests, streaks] = - await Promise.all([ - con.getRepository(UserQuestProfile).findOne({ - where: { - userId, - }, - }), - getCurrentUserQuestsByType({ - con, - userId, - type: QuestType.Daily, - isPlus, - now, - }), - getCurrentUserQuestsByType({ - con, - userId, - type: QuestType.Weekly, - isPlus, - now, - }), - getCurrentUserQuestsByType({ - con, - userId, - type: QuestType.Milestone, - isPlus, - now, - }), - getQuestStreaks({ - con, + const [ + profile, + dailyQuests, + weeklyQuests, + milestoneQuests, + streaks, + latestActiveQuestRotationCreatedAt, + ] = await Promise.all([ + con.getRepository(UserQuestProfile).findOne({ + where: { userId, - now, - }), - ]); + }, + }), + getCurrentUserQuestsByType({ + con, + userId, + 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, + }), + getLatestActiveQuestRotationCreatedAt({ + con, + now, + }), + ]); return { level: getQuestLevelState(profile?.totalXp ?? 0), ...streaks, + hasNewQuestRotations: + !!latestActiveQuestRotationCreatedAt && + (!profile?.lastViewedQuestRotationsAt || + profile.lastViewedQuestRotationsAt < + latestActiveQuestRotationCreatedAt), daily: toQuestBucket(dailyQuests), weekly: toQuestBucket(weeklyQuests), milestone: milestoneQuests, }; }; +const saveQuestRotationsViewedAt = async ({ + con, + userId, + now, +}: { + con: EntityManager; + userId: string; + now: Date; +}): Promise => { + await con + .createQueryBuilder() + .insert() + .into(UserQuestProfile) + .values({ + userId, + lastViewedQuestRotationsAt: now, + }) + .orUpdate(['lastViewedQuestRotationsAt'], ['userId']) + .execute(); +}; + const getClaimQuestRewardPayload = async ({ con, userId, @@ -596,6 +657,7 @@ export const typeDefs = /* GraphQL */ ` level: QuestLevel! currentStreak: Int! longestStreak: Int! + hasNewQuestRotations: Boolean! daily: QuestBucket! weekly: QuestBucket! milestone: [UserQuest!]! @@ -633,6 +695,7 @@ export const typeDefs = /* GraphQL */ ` extend type Mutation { claimQuestReward(userQuestId: ID!): ClaimQuestRewardPayload! @auth + markQuestRotationsViewed: EmptyResponse! @auth trackQuestEvent(eventType: ClientQuestEventType!): EmptyResponse! @auth } @@ -697,6 +760,21 @@ export const resolvers: IResolvers = { return dashboard; }, + markQuestRotationsViewed: async ( + _, + __, + ctx: AuthContext, + ): Promise => { + await saveQuestRotationsViewedAt({ + con: ctx.con.manager, + userId: ctx.userId, + now: new Date(), + }); + + return { + _: true, + }; + }, trackQuestEvent: async ( _, { eventType }: { eventType: QuestEventType },