From cb042567c135d809c743344f5c9933f98d5dbcd3 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 6 Jul 2026 00:00:01 +0200 Subject: [PATCH 1/4] feat: add challenges (@fehmer, @ShizukoV) --- .../__integration__/dal/user.spec.ts | 47 ++- .../__tests__/api/controllers/result.spec.ts | 80 ++++ .../__tests__/api/controllers/user.spec.ts | 80 +++- backend/src/api/controllers/result.ts | 9 +- backend/src/api/controllers/user.ts | 55 ++- backend/src/dal/user.ts | 18 + backend/src/utils/discord.ts | 63 ++- .../ts/components/modals/EditProfileModal.tsx | 20 +- .../pages/account-settings/AccountTab.tsx | 40 +- .../components/pages/profile/Challenges.tsx | 348 ++++++++++++++++ .../components/pages/profile/UserDetails.tsx | 20 +- .../components/pages/profile/UserProfile.tsx | 7 +- frontend/src/ts/controllers/url-handler.tsx | 8 +- frontend/src/ts/db.ts | 1 + frontend/src/ts/states/modals.ts | 3 +- packages/challenges/src/index.ts | 384 +++++++++++++++++- packages/contracts/src/users.ts | 12 +- packages/schemas/src/challenges.ts | 43 ++ packages/schemas/src/users.ts | 12 + 19 files changed, 1191 insertions(+), 59 deletions(-) create mode 100644 frontend/src/ts/components/pages/profile/Challenges.tsx diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index 5f44cffe1d41..1b035c7f7ab1 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..494e109117f9 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -5,6 +5,7 @@ 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"; @@ -583,6 +584,11 @@ 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"); @@ -597,6 +603,8 @@ describe("result controller test", () => { userCheckIfPbMock, userIncrementXpMock, userUpdateTypingStatsMock, + userUpdateChallengeMock, + georgeAwardChallengeMock, resultAddMock, publicUpdateStatsMock, ].forEach((it) => it.mockClear()); @@ -605,6 +613,8 @@ describe("result controller test", () => { userUpdateStreakMock.mockResolvedValue(0); userCheckIfTagPbMock.mockResolvedValue([]); userCheckIfPbMock.mockResolvedValue(true); + userUpdateChallengeMock.mockResolvedValue(); + georgeAwardChallengeMock.mockResolvedValue(); resultAddMock.mockResolvedValue({ insertedId }); userIncrementXpMock.mockResolvedValue(); }); @@ -687,6 +697,76 @@ 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 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 25a036a47fbf..15e31b230001 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -37,6 +37,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(); @@ -730,6 +731,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 () => { @@ -1558,7 +1576,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); @@ -1567,7 +1585,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 @@ -1593,18 +1613,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(); @@ -1616,15 +1642,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 @@ -1635,6 +1664,7 @@ describe("user controller test", () => { tokenType: "tokenType", accessToken: "accessToken", state: "statestatestatestate", + scope: ["scopeOne", "scopeTwo"], }) .expect(200); @@ -1644,6 +1674,10 @@ describe("user controller test", () => { data: { discordId: "discordUserId", discordAvatar: "discordUserAvatar", + challenges: { + "100hours": { addedAt: Date.now() }, + "250hours": { addedAt: Date.now() }, + }, }, }); expect(isStateValidForUserMock).toHaveBeenCalledWith( @@ -1659,6 +1693,11 @@ describe("user controller test", () => { "tokenType", "accessToken", ); + expect(getDiscordRoleIdsMock).toHaveBeenCalledWith( + "tokenType", + "accessToken", + ["scopeOne", "scopeTwo"], + ); expect(isDiscordIdAvailableMock).toHaveBeenCalledWith("discordUserId"); expect(blocklistContainsMock).toHaveBeenCalledWith({ discordId: "discordUserId", @@ -1667,6 +1706,10 @@ describe("user controller test", () => { uid, "discordUserId", "discordUserAvatar", + { + "100hours": { addedAt: Date.now() }, + "250hours": { addedAt: Date.now() }, + }, ); expect(georgeLinkDiscordMock).toHaveBeenCalledWith( "discordUserId", @@ -1682,7 +1725,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 @@ -1701,12 +1747,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(); @@ -2968,6 +3022,9 @@ describe("user controller test", () => { testActivity: { "2024": fillYearWithDay(94), }, + challenges: { + "100hours": { addedAt: 1 }, + }, }; beforeEach(async () => { @@ -3039,12 +3096,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); @@ -3060,13 +3120,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); @@ -3077,6 +3142,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 () => { @@ -3194,6 +3260,7 @@ describe("user controller test", () => { website: "https://monkeytype.com", }, showActivityOnPublicProfile: false, + showChallengesOnPublicProfile: false, }; //WHEN @@ -3222,6 +3289,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..e3514be858c8 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -465,11 +465,12 @@ 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); + await UserDAL.updateChallenge(uid, completedEvent.challenge); + if (user.discordId !== undefined && user.discordId !== "") { + void GeorgeQueue.awardChallenge(user.discordId, completedEvent.challenge); + } } else { delete completedEvent.challenge; } diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index c9eb977bbad4..c131cb46da9d 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/components/modals/EditProfileModal.tsx b/frontend/src/ts/components/modals/EditProfileModal.tsx index 088f8e5ce2de..f0dc7d04ea50 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"; @@ -40,11 +41,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: { @@ -53,6 +56,7 @@ export function EditProfile() { website: value.website || undefined, }, showActivityOnPublicProfile: value.showActivityOnPublicProfile, + showChallengesOnPublicProfile: value.showActivityOnPublicProfile, }; const response = await Ape.users.updateProfile({ @@ -259,6 +263,18 @@ export function EditProfile() { +
+ + + {(field) => ( + + )} + +
+ save 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}`, + ); + } + }); }} />