Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions __tests__/quests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ query QuestDashboard {
questDashboard {
currentStreak
longestStreak
hasNewQuestRotations
daily {
regular {
rotationId
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down
3 changes: 3 additions & 0 deletions src/entity/user/UserQuestProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions src/migration/1776100000000-QuestRotationSeenAt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class QuestRotationSeenAt1776100000000 implements MigrationInterface {
name = 'QuestRotationSeenAt1776100000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(/* sql */ `
ALTER TABLE "user_quest_profile"
ADD COLUMN "lastViewedQuestRotationsAt" TIMESTAMP
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(/* sql */ `
ALTER TABLE "user_quest_profile"
DROP COLUMN "lastViewedQuestRotationsAt"
`);
}
}
144 changes: 111 additions & 33 deletions src/schema/quests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type GQLQuestDashboard = {
level: GQLQuestLevel;
currentStreak: number;
longestStreak: number;
hasNewQuestRotations: boolean;
daily: GQLQuestBucket;
weekly: GQLQuestBucket;
milestone: GQLUserQuest[];
Expand Down Expand Up @@ -259,6 +260,30 @@ const getQuestStreaks = async ({
};
};

const getLatestActiveQuestRotationCreatedAt = async ({
con,
now,
}: {
con: EntityManager;
now: Date;
}): Promise<Date | null> => {
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,
Expand All @@ -270,50 +295,86 @@ const getQuestDashboard = async ({
isPlus: boolean;
now: Date;
}): Promise<GQLQuestDashboard> => {
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<void> => {
await con
.createQueryBuilder()
.insert()
.into(UserQuestProfile)
.values({
userId,
lastViewedQuestRotationsAt: now,
})
.orUpdate(['lastViewedQuestRotationsAt'], ['userId'])
.execute();
};

const getClaimQuestRewardPayload = async ({
con,
userId,
Expand Down Expand Up @@ -596,6 +657,7 @@ export const typeDefs = /* GraphQL */ `
level: QuestLevel!
currentStreak: Int!
longestStreak: Int!
hasNewQuestRotations: Boolean!
daily: QuestBucket!
weekly: QuestBucket!
milestone: [UserQuest!]!
Expand Down Expand Up @@ -633,6 +695,7 @@ export const typeDefs = /* GraphQL */ `

extend type Mutation {
claimQuestReward(userQuestId: ID!): ClaimQuestRewardPayload! @auth
markQuestRotationsViewed: EmptyResponse! @auth
trackQuestEvent(eventType: ClientQuestEventType!): EmptyResponse! @auth
}

Expand Down Expand Up @@ -697,6 +760,21 @@ export const resolvers: IResolvers<unknown, BaseContext> = {

return dashboard;
},
markQuestRotationsViewed: async (
_,
__,
ctx: AuthContext,
): Promise<GQLEmptyResponse> => {
await saveQuestRotationsViewedAt({
con: ctx.con.manager,
userId: ctx.userId,
now: new Date(),
});

return {
_: true,
};
},
trackQuestEvent: async (
_,
{ eventType }: { eventType: QuestEventType },
Expand Down
Loading