diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index 5b3200f1add5..fd6a8eeaccdf 100644 --- a/backend/__tests__/__integration__/dal/user.spec.ts +++ b/backend/__tests__/__integration__/dal/user.spec.ts @@ -755,6 +755,8 @@ describe("UserDal", () => { const uid = new ObjectId().toHexString(); await UserDAL.addUser("test name", "test email", uid); + await UserDAL.updateChallenge(uid, "69"); + await UserDAL.updateProfile( uid, { @@ -793,6 +795,7 @@ describe("UserDal", () => { lastResultTimestamp: 0, maxLength: 0, }); + expect(resetUser.challenges).toStrictEqual({}); }); it("getInbox should return the user's inbox", async () => { @@ -1271,7 +1274,7 @@ describe("UserDal", () => { describe("linkDiscord", () => { it("throws for nonexisting user", async () => { await expect(async () => - UserDAL.linkDiscord("unknown", "", ""), + UserDAL.linkDiscord("unknown", "", "", {}), ).rejects.toThrow("User not found\nStack: link discord"); }); it("should update", async () => { @@ -1279,14 +1282,18 @@ describe("UserDal", () => { const { uid } = await UserTestData.createUser({ discordId: "discordId", discordAvatar: "discordAvatar", + challenges: { + "100hours": {}, + }, }); //when - await UserDAL.linkDiscord(uid, "newId", "newAvatar"); + await UserDAL.linkDiscord(uid, "newId", "newAvatar", { "250hours": {} }); //then const read = await UserDAL.getUser(uid, "read"); expect(read.discordId).toEqual("newId"); expect(read.discordAvatar).toEqual("newAvatar"); + expect(read.challenges).toEqual({ "250hours": {} }); }); it("should update without avatar", async () => { //given @@ -1312,9 +1319,13 @@ describe("UserDal", () => { }); it("should update", async () => { //given - const { uid } = await UserTestData.createUser({ + const { uid, challenges } = await UserTestData.createUser({ discordId: "discordId", discordAvatar: "discordAvatar", + challenges: { + "100hours": {}, + "250hours": { addedAt: Date.now() }, + }, }); //when @@ -1324,6 +1335,36 @@ describe("UserDal", () => { const read = await UserDAL.getUser(uid, "read"); expect(read.discordId).toBeUndefined(); expect(read.discordAvatar).toBeUndefined(); + expect(read.challenges).toEqual(challenges); + }); + }); + + describe("updateChallenge", () => { + it("throws for nonexisting user", async () => { + await expect(async () => + UserDAL.updateChallenge("unknown", "69"), + ).rejects.toThrow("User not found\nStack: update challenge"); + }); + it("should update", async () => { + //given + vi.useFakeTimers(); + const { uid } = await UserTestData.createUser({ + challenges: { + "100hours": {}, + "250hours": { addedAt: 1 }, + }, + }); + + //when + await UserDAL.updateChallenge(uid, "69"); + + //then + const read = await UserDAL.getUser(uid, "read"); + expect(read.challenges).toEqual({ + "100hours": {}, + "250hours": { addedAt: 1 }, + "69": { addedAt: Date.now() }, + }); }); }); describe("updateInbox", () => { diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index 516f66549f3b..03efe1a9f22f 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -5,12 +5,14 @@ import * as ResultDal from "../../../src/dal/result"; import * as UserDal from "../../../src/dal/user"; import * as LogsDal from "../../../src/dal/logs"; import * as PublicDal from "../../../src/dal/public"; +import * as GeorgeQueue from "../../../src/queues/george-queue"; import { ObjectId } from "mongodb"; import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; import { enableRateLimitExpects } from "../../__testData__/rate-limit"; import { DBResult } from "../../../src/utils/result"; import { omit } from "../../../src/utils/misc"; import { CompletedEvent } from "@monkeytype/schemas/results"; +import * as ChallengeVerify from "@monkeytype/challenges/verify"; const { mockApp, uid, mockAuth } = setup(); const configuration = Configuration.getCachedConfiguration(); @@ -583,8 +585,14 @@ describe("result controller test", () => { const userCheckIfPbMock = vi.spyOn(UserDal, "checkIfPb"); const userIncrementXpMock = vi.spyOn(UserDal, "incrementXp"); const userUpdateTypingStatsMock = vi.spyOn(UserDal, "updateTypingStats"); + const userUpdateChallengeMock = vi.spyOn(UserDal, "updateChallenge"); + const georgeAwardChallengeMock = vi.spyOn( + GeorgeQueue.default, + "awardChallenge", + ); const resultAddMock = vi.spyOn(ResultDal, "addResult"); const publicUpdateStatsMock = vi.spyOn(PublicDal, "updateStats"); + const challengeVerifyMock = vi.spyOn(ChallengeVerify, "verify"); beforeEach(async () => { await enableResultsSaving(true); @@ -597,16 +605,22 @@ describe("result controller test", () => { userCheckIfPbMock, userIncrementXpMock, userUpdateTypingStatsMock, + userUpdateChallengeMock, + georgeAwardChallengeMock, resultAddMock, publicUpdateStatsMock, + challengeVerifyMock, ].forEach((it) => it.mockClear()); userGetMock.mockResolvedValue({ name: "bob" } as any); userUpdateStreakMock.mockResolvedValue(0); userCheckIfTagPbMock.mockResolvedValue([]); userCheckIfPbMock.mockResolvedValue(true); + userUpdateChallengeMock.mockResolvedValue(); + georgeAwardChallengeMock.mockResolvedValue(); resultAddMock.mockResolvedValue({ insertedId }); userIncrementXpMock.mockResolvedValue(); + challengeVerifyMock.mockReturnValue({ state: "success" } as any); }); it("should add result", async () => { @@ -687,6 +701,123 @@ describe("result controller test", () => { 15.1 + 2 - 5, //duration + incompleteTestSeconds-afk ); }); + + it("should add result with challenge", async () => { + //GIVEN + userGetMock.mockClear(); + userGetMock.mockResolvedValue({ + uid, + name: "bob", + discordId: "discordId", + } as any); + + const completedEvent = buildCompletedEvent({ + challenge: "69", + }); + //WHEN + await mockApp + .post("/results") + .set("Authorization", `Bearer ${uid}`) + .send({ + result: completedEvent, + }) + .expect(200); + + //THEN + expect(userUpdateChallengeMock).toHaveBeenCalledWith(uid, "69"); + expect(georgeAwardChallengeMock).toHaveBeenCalledWith("discordId", "69"); + }); + + it("should not add challenge if not auto-role", async () => { + //GIVEN + userGetMock.mockClear(); + userGetMock.mockResolvedValue({ + uid, + name: "bob", + discordId: "discordId", + } as any); + + const completedEvent = buildCompletedEvent({ + challenge: "roleAddict", + }); + //WHEN + await mockApp + .post("/results") + .set("Authorization", `Bearer ${uid}`) + .send({ + result: completedEvent, + }) + .expect(200); + + //THEN + expect(userUpdateChallengeMock).not.toHaveBeenCalled(); + expect(georgeAwardChallengeMock).not.toHaveBeenCalled(); + }); + it("should dd challenge without discord connected", async () => { + const completedEvent = buildCompletedEvent({ + challenge: "69", + }); + //WHEN + await mockApp + .post("/results") + .set("Authorization", `Bearer ${uid}`) + .send({ + result: completedEvent, + }) + .expect(200); + + //THEN + expect(userUpdateChallengeMock).toHaveBeenCalledWith(uid, "69"); + expect(georgeAwardChallengeMock).not.toHaveBeenCalled(); + }); + it("should fail when challenge verification fails", async () => { + //GIVEN + challengeVerifyMock.mockReturnValue({ + state: "failed", + reason: "Challenge failed: WPM below 69", + } as any); + + //WHEN + const { body } = await mockApp + .post("/results") + .set("Authorization", `Bearer ${uid}`) + .send({ + result: buildCompletedEvent({ + challenge: "69", + }), + }) + .expect(400); + + //THEN + expect(body.message).toEqual("Challenge failed: WPM below 69"); + expect(userUpdateChallengeMock).not.toHaveBeenCalled(); + expect(georgeAwardChallengeMock).not.toHaveBeenCalled(); + }); + + it("should fail when challenge verification has error", async () => { + //GIVEN + challengeVerifyMock.mockReturnValue({ + state: "error", + errorMessage: "failed to verify challenge", + } as any); + + //WHEN + const { body } = await mockApp + .post("/results") + .set("Authorization", `Bearer ${uid}`) + .send({ + result: buildCompletedEvent({ + challenge: "69", + }), + }) + .expect(500); + + //THEN + expect(body.message).toEqual("failed to verify challenge"); + expect(userUpdateChallengeMock).not.toHaveBeenCalled(); + expect(georgeAwardChallengeMock).not.toHaveBeenCalled(); + }); + it("should fail if result saving is disabled", async () => { //GIVEN await enableResultsSaving(false); diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 17e9ac1f64bb..9bbec3c6edf5 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -36,6 +36,7 @@ import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboar import * as ConnectionsDal from "../../../src/dal/connections"; import { pb } from "../../__testData__/users"; import Test from "supertest/lib/test"; +import { getChallenge } from "@monkeytype/challenges"; const { mockApp, uid, mockAuth } = setup(); const configuration = Configuration.getCachedConfiguration(); @@ -729,6 +730,23 @@ describe("user controller test", () => { //THEN expect(blocklistAddMock).not.toHaveBeenCalled(); + + expect(deleteUserMock).toHaveBeenCalledWith(uid); + expect(firebaseDeleteUserMock).toHaveBeenCalledWith(uid); + expect(deleteAllApeKeysMock).toHaveBeenCalledWith(uid); + expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); + expect(deleteConfigMock).toHaveBeenCalledWith(uid); + expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); + expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( + uid, + (await configuration).dailyLeaderboards, + ); + expect(purgeUserFromXpLeaderboardsMock).toHaveBeenCalledWith( + uid, + (await configuration).leaderboards.weeklyXp, + ); + expect(logsDeleteUserMock).toHaveBeenCalledWith(uid); }); it("should not fail if userInfo cannot be found", async () => { @@ -1557,7 +1575,7 @@ describe("user controller test", () => { it("should get oauth link", async () => { //WHEN const { body } = await mockApp - .get("/users/discord/oauth") + .get("/users/discord/oauth?includeRoles=true") .set("Authorization", `Bearer ${uid}`) .expect(200); @@ -1566,7 +1584,9 @@ describe("user controller test", () => { message: "Discord oauth link generated", data: { url }, }); - expect(getOauthLinkMock).toHaveBeenCalledWith(uid); + expect(getOauthLinkMock).toHaveBeenCalledWith(uid, { + includeRoles: true, + }); }); it("should fail if feature is not enabled", async () => { //GIVEN @@ -1592,18 +1612,24 @@ describe("user controller test", () => { "iStateValidForUser", ); const getDiscordUserMock = vi.spyOn(DiscordUtils, "getDiscordUser"); + const getDiscordRoleIdsMock = vi.spyOn(DiscordUtils, "getDiscordRoleIds"); const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); const userLinkDiscordMock = vi.spyOn(UserDal, "linkDiscord"); const georgeLinkDiscordMock = vi.spyOn(GeorgeQueue, "linkDiscord"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(async () => { + vi.useFakeTimers(); isStateValidForUserMock.mockResolvedValue(true); getUserMock.mockResolvedValue({} as any); getDiscordUserMock.mockResolvedValue({ id: "discordUserId", avatar: "discordUserAvatar", }); + getDiscordRoleIdsMock.mockResolvedValue([ + getChallenge("100hours").discordRoleId, + getChallenge("250hours").discordRoleId, + ]); isDiscordIdAvailableMock.mockResolvedValue(true); blocklistContainsMock.mockResolvedValue(false); userLinkDiscordMock.mockResolvedValue(); @@ -1615,15 +1641,18 @@ describe("user controller test", () => { isStateValidForUserMock, isDiscordIdAvailableMock, getDiscordUserMock, + getDiscordRoleIdsMock, blocklistContainsMock, userLinkDiscordMock, georgeLinkDiscordMock, addImportantLogMock, ].forEach((it) => it.mockClear()); + vi.useRealTimers(); }); it("should link discord", async () => { //GIVEN + getUserMock.mockResolvedValue({} as any); //WHEN @@ -1634,6 +1663,7 @@ describe("user controller test", () => { tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", + scope: ["scopeOne", "scopeTwo"], }) .expect(200); @@ -1643,6 +1673,10 @@ describe("user controller test", () => { data: { discordId: "discordUserId", discordAvatar: "discordUserAvatar", + challenges: { + "100hours": { addedAt: Date.now() }, + "250hours": { addedAt: Date.now() }, + }, }, }); expect(isStateValidForUserMock).toHaveBeenCalledWith( @@ -1658,6 +1692,11 @@ describe("user controller test", () => { "tokenType", "accessToken", ); + expect(getDiscordRoleIdsMock).toHaveBeenCalledWith( + "tokenType", + "accessToken", + ["scopeOne", "scopeTwo"], + ); expect(isDiscordIdAvailableMock).toHaveBeenCalledWith("discordUserId"); expect(blocklistContainsMock).toHaveBeenCalledWith({ discordId: "discordUserId", @@ -1666,6 +1705,10 @@ describe("user controller test", () => { uid, "discordUserId", "discordUserAvatar", + { + "100hours": { addedAt: Date.now() }, + "250hours": { addedAt: Date.now() }, + }, ); expect(georgeLinkDiscordMock).toHaveBeenCalledWith( "discordUserId", @@ -1681,7 +1724,10 @@ describe("user controller test", () => { it("should update existing discord avatar", async () => { //GIVEN - getUserMock.mockResolvedValue({ discordId: "existingDiscordId" } as any); + getUserMock.mockResolvedValue({ + discordId: "existingDiscordId", + challenges: { "100hours": { addedAt: 1 } }, + } as any); //WHEN const { body } = await mockApp @@ -1700,12 +1746,20 @@ describe("user controller test", () => { data: { discordId: "discordUserId", discordAvatar: "discordUserAvatar", + challenges: { + "100hours": { addedAt: 1 }, //existing + "250hours": { addedAt: Date.now() }, //newly added + }, }, }); expect(userLinkDiscordMock).toHaveBeenCalledWith( uid, "existingDiscordId", "discordUserAvatar", + { + "100hours": { addedAt: 1 }, //existing + "250hours": { addedAt: Date.now() }, //newly added + }, ); expect(isDiscordIdAvailableMock).not.toHaveBeenCalled(); expect(blocklistContainsMock).not.toHaveBeenCalled(); @@ -2967,6 +3021,9 @@ describe("user controller test", () => { testActivity: { "2024": fillYearWithDay(94), }, + challenges: { + "100hours": { addedAt: 1 }, + }, }; beforeEach(async () => { @@ -3038,12 +3095,15 @@ describe("user controller test", () => { expect(getUserByNameMock).toHaveBeenCalledWith("bob", "get user profile"); expect(getUserMock).not.toHaveBeenCalled(); }); - it("should get testActivity if enabled", async () => { + it("should get testActivity/challenges if enabled", async () => { //GIVEN vi.useFakeTimers().setSystemTime(1712102400000); getUserByNameMock.mockResolvedValue({ ...foundUser, - profileDetails: { showActivityOnPublicProfile: true }, + profileDetails: { + showActivityOnPublicProfile: true, + showChallengesOnPublicProfile: true, + }, } as any); const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry; leaderboardGetRankMock.mockResolvedValue(rank); @@ -3059,13 +3119,18 @@ describe("user controller test", () => { testsByDays: expect.arrayContaining([]), }), ); + + expect(body.data.challenges).toEqual({ "100hours": { addedAt: 1 } }); }); it("should not get testActivity if disabled", async () => { //GIVEN vi.useFakeTimers().setSystemTime(1712102400000); getUserByNameMock.mockResolvedValue({ ...foundUser, - profileDetails: { showActivityOnPublicProfile: false }, + profileDetails: { + showActivityOnPublicProfile: false, + showChallengesOnPublicProfile: false, + }, } as any); const rank = { rank: 24 } as LeaderboardDal.DBLeaderboardEntry; leaderboardGetRankMock.mockResolvedValue(rank); @@ -3076,6 +3141,7 @@ describe("user controller test", () => { //THEN expect(body.data.testActivity).toBeUndefined(); + expect(body.data.challenges).toBeUndefined(); }); it("should get base profile for banned user", async () => { @@ -3193,6 +3259,7 @@ describe("user controller test", () => { website: "https://monkeytype.com", }, showActivityOnPublicProfile: false, + showChallengesOnPublicProfile: false, }; //WHEN @@ -3221,6 +3288,7 @@ describe("user controller test", () => { website: "https://monkeytype.com", }, showActivityOnPublicProfile: false, + showChallengesOnPublicProfile: false, }, { badges: [{ id: 4 }, { id: 2, selected: true }, { id: 3 }], diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index d56ad0c0c54d..4e70a6b1fcf1 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -64,6 +64,7 @@ import { getFunbox, checkCompatibility } from "@monkeytype/funbox"; import { tryCatch } from "@monkeytype/util/trycatch"; import { getCachedConfiguration } from "../../init/configuration"; import { getChallenges } from "@monkeytype/challenges"; +import { verify as verifyChallenge } from "@monkeytype/challenges/verify"; try { if (!anticheatImplemented()) throw new Error("undefined"); @@ -465,11 +466,28 @@ export async function addResult( if ( completedEvent.challenge !== null && completedEvent.challenge !== undefined && - autoRoleChallengeNames.has(completedEvent.challenge) && - user.discordId !== undefined && - user.discordId !== "" + autoRoleChallengeNames.has(completedEvent.challenge) ) { - void GeorgeQueue.awardChallenge(user.discordId, completedEvent.challenge); + const verification = verifyChallenge(completedEvent); + switch (verification.state) { + case "success": + { + await UserDAL.updateChallenge(uid, completedEvent.challenge); + if (user.discordId !== undefined && user.discordId !== "") { + void GeorgeQueue.awardChallenge( + user.discordId, + completedEvent.challenge, + ); + } + } + break; + case "failed": { + throw new MonkeyError(400, verification.reason); + } + case "error": { + throw new MonkeyError(500, verification.errorMessage); + } + } } else { delete completedEvent.challenge; } diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 80baab7dfb71..41576dc0ba73 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -39,6 +39,7 @@ import { CountByYearAndDay, TestActivity, UserProfileDetails, + UserChallenges, } from "@monkeytype/schemas/users"; import { addImportantLog, addLog, deleteUserLogs } from "../../dal/logs"; import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth"; @@ -59,6 +60,7 @@ import { ForgotPasswordEmailRequest, GetCurrentTestActivityResponse, GetCustomThemesResponse, + GetDiscordOauthLinkQuery, GetDiscordOauthLinkResponse, GetFavoriteQuotesResponse, GetFriendsResponse, @@ -94,6 +96,15 @@ import { tryCatch } from "@monkeytype/util/trycatch"; import * as ConnectionsDal from "../../dal/connections"; import { PersonalBest } from "@monkeytype/schemas/shared"; +import { ChallengeName } from "@monkeytype/schemas/challenges"; +import { getChallenges } from "@monkeytype/challenges"; + +const challengeNameByRoleId: Record = Object.fromEntries( + getChallenges() + .filter((it) => it.discordRoleId !== undefined) + .map((it) => [it.discordRoleId, it.name]), +); + async function verifyCaptcha(captcha: string): Promise { const { data: verified, error } = await tryCatch(verify(captcha)); if (error) { @@ -635,12 +646,13 @@ export async function getUser(req: MonkeyRequest): Promise { } export async function getOauthLink( - req: MonkeyRequest, + req: MonkeyRequest, ): Promise { const { uid } = req.ctx.decodedToken; + const { includeRoles } = req.query; //build the url - const url = await DiscordUtils.getOauthLink(uid); + const url = await DiscordUtils.getOauthLink(uid, { includeRoles }); //return return new MonkeyResponse("Discord oauth link generated", { @@ -652,7 +664,7 @@ export async function linkDiscord( req: MonkeyRequest, ): Promise { const { uid } = req.ctx.decodedToken; - const { tokenType, accessToken, state } = req.body; + const { tokenType, accessToken, state, scope } = req.body; if (!(await DiscordUtils.iStateValidForUser(state, uid))) { throw new MonkeyError(403, "Invalid user token"); @@ -662,6 +674,7 @@ export async function linkDiscord( "banned", "discordId", "lbOptOut", + "challenges", ]); if (userInfo.banned) { throw new MonkeyError(403, "Banned accounts cannot link with Discord"); @@ -670,11 +683,33 @@ export async function linkDiscord( const { id: discordId, avatar: discordAvatar } = await DiscordUtils.getDiscordUser(tokenType, accessToken); + let roles = await DiscordUtils.getDiscordRoleIds( + tokenType, + accessToken, + scope, + ); + + const challenges: UserChallenges = Object.fromEntries( + roles + .map((roleId) => challengeNameByRoleId[roleId]) + .filter((it) => it !== undefined) + .map((it) => [ + it, + { addedAt: userInfo.challenges?.[it]?.addedAt ?? Date.now() }, + ]), + ); + if (userInfo.discordId !== undefined && userInfo.discordId !== "") { - await UserDAL.linkDiscord(uid, userInfo.discordId, discordAvatar); + await UserDAL.linkDiscord( + uid, + userInfo.discordId, + discordAvatar, + challenges, + ); return new MonkeyResponse("Discord avatar updated", { discordId, discordAvatar, + challenges, }); } @@ -698,7 +733,7 @@ export async function linkDiscord( throw new MonkeyError(409, "The Discord account is blocked"); } - await UserDAL.linkDiscord(uid, discordId, discordAvatar); + await UserDAL.linkDiscord(uid, discordId, discordAvatar, challenges); await GeorgeQueue.linkDiscord(discordId, uid, userInfo.lbOptOut ?? false); void addImportantLog("user_discord_link", `linked to ${discordId}`, uid); @@ -706,6 +741,7 @@ export async function linkDiscord( return new MonkeyResponse("Discord account linked", { discordId, discordAvatar, + challenges, }); } @@ -1012,6 +1048,13 @@ export async function getProfile( } else { delete profileData.testActivity; } + + if (user.profileDetails?.showChallengesOnPublicProfile) { + profileData.challenges = user.challenges; + } else { + delete profileData.challenges; + } + return new MonkeyResponse("Profile retrieved", profileData); } @@ -1025,6 +1068,7 @@ export async function updateProfile( socialProfiles, selectedBadgeId, showActivityOnPublicProfile, + showChallengesOnPublicProfile, } = req.body; const user = await UserDAL.getPartialUser(uid, "update user profile", [ @@ -1054,6 +1098,7 @@ export async function updateProfile( ]), ), showActivityOnPublicProfile, + showChallengesOnPublicProfile, }; await UserDAL.updateProfile(uid, profileDetailsUpdates, user.inventory); diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index ada92f0ee764..c7c7f626bca9 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -26,6 +26,7 @@ import { User, CountByYearAndDay, Friend, + UserChallenges, } from "@monkeytype/schemas/users"; import { Mode, @@ -39,6 +40,7 @@ import { Configuration } from "@monkeytype/schemas/configuration"; import { isToday, isYesterday } from "@monkeytype/util/date-and-time"; import GeorgeQueue from "../queues/george-queue"; import { aggregateWithAcceptedConnections } from "./connections"; +import { ChallengeName } from "@monkeytype/schemas/challenges"; export type DBUserTag = WithObjectId; @@ -149,6 +151,7 @@ export async function resetUser(uid: string): Promise { maxLength: 0, }, testActivity: {}, + challenges: {}, }, $unset: { discordAvatar: "", @@ -613,11 +616,15 @@ export async function linkDiscord( uid: string, discordId: string, discordAvatar?: string, + challenges?: UserChallenges, ): Promise { const updates: Partial = { discordId }; if (discordAvatar !== undefined && discordAvatar !== null) { updates.discordAvatar = discordAvatar; } + if (challenges !== undefined) { + updates.challenges = challenges; + } await updateUser({ uid }, { $set: updates }, { stack: "link discord" }); } @@ -630,6 +637,17 @@ export async function unlinkDiscord(uid: string): Promise { ); } +export async function updateChallenge( + uid: string, + challengeName: ChallengeName, +): Promise { + await updateUser( + { uid }, + { $set: { [`challenges.${challengeName}`]: { addedAt: Date.now() } } }, + { stack: "update challenge" }, + ); +} + export async function incrementBananas( uid: string, wpm: number, diff --git a/backend/src/utils/discord.ts b/backend/src/utils/discord.ts index e290c02d5f75..b2533e2db581 100644 --- a/backend/src/utils/discord.ts +++ b/backend/src/utils/discord.ts @@ -6,16 +6,27 @@ import { z } from "zod"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; const BASE_URL = "https://discord.com/api"; +const CLIENT_ID = "798272335035498557"; +const SERVER_ID = "713194177403420752"; +const READ_ROLE_SCOPE = "guilds.members.read"; -const DiscordIdAndAvatarSchema = z.object({ - id: z.string(), - avatar: z - .string() - .optional() - .or(z.null().transform(() => undefined)), -}); +const DiscordIdAndAvatarSchema = z + .object({ + id: z.string(), + avatar: z + .string() + .optional() + .or(z.null().transform(() => undefined)), + }) + .strip(); type DiscordIdAndAvatar = z.infer; +const DiscordGuildMemberSchema = z + .object({ + roles: z.array(z.string()), + }) + .strip(); + export async function getDiscordUser( tokenType: string, accessToken: string, @@ -34,21 +45,51 @@ export async function getDiscordUser( return parsed; } -export async function getOauthLink(uid: string): Promise { +export async function getDiscordRoleIds( + tokenType: string, + accessToken: string, + scope?: string[], +): Promise { + if (!scope?.includes(READ_ROLE_SCOPE)) return []; + + const response = await fetch( + `${BASE_URL}/users/@me/guilds/${SERVER_ID}/member`, + { + headers: { + authorization: `${tokenType} ${accessToken}`, + }, + }, + ); + + const parsed = parseJsonWithSchema( + await response.text(), + DiscordGuildMemberSchema, + ); + + return parsed.roles; +} + +export async function getOauthLink( + uid: string, + options: { includeRoles?: boolean }, +): Promise { const connection = RedisClient.getConnection(); if (!connection) { throw new MonkeyError(500, "Redis connection not found"); } const token = randomBytes(10).toString("hex"); + const scope = ["identify"]; + + if (options.includeRoles) scope.push(READ_ROLE_SCOPE); - //add the token uid pair to reids + //add the token uid pair to redis await connection.setex(`discordoauth:${uid}`, 60, token); - return `${BASE_URL}/oauth2/authorize?client_id=798272335035498557&redirect_uri=${ + return `${BASE_URL}/oauth2/authorize?client_id=${CLIENT_ID}&redirect_uri=${ isDevEnvironment() ? `http%3A%2F%2Flocalhost%3A3000%2Fverify` : `https%3A%2F%2Fmonkeytype.com%2Fverify` - }&response_type=token&scope=identify&state=${token}`; + }&response_type=token&scope=${scope.join("+")}&state=${token}`; } export async function iStateValidForUser( diff --git a/frontend/src/ts/commandline/commandline-metadata.ts b/frontend/src/ts/commandline/commandline-metadata.ts index 5c8af2d95cfa..01a0ba48e032 100644 --- a/frontend/src/ts/commandline/commandline-metadata.ts +++ b/frontend/src/ts/commandline/commandline-metadata.ts @@ -6,7 +6,7 @@ import { replaceUnderscoresWithSpaces, } from "../utils/strings"; -import { areUnsortedArraysEqual } from "../utils/arrays"; +import { areUnsortedArraysEqual } from "@monkeytype/util/arrays"; import { Config } from "../config/store"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; import { getActivePage, isAuthenticated } from "../states/core"; diff --git a/frontend/src/ts/commandline/commandline.ts b/frontend/src/ts/commandline/commandline.ts index 6ef92d7e36d1..5659b1f8ec2d 100644 --- a/frontend/src/ts/commandline/commandline.ts +++ b/frontend/src/ts/commandline/commandline.ts @@ -19,10 +19,13 @@ import { CommandsSubgroup, CommandWithValidation, } from "./types"; -import { areSortedArraysEqual, areUnsortedArraysEqual } from "../utils/arrays"; import { parseIntOptional } from "../utils/numbers"; import { debounce } from "throttle-debounce"; -import { intersect } from "@monkeytype/util/arrays"; +import { + areSortedArraysEqual, + areUnsortedArraysEqual, + intersect, +} from "@monkeytype/util/arrays"; import { createInputEventHandler } from "../elements/input-validation"; import { isInputElementFocused } from "../input/input-element"; import { qs } from "../utils/dom"; diff --git a/frontend/src/ts/components/modals/EditProfileModal.tsx b/frontend/src/ts/components/modals/EditProfileModal.tsx index 23c8cc30b968..4cec57df00d1 100644 --- a/frontend/src/ts/components/modals/EditProfileModal.tsx +++ b/frontend/src/ts/components/modals/EditProfileModal.tsx @@ -1,6 +1,7 @@ import { GithubProfileSchema, TwitterProfileSchema, + UserProfileDetails, UserProfileDetailsSchema, WebsiteSchema, } from "@monkeytype/schemas/users"; @@ -39,11 +40,13 @@ export function EditProfile() { twitter: snapshot.details?.socialProfiles?.twitter ?? "", website: snapshot.details?.socialProfiles?.website ?? "", showActivityOnPublicProfile: - snapshot.details?.showActivityOnPublicProfile ?? true, + snapshot.details?.showActivityOnPublicProfile ?? false, badgeId: badges.find((b) => b.selected)?.id ?? -1, + showChallengesOnPublicProfile: + snapshot.details?.showChallengesOnPublicProfile ?? false, }, onSubmit: async ({ value }) => { - const updates = { + const updates: UserProfileDetails = { bio: value.bio, keyboard: value.keyboard, socialProfiles: { @@ -52,6 +55,7 @@ export function EditProfile() { website: value.website, }, showActivityOnPublicProfile: value.showActivityOnPublicProfile, + showChallengesOnPublicProfile: value.showActivityOnPublicProfile, }; const response = await Ape.users.updateProfile({ @@ -258,6 +262,18 @@ export function EditProfile() { +
+ + + {(field) => ( + + )} + +
+ save diff --git a/frontend/src/ts/components/modals/EditResultTagsModal.tsx b/frontend/src/ts/components/modals/EditResultTagsModal.tsx index 2146e173ea29..2c128d9f5542 100644 --- a/frontend/src/ts/components/modals/EditResultTagsModal.tsx +++ b/frontend/src/ts/components/modals/EditResultTagsModal.tsx @@ -1,3 +1,4 @@ +import { areUnsortedArraysEqual } from "@monkeytype/util/arrays"; import { createEffect, createSignal, For } from "solid-js"; import { updateTags } from "../../collections/results"; @@ -10,7 +11,6 @@ import { showSuccessNotification, } from "../../states/notifications"; import { updateTagsAfterEdit } from "../../test/result"; -import { areUnsortedArraysEqual } from "../../utils/arrays"; import { createErrorMessage } from "../../utils/error"; import { AnimatedModal } from "../common/AnimatedModal"; import { Button } from "../common/Button"; diff --git a/frontend/src/ts/components/modals/MobileTestConfigModal.tsx b/frontend/src/ts/components/modals/MobileTestConfigModal.tsx index dd16f99e979c..a5636b9c9693 100644 --- a/frontend/src/ts/components/modals/MobileTestConfigModal.tsx +++ b/frontend/src/ts/components/modals/MobileTestConfigModal.tsx @@ -4,6 +4,7 @@ import type { } from "@monkeytype/schemas/configs"; import type { Mode } from "@monkeytype/schemas/shared"; +import { areUnsortedArraysEqual } from "@monkeytype/util/arrays"; import { For, JSXElement, Show } from "solid-js"; import { setConfig, setQuoteLengthAll } from "../../config/setters"; @@ -11,7 +12,6 @@ import { getConfig } from "../../config/store"; import { restartTestEvent } from "../../events/test"; import { isAuthenticated } from "../../states/core"; import { showModal } from "../../states/modals"; -import { areUnsortedArraysEqual } from "../../utils/arrays"; import { AnimatedModal } from "../common/AnimatedModal"; import { Button } from "../common/Button"; import { Separator } from "../common/Separator"; diff --git a/frontend/src/ts/components/pages/account-settings/AccountTab.tsx b/frontend/src/ts/components/pages/account-settings/AccountTab.tsx index 99e8ee1350a9..7fa26e79d323 100644 --- a/frontend/src/ts/components/pages/account-settings/AccountTab.tsx +++ b/frontend/src/ts/components/pages/account-settings/AccountTab.tsx @@ -47,15 +47,17 @@ function Discord() { text: "link", onClick: () => { showLoaderBar(); - void Ape.users.getDiscordOAuth().then((response) => { - if (response.status === 200) { - window.open(response.body.data.url, "_self"); - } else { - showErrorNotification( - `Failed to get OAuth from discord: ${response.body.message}`, - ); - } - }); + void Ape.users + .getDiscordOAuth({ query: { includeRoles: true } }) + .then((response) => { + if (response.status === 200) { + window.open(response.body.data.url, "_self"); + } else { + showErrorNotification( + `Failed to get OAuth from discord: ${response.body.message}`, + ); + } + }); }, } } @@ -72,15 +74,17 @@ function Discord() { fa={{ icon: "fa-sync-alt" }} onClick={() => { showLoaderBar(); - void Ape.users.getDiscordOAuth().then((response) => { - if (response.status === 200) { - window.open(response.body.data.url, "_self"); - } else { - showErrorNotification( - `Failed to get OAuth from discord: ${response.body.message}`, - ); - } - }); + void Ape.users + .getDiscordOAuth({ query: { includeRoles: true } }) + .then((response) => { + if (response.status === 200) { + window.open(response.body.data.url, "_self"); + } else { + showErrorNotification( + `Failed to get OAuth from discord: ${response.body.message}`, + ); + } + }); }} />