From 6056111eae2b05ecd58ccfcd4d7b69c3c88da529 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 24 Jun 2025 12:21:46 +0200 Subject: [PATCH 01/28] feat: add friends (@fehmer) --- .../__tests__/api/controllers/friends.spec.ts | 101 ++++++++++++++++++ backend/scripts/openapi.ts | 7 +- backend/src/api/controllers/friends.ts | 40 +++++++ backend/src/api/routes/friends.ts | 18 ++++ backend/src/api/routes/index.ts | 2 + backend/src/constants/base-configuration.ts | 11 ++ packages/contracts/src/friends.ts | 92 ++++++++++++++++ packages/contracts/src/index.ts | 2 + packages/contracts/src/rate-limit/index.ts | 15 +++ packages/contracts/src/schemas/api.ts | 3 +- .../contracts/src/schemas/configuration.ts | 3 + packages/contracts/src/schemas/friends.ts | 17 +++ 12 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/api/controllers/friends.spec.ts create mode 100644 backend/src/api/controllers/friends.ts create mode 100644 backend/src/api/routes/friends.ts create mode 100644 packages/contracts/src/friends.ts create mode 100644 packages/contracts/src/schemas/friends.ts diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts new file mode 100644 index 000000000000..9bae61f5d697 --- /dev/null +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -0,0 +1,101 @@ +import request, { Test as SuperTest } from "supertest"; +import app from "../../../src/app"; +import { mockBearerAuthentication } from "../../__testData__/auth"; +import * as Configuration from "../../../src/init/configuration"; +import { ObjectId } from "mongodb"; +import _ from "lodash"; +const mockApp = request(app); +const configuration = Configuration.getCachedConfiguration(); +const uid = new ObjectId().toHexString(); +const mockAuth = mockBearerAuthentication(uid); + +describe("FriendsController", () => { + beforeEach(async () => { + await enableFriendsEndpoints(true); + vi.useFakeTimers(); + vi.setSystemTime(1000); + mockAuth.beforeEach(); + }); + + describe("get friends", () => { + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp.get("/friends").set("Authorization", `Bearer ${uid}`) + ); + }); + it("should fail without authentication", async () => { + await mockApp.get("/friends").expect(401); + }); + }); + + describe("create friend", () => { + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/friends") + .send({}) + .set("Authorization", `Bearer ${uid}`) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: [`"friendUid" Required`], + }); + }); + it("should fail with extra properties", async () => { + //WHEN + const { body } = await mockApp + .post("/friends") + .send({ friendUid: "1", extra: "value" }) + .set("Authorization", `Bearer ${uid}`) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp + .post("/friends") + .send({ friendUid: "1" }) + .set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp.post("/friends").expect(401); + }); + }); + + describe("delete friend", () => { + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp.delete("/friends/1").set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp.delete("/friends/1").expect(401); + }); + }); +}); + +async function enableFriendsEndpoints(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + users: { friends: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} +async function expectFailForDisabledEndpoint(call: SuperTest): Promise { + await enableFriendsEndpoints(false); + const { body } = await call.expect(503); + expect(body.message).toEqual("Friends are not available at this time."); +} diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 80b6aaf35934..8512c8add7f2 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -140,6 +140,12 @@ export function getOpenApi(): OpenAPIObject { "x-displayName": "Webhooks", "x-public": "yes", }, + { + name: "friends", + description: "User friends", + "x-displayName": "Friends", + "x-public": "no", + }, ], }, @@ -277,7 +283,6 @@ function addRequiredConfiguration( if (metadata === undefined || metadata.requireConfiguration === undefined) return; - //@ts-expect-error operation.description += `**Required configuration:** This operation can only be called if the [configuration](#tag/configuration/operation/configuration.get) for \`${metadata.requireConfiguration.path}\` is \`true\`.\n\n`; } diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts new file mode 100644 index 000000000000..67c78960e6fa --- /dev/null +++ b/backend/src/api/controllers/friends.ts @@ -0,0 +1,40 @@ +import { + CreateFriendRequest, + CreateFriendResponse, + FriendIdPathParams, + GetFriendsQuery, + GetFriendsResponse, +} from "@monkeytype/contracts/friends"; +import { MonkeyRequest } from "../types"; +import { MonkeyResponse } from "../../utils/monkey-response"; +import { Friend } from "@monkeytype/contracts/schemas/friends"; + +export async function get( + req: MonkeyRequest +): Promise { + const _status = req.query.status; + return new MonkeyResponse("Friends retrieved", []); +} + +export async function create( + req: MonkeyRequest +): Promise { + const _friendUid = req.body.friendUid; + const data: Friend = { + _id: "id", + friendUid: "uid1", + friendName: "Bob", + addedAt: Date.now(), + initiatorName: "me", + initiatorUid: "myUid", + status: "pending", + }; + return new MonkeyResponse("Friend created", data); +} + +export async function deleteFriend( + req: MonkeyRequest +): Promise { + const _id = req.params.id; + return new MonkeyResponse("Friend deleted", null); +} diff --git a/backend/src/api/routes/friends.ts b/backend/src/api/routes/friends.ts new file mode 100644 index 000000000000..d091e0a5c7e8 --- /dev/null +++ b/backend/src/api/routes/friends.ts @@ -0,0 +1,18 @@ +import { friendsContract } from "@monkeytype/contracts/friends"; +import { initServer } from "@ts-rest/express"; +import { callController } from "../ts-rest-adapter"; + +import * as FriendsController from "../controllers/friends"; + +const s = initServer(); +export default s.router(friendsContract, { + get: { + handler: async (r) => callController(FriendsController.get)(r), + }, + create: { + handler: async (r) => callController(FriendsController.create)(r), + }, + delete: { + handler: async (r) => callController(FriendsController.deleteFriend)(r), + }, +}); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 91d061fd88df..888d6a4afe1e 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -16,6 +16,7 @@ import configs from "./configs"; import configuration from "./configuration"; import { version } from "../../version"; import leaderboards from "./leaderboards"; +import friends from "./friends"; import addSwaggerMiddlewares from "./swagger"; import { MonkeyResponse } from "../../utils/monkey-response"; import { @@ -61,6 +62,7 @@ const router = s.router(contract, { users, quotes, webhooks, + friends, }); export function addApiRoutes(app: Application): void { diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index c4c77a770280..2114d0ebd040 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -78,6 +78,7 @@ export const BASE_CONFIGURATION: Configuration = { premium: { enabled: false, }, + friends: { enabled: false }, }, rateLimiting: { badAuthentication: { @@ -302,6 +303,16 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { }, }, }, + friends: { + type: "object", + label: "Friends", + fields: { + enabled: { + type: "boolean", + label: "Enabled", + }, + }, + }, signUp: { type: "boolean", label: "Sign Up Enabled", diff --git a/packages/contracts/src/friends.ts b/packages/contracts/src/friends.ts new file mode 100644 index 000000000000..d6428cffd407 --- /dev/null +++ b/packages/contracts/src/friends.ts @@ -0,0 +1,92 @@ +import { initContract } from "@ts-rest/core"; +import { + CommonResponses, + meta, + MonkeyResponseSchema, + responseWithData, +} from "./schemas/api"; +import { FriendSchema, FriendStatusSchema } from "./schemas/friends"; +import { z } from "zod"; +import { IdSchema } from "./schemas/util"; + +const c = initContract(); + +export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema)); +export type GetFriendsResponse = z.infer; + +export const GetFriendsQuerySchema = z.object({ + status: z.array(FriendStatusSchema).optional(), +}); +export type GetFriendsQuery = z.infer; + +export const CreateFriendRequestSchema = FriendSchema.pick({ + friendUid: true, +}).strict(); +export type CreateFriendRequest = z.infer; + +export const CreateFriendResponseSchema = responseWithData(FriendSchema); +export type CreateFriendResponse = z.infer; + +export const FriendIdPathParamsSchema = z.object({ + id: IdSchema, +}); +export type FriendIdPathParams = z.infer; + +export const friendsContract = c.router( + { + get: { + summary: "Get friends", + description: "Get friends of the current user", + method: "GET", + path: "", + query: GetFriendsQuerySchema, + responses: { + 200: GetFriendsResponseSchema, + }, + metadata: meta({ + rateLimit: "friendsGet", + }), + }, + create: { + summary: "Create friend", + description: "Request a user to become a friend", + method: "POST", + path: "", + body: CreateFriendRequestSchema, + responses: { + 200: CreateFriendResponseSchema, + 404: MonkeyResponseSchema.describe("FriendUid unknown"), + 409: MonkeyResponseSchema.describe("Duplicate friend"), + }, + metadata: meta({ + rateLimit: "friendsCreate", + }), + }, + delete: { + summary: "Delete friend", + description: "Remove a friend", + method: "DELETE", + path: "/:id", + pathParams: FriendIdPathParamsSchema.strict(), + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: meta({ + rateLimit: "friendsDelete", + }), + }, + }, + { + pathPrefix: "/friends", + strictStatusCodes: true, + metadata: meta({ + openApiTags: "friends", + requireConfiguration: { + path: "users.friends.enabled", + invalidMessage: "Friends are not available at this time.", + }, + }), + commonResponses: CommonResponses, + } +); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3f88073b1bf0..06e0b5fac969 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -12,6 +12,7 @@ import { devContract } from "./dev"; import { usersContract } from "./users"; import { quotesContract } from "./quotes"; import { webhooksContract } from "./webhooks"; +import { friendsContract } from "./friends"; const c = initContract(); @@ -29,6 +30,7 @@ export const contract = c.router({ users: usersContract, quotes: quotesContract, webhooks: webhooksContract, + friends: friendsContract, }); /** diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index 205e3115caa8..7ce86babb87a 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -361,6 +361,21 @@ export const limits = { window: "second", max: 1, }, + + friendsGet: { + window: "hour", + max: 60, + }, + + friendsCreate: { + window: "hour", + max: 60, + }, + + friendsDelete: { + window: "hour", + max: 60, + }, } satisfies Record; export type RateLimiterId = keyof typeof limits; diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index 7697b3e54a66..1d42ab160ccd 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -15,7 +15,8 @@ export type OpenApiTag = | "development" | "users" | "quotes" - | "webhooks"; + | "webhooks" + | "friends"; export type PermissionId = | "quoteMod" diff --git a/packages/contracts/src/schemas/configuration.ts b/packages/contracts/src/schemas/configuration.ts index 1d4b404f23e5..ea404cc628ce 100644 --- a/packages/contracts/src/schemas/configuration.ts +++ b/packages/contracts/src/schemas/configuration.ts @@ -83,6 +83,9 @@ export const ConfigurationSchema = z.object({ premium: z.object({ enabled: z.boolean(), }), + friends: z.object({ + enabled: z.boolean(), + }), }), admin: z.object({ endpointsEnabled: z.boolean(), diff --git a/packages/contracts/src/schemas/friends.ts b/packages/contracts/src/schemas/friends.ts new file mode 100644 index 000000000000..b16ff5678a02 --- /dev/null +++ b/packages/contracts/src/schemas/friends.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { IdSchema } from "./util"; + +export const FriendStatusSchema = z.enum(["pending", "accepted", "rejected"]); +export type FriendStatus = z.infer; + +export const FriendSchema = z.object({ + _id: IdSchema, + initiatorUid: IdSchema, + initiatorName: z.string(), + friendUid: IdSchema, + friendName: z.string(), + addedAt: z.number().int().nonnegative(), + status: FriendStatusSchema, +}); + +export type Friend = z.infer; From dd8ebf1f7a03d499df1dabc6e50d9dd6201ae517 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 24 Jun 2025 15:11:07 +0200 Subject: [PATCH 02/28] add dal --- .../__tests__/api/controllers/user.spec.ts | 16 ++- backend/src/api/controllers/friends.ts | 60 ++++++--- backend/src/api/controllers/user.ts | 4 + backend/src/dal/friends.ts | 119 ++++++++++++++++++ backend/src/dal/user.ts | 25 ++++ backend/src/server.ts | 4 + packages/contracts/src/friends.ts | 2 +- 7 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 backend/src/dal/friends.ts diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index bcc26e5b1c9a..75d456bfb07f 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -31,6 +31,7 @@ import { MonkeyMail, UserStreak } from "@monkeytype/contracts/schemas/users"; import MonkeyError, { isFirebaseError } from "../../../src/utils/error"; import { LeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards"; import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard"; +import * as FriendsDal from "../../../src/dal/friends"; const mockApp = request(app); const configuration = Configuration.getCachedConfiguration(); @@ -602,6 +603,7 @@ describe("user controller test", () => { "purgeUserFromXpLeaderboards" ); const blocklistAddMock = vi.spyOn(BlocklistDal, "add"); + const friendsDeletebyUidMock = vi.spyOn(FriendsDal, "deleteByUid"); beforeEach(() => { mockAuth.beforeEach(); @@ -614,6 +616,7 @@ describe("user controller test", () => { deleteConfigMock, purgeUserFromDailyLeaderboardsMock, purgeUserFromXpLeaderboardsMock, + friendsDeletebyUidMock, ].forEach((it) => it.mockResolvedValue(undefined)); deleteAllResultMock.mockResolvedValue({} as any); @@ -631,6 +634,7 @@ describe("user controller test", () => { deleteAllPresetsMock, purgeUserFromDailyLeaderboardsMock, purgeUserFromXpLeaderboardsMock, + friendsDeletebyUidMock, ].forEach((it) => it.mockReset()); }); @@ -660,6 +664,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -694,6 +699,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -723,6 +729,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -751,6 +758,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).not.toHaveBeenCalledWith(uid); expect(deleteConfigMock).not.toHaveBeenCalledWith(uid); expect(deleteAllResultMock).not.toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).not.toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).not.toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -790,6 +798,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -829,6 +838,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(friendsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -948,6 +958,7 @@ describe("user controller test", () => { const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); const updateNameMock = vi.spyOn(UserDal, "updateName"); + const friendsUpdateNameMock = vi.spyOn(FriendsDal, "updateName"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { @@ -955,6 +966,7 @@ describe("user controller test", () => { updateNameMock.mockReset(); addImportantLogMock.mockReset(); blocklistContainsMock.mockReset(); + friendsUpdateNameMock.mockReset(); }); it("should update the username", async () => { @@ -982,6 +994,7 @@ describe("user controller test", () => { "changed name from Bob to newName", uid ); + expect(friendsUpdateNameMock).toHaveBeenCalledWith(uid, "newName"); }); it("should fail if username is blocked", async () => { @@ -998,6 +1011,7 @@ describe("user controller test", () => { //THEN expect(body.message).toEqual("Username blocked"); expect(updateNameMock).not.toHaveBeenCalled(); + expect(friendsUpdateNameMock).not.toHaveBeenCalled(); }); it("should fail for banned users", async () => { @@ -1538,8 +1552,6 @@ describe("user controller test", () => { getDiscordUserMock.mockResolvedValue({ id: "discordUserId", avatar: "discordUserAvatar", - username: "discordUserName", - discriminator: "discordUserDiscriminator", }); isDiscordIdAvailableMock.mockResolvedValue(true); blocklistContainsMock.mockResolvedValue(false); diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts index 67c78960e6fa..112a968f0394 100644 --- a/backend/src/api/controllers/friends.ts +++ b/backend/src/api/controllers/friends.ts @@ -7,34 +7,64 @@ import { } from "@monkeytype/contracts/friends"; import { MonkeyRequest } from "../types"; import { MonkeyResponse } from "../../utils/monkey-response"; -import { Friend } from "@monkeytype/contracts/schemas/friends"; + +import * as FriendsDal from "../../dal/friends"; +import * as UserDal from "../../dal/user"; +import { replaceObjectId, replaceObjectIds } from "../../utils/misc"; +import MonkeyError from "../../utils/error"; + +import { buildMonkeyMail } from "../../utils/monkey-mail"; export async function get( req: MonkeyRequest ): Promise { - const _status = req.query.status; - return new MonkeyResponse("Friends retrieved", []); + const { uid } = req.ctx.decodedToken; + const status = req.query.status; + + const results = await FriendsDal.get(uid, status); + + return new MonkeyResponse("Friends retrieved", replaceObjectIds(results)); } export async function create( req: MonkeyRequest ): Promise { - const _friendUid = req.body.friendUid; - const data: Friend = { - _id: "id", - friendUid: "uid1", - friendName: "Bob", - addedAt: Date.now(), - initiatorName: "me", - initiatorUid: "myUid", - status: "pending", - }; - return new MonkeyResponse("Friend created", data); + const { uid } = req.ctx.decodedToken; + const friendName = req.body.friendName; + + const friend = await UserDal.getUserByName(friendName, "create friend"); + + if (uid === friend.uid) { + throw new MonkeyError(400, "You cannot be your own friend, sorry."); + } + + const initiator = await UserDal.getPartialUser(uid, "create friend", [ + "uid", + "name", + ]); + const result = await FriendsDal.create(initiator, friend); + + //notify user + const mail = buildMonkeyMail({ + subject: "Friend request", + body: `${initiator.name} wants to be your friend. You can accept/deny this request in [FRIEND_SETTINGS]`, + }); + await UserDal.addToInbox( + friend.uid, + [mail], + req.ctx.configuration.users.inbox + ); + + return new MonkeyResponse("Friend created", replaceObjectId(result)); } export async function deleteFriend( req: MonkeyRequest ): Promise { - const _id = req.params.id; + const { uid } = req.ctx.decodedToken; + const { id } = req.params; + + await FriendsDal.deleteFriend(uid, id); + return new MonkeyResponse("Friend deleted", null); } diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 53a86fa7d78a..e282df86d834 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -89,6 +89,7 @@ import { import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; import { MonkeyRequest } from "../types"; import { tryCatch } from "@monkeytype/util/trycatch"; +import * as FriendsDal from "../../dal/friends"; async function verifyCaptcha(captcha: string): Promise { const { data: verified, error } = await tryCatch(verify(captcha)); @@ -296,6 +297,7 @@ export async function deleteUser(req: MonkeyRequest): Promise { uid, req.ctx.configuration.leaderboards.weeklyXp ), + FriendsDal.deleteByUid(uid), ]); try { @@ -386,6 +388,8 @@ export async function updateName( } await UserDAL.updateName(uid, name, user.name); + + await FriendsDal.updateName(uid, name); void addImportantLog( "user_name_updated", `changed name from ${user.name} to ${name}`, diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts new file mode 100644 index 000000000000..3160c96e5952 --- /dev/null +++ b/backend/src/dal/friends.ts @@ -0,0 +1,119 @@ +import { Friend, FriendStatus } from "@monkeytype/contracts/schemas/friends"; +import { Collection, Filter, ObjectId } from "mongodb"; +import * as db from "../init/db"; +import MonkeyError from "../utils/error"; +import { WithObjectId } from "../utils/misc"; +import _ from "lodash"; +export type DBFriend = WithObjectId< + Friend & { + key: string; //sorted uid + } +>; + +export async function get( + uid: string, + status?: FriendStatus[] +): Promise { + let filter: Filter = { + $or: [{ initiatorUid: uid }, { friendUid: uid }], + }; + if (status !== undefined) { + filter = { $and: [filter, { status: { $in: status } }] }; + } + return await getFriendsCollection().find(filter).toArray(); +} + +export async function create( + initiator: { uid: string; name: string }, + friend: { uid: string; name: string } +): Promise { + try { + const created = await getFriendsCollection().insertOne({ + _id: new ObjectId(), + key: getKey(initiator.uid, friend.uid), + initiatorUid: initiator.uid, + initiatorName: initiator.name, + friendUid: friend.uid, + friendName: friend.name, + addedAt: Date.now(), + status: "pending", + }); + const inserted = await getFriendsCollection().findOne({ + _id: created.insertedId, + }); + + if (inserted === null) { + throw new MonkeyError(500, "Insert friend failed"); + } + return inserted; + } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (e.name === "MongoServerError" && e.code === 11000) { + throw new MonkeyError(409, "Duplicate friend"); + } + + throw e; + } +} + +export async function deleteFriend( + initiatorUid: string, + id: string +): Promise { + const deletionResult = await getFriendsCollection().deleteOne({ + _id: new ObjectId(id), + initiatorUid, + }); + + if (deletionResult.deletedCount === 0) { + throw new MonkeyError(404, "Friend not found"); + } +} + +/** + * Update all friends for the uid (initiator or friend) with the given name. + * @param uid + * @param newName + */ +export async function updateName(uid: string, newName: string): Promise { + await getFriendsCollection().bulkWrite([ + { + updateMany: { + filter: { initiatorUid: uid }, + update: { $set: { initiatorName: newName } }, + }, + }, + { + updateMany: { + filter: { friendUid: uid }, + update: { $set: { friendName: newName } }, + }, + }, + ]); +} + +/** + * Remove all friends containing the uid as initiatorUid or friendUid + * @param uid + */ +export async function deleteByUid(uid: string): Promise { + await getFriendsCollection().deleteMany({ + $or: [{ initiatorUid: uid }, { friendUid: uid }], + }); +} + +function getFriendsCollection(): Collection { + return db.collection("friends"); +} + +function getKey(initiatorUid: string, friendUid: string): string { + const ids = [initiatorUid, friendUid]; + ids.sort(); + return ids.join("/"); +} + +export async function createIndicies(): Promise { + //index used for search + //make sure there is only one friend entry for each friend/creator pair + await getFriendsCollection().createIndex({ key: 1 }, { unique: true }); +} diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 64a929c6194e..af6b0cc8d0db 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -267,6 +267,31 @@ export async function getPartialUser( return results; } +/** + * Get users document only containing requested fields + * @param uids user ids + * @param stack stack description used in the error + * @param fields list of fields + * @returns map of userids with partial DBUser only containing requested fields + */ +/*export async function getPartialUsers( + uids: string[], + stack: string, + fields: K[] +): Promise>> { + const projection = new Map(fields.map((it) => [it, 1])); + const results = await getUsersCollection().find( + { uid: { $in: uids } }, + { projection } + ); + if (results === null) return {}; + + return Object.fromEntries( + (await results.toArray()).map((it) => [it.uid, it]) + ); +} + */ + export async function findByName(name: string): Promise { return ( await getUsersCollection() diff --git a/backend/src/server.ts b/backend/src/server.ts index 7570078a0bd7..d23e0ac87e31 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -17,6 +17,7 @@ import * as EmailClient from "./init/email-client"; import { init as initFirebaseAdmin } from "./init/firebase-admin"; import { createIndicies as leaderboardDbSetup } from "./dal/leaderboards"; import { createIndicies as blocklistDbSetup } from "./dal/blocklist"; +import { createIndicies as friendsDbSetup } from "./dal/friends"; import { getErrorMessage } from "./utils/error"; async function bootServer(port: number): Promise { @@ -76,6 +77,9 @@ async function bootServer(port: number): Promise { Logger.info("Setting up blocklist indicies..."); await blocklistDbSetup(); + Logger.info("Setting up friends indicies..."); + await friendsDbSetup(); + recordServerVersion(version); } catch (error) { Logger.error("Failed to boot server"); diff --git a/packages/contracts/src/friends.ts b/packages/contracts/src/friends.ts index d6428cffd407..20a799da38cb 100644 --- a/packages/contracts/src/friends.ts +++ b/packages/contracts/src/friends.ts @@ -20,7 +20,7 @@ export const GetFriendsQuerySchema = z.object({ export type GetFriendsQuery = z.infer; export const CreateFriendRequestSchema = FriendSchema.pick({ - friendUid: true, + friendName: true, }).strict(); export type CreateFriendRequest = z.infer; From f513f742fcb1ee8fbf418bbe28e26e5a7e5212bc Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 24 Jun 2025 19:29:32 +0200 Subject: [PATCH 03/28] add tests --- .../__tests__/api/controllers/friends.spec.ts | 239 ++++++++++++++++- backend/__tests__/dal/friends.spec.ts | 251 ++++++++++++++++++ backend/src/api/controllers/friends.ts | 25 +- backend/src/api/routes/friends.ts | 3 + backend/src/dal/friends.ts | 73 +++-- packages/contracts/src/friends.ts | 30 ++- packages/contracts/src/rate-limit/index.ts | 5 + 7 files changed, 595 insertions(+), 31 deletions(-) create mode 100644 backend/__tests__/dal/friends.spec.ts diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts index 9bae61f5d697..4bfdd18f7781 100644 --- a/backend/__tests__/api/controllers/friends.spec.ts +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -4,6 +4,9 @@ import { mockBearerAuthentication } from "../../__testData__/auth"; import * as Configuration from "../../../src/init/configuration"; import { ObjectId } from "mongodb"; import _ from "lodash"; +import * as FriendsDal from "../../../src/dal/friends"; +import * as UserDal from "../../../src/dal/user"; + const mockApp = request(app); const configuration = Configuration.getCachedConfiguration(); const uid = new ObjectId().toHexString(); @@ -18,6 +21,72 @@ describe("FriendsController", () => { }); describe("get friends", () => { + const getFriendsMock = vi.spyOn(FriendsDal, "get"); + + beforeEach(() => { + getFriendsMock.mockReset(); + }); + + it("should get for the current user", async () => { + //GIVEN + const friend: FriendsDal.DBFriend = { + _id: new ObjectId(), + addedAt: 42, + initiatorUid: new ObjectId().toHexString(), + initiatorName: "Bob", + friendUid: new ObjectId().toHexString(), + friendName: "Kevin", + status: "pending", + key: "key", + }; + + getFriendsMock.mockResolvedValue([friend]); + + //WHEN + const { body } = await mockApp + .get("/friends") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual([{ ...friend, _id: friend._id.toHexString() }]); + expect(getFriendsMock).toHaveBeenCalledWith(uid, undefined); + }); + + it("should filter by status", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + const { body } = await mockApp + .get("/friends") + .query({ status: "accepted" }) + .set("Authorization", `Bearer ${uid}`); + //.expect(200); + + console.log(body); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith(uid, ["accepted"]); + }); + it("should filter by multiple status", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/friends") + .query({ status: ["accepted", "rejected"] }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith(uid, [ + "accepted", + "rejected", + ]); + }); + it("should fail if friends endpoints are disabled", async () => { await expectFailForDisabledEndpoint( mockApp.get("/friends").set("Authorization", `Bearer ${uid}`) @@ -26,9 +95,107 @@ describe("FriendsController", () => { it("should fail without authentication", async () => { await mockApp.get("/friends").expect(401); }); + it("should fail for unknown query parameter", async () => { + const { body } = await mockApp + .get("/friends") + .query({ extra: "yes" }) + .set("Authorization", `Bearer ${uid}`) + .expect(422); + + expect(body).toStrictEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); }); describe("create friend", () => { + const getUserByNameMock = vi.spyOn(UserDal, "getUserByName"); + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + const addToInboxMock = vi.spyOn(UserDal, "addToInbox"); + const createUserMock = vi.spyOn(FriendsDal, "create"); + + beforeEach(() => { + [ + getUserByNameMock, + getPartialUserMock, + addToInboxMock, + createUserMock, + ].forEach((it) => it.mockReset()); + }); + + it("should create friend", async () => { + //GIVEN + const me = { uid, name: "Bob" }; + const myFriend = { uid: new ObjectId().toHexString(), name: "Kevin" }; + getUserByNameMock.mockResolvedValue(myFriend as any); + getPartialUserMock.mockResolvedValue(me as any); + + const result: FriendsDal.DBFriend = { + _id: new ObjectId(), + addedAt: 42, + initiatorUid: me.uid, + initiatorName: me.name, + friendUid: myFriend.uid, + friendName: myFriend.name, + key: "test", + status: "pending", + }; + createUserMock.mockResolvedValue(result); + + //WHEN + const { body } = await mockApp + .post("/friends") + .send({ friendName: "Kevin" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual({ + _id: result._id.toHexString(), + addedAt: 42, + initiatorUid: me.uid, + initiatorName: me.name, + friendUid: myFriend.uid, + friendName: myFriend.name, + status: "pending", + }); + + expect(getUserByNameMock).toHaveBeenCalledWith("Kevin", "create friend"); + expect(getPartialUserMock).toHaveBeenCalledWith(uid, "create friend", [ + "uid", + "name", + ]); + expect(addToInboxMock).toBeCalledWith( + myFriend.uid, + [ + expect.objectContaining({ + body: "Bob wants to be your friend. You can accept/deny this request in [FRIEND_SETTINGS]", + subject: "Friend request", + }), + ], + expect.anything() + ); + }); + + it("should fail if user and friend are the same", async () => { + //GIVEN + const me = { uid, name: "Bob" }; + + getUserByNameMock.mockResolvedValue(me as any); + getPartialUserMock.mockResolvedValue(me as any); + + //WHEN + const { body } = await mockApp + .post("/friends") + .send({ friendName: "Bob" }) + .set("Authorization", `Bearer ${uid}`) + .expect(400); + + //THEN + expect(body.message).toEqual("You cannot be your own friend, sorry."); + }); + it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp @@ -40,14 +207,14 @@ describe("FriendsController", () => { //THEN expect(body).toStrictEqual({ message: "Invalid request data schema", - validationErrors: [`"friendUid" Required`], + validationErrors: [`"friendName" Required`], }); }); it("should fail with extra properties", async () => { //WHEN const { body } = await mockApp .post("/friends") - .send({ friendUid: "1", extra: "value" }) + .send({ friendName: "1", extra: "value" }) .set("Authorization", `Bearer ${uid}`) .expect(422); @@ -62,7 +229,7 @@ describe("FriendsController", () => { await expectFailForDisabledEndpoint( mockApp .post("/friends") - .send({ friendUid: "1" }) + .send({ friendName: "1" }) .set("Authorization", `Bearer ${uid}`) ); }); @@ -73,6 +240,22 @@ describe("FriendsController", () => { }); describe("delete friend", () => { + const deleteByIdMock = vi.spyOn(FriendsDal, "deleteById"); + + beforeEach(() => { + deleteByIdMock.mockReset(); + }); + + it("should delete by id", async () => { + //WHEN + await mockApp + .delete("/friends/1") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(deleteByIdMock).toHaveBeenCalledWith(uid, "1"); + }); it("should fail if friends endpoints are disabled", async () => { await expectFailForDisabledEndpoint( mockApp.delete("/friends/1").set("Authorization", `Bearer ${uid}`) @@ -83,6 +266,56 @@ describe("FriendsController", () => { await mockApp.delete("/friends/1").expect(401); }); }); + + describe("update friend", () => { + const updateStatusMock = vi.spyOn(FriendsDal, "updateStatus"); + + beforeEach(() => { + updateStatusMock.mockReset(); + }); + + it("should update friend", async () => { + //WHEN + await mockApp + .patch("/friends/1") + .send({ status: "accepted" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "accepted"); + }); + + it("should fail for invalid status", async () => { + const { body } = await mockApp + .patch("/friends/1") + .send({ status: "invalid" }) + .set("Authorization", `Bearer ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"status" Invalid enum value. Expected 'accepted' | 'rejected', received 'invalid'`, + ], + }); + }); + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp + .patch("/friends/1") + .send({ status: "accepted" }) + .set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp + .patch("/friends/1") + .send({ status: "accepted" }) + .expect(401); + }); + }); }); async function enableFriendsEndpoints(enabled: boolean): Promise { diff --git a/backend/__tests__/dal/friends.spec.ts b/backend/__tests__/dal/friends.spec.ts new file mode 100644 index 000000000000..1357bd460a98 --- /dev/null +++ b/backend/__tests__/dal/friends.spec.ts @@ -0,0 +1,251 @@ +import { ObjectId } from "mongodb"; + +import * as FriendsDal from "../../src/dal/friends"; + +describe("FriendsDal", () => { + beforeAll(async () => { + FriendsDal.createIndicies(); + }); + + describe("get", () => { + it("get by uid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const initOne = await createFriend({ initiatorUid: uid }); + const initTwo = await createFriend({ initiatorUid: uid }); + const friendOne = await createFriend({ friendUid: uid }); + const _decoy = await createFriend({}); + + //WHEN + const myFriends = await FriendsDal.get(uid); + + //THEN + expect(myFriends).toStrictEqual([initOne, initTwo, friendOne]); + }); + + it("get by uid and status", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const initAccepted = await createFriend({ + initiatorUid: uid, + status: "accepted", + }); + const _initPending = await createFriend({ + initiatorUid: uid, + status: "pending", + }); + const initRejected = await createFriend({ + initiatorUid: uid, + status: "rejected", + }); + + const friendAccepted = await createFriend({ + friendUid: uid, + status: "accepted", + }); + const _friendPending = await createFriend({ + friendUid: uid, + status: "pending", + }); + + const _decoy = await createFriend({ status: "accepted" }); + + //WHEN + const nonPending = await FriendsDal.get(uid, ["accepted", "rejected"]); + + //THEN + expect(nonPending).toStrictEqual([ + initAccepted, + initRejected, + friendAccepted, + ]); + }); + }); + + describe("create", () => { + const now = 1715082588; + beforeEach(() => { + vitest.useFakeTimers(); + vitest.setSystemTime(now); + }); + afterEach(() => { + vitest.useRealTimers(); + }); + + it("should fail creating duplicates", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + initiatorUid: uid, + }); + + //WHEN/THEN + await expect( + createFriend({ + initiatorUid: first.friendUid, + friendUid: uid, + }) + ).rejects.toThrow("Duplicate friend"); + }); + + it("should create", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const friendUid = new ObjectId().toHexString(); + + //WHEN + const created = await FriendsDal.create( + { uid, name: "Bob" }, + { uid: friendUid, name: "Kevin" } + ); + + //THEN + expect(created).toEqual( + expect.objectContaining({ + initiatorUid: uid, + initiatorName: "Bob", + friendUid: friendUid, + friendName: "Kevin", + addedAt: now, + status: "pending", + key: `${uid}/${friendUid}`, + }) + ); + }); + }); + describe("updateStatus", () => { + it("should update the status", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + friendUid: uid, + }); + const second = await createFriend({ + initiatorUid: uid, + }); + + //WHEN + await FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted"); + + //THEN + expect(await FriendsDal.get(uid)).toEqual( + expect.arrayContaining([{ ...first, status: "accepted" }, second]) + ); + + //can update twice to the same status + await FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted"); + }); + it("should fail if uid does not match the friendUid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + initiatorUid: uid, + }); + + //WHEN / THEN + await expect( + FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted") + ).rejects.toThrow("Friend not found"); + }); + }); + + describe("deleteById", () => { + it("should delete", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + initiatorUid: uid, + }); + const second = await createFriend({ + initiatorUid: uid, + }); + + //WHEN + await FriendsDal.deleteById(uid, first._id.toHexString()); + + //THEN + expect(await FriendsDal.get(uid)).toStrictEqual([second]); + }); + it("should fail if uid does not match", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + initiatorUid: uid, + }); + + //WHEN / THEN + await expect( + FriendsDal.deleteById("Bob", first._id.toHexString()) + ).rejects.toThrow("Friend not found"); + }); + }); + + describe("deleteByUid", () => { + it("should delete by uid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const _initOne = await createFriend({ initiatorUid: uid }); + const _initTwo = await createFriend({ initiatorUid: uid }); + const _friendOne = await createFriend({ friendUid: uid }); + const decoy = await createFriend({}); + + //WHEN + await FriendsDal.deleteByUid(uid); + + //THEN + expect(await FriendsDal.get(uid)).toEqual([]); + expect(await FriendsDal.get(decoy.initiatorUid)).toEqual([decoy]); + }); + }); + describe("updateName", () => { + it("should update the name", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const initOne = await createFriend({ + initiatorUid: uid, + initiatorName: "Bob", + }); + const initTwo = await createFriend({ + initiatorUid: uid, + initiatorName: "Bob", + }); + const friendOne = await createFriend({ + friendUid: uid, + friendName: "Bob", + }); + const decoy = await createFriend({}); + + //WHEN + await FriendsDal.updateName(uid, "King Bob"); + + //THEN + expect(await FriendsDal.get(uid)).toEqual([ + { ...initOne, initiatorName: "King Bob" }, + { ...initTwo, initiatorName: "King Bob" }, + { ...friendOne, friendName: "King Bob" }, + ]); + + expect(await FriendsDal.get(decoy.initiatorUid)).toEqual([decoy]); + }); + }); +}); + +async function createFriend( + data: Partial +): Promise { + const result = await FriendsDal.create( + { + uid: data.initiatorUid ?? new ObjectId().toHexString(), + name: data.initiatorName ?? "user" + new ObjectId().toHexString(), + }, + { + uid: data.friendUid ?? new ObjectId().toHexString(), + name: data.friendName ?? "user" + new ObjectId().toHexString(), + } + ); + await FriendsDal.getCollection().updateOne( + { _id: result._id }, + { $set: data } + ); + return { ...result, ...data }; +} diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts index 112a968f0394..af3019b3ceb8 100644 --- a/backend/src/api/controllers/friends.ts +++ b/backend/src/api/controllers/friends.ts @@ -4,6 +4,7 @@ import { FriendIdPathParams, GetFriendsQuery, GetFriendsResponse, + UpdateFriendsRequest, } from "@monkeytype/contracts/friends"; import { MonkeyRequest } from "../types"; import { MonkeyResponse } from "../../utils/monkey-response"; @@ -14,6 +15,8 @@ import { replaceObjectId, replaceObjectIds } from "../../utils/misc"; import MonkeyError from "../../utils/error"; import { buildMonkeyMail } from "../../utils/monkey-mail"; +import { Friend } from "@monkeytype/contracts/schemas/friends"; +import { omit } from "lodash"; export async function get( req: MonkeyRequest @@ -42,7 +45,11 @@ export async function create( "uid", "name", ]); - const result = await FriendsDal.create(initiator, friend); + + const result: Friend = omit( + replaceObjectId(await FriendsDal.create(initiator, friend)), + "key" + ); //notify user const mail = buildMonkeyMail({ @@ -55,7 +62,7 @@ export async function create( req.ctx.configuration.users.inbox ); - return new MonkeyResponse("Friend created", replaceObjectId(result)); + return new MonkeyResponse("Friend created", result); } export async function deleteFriend( @@ -64,7 +71,19 @@ export async function deleteFriend( const { uid } = req.ctx.decodedToken; const { id } = req.params; - await FriendsDal.deleteFriend(uid, id); + await FriendsDal.deleteById(uid, id); return new MonkeyResponse("Friend deleted", null); } + +export async function update( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const { id } = req.params; + const { status } = req.body; + + await FriendsDal.updateStatus(uid, id, status); + + return new MonkeyResponse("Friend updated", null); +} diff --git a/backend/src/api/routes/friends.ts b/backend/src/api/routes/friends.ts index d091e0a5c7e8..29b03226565d 100644 --- a/backend/src/api/routes/friends.ts +++ b/backend/src/api/routes/friends.ts @@ -15,4 +15,7 @@ export default s.router(friendsContract, { delete: { handler: async (r) => callController(FriendsController.deleteFriend)(r), }, + update: { + handler: async (r) => callController(FriendsController.update)(r), + }, }); diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index 3160c96e5952..d923f93ba9ae 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -1,15 +1,19 @@ -import { Friend, FriendStatus } from "@monkeytype/contracts/schemas/friends"; import { Collection, Filter, ObjectId } from "mongodb"; import * as db from "../init/db"; +import { Friend, FriendStatus } from "@monkeytype/contracts/schemas/friends"; import MonkeyError from "../utils/error"; import { WithObjectId } from "../utils/misc"; -import _ from "lodash"; + export type DBFriend = WithObjectId< Friend & { key: string; //sorted uid } >; +// Export for use in tests +export const getCollection = (): Collection => + db.collection("friends"); + export async function get( uid: string, status?: FriendStatus[] @@ -20,7 +24,8 @@ export async function get( if (status !== undefined) { filter = { $and: [filter, { status: { $in: status } }] }; } - return await getFriendsCollection().find(filter).toArray(); + + return await getCollection().find(filter).toArray(); } export async function create( @@ -28,7 +33,7 @@ export async function create( friend: { uid: string; name: string } ): Promise { try { - const created = await getFriendsCollection().insertOne({ + const created: DBFriend = { _id: new ObjectId(), key: getKey(initiator.uid, friend.uid), initiatorUid: initiator.uid, @@ -37,15 +42,11 @@ export async function create( friendName: friend.name, addedAt: Date.now(), status: "pending", - }); - const inserted = await getFriendsCollection().findOne({ - _id: created.insertedId, - }); + }; - if (inserted === null) { - throw new MonkeyError(500, "Insert friend failed"); - } - return inserted; + await getCollection().insertOne(created); + + return created; } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (e.name === "MongoServerError" && e.code === 11000) { @@ -56,11 +57,42 @@ export async function create( } } -export async function deleteFriend( +/** + *Update the status of a friend by id + * @param friendUid + * @param id + * @param status + * @throws MonkeyError if the friend id is unknown or the friendUid does not match + */ +export async function updateStatus( + friendUid: string, + id: string, + status: FriendStatus +): Promise { + const updateResult = await getCollection().updateOne( + { + _id: new ObjectId(id), + friendUid, + }, + { $set: { status } } + ); + + if (updateResult.matchedCount === 0) { + throw new MonkeyError(404, "Friend not found"); + } +} + +/** + * delete a friend by the id. + * @param initiatorUid + * @param id + * @throws MonkeyError if the friend id is unknown or the initiatorUid does not match + */ +export async function deleteById( initiatorUid: string, id: string ): Promise { - const deletionResult = await getFriendsCollection().deleteOne({ + const deletionResult = await getCollection().deleteOne({ _id: new ObjectId(id), initiatorUid, }); @@ -76,7 +108,7 @@ export async function deleteFriend( * @param newName */ export async function updateName(uid: string, newName: string): Promise { - await getFriendsCollection().bulkWrite([ + await getCollection().bulkWrite([ { updateMany: { filter: { initiatorUid: uid }, @@ -97,15 +129,11 @@ export async function updateName(uid: string, newName: string): Promise { * @param uid */ export async function deleteByUid(uid: string): Promise { - await getFriendsCollection().deleteMany({ + await getCollection().deleteMany({ $or: [{ initiatorUid: uid }, { friendUid: uid }], }); } -function getFriendsCollection(): Collection { - return db.collection("friends"); -} - function getKey(initiatorUid: string, friendUid: string): string { const ids = [initiatorUid, friendUid]; ids.sort(); @@ -114,6 +142,9 @@ function getKey(initiatorUid: string, friendUid: string): string { export async function createIndicies(): Promise { //index used for search + await getCollection().createIndex({ initiatorUid: 1 }); + await getCollection().createIndex({ friendUid: 1 }); + //make sure there is only one friend entry for each friend/creator pair - await getFriendsCollection().createIndex({ key: 1 }, { unique: true }); + await getCollection().createIndex({ key: 1 }, { unique: true }); } diff --git a/packages/contracts/src/friends.ts b/packages/contracts/src/friends.ts index 20a799da38cb..bd423f61e5ed 100644 --- a/packages/contracts/src/friends.ts +++ b/packages/contracts/src/friends.ts @@ -15,13 +15,16 @@ export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema)); export type GetFriendsResponse = z.infer; export const GetFriendsQuerySchema = z.object({ - status: z.array(FriendStatusSchema).optional(), + status: z + .array(FriendStatusSchema) + .or(FriendStatusSchema.transform((it) => [it])) + .optional(), }); export type GetFriendsQuery = z.infer; export const CreateFriendRequestSchema = FriendSchema.pick({ friendName: true, -}).strict(); +}); export type CreateFriendRequest = z.infer; export const CreateFriendResponseSchema = responseWithData(FriendSchema); @@ -32,6 +35,11 @@ export const FriendIdPathParamsSchema = z.object({ }); export type FriendIdPathParams = z.infer; +export const UpdateFriendsRequestSchema = z.object({ + status: FriendStatusSchema.exclude(["pending"]), +}); +export type UpdateFriendsRequest = z.infer; + export const friendsContract = c.router( { get: { @@ -39,7 +47,7 @@ export const friendsContract = c.router( description: "Get friends of the current user", method: "GET", path: "", - query: GetFriendsQuerySchema, + query: GetFriendsQuerySchema.strict(), responses: { 200: GetFriendsResponseSchema, }, @@ -52,7 +60,7 @@ export const friendsContract = c.router( description: "Request a user to become a friend", method: "POST", path: "", - body: CreateFriendRequestSchema, + body: CreateFriendRequestSchema.strict(), responses: { 200: CreateFriendResponseSchema, 404: MonkeyResponseSchema.describe("FriendUid unknown"), @@ -76,6 +84,20 @@ export const friendsContract = c.router( rateLimit: "friendsDelete", }), }, + update: { + summary: "Update friend", + description: "Update a friends status", + method: "PATCH", + path: "/:id", + pathParams: FriendIdPathParamsSchema.strict(), + body: UpdateFriendsRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: meta({ + rateLimit: "friendsUpdate", + }), + }, }, { pathPrefix: "/friends", diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index 7ce86babb87a..ca847c394c0e 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -376,6 +376,11 @@ export const limits = { window: "hour", max: 60, }, + + friendsUpdate: { + window: "hour", + max: 60, + }, } satisfies Record; export type RateLimiterId = keyof typeof limits; From b446ba80bcee85781650f4aea0a05dee41b2192b Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 2 Jul 2025 11:00:12 +0200 Subject: [PATCH 04/28] move friends requests to /friends/requests, change rejected to blocked --- .../__tests__/api/controllers/friends.spec.ts | 49 +++++---- backend/__tests__/dal/friends.spec.ts | 8 +- backend/scripts/openapi.ts | 12 +-- backend/src/api/controllers/friends.ts | 37 ++++--- backend/src/api/routes/friends.ts | 16 +-- backend/src/dal/friends.ts | 11 +- packages/contracts/src/friends.ts | 100 ++++++++++-------- packages/contracts/src/rate-limit/index.ts | 8 +- packages/contracts/src/schemas/friends.ts | 14 ++- 9 files changed, 138 insertions(+), 117 deletions(-) diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts index 4bfdd18f7781..35c8fe9cc90a 100644 --- a/backend/__tests__/api/controllers/friends.spec.ts +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -44,7 +44,7 @@ describe("FriendsController", () => { //WHEN const { body } = await mockApp - .get("/friends") + .get("/friends/requests") .set("Authorization", `Bearer ${uid}`) .expect(200); @@ -59,7 +59,7 @@ describe("FriendsController", () => { //WHEN const { body } = await mockApp - .get("/friends") + .get("/friends/requests") .query({ status: "accepted" }) .set("Authorization", `Bearer ${uid}`); //.expect(200); @@ -75,29 +75,26 @@ describe("FriendsController", () => { //WHEN await mockApp - .get("/friends") - .query({ status: ["accepted", "rejected"] }) + .get("/friends/requests") + .query({ status: ["accepted", "blocked"] }) .set("Authorization", `Bearer ${uid}`) .expect(200); //THEN - expect(getFriendsMock).toHaveBeenCalledWith(uid, [ - "accepted", - "rejected", - ]); + expect(getFriendsMock).toHaveBeenCalledWith(uid, ["accepted", "blocked"]); }); it("should fail if friends endpoints are disabled", async () => { await expectFailForDisabledEndpoint( - mockApp.get("/friends").set("Authorization", `Bearer ${uid}`) + mockApp.get("/friends/requests").set("Authorization", `Bearer ${uid}`) ); }); it("should fail without authentication", async () => { - await mockApp.get("/friends").expect(401); + await mockApp.get("/friends/requests").expect(401); }); it("should fail for unknown query parameter", async () => { const { body } = await mockApp - .get("/friends") + .get("/friends/requests") .query({ extra: "yes" }) .set("Authorization", `Bearer ${uid}`) .expect(422); @@ -145,7 +142,7 @@ describe("FriendsController", () => { //WHEN const { body } = await mockApp - .post("/friends") + .post("/friends/requests") .send({ friendName: "Kevin" }) .set("Authorization", `Bearer ${uid}`) .expect(200); @@ -187,7 +184,7 @@ describe("FriendsController", () => { //WHEN const { body } = await mockApp - .post("/friends") + .post("/friends/requests") .send({ friendName: "Bob" }) .set("Authorization", `Bearer ${uid}`) .expect(400); @@ -199,7 +196,7 @@ describe("FriendsController", () => { it("should fail without mandatory properties", async () => { //WHEN const { body } = await mockApp - .post("/friends") + .post("/friends/requests") .send({}) .set("Authorization", `Bearer ${uid}`) .expect(422); @@ -213,7 +210,7 @@ describe("FriendsController", () => { it("should fail with extra properties", async () => { //WHEN const { body } = await mockApp - .post("/friends") + .post("/friends/requests") .send({ friendName: "1", extra: "value" }) .set("Authorization", `Bearer ${uid}`) .expect(422); @@ -228,14 +225,14 @@ describe("FriendsController", () => { it("should fail if friends endpoints are disabled", async () => { await expectFailForDisabledEndpoint( mockApp - .post("/friends") + .post("/friends/requests") .send({ friendName: "1" }) .set("Authorization", `Bearer ${uid}`) ); }); it("should fail without authentication", async () => { - await mockApp.post("/friends").expect(401); + await mockApp.post("/friends/requests").expect(401); }); }); @@ -249,7 +246,7 @@ describe("FriendsController", () => { it("should delete by id", async () => { //WHEN await mockApp - .delete("/friends/1") + .delete("/friends/requests/1") .set("Authorization", `Bearer ${uid}`) .expect(200); @@ -258,12 +255,14 @@ describe("FriendsController", () => { }); it("should fail if friends endpoints are disabled", async () => { await expectFailForDisabledEndpoint( - mockApp.delete("/friends/1").set("Authorization", `Bearer ${uid}`) + mockApp + .delete("/friends/requests/1") + .set("Authorization", `Bearer ${uid}`) ); }); it("should fail without authentication", async () => { - await mockApp.delete("/friends/1").expect(401); + await mockApp.delete("/friends/requests/1").expect(401); }); }); @@ -277,7 +276,7 @@ describe("FriendsController", () => { it("should update friend", async () => { //WHEN await mockApp - .patch("/friends/1") + .patch("/friends/requests/1") .send({ status: "accepted" }) .set("Authorization", `Bearer ${uid}`) .expect(200); @@ -288,7 +287,7 @@ describe("FriendsController", () => { it("should fail for invalid status", async () => { const { body } = await mockApp - .patch("/friends/1") + .patch("/friends/requests/1") .send({ status: "invalid" }) .set("Authorization", `Bearer ${uid}`) .expect(422); @@ -296,14 +295,14 @@ describe("FriendsController", () => { expect(body).toEqual({ message: "Invalid request data schema", validationErrors: [ - `"status" Invalid enum value. Expected 'accepted' | 'rejected', received 'invalid'`, + `"status" Invalid enum value. Expected 'accepted' | 'blocked', received 'invalid'`, ], }); }); it("should fail if friends endpoints are disabled", async () => { await expectFailForDisabledEndpoint( mockApp - .patch("/friends/1") + .patch("/friends/requests/1") .send({ status: "accepted" }) .set("Authorization", `Bearer ${uid}`) ); @@ -311,7 +310,7 @@ describe("FriendsController", () => { it("should fail without authentication", async () => { await mockApp - .patch("/friends/1") + .patch("/friends/requests/1") .send({ status: "accepted" }) .expect(401); }); diff --git a/backend/__tests__/dal/friends.spec.ts b/backend/__tests__/dal/friends.spec.ts index 1357bd460a98..a53e938d2572 100644 --- a/backend/__tests__/dal/friends.spec.ts +++ b/backend/__tests__/dal/friends.spec.ts @@ -34,9 +34,9 @@ describe("FriendsDal", () => { initiatorUid: uid, status: "pending", }); - const initRejected = await createFriend({ + const initBlocked = await createFriend({ initiatorUid: uid, - status: "rejected", + status: "blocked", }); const friendAccepted = await createFriend({ @@ -51,12 +51,12 @@ describe("FriendsDal", () => { const _decoy = await createFriend({ status: "accepted" }); //WHEN - const nonPending = await FriendsDal.get(uid, ["accepted", "rejected"]); + const nonPending = await FriendsDal.get(uid, ["accepted", "blocked"]); //THEN expect(nonPending).toStrictEqual([ initAccepted, - initRejected, + initBlocked, friendAccepted, ]); }); diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 8512c8add7f2..bf7e56722ec0 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -102,6 +102,12 @@ export function getOpenApi(): OpenAPIObject { description: "All-time and daily leaderboards of the fastest typers.", "x-displayName": "Leaderboards", }, + { + name: "friends", + description: "User friend requests and friends list.", + "x-displayName": "Friends", + "x-public": "no", + }, { name: "psas", description: "Public service announcements.", @@ -140,12 +146,6 @@ export function getOpenApi(): OpenAPIObject { "x-displayName": "Webhooks", "x-public": "yes", }, - { - name: "friends", - description: "User friends", - "x-displayName": "Friends", - "x-public": "no", - }, ], }, diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts index af3019b3ceb8..157a2850b673 100644 --- a/backend/src/api/controllers/friends.ts +++ b/backend/src/api/controllers/friends.ts @@ -1,10 +1,10 @@ import { - CreateFriendRequest, - CreateFriendResponse, - FriendIdPathParams, - GetFriendsQuery, - GetFriendsResponse, - UpdateFriendsRequest, + CreateFriendRequestRequest, + CreateFriendRequestResponse, + GetFriendRequestsQuery, + GetFriendRequestsResponse, + IdPathParams, + UpdateFriendRequestsRequest, } from "@monkeytype/contracts/friends"; import { MonkeyRequest } from "../types"; import { MonkeyResponse } from "../../utils/monkey-response"; @@ -13,14 +13,13 @@ import * as FriendsDal from "../../dal/friends"; import * as UserDal from "../../dal/user"; import { replaceObjectId, replaceObjectIds } from "../../utils/misc"; import MonkeyError from "../../utils/error"; - import { buildMonkeyMail } from "../../utils/monkey-mail"; -import { Friend } from "@monkeytype/contracts/schemas/friends"; import { omit } from "lodash"; +import { FriendRequest } from "@monkeytype/contracts/schemas/friends"; -export async function get( - req: MonkeyRequest -): Promise { +export async function getRequests( + req: MonkeyRequest +): Promise { const { uid } = req.ctx.decodedToken; const status = req.query.status; @@ -29,9 +28,9 @@ export async function get( return new MonkeyResponse("Friends retrieved", replaceObjectIds(results)); } -export async function create( - req: MonkeyRequest -): Promise { +export async function createRequest( + req: MonkeyRequest +): Promise { const { uid } = req.ctx.decodedToken; const friendName = req.body.friendName; @@ -46,7 +45,7 @@ export async function create( "name", ]); - const result: Friend = omit( + const result: FriendRequest = omit( replaceObjectId(await FriendsDal.create(initiator, friend)), "key" ); @@ -65,8 +64,8 @@ export async function create( return new MonkeyResponse("Friend created", result); } -export async function deleteFriend( - req: MonkeyRequest +export async function deleteRequest( + req: MonkeyRequest ): Promise { const { uid } = req.ctx.decodedToken; const { id } = req.params; @@ -76,8 +75,8 @@ export async function deleteFriend( return new MonkeyResponse("Friend deleted", null); } -export async function update( - req: MonkeyRequest +export async function updateRequest( + req: MonkeyRequest ): Promise { const { uid } = req.ctx.decodedToken; const { id } = req.params; diff --git a/backend/src/api/routes/friends.ts b/backend/src/api/routes/friends.ts index 29b03226565d..fdd5c7189a69 100644 --- a/backend/src/api/routes/friends.ts +++ b/backend/src/api/routes/friends.ts @@ -6,16 +6,16 @@ import * as FriendsController from "../controllers/friends"; const s = initServer(); export default s.router(friendsContract, { - get: { - handler: async (r) => callController(FriendsController.get)(r), + getRequests: { + handler: async (r) => callController(FriendsController.getRequests)(r), }, - create: { - handler: async (r) => callController(FriendsController.create)(r), + createRequest: { + handler: async (r) => callController(FriendsController.createRequest)(r), }, - delete: { - handler: async (r) => callController(FriendsController.deleteFriend)(r), + deleteRequest: { + handler: async (r) => callController(FriendsController.deleteRequest)(r), }, - update: { - handler: async (r) => callController(FriendsController.update)(r), + updateRequest: { + handler: async (r) => callController(FriendsController.updateRequest)(r), }, }); diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index d923f93ba9ae..804889319f19 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -1,11 +1,14 @@ import { Collection, Filter, ObjectId } from "mongodb"; import * as db from "../init/db"; -import { Friend, FriendStatus } from "@monkeytype/contracts/schemas/friends"; +import { + FriendRequest, + FriendRequestStatus, +} from "@monkeytype/contracts/schemas/friends"; import MonkeyError from "../utils/error"; import { WithObjectId } from "../utils/misc"; export type DBFriend = WithObjectId< - Friend & { + FriendRequest & { key: string; //sorted uid } >; @@ -16,7 +19,7 @@ export const getCollection = (): Collection => export async function get( uid: string, - status?: FriendStatus[] + status?: FriendRequestStatus[] ): Promise { let filter: Filter = { $or: [{ initiatorUid: uid }, { friendUid: uid }], @@ -67,7 +70,7 @@ export async function create( export async function updateStatus( friendUid: string, id: string, - status: FriendStatus + status: FriendRequestStatus ): Promise { const updateResult = await getCollection().updateOne( { diff --git a/packages/contracts/src/friends.ts b/packages/contracts/src/friends.ts index bd423f61e5ed..dd2b5b5201aa 100644 --- a/packages/contracts/src/friends.ts +++ b/packages/contracts/src/friends.ts @@ -5,97 +5,113 @@ import { MonkeyResponseSchema, responseWithData, } from "./schemas/api"; -import { FriendSchema, FriendStatusSchema } from "./schemas/friends"; +import { + FriendRequestSchema, + FriendRequestStatusSchema, +} from "./schemas/friends"; import { z } from "zod"; import { IdSchema } from "./schemas/util"; const c = initContract(); -export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema)); -export type GetFriendsResponse = z.infer; +export const GetFriendRequestsResponseSchema = responseWithData( + z.array(FriendRequestSchema) +); +export type GetFriendRequestsResponse = z.infer< + typeof GetFriendRequestsResponseSchema +>; -export const GetFriendsQuerySchema = z.object({ +export const GetFriendRequestsQuerySchema = z.object({ status: z - .array(FriendStatusSchema) - .or(FriendStatusSchema.transform((it) => [it])) + .array(FriendRequestStatusSchema) + .or(FriendRequestStatusSchema.transform((it) => [it])) .optional(), }); -export type GetFriendsQuery = z.infer; +export type GetFriendRequestsQuery = z.infer< + typeof GetFriendRequestsQuerySchema +>; -export const CreateFriendRequestSchema = FriendSchema.pick({ +export const CreateFriendRequestRequestSchema = FriendRequestSchema.pick({ friendName: true, }); -export type CreateFriendRequest = z.infer; +export type CreateFriendRequestRequest = z.infer< + typeof CreateFriendRequestRequestSchema +>; -export const CreateFriendResponseSchema = responseWithData(FriendSchema); -export type CreateFriendResponse = z.infer; +export const CreateFriendRequestResponseSchema = + responseWithData(FriendRequestSchema); +export type CreateFriendRequestResponse = z.infer< + typeof CreateFriendRequestResponseSchema +>; -export const FriendIdPathParamsSchema = z.object({ +export const IdPathParamsSchema = z.object({ id: IdSchema, }); -export type FriendIdPathParams = z.infer; +export type IdPathParams = z.infer; -export const UpdateFriendsRequestSchema = z.object({ - status: FriendStatusSchema.exclude(["pending"]), +export const UpdateFriendRequestsRequestSchema = z.object({ + status: FriendRequestStatusSchema.exclude(["pending"]), }); -export type UpdateFriendsRequest = z.infer; +export type UpdateFriendRequestsRequest = z.infer< + typeof UpdateFriendRequestsRequestSchema +>; export const friendsContract = c.router( { - get: { - summary: "Get friends", - description: "Get friends of the current user", + getRequests: { + summary: "get friend requests", + description: "Get friend requests of the current user", method: "GET", - path: "", - query: GetFriendsQuerySchema.strict(), + path: "/requests", + query: GetFriendRequestsQuerySchema.strict(), responses: { - 200: GetFriendsResponseSchema, + 200: GetFriendRequestsResponseSchema, }, metadata: meta({ - rateLimit: "friendsGet", + rateLimit: "friendRequestsGet", }), }, - create: { - summary: "Create friend", + createRequest: { + summary: "create friend request", description: "Request a user to become a friend", method: "POST", - path: "", - body: CreateFriendRequestSchema.strict(), + path: "/requests", + body: CreateFriendRequestRequestSchema.strict(), responses: { - 200: CreateFriendResponseSchema, + 200: CreateFriendRequestResponseSchema, 404: MonkeyResponseSchema.describe("FriendUid unknown"), 409: MonkeyResponseSchema.describe("Duplicate friend"), }, metadata: meta({ - rateLimit: "friendsCreate", + rateLimit: "friendRequestsCreate", }), }, - delete: { - summary: "Delete friend", - description: "Remove a friend", + deleteRequest: { + summary: "delete friend request", + description: "Delete a friend request", method: "DELETE", - path: "/:id", - pathParams: FriendIdPathParamsSchema.strict(), + path: "/requests/:id", + pathParams: IdPathParamsSchema.strict(), body: c.noBody(), responses: { 200: MonkeyResponseSchema, }, metadata: meta({ - rateLimit: "friendsDelete", + rateLimit: "friendRequestsDelete", }), }, - update: { - summary: "Update friend", - description: "Update a friends status", + updateRequest: { + summary: "update friend request", + description: "Update a friend request status", method: "PATCH", - path: "/:id", - pathParams: FriendIdPathParamsSchema.strict(), - body: UpdateFriendsRequestSchema.strict(), + path: "/requests/:id", + pathParams: IdPathParamsSchema.strict(), + body: UpdateFriendRequestsRequestSchema.strict(), responses: { 200: MonkeyResponseSchema, }, metadata: meta({ - rateLimit: "friendsUpdate", + rateLimit: "friendRequestsUpdate", }), }, }, diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index ca847c394c0e..cfc1a9c6789a 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -362,22 +362,22 @@ export const limits = { max: 1, }, - friendsGet: { + friendRequestsGet: { window: "hour", max: 60, }, - friendsCreate: { + friendRequestsCreate: { window: "hour", max: 60, }, - friendsDelete: { + friendRequestsDelete: { window: "hour", max: 60, }, - friendsUpdate: { + friendRequestsUpdate: { window: "hour", max: 60, }, diff --git a/packages/contracts/src/schemas/friends.ts b/packages/contracts/src/schemas/friends.ts index b16ff5678a02..29a7f58cf9f8 100644 --- a/packages/contracts/src/schemas/friends.ts +++ b/packages/contracts/src/schemas/friends.ts @@ -1,17 +1,21 @@ import { z } from "zod"; import { IdSchema } from "./util"; -export const FriendStatusSchema = z.enum(["pending", "accepted", "rejected"]); -export type FriendStatus = z.infer; +export const FriendRequestStatusSchema = z.enum([ + "pending", + "accepted", + "blocked", +]); +export type FriendRequestStatus = z.infer; -export const FriendSchema = z.object({ +export const FriendRequestSchema = z.object({ _id: IdSchema, initiatorUid: IdSchema, initiatorName: z.string(), friendUid: IdSchema, friendName: z.string(), addedAt: z.number().int().nonnegative(), - status: FriendStatusSchema, + status: FriendRequestStatusSchema, }); -export type Friend = z.infer; +export type FriendRequest = z.infer; From fdbc26cdc901525818300d31de05374f3da406aa Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 2 Jul 2025 11:34:45 +0200 Subject: [PATCH 05/28] move configuration top level, add max friends per user --- .../__tests__/api/controllers/friends.spec.ts | 26 ++++++++++++++----- backend/__tests__/dal/friends.spec.ts | 21 ++++++++++++--- backend/src/api/controllers/friends.ts | 7 +++-- backend/src/constants/base-configuration.ts | 23 ++++++++-------- backend/src/dal/friends.ts | 14 +++++++++- packages/contracts/src/friends.ts | 6 +++-- .../contracts/src/schemas/configuration.ts | 7 ++--- 7 files changed, 75 insertions(+), 29 deletions(-) diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts index 35c8fe9cc90a..8cfba6d3dda8 100644 --- a/backend/__tests__/api/controllers/friends.spec.ts +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -20,7 +20,7 @@ describe("FriendsController", () => { mockAuth.beforeEach(); }); - describe("get friends", () => { + describe("get friend requests", () => { const getFriendsMock = vi.spyOn(FriendsDal, "get"); beforeEach(() => { @@ -106,7 +106,7 @@ describe("FriendsController", () => { }); }); - describe("create friend", () => { + describe("create friend request", () => { const getUserByNameMock = vi.spyOn(UserDal, "getUserByName"); const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); const addToInboxMock = vi.spyOn(UserDal, "addToInbox"); @@ -121,7 +121,7 @@ describe("FriendsController", () => { ].forEach((it) => it.mockReset()); }); - it("should create friend", async () => { + it("should create", async () => { //GIVEN const me = { uid, name: "Bob" }; const myFriend = { uid: new ObjectId().toHexString(), name: "Kevin" }; @@ -163,6 +163,7 @@ describe("FriendsController", () => { "uid", "name", ]); + expect(createUserMock).toHaveBeenCalledWith(me, myFriend, 100); expect(addToInboxMock).toBeCalledWith( myFriend.uid, [ @@ -236,7 +237,7 @@ describe("FriendsController", () => { }); }); - describe("delete friend", () => { + describe("delete friend request", () => { const deleteByIdMock = vi.spyOn(FriendsDal, "deleteById"); beforeEach(() => { @@ -266,14 +267,14 @@ describe("FriendsController", () => { }); }); - describe("update friend", () => { + describe("update friend request", () => { const updateStatusMock = vi.spyOn(FriendsDal, "updateStatus"); beforeEach(() => { updateStatusMock.mockReset(); }); - it("should update friend", async () => { + it("should accept", async () => { //WHEN await mockApp .patch("/friends/requests/1") @@ -284,6 +285,17 @@ describe("FriendsController", () => { //THEN expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "accepted"); }); + it("should block", async () => { + //WHEN + await mockApp + .patch("/friends/requests/1") + .send({ status: "blocked" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "blocked"); + }); it("should fail for invalid status", async () => { const { body } = await mockApp @@ -319,7 +331,7 @@ describe("FriendsController", () => { async function enableFriendsEndpoints(enabled: boolean): Promise { const mockConfig = _.merge(await configuration, { - users: { friends: { enabled } }, + friends: { enabled }, }); vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( diff --git a/backend/__tests__/dal/friends.spec.ts b/backend/__tests__/dal/friends.spec.ts index a53e938d2572..8a5ef18782e3 100644 --- a/backend/__tests__/dal/friends.spec.ts +++ b/backend/__tests__/dal/friends.spec.ts @@ -96,7 +96,8 @@ describe("FriendsDal", () => { //WHEN const created = await FriendsDal.create( { uid, name: "Bob" }, - { uid: friendUid, name: "Kevin" } + { uid: friendUid, name: "Kevin" }, + 2 ); //THEN @@ -112,6 +113,18 @@ describe("FriendsDal", () => { }) ); }); + + it("should fail if maximum friends are reached", async () => { + //GIVEN + const initiatorUid = new ObjectId().toHexString(); + await createFriend({ initiatorUid }); + await createFriend({ initiatorUid }); + + //WHEN / THEM + await expect(createFriend({ initiatorUid }, 2)).rejects.toThrow( + "Maximum number of friends reached\nStack: create friend request" + ); + }); }); describe("updateStatus", () => { it("should update the status", async () => { @@ -231,7 +244,8 @@ describe("FriendsDal", () => { }); async function createFriend( - data: Partial + data: Partial, + maxFriendsPerUser = 25 ): Promise { const result = await FriendsDal.create( { @@ -241,7 +255,8 @@ async function createFriend( { uid: data.friendUid ?? new ObjectId().toHexString(), name: data.friendName ?? "user" + new ObjectId().toHexString(), - } + }, + maxFriendsPerUser ); await FriendsDal.getCollection().updateOne( { _id: result._id }, diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts index 157a2850b673..f378a8ea652f 100644 --- a/backend/src/api/controllers/friends.ts +++ b/backend/src/api/controllers/friends.ts @@ -32,7 +32,8 @@ export async function createRequest( req: MonkeyRequest ): Promise { const { uid } = req.ctx.decodedToken; - const friendName = req.body.friendName; + const { friendName } = req.body; + const { maxFriendsPerUser } = req.ctx.configuration.friends; const friend = await UserDal.getUserByName(friendName, "create friend"); @@ -46,7 +47,9 @@ export async function createRequest( ]); const result: FriendRequest = omit( - replaceObjectId(await FriendsDal.create(initiator, friend)), + replaceObjectId( + await FriendsDal.create(initiator, friend, maxFriendsPerUser) + ), "key" ); diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index 2114d0ebd040..58dc39caa89f 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -78,7 +78,6 @@ export const BASE_CONFIGURATION: Configuration = { premium: { enabled: false, }, - friends: { enabled: false }, }, rateLimiting: { badAuthentication: { @@ -104,6 +103,7 @@ export const BASE_CONFIGURATION: Configuration = { xpRewardBrackets: [], }, }, + friends: { enabled: false, maxFriendsPerUser: 100 }, }; type BaseSchema = { @@ -303,16 +303,6 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { }, }, }, - friends: { - type: "object", - label: "Friends", - fields: { - enabled: { - type: "boolean", - label: "Enabled", - }, - }, - }, signUp: { type: "boolean", label: "Sign Up Enabled", @@ -614,5 +604,16 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { }, }, }, + friends: { + type: "object", + label: "Friends", + fields: { + enabled: { type: "boolean", label: "Enabled" }, + maxFriendsPerUser: { + type: "number", + label: "Max Friends per user", + }, + }, + }, }, }; diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index 804889319f19..d9ee1ee5105e 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -33,8 +33,20 @@ export async function get( export async function create( initiator: { uid: string; name: string }, - friend: { uid: string; name: string } + friend: { uid: string; name: string }, + maxFriendsPerUser: number ): Promise { + const count = await getCollection().countDocuments({ + initiatorUid: initiator.uid, + }); + + if (count >= maxFriendsPerUser) { + throw new MonkeyError( + 409, + "Maximum number of friends reached", + "create friend request" + ); + } try { const created: DBFriend = { _id: new ObjectId(), diff --git a/packages/contracts/src/friends.ts b/packages/contracts/src/friends.ts index dd2b5b5201aa..aec0e2e1758c 100644 --- a/packages/contracts/src/friends.ts +++ b/packages/contracts/src/friends.ts @@ -80,7 +80,9 @@ export const friendsContract = c.router( responses: { 200: CreateFriendRequestResponseSchema, 404: MonkeyResponseSchema.describe("FriendUid unknown"), - 409: MonkeyResponseSchema.describe("Duplicate friend"), + 409: MonkeyResponseSchema.describe( + "Duplicate friend or max friends reached" + ), }, metadata: meta({ rateLimit: "friendRequestsCreate", @@ -121,7 +123,7 @@ export const friendsContract = c.router( metadata: meta({ openApiTags: "friends", requireConfiguration: { - path: "users.friends.enabled", + path: "friends.enabled", invalidMessage: "Friends are not available at this time.", }, }), diff --git a/packages/contracts/src/schemas/configuration.ts b/packages/contracts/src/schemas/configuration.ts index ea404cc628ce..55b645567ab3 100644 --- a/packages/contracts/src/schemas/configuration.ts +++ b/packages/contracts/src/schemas/configuration.ts @@ -83,9 +83,6 @@ export const ConfigurationSchema = z.object({ premium: z.object({ enabled: z.boolean(), }), - friends: z.object({ - enabled: z.boolean(), - }), }), admin: z.object({ endpointsEnabled: z.boolean(), @@ -126,5 +123,9 @@ export const ConfigurationSchema = z.object({ xpRewardBrackets: z.array(RewardBracketSchema), }), }), + friends: z.object({ + enabled: z.boolean(), + maxFriendsPerUser: z.number().int().nonnegative(), + }), }); export type Configuration = z.infer; From f1e38226fe95d5e67eef4f3851a0e54747f78b6e Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 2 Jul 2025 11:39:31 +0200 Subject: [PATCH 06/28] don't add new friend requests to inbox --- .../__tests__/api/controllers/friends.spec.ts | 28 ++++--------------- backend/src/api/controllers/friends.ts | 13 --------- 2 files changed, 6 insertions(+), 35 deletions(-) diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts index 8cfba6d3dda8..7bf8478ced6b 100644 --- a/backend/__tests__/api/controllers/friends.spec.ts +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -58,13 +58,11 @@ describe("FriendsController", () => { getFriendsMock.mockResolvedValue([]); //WHEN - const { body } = await mockApp + await mockApp .get("/friends/requests") .query({ status: "accepted" }) - .set("Authorization", `Bearer ${uid}`); - //.expect(200); - - console.log(body); + .set("Authorization", `Bearer ${uid}`) + .expect(200); //THEN expect(getFriendsMock).toHaveBeenCalledWith(uid, ["accepted"]); @@ -109,16 +107,12 @@ describe("FriendsController", () => { describe("create friend request", () => { const getUserByNameMock = vi.spyOn(UserDal, "getUserByName"); const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); - const addToInboxMock = vi.spyOn(UserDal, "addToInbox"); const createUserMock = vi.spyOn(FriendsDal, "create"); beforeEach(() => { - [ - getUserByNameMock, - getPartialUserMock, - addToInboxMock, - createUserMock, - ].forEach((it) => it.mockReset()); + [getUserByNameMock, getPartialUserMock, createUserMock].forEach((it) => + it.mockReset() + ); }); it("should create", async () => { @@ -164,16 +158,6 @@ describe("FriendsController", () => { "name", ]); expect(createUserMock).toHaveBeenCalledWith(me, myFriend, 100); - expect(addToInboxMock).toBeCalledWith( - myFriend.uid, - [ - expect.objectContaining({ - body: "Bob wants to be your friend. You can accept/deny this request in [FRIEND_SETTINGS]", - subject: "Friend request", - }), - ], - expect.anything() - ); }); it("should fail if user and friend are the same", async () => { diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts index f378a8ea652f..7f4ab13c888d 100644 --- a/backend/src/api/controllers/friends.ts +++ b/backend/src/api/controllers/friends.ts @@ -8,12 +8,10 @@ import { } from "@monkeytype/contracts/friends"; import { MonkeyRequest } from "../types"; import { MonkeyResponse } from "../../utils/monkey-response"; - import * as FriendsDal from "../../dal/friends"; import * as UserDal from "../../dal/user"; import { replaceObjectId, replaceObjectIds } from "../../utils/misc"; import MonkeyError from "../../utils/error"; -import { buildMonkeyMail } from "../../utils/monkey-mail"; import { omit } from "lodash"; import { FriendRequest } from "@monkeytype/contracts/schemas/friends"; @@ -53,17 +51,6 @@ export async function createRequest( "key" ); - //notify user - const mail = buildMonkeyMail({ - subject: "Friend request", - body: `${initiator.name} wants to be your friend. You can accept/deny this request in [FRIEND_SETTINGS]`, - }); - await UserDal.addToInbox( - friend.uid, - [mail], - req.ctx.configuration.users.inbox - ); - return new MonkeyResponse("Friend created", result); } From 358cfff4e440c4c63853d4a4c9cdcfe04ccdf0d5 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 2 Jul 2025 13:04:53 +0200 Subject: [PATCH 07/28] filter friend requests by status and type --- .../__tests__/api/controllers/friends.spec.ts | 72 +++++++++++++++++-- backend/__tests__/dal/friends.spec.ts | 51 +++++++------ backend/src/api/controllers/friends.ts | 10 ++- backend/src/dal/friends.ts | 29 +++++--- packages/contracts/src/friends.ts | 5 ++ packages/contracts/src/schemas/friends.ts | 3 + 6 files changed, 136 insertions(+), 34 deletions(-) diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts index 7bf8478ced6b..944660dce45f 100644 --- a/backend/__tests__/api/controllers/friends.spec.ts +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -21,7 +21,7 @@ describe("FriendsController", () => { }); describe("get friend requests", () => { - const getFriendsMock = vi.spyOn(FriendsDal, "get"); + const getFriendsMock = vi.spyOn(FriendsDal, "getRequests"); beforeEach(() => { getFriendsMock.mockReset(); @@ -50,7 +50,10 @@ describe("FriendsController", () => { //THEN expect(body.data).toEqual([{ ...friend, _id: friend._id.toHexString() }]); - expect(getFriendsMock).toHaveBeenCalledWith(uid, undefined); + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + friendUid: uid, + }); }); it("should filter by status", async () => { @@ -65,8 +68,13 @@ describe("FriendsController", () => { .expect(200); //THEN - expect(getFriendsMock).toHaveBeenCalledWith(uid, ["accepted"]); + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + friendUid: uid, + status: ["accepted"], + }); }); + it("should filter by multiple status", async () => { //GIVEN getFriendsMock.mockResolvedValue([]); @@ -79,7 +87,63 @@ describe("FriendsController", () => { .expect(200); //THEN - expect(getFriendsMock).toHaveBeenCalledWith(uid, ["accepted", "blocked"]); + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + friendUid: uid, + status: ["accepted", "blocked"], + }); + }); + + it("should filter by type incoming", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/friends/requests") + .query({ type: "incoming" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith({ + friendUid: uid, + }); + }); + + it("should filter by type outgoing", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/friends/requests") + .query({ type: "outgoing" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + }); + }); + + it("should filter by multiple types", async () => { + //GIVEN + getFriendsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/friends/requests") + .query({ type: ["incoming", "outgoing"] }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getFriendsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + friendUid: uid, + }); }); it("should fail if friends endpoints are disabled", async () => { diff --git a/backend/__tests__/dal/friends.spec.ts b/backend/__tests__/dal/friends.spec.ts index 8a5ef18782e3..d224268cdad6 100644 --- a/backend/__tests__/dal/friends.spec.ts +++ b/backend/__tests__/dal/friends.spec.ts @@ -7,7 +7,7 @@ describe("FriendsDal", () => { FriendsDal.createIndicies(); }); - describe("get", () => { + describe("getRequests", () => { it("get by uid", async () => { //GIVEN const uid = new ObjectId().toHexString(); @@ -16,11 +16,11 @@ describe("FriendsDal", () => { const friendOne = await createFriend({ friendUid: uid }); const _decoy = await createFriend({}); - //WHEN - const myFriends = await FriendsDal.get(uid); + //WHEN / THEM - //THEN - expect(myFriends).toStrictEqual([initOne, initTwo, friendOne]); + expect( + await FriendsDal.getRequests({ initiatorUid: uid, friendUid: uid }) + ).toStrictEqual([initOne, initTwo, friendOne]); }); it("get by uid and status", async () => { @@ -50,15 +50,15 @@ describe("FriendsDal", () => { const _decoy = await createFriend({ status: "accepted" }); - //WHEN - const nonPending = await FriendsDal.get(uid, ["accepted", "blocked"]); + //WHEN / THEN - //THEN - expect(nonPending).toStrictEqual([ - initAccepted, - initBlocked, - friendAccepted, - ]); + expect( + await FriendsDal.getRequests({ + initiatorUid: uid, + friendUid: uid, + status: ["accepted", "blocked"], + }) + ).toStrictEqual([initAccepted, initBlocked, friendAccepted]); }); }); @@ -134,14 +134,14 @@ describe("FriendsDal", () => { friendUid: uid, }); const second = await createFriend({ - initiatorUid: uid, + friendUid: uid, }); //WHEN await FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted"); //THEN - expect(await FriendsDal.get(uid)).toEqual( + expect(await FriendsDal.getRequests({ friendUid: uid })).toEqual( expect.arrayContaining([{ ...first, status: "accepted" }, second]) ); @@ -177,7 +177,9 @@ describe("FriendsDal", () => { await FriendsDal.deleteById(uid, first._id.toHexString()); //THEN - expect(await FriendsDal.get(uid)).toStrictEqual([second]); + expect(await FriendsDal.getRequests({ initiatorUid: uid })).toStrictEqual( + [second] + ); }); it("should fail if uid does not match", async () => { //GIVEN @@ -206,8 +208,13 @@ describe("FriendsDal", () => { await FriendsDal.deleteByUid(uid); //THEN - expect(await FriendsDal.get(uid)).toEqual([]); - expect(await FriendsDal.get(decoy.initiatorUid)).toEqual([decoy]); + expect( + await FriendsDal.getRequests({ initiatorUid: uid, friendUid: uid }) + ).toEqual([]); + + expect( + await FriendsDal.getRequests({ initiatorUid: decoy.initiatorUid }) + ).toEqual([decoy]); }); }); describe("updateName", () => { @@ -232,13 +239,17 @@ describe("FriendsDal", () => { await FriendsDal.updateName(uid, "King Bob"); //THEN - expect(await FriendsDal.get(uid)).toEqual([ + expect( + await FriendsDal.getRequests({ initiatorUid: uid, friendUid: uid }) + ).toEqual([ { ...initOne, initiatorName: "King Bob" }, { ...initTwo, initiatorName: "King Bob" }, { ...friendOne, friendName: "King Bob" }, ]); - expect(await FriendsDal.get(decoy.initiatorUid)).toEqual([decoy]); + expect( + await FriendsDal.getRequests({ initiatorUid: decoy.initiatorUid }) + ).toEqual([decoy]); }); }); }); diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts index 7f4ab13c888d..99c4373e3c17 100644 --- a/backend/src/api/controllers/friends.ts +++ b/backend/src/api/controllers/friends.ts @@ -19,9 +19,15 @@ export async function getRequests( req: MonkeyRequest ): Promise { const { uid } = req.ctx.decodedToken; - const status = req.query.status; + const { status, type } = req.query; - const results = await FriendsDal.get(uid, status); + const results = await FriendsDal.getRequests({ + initiatorUid: + type === undefined || type.includes("outgoing") ? uid : undefined, + friendUid: + type === undefined || type?.includes("incoming") ? uid : undefined, + status: status, + }); return new MonkeyResponse("Friends retrieved", replaceObjectIds(results)); } diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index d9ee1ee5105e..9b1efa2f4ae2 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -17,15 +17,28 @@ export type DBFriend = WithObjectId< export const getCollection = (): Collection => db.collection("friends"); -export async function get( - uid: string, - status?: FriendRequestStatus[] -): Promise { - let filter: Filter = { - $or: [{ initiatorUid: uid }, { friendUid: uid }], - }; +export async function getRequests(options: { + initiatorUid?: string; + friendUid?: string; + status?: FriendRequestStatus[]; +}): Promise { + const { initiatorUid, friendUid, status } = options; + + if (initiatorUid === undefined && friendUid === undefined) + throw new Error("no filter provided"); + + let filter: Filter = { $or: [] }; + + if (initiatorUid !== undefined) { + filter.$or?.push({ initiatorUid }); + } + + if (friendUid !== undefined) { + filter.$or?.push({ friendUid }); + } + if (status !== undefined) { - filter = { $and: [filter, { status: { $in: status } }] }; + filter.status = { $in: status }; } return await getCollection().find(filter).toArray(); diff --git a/packages/contracts/src/friends.ts b/packages/contracts/src/friends.ts index aec0e2e1758c..018524487286 100644 --- a/packages/contracts/src/friends.ts +++ b/packages/contracts/src/friends.ts @@ -8,6 +8,7 @@ import { import { FriendRequestSchema, FriendRequestStatusSchema, + FriendRequestTypeSchema, } from "./schemas/friends"; import { z } from "zod"; import { IdSchema } from "./schemas/util"; @@ -26,6 +27,10 @@ export const GetFriendRequestsQuerySchema = z.object({ .array(FriendRequestStatusSchema) .or(FriendRequestStatusSchema.transform((it) => [it])) .optional(), + type: z + .array(FriendRequestTypeSchema) + .or(FriendRequestTypeSchema.transform((it) => [it])) + .optional(), }); export type GetFriendRequestsQuery = z.infer< typeof GetFriendRequestsQuerySchema diff --git a/packages/contracts/src/schemas/friends.ts b/packages/contracts/src/schemas/friends.ts index 29a7f58cf9f8..fbbc00f4f525 100644 --- a/packages/contracts/src/schemas/friends.ts +++ b/packages/contracts/src/schemas/friends.ts @@ -8,6 +8,9 @@ export const FriendRequestStatusSchema = z.enum([ ]); export type FriendRequestStatus = z.infer; +export const FriendRequestTypeSchema = z.enum(["incoming", "outgoing"]); +export type FriendRequestType = z.infer; + export const FriendRequestSchema = z.object({ _id: IdSchema, initiatorUid: IdSchema, From 9a8e678b77f8cf377ef18afc21e3e3f789120779 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 7 Jul 2025 12:18:58 +0200 Subject: [PATCH 08/28] allow friend to delete request if not blocked --- backend/__tests__/dal/friends.spec.ts | 41 +++++++++++++++++++++++++-- backend/src/dal/friends.ts | 17 ++++++++--- packages/contracts/src/friends.ts | 2 +- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/backend/__tests__/dal/friends.spec.ts b/backend/__tests__/dal/friends.spec.ts index d224268cdad6..cd9379efdbde 100644 --- a/backend/__tests__/dal/friends.spec.ts +++ b/backend/__tests__/dal/friends.spec.ts @@ -85,7 +85,7 @@ describe("FriendsDal", () => { initiatorUid: first.friendUid, friendUid: uid, }) - ).rejects.toThrow("Duplicate friend"); + ).rejects.toThrow("Duplicate friend or blocked"); }); it("should create", async () => { @@ -163,7 +163,7 @@ describe("FriendsDal", () => { }); describe("deleteById", () => { - it("should delete", async () => { + it("should delete by initiator", async () => { //GIVEN const uid = new ObjectId().toHexString(); const first = await createFriend({ @@ -181,6 +181,27 @@ describe("FriendsDal", () => { [second] ); }); + + it("should delete by friend", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + friendUid: uid, + }); + const second = await createFriend({ + friendUid: uid, + status: "accepted", + }); + + //WHEN + await FriendsDal.deleteById(uid, first._id.toHexString()); + + //THEN + expect( + await FriendsDal.getRequests({ initiatorUid: second.initiatorUid }) + ).toStrictEqual([second]); + }); + it("should fail if uid does not match", async () => { //GIVEN const uid = new ObjectId().toHexString(); @@ -191,7 +212,21 @@ describe("FriendsDal", () => { //WHEN / THEN await expect( FriendsDal.deleteById("Bob", first._id.toHexString()) - ).rejects.toThrow("Friend not found"); + ).rejects.toThrow("Cannot be deleted"); + }); + + it("should fail if friend deletes blocked", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createFriend({ + friendUid: uid, + status: "blocked", + }); + + //WHEN / THEN + await expect( + FriendsDal.deleteById(uid, first._id.toHexString()) + ).rejects.toThrow("Cannot be deleted"); }); }); diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index 9b1efa2f4ae2..28e8cf145507 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -78,7 +78,7 @@ export async function create( } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (e.name === "MongoServerError" && e.code === 11000) { - throw new MonkeyError(409, "Duplicate friend"); + throw new MonkeyError(409, "Duplicate friend or blocked"); } throw e; @@ -121,12 +121,21 @@ export async function deleteById( id: string ): Promise { const deletionResult = await getCollection().deleteOne({ - _id: new ObjectId(id), - initiatorUid, + $and: [ + { + _id: new ObjectId(id), + }, + { + $or: [ + { initiatorUid }, + { status: { $in: ["accepted", "pending"] }, friendUid: initiatorUid }, + ], + }, + ], }); if (deletionResult.deletedCount === 0) { - throw new MonkeyError(404, "Friend not found"); + throw new MonkeyError(404, "Cannot be deleted"); } } diff --git a/packages/contracts/src/friends.ts b/packages/contracts/src/friends.ts index 018524487286..04c9eaca10d9 100644 --- a/packages/contracts/src/friends.ts +++ b/packages/contracts/src/friends.ts @@ -86,7 +86,7 @@ export const friendsContract = c.router( 200: CreateFriendRequestResponseSchema, 404: MonkeyResponseSchema.describe("FriendUid unknown"), 409: MonkeyResponseSchema.describe( - "Duplicate friend or max friends reached" + "Duplicate friend, blocked or max friends reached" ), }, metadata: meta({ From 0407a9fc621a594484ca6a6d7ded8b5075fee0c9 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 11 Jul 2025 15:23:24 +0200 Subject: [PATCH 09/28] add friends list endpoint --- .../__tests__/api/controllers/friends.spec.ts | 4 +- backend/__tests__/dal/friends.spec.ts | 103 +++++++++- backend/src/api/controllers/friends.ts | 15 +- backend/src/api/routes/friends.ts | 3 + backend/src/dal/friends.ts | 178 +++++++++++++++++- packages/contracts/src/friends.ts | 16 ++ packages/contracts/src/rate-limit/index.ts | 5 + packages/contracts/src/schemas/friends.ts | 21 +++ 8 files changed, 334 insertions(+), 11 deletions(-) diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts index 944660dce45f..8aef0af38e7e 100644 --- a/backend/__tests__/api/controllers/friends.spec.ts +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -29,7 +29,7 @@ describe("FriendsController", () => { it("should get for the current user", async () => { //GIVEN - const friend: FriendsDal.DBFriend = { + const friend: FriendsDal.DBFriendRequest = { _id: new ObjectId(), addedAt: 42, initiatorUid: new ObjectId().toHexString(), @@ -186,7 +186,7 @@ describe("FriendsController", () => { getUserByNameMock.mockResolvedValue(myFriend as any); getPartialUserMock.mockResolvedValue(me as any); - const result: FriendsDal.DBFriend = { + const result: FriendsDal.DBFriendRequest = { _id: new ObjectId(), addedAt: 42, initiatorUid: me.uid, diff --git a/backend/__tests__/dal/friends.spec.ts b/backend/__tests__/dal/friends.spec.ts index cd9379efdbde..6f7a4b3e4da0 100644 --- a/backend/__tests__/dal/friends.spec.ts +++ b/backend/__tests__/dal/friends.spec.ts @@ -1,6 +1,8 @@ import { ObjectId } from "mongodb"; import * as FriendsDal from "../../src/dal/friends"; +import { createUser } from "../__testData__/users"; +import { pb } from "./leaderboards.spec"; describe("FriendsDal", () => { beforeAll(async () => { @@ -287,12 +289,109 @@ describe("FriendsDal", () => { ).toEqual([decoy]); }); }); + + describe("getFriends", () => { + it("get list of friends", async () => { + //GIVEN + + const me = await createUser({ name: "Me" }); + const uid = me.uid; + + const friendOne = await createUser({ + name: "One", + personalBests: { + time: { "15": [pb(100)], "60": [pb(85), pb(90)] }, + } as any, + }); + const friendOneRequest = await createFriend({ + initiatorUid: uid, + friendUid: friendOne.uid, + status: "accepted", + addedAt: 100, + }); + const friendTwo = await createUser({ + name: "Two", + discordId: "discordId", + discordAvatar: "discordAvatar", + timeTyping: 600, + startedTests: 150, + completedTests: 125, + streak: { + length: 10, + maxLength: 50, + } as any, + xp: 42, + }); + const friendTwoRequest = await createFriend({ + initiatorUid: uid, + friendUid: friendTwo.uid, + status: "accepted", + addedAt: 200, + }); + + const friendThree = await createUser({ name: "Three" }); + const friendThreeRequest = await createFriend({ + friendUid: uid, + initiatorUid: friendThree.uid, + status: "accepted", + addedAt: 300, + }); + + //non accepted + await createFriend({ friendUid: uid, status: "pending" }); + await createFriend({ initiatorUid: uid, status: "blocked" }); + + //WHEN + const friends = await FriendsDal.getFriends(uid); + + console.log(friends); + + //THEN + expect(friends).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + uid: friendOne.uid, + name: "One", + addedAt: 100, + friendRequestId: friendOneRequest._id, + // oxlint-disable-next-line no-non-null-assertion + top15: friendOne.personalBests.time["15"]![0] as any, + // oxlint-disable-next-line no-non-null-assertion + top60: friendOne.personalBests.time["60"]![1] as any, + }), + expect.objectContaining({ + uid: friendTwo.uid, + name: "Two", + addedAt: 200, + friendRequestId: friendTwoRequest._id, + discordId: friendTwo.discordId, + discordAvatar: friendTwo.discordAvatar, + timeTyping: friendTwo.timeTyping, + startedTests: friendTwo.startedTests, + completedTests: friendTwo.completedTests, + streak: friendTwo.streak, + xp: friendTwo.xp, + }), + expect.objectContaining({ + uid: friendThree.uid, + name: "Three", + addedAt: 300, + friendRequestId: friendThreeRequest._id, + }), + expect.objectContaining({ + uid: me.uid, + name: "Me", + }), + ]) + ); + }); + }); }); async function createFriend( - data: Partial, + data: Partial, maxFriendsPerUser = 25 -): Promise { +): Promise { const result = await FriendsDal.create( { uid: data.initiatorUid ?? new ObjectId().toHexString(), diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts index 99c4373e3c17..ca2556aeac86 100644 --- a/backend/src/api/controllers/friends.ts +++ b/backend/src/api/controllers/friends.ts @@ -3,6 +3,7 @@ import { CreateFriendRequestResponse, GetFriendRequestsQuery, GetFriendRequestsResponse, + GetFriendsResponse, IdPathParams, UpdateFriendRequestsRequest, } from "@monkeytype/contracts/friends"; @@ -29,7 +30,10 @@ export async function getRequests( status: status, }); - return new MonkeyResponse("Friends retrieved", replaceObjectIds(results)); + return new MonkeyResponse( + "Friend requests retrieved", + replaceObjectIds(results) + ); } export async function createRequest( @@ -82,3 +86,12 @@ export async function updateRequest( return new MonkeyResponse("Friend updated", null); } + +export async function getFriends( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const data = await FriendsDal.getFriends(uid); + + return new MonkeyResponse("Friends retrieved", data); +} diff --git a/backend/src/api/routes/friends.ts b/backend/src/api/routes/friends.ts index fdd5c7189a69..a4fb3502feb2 100644 --- a/backend/src/api/routes/friends.ts +++ b/backend/src/api/routes/friends.ts @@ -18,4 +18,7 @@ export default s.router(friendsContract, { updateRequest: { handler: async (r) => callController(FriendsController.updateRequest)(r), }, + getFriends: { + handler: async (r) => callController(FriendsController.getFriends)(r), + }, }); diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index 28e8cf145507..df4b56411089 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -1,33 +1,36 @@ import { Collection, Filter, ObjectId } from "mongodb"; import * as db from "../init/db"; import { + Friend, FriendRequest, FriendRequestStatus, } from "@monkeytype/contracts/schemas/friends"; import MonkeyError from "../utils/error"; import { WithObjectId } from "../utils/misc"; -export type DBFriend = WithObjectId< +export type DBFriendRequest = WithObjectId< FriendRequest & { key: string; //sorted uid } >; +export type DBFriend = Friend; + // Export for use in tests -export const getCollection = (): Collection => +export const getCollection = (): Collection => db.collection("friends"); export async function getRequests(options: { initiatorUid?: string; friendUid?: string; status?: FriendRequestStatus[]; -}): Promise { +}): Promise { const { initiatorUid, friendUid, status } = options; if (initiatorUid === undefined && friendUid === undefined) throw new Error("no filter provided"); - let filter: Filter = { $or: [] }; + let filter: Filter = { $or: [] }; if (initiatorUid !== undefined) { filter.$or?.push({ initiatorUid }); @@ -48,7 +51,7 @@ export async function create( initiator: { uid: string; name: string }, friend: { uid: string; name: string }, maxFriendsPerUser: number -): Promise { +): Promise { const count = await getCollection().countDocuments({ initiatorUid: initiator.uid, }); @@ -61,7 +64,7 @@ export async function create( ); } try { - const created: DBFriend = { + const created: DBFriendRequest = { _id: new ObjectId(), key: getKey(initiator.uid, friend.uid), initiatorUid: initiator.uid, @@ -177,6 +180,169 @@ function getKey(initiatorUid: string, friendUid: string): string { return ids.join("/"); } +export async function getFriends(uid: string): Promise { + return (await getCollection() + .aggregate([ + { + $match: { + //uid is friend or initiator + $and: [ + { + $or: [{ initiatorUid: uid }, { friendUid: uid }], + status: "accepted", + }, + ], + }, + }, + { + $project: { + friendUid: true, + initiatorUid: true, + addedAt: true, + }, + }, + { + $addFields: { + //pick the other user, not uid + uid: { + $cond: { + if: { $eq: ["$friendUid", uid] }, + // oxlint-disable-next-line no-thenable + then: "$initiatorUid", + else: "$friendUid", + }, + }, + }, + }, + // we want to fetch the data for our uid as well, add it to the list of documents + // workaround for missing unionWith + $documents in mongodb 5.0 + { + $group: { + _id: null, + data: { + $push: { + uid: "$uid", + addedAt: "$addedAt", + friendRequestId: "$_id", + }, + }, + }, + }, + { + $project: { + data: { + $concatArrays: ["$data", [{ uid }]], + }, + }, + }, + { + $unwind: "$data", + }, + + /* end of workaround, this is the replacement for >= 5.1 + + { $addFields: { friendRequestId: "$_id" } }, + { $project: { uid: true, addedAt: true, friendRequestId: true } }, + { + $unionWith: { + pipeline: [{ $documents: [{ uid }] }], + }, + }, + */ + + { + $lookup: { + /* query users to get the friend data */ + from: "users", + localField: "data.uid", //just uid if we remove the workaround above + foreignField: "uid", + as: "result", + let: { + addedAt: "$data.addedAt", //just $addedAt if we remove the workaround above + friendRequestId: "$data.friendRequestId", //just $friendRequestId if we remove the workaround above + }, + pipeline: [ + { + $project: { + _id: false, + uid: true, + friendRequestId: true, + name: true, + discordId: true, + discordAvatar: true, + startedTests: true, + completedTests: true, + timeTyping: true, + xp: true, + streak: true, + personalBests: true, + }, + }, + { + $addFields: { + addedAt: "$$addedAt", + friendRequestId: "$$friendRequestId", + top15: { + $reduce: { + //find highest wpm from time 15 PBs + input: "$personalBests.time.15", + initialValue: {}, + in: { + $cond: [ + { $gte: ["$$this.wpm", "$$value.wpm"] }, + "$$this", + "$$value", + ], + }, + }, + }, + top60: { + $reduce: { + //find highest wpm from time 60 PBs + input: "$personalBests.time.60", + initialValue: {}, + in: { + $cond: [ + { $gte: ["$$this.wpm", "$$value.wpm"] }, + "$$this", + "$$value", + ], + }, + }, + }, + }, + }, + { + $addFields: { + //remove nulls + top15: { $ifNull: ["$top15", "$$REMOVE"] }, + top60: { $ifNull: ["$top60", "$$REMOVE"] }, + addedAt: "$addedAt", + }, + }, + { + $project: { + personalBests: false, + }, + }, + ], + }, + }, + { + $replaceRoot: { + newRoot: { + $cond: [ + { $gt: [{ $size: "$result" }, 0] }, + { $first: "$result" }, + {}, // empty document fallback, this can happen if the user is not present + ], + }, + }, + }, + ]) + .toArray()) as DBFriend[]; +} + export async function createIndicies(): Promise { //index used for search await getCollection().createIndex({ initiatorUid: 1 }); diff --git a/packages/contracts/src/friends.ts b/packages/contracts/src/friends.ts index 04c9eaca10d9..206c17f6ffce 100644 --- a/packages/contracts/src/friends.ts +++ b/packages/contracts/src/friends.ts @@ -9,6 +9,7 @@ import { FriendRequestSchema, FriendRequestStatusSchema, FriendRequestTypeSchema, + FriendSchema, } from "./schemas/friends"; import { z } from "zod"; import { IdSchema } from "./schemas/util"; @@ -61,6 +62,9 @@ export type UpdateFriendRequestsRequest = z.infer< typeof UpdateFriendRequestsRequestSchema >; +export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema)); +export type GetFriendsResponse = z.infer; + export const friendsContract = c.router( { getRequests: { @@ -121,6 +125,18 @@ export const friendsContract = c.router( rateLimit: "friendRequestsUpdate", }), }, + getFriends: { + summary: "get friends", + description: "get friends list", + method: "GET", + path: "/", + responses: { + 200: GetFriendsResponseSchema, + }, + metadata: meta({ + rateLimit: "friendGet", + }), + }, }, { pathPrefix: "/friends", diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index cfc1a9c6789a..0701b80ffafa 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -381,6 +381,11 @@ export const limits = { window: "hour", max: 60, }, + + friendGet: { + window: "hour", + max: 60, + }, } satisfies Record; export type RateLimiterId = keyof typeof limits; diff --git a/packages/contracts/src/schemas/friends.ts b/packages/contracts/src/schemas/friends.ts index fbbc00f4f525..675c7deff472 100644 --- a/packages/contracts/src/schemas/friends.ts +++ b/packages/contracts/src/schemas/friends.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import { IdSchema } from "./util"; +import { UserSchema } from "./users"; +import { PersonalBestSchema } from "./shared"; export const FriendRequestStatusSchema = z.enum([ "pending", @@ -22,3 +24,22 @@ export const FriendRequestSchema = z.object({ }); export type FriendRequest = z.infer; + +export const FriendSchema = UserSchema.pick({ + uid: true, + name: true, + discordId: true, + discordAvatar: true, + startedTests: true, + completedTests: true, + timeTyping: true, + xp: true, + streak: true, +}).extend({ + addedAt: z.number().int().nonnegative().optional(), + friendRequestId: IdSchema.optional(), + top15: PersonalBestSchema.optional(), + top60: PersonalBestSchema.optional(), +}); + +export type Friend = z.infer; From 218ed100625380649e85011efde9a43059f3b320 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 14 Jul 2025 13:16:51 +0200 Subject: [PATCH 10/28] add selectedBadgeId to friends list --- backend/__tests__/dal/friends.spec.ts | 7 +++++++ backend/src/dal/friends.ts | 18 ++++++++++++++++++ packages/contracts/src/schemas/friends.ts | 1 + 3 files changed, 26 insertions(+) diff --git a/backend/__tests__/dal/friends.spec.ts b/backend/__tests__/dal/friends.spec.ts index 6f7a4b3e4da0..0eeb44fff386 100644 --- a/backend/__tests__/dal/friends.spec.ts +++ b/backend/__tests__/dal/friends.spec.ts @@ -302,6 +302,9 @@ describe("FriendsDal", () => { personalBests: { time: { "15": [pb(100)], "60": [pb(85), pb(90)] }, } as any, + inventory: { + badges: [{ id: 42, selected: true }, { id: 23 }, { id: 5 }], + }, }); const friendOneRequest = await createFriend({ initiatorUid: uid, @@ -321,6 +324,9 @@ describe("FriendsDal", () => { maxLength: 50, } as any, xp: 42, + inventory: { + badges: [{ id: 23 }, { id: 5 }], + }, }); const friendTwoRequest = await createFriend({ initiatorUid: uid, @@ -358,6 +364,7 @@ describe("FriendsDal", () => { top15: friendOne.personalBests.time["15"]![0] as any, // oxlint-disable-next-line no-non-null-assertion top60: friendOne.personalBests.time["60"]![1] as any, + badgeId: 42, }), expect.objectContaining({ uid: friendTwo.uid, diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index df4b56411089..4b6c20b8f456 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -276,6 +276,7 @@ export async function getFriends(uid: string): Promise { xp: true, streak: true, personalBests: true, + "inventory.badges": true, }, }, { @@ -310,6 +311,21 @@ export async function getFriends(uid: string): Promise { }, }, }, + badgeId: { + $first: { + $map: { + input: { + $filter: { + input: "$inventory.badges", + as: "badge", + cond: { $eq: ["$$badge.selected", true] }, + }, + }, + as: "selectedBadge", + in: "$$selectedBadge.id", + }, + }, + }, }, }, { @@ -317,12 +333,14 @@ export async function getFriends(uid: string): Promise { //remove nulls top15: { $ifNull: ["$top15", "$$REMOVE"] }, top60: { $ifNull: ["$top60", "$$REMOVE"] }, + badgeId: { $ifNull: ["$badgeId", "$$REMOVE"] }, addedAt: "$addedAt", }, }, { $project: { personalBests: false, + inventory: false, }, }, ], diff --git a/packages/contracts/src/schemas/friends.ts b/packages/contracts/src/schemas/friends.ts index 675c7deff472..80bac7fe8951 100644 --- a/packages/contracts/src/schemas/friends.ts +++ b/packages/contracts/src/schemas/friends.ts @@ -40,6 +40,7 @@ export const FriendSchema = UserSchema.pick({ friendRequestId: IdSchema.optional(), top15: PersonalBestSchema.optional(), top60: PersonalBestSchema.optional(), + badgeId: z.number().int().optional(), }); export type Friend = z.infer; From 477cbb4af7360bd3ca4477312e2963f5eb20e81d Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 17 Jul 2025 11:37:09 +0200 Subject: [PATCH 11/28] add userflags to Friends --- .../__tests__/api/controllers/friends.spec.ts | 65 ++++++++++ backend/__tests__/dal/friends.spec.ts | 112 +++++++++--------- backend/src/api/controllers/friends.ts | 7 ++ backend/src/dal/friends.ts | 45 +++++-- packages/contracts/src/schemas/friends.ts | 3 + 5 files changed, 169 insertions(+), 63 deletions(-) diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts index 8aef0af38e7e..113a8162453f 100644 --- a/backend/__tests__/api/controllers/friends.spec.ts +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -375,6 +375,61 @@ describe("FriendsController", () => { .expect(401); }); }); + describe("get friends", () => { + const getFriendsMock = vi.spyOn(FriendsDal, "getFriends"); + + beforeEach(() => { + getFriendsMock.mockReset(); + }); + + it("gets with premium enabled", async () => { + //GIVEN + enablePremiumFeatures(true); + const friend: FriendsDal.DBFriend = { + name: "Bob", + isPremium: true, + } as any; + getFriendsMock.mockResolvedValue([friend]); + + //WHEN + const { body } = await mockApp + .get("/friends") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual([{ name: "Bob", isPremium: true }]); + }); + + it("gets with premium disabled", async () => { + //GIVEN + enablePremiumFeatures(false); + const friend: FriendsDal.DBFriend = { + name: "Bob", + isPremium: true, + } as any; + getFriendsMock.mockResolvedValue([friend]); + + //WHEN + const { body } = await mockApp + .get("/friends") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual([{ name: "Bob" }]); + }); + + it("should fail if friends endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp.get("/friends").set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp.get("/friends").expect(401); + }); + }); }); async function enableFriendsEndpoints(enabled: boolean): Promise { @@ -391,3 +446,13 @@ async function expectFailForDisabledEndpoint(call: SuperTest): Promise { const { body } = await call.expect(503); expect(body.message).toEqual("Friends are not available at this time."); } + +async function enablePremiumFeatures(premium: boolean): Promise { + const mockConfig = _.merge(await configuration, { + users: { premium: { enabled: premium } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/__tests__/dal/friends.spec.ts b/backend/__tests__/dal/friends.spec.ts index 0eeb44fff386..b4ef7dceb7de 100644 --- a/backend/__tests__/dal/friends.spec.ts +++ b/backend/__tests__/dal/friends.spec.ts @@ -103,17 +103,16 @@ describe("FriendsDal", () => { ); //THEN - expect(created).toEqual( - expect.objectContaining({ - initiatorUid: uid, - initiatorName: "Bob", - friendUid: friendUid, - friendName: "Kevin", - addedAt: now, - status: "pending", - key: `${uid}/${friendUid}`, - }) - ); + expect(created).toEqual({ + _id: created._id, + initiatorUid: uid, + initiatorName: "Bob", + friendUid: friendUid, + friendName: "Kevin", + addedAt: now, + status: "pending", + key: `${uid}/${friendUid}`, + }); }); it("should fail if maximum friends are reached", async () => { @@ -143,9 +142,10 @@ describe("FriendsDal", () => { await FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted"); //THEN - expect(await FriendsDal.getRequests({ friendUid: uid })).toEqual( - expect.arrayContaining([{ ...first, status: "accepted" }, second]) - ); + expect(await FriendsDal.getRequests({ friendUid: uid })).toEqual([ + { ...first, status: "accepted" }, + second, + ]); //can update twice to the same status await FriendsDal.updateStatus(uid, first._id.toHexString(), "accepted"); @@ -305,6 +305,9 @@ describe("FriendsDal", () => { inventory: { badges: [{ id: 42, selected: true }, { id: 23 }, { id: 5 }], }, + banned: true, + lbOptOut: true, + premium: { expirationTimestamp: -1 } as any, }); const friendOneRequest = await createFriend({ initiatorUid: uid, @@ -327,6 +330,7 @@ describe("FriendsDal", () => { inventory: { badges: [{ id: 23 }, { id: 5 }], }, + premium: { expirationTimestamp: Date.now() + 5000 } as any, }); const friendTwoRequest = await createFriend({ initiatorUid: uid, @@ -350,47 +354,47 @@ describe("FriendsDal", () => { //WHEN const friends = await FriendsDal.getFriends(uid); - console.log(friends); - //THEN - expect(friends).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - uid: friendOne.uid, - name: "One", - addedAt: 100, - friendRequestId: friendOneRequest._id, - // oxlint-disable-next-line no-non-null-assertion - top15: friendOne.personalBests.time["15"]![0] as any, - // oxlint-disable-next-line no-non-null-assertion - top60: friendOne.personalBests.time["60"]![1] as any, - badgeId: 42, - }), - expect.objectContaining({ - uid: friendTwo.uid, - name: "Two", - addedAt: 200, - friendRequestId: friendTwoRequest._id, - discordId: friendTwo.discordId, - discordAvatar: friendTwo.discordAvatar, - timeTyping: friendTwo.timeTyping, - startedTests: friendTwo.startedTests, - completedTests: friendTwo.completedTests, - streak: friendTwo.streak, - xp: friendTwo.xp, - }), - expect.objectContaining({ - uid: friendThree.uid, - name: "Three", - addedAt: 300, - friendRequestId: friendThreeRequest._id, - }), - expect.objectContaining({ - uid: me.uid, - name: "Me", - }), - ]) - ); + expect(friends).toEqual([ + { + uid: friendOne.uid, + name: "One", + addedAt: 100, + friendRequestId: friendOneRequest._id, + // oxlint-disable-next-line no-non-null-assertion + top15: friendOne.personalBests.time["15"]![0] as any, + // oxlint-disable-next-line no-non-null-assertion + top60: friendOne.personalBests.time["60"]![1] as any, + badgeId: 42, + banned: true, + lbOptOut: true, + isPremium: true, + }, + { + uid: friendTwo.uid, + name: "Two", + addedAt: 200, + friendRequestId: friendTwoRequest._id, + discordId: friendTwo.discordId, + discordAvatar: friendTwo.discordAvatar, + timeTyping: friendTwo.timeTyping, + startedTests: friendTwo.startedTests, + completedTests: friendTwo.completedTests, + streak: friendTwo.streak, + xp: friendTwo.xp, + isPremium: true, + }, + { + uid: friendThree.uid, + name: "Three", + addedAt: 300, + friendRequestId: friendThreeRequest._id, + }, + { + uid: me.uid, + name: "Me", + }, + ]); }); }); }); diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts index ca2556aeac86..ea742dac3041 100644 --- a/backend/src/api/controllers/friends.ts +++ b/backend/src/api/controllers/friends.ts @@ -91,7 +91,14 @@ export async function getFriends( req: MonkeyRequest ): Promise { const { uid } = req.ctx.decodedToken; + const premiumEnabled = req.ctx.configuration.users.premium.enabled; const data = await FriendsDal.getFriends(uid); + if (!premiumEnabled) { + for (const friend of data) { + delete friend.isPremium; + } + } + return new MonkeyResponse("Friends retrieved", data); } diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index 4b6c20b8f456..e7b25350c1a0 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -277,6 +277,9 @@ export async function getFriends(uid: string): Promise { streak: true, personalBests: true, "inventory.badges": true, + "premium.expirationTimestamp": true, + banned: 1, + lbOptOut: 1, }, }, { @@ -312,18 +315,41 @@ export async function getFriends(uid: string): Promise { }, }, badgeId: { - $first: { - $map: { - input: { - $filter: { - input: "$inventory.badges", - as: "badge", - cond: { $eq: ["$$badge.selected", true] }, + $ifNull: [ + { + $first: { + $map: { + input: { + $filter: { + input: "$inventory.badges", + as: "badge", + cond: { $eq: ["$$badge.selected", true] }, + }, + }, + as: "selectedBadge", + in: "$$selectedBadge.id", }, }, - as: "selectedBadge", - in: "$$selectedBadge.id", }, + "$$REMOVE", + ], + }, + isPremium: { + $cond: { + if: { + $or: [ + { $eq: ["$premium.expirationTimestamp", -1] }, + { + $gt: [ + "$premium.expirationTimestamp", + { $toLong: "$$NOW" }, + ], + }, + ], + }, + // oxlint-disable-next-line no-thenable + then: true, + else: "$$REMOVE", }, }, }, @@ -341,6 +367,7 @@ export async function getFriends(uid: string): Promise { $project: { personalBests: false, inventory: false, + premium: false, }, }, ], diff --git a/packages/contracts/src/schemas/friends.ts b/packages/contracts/src/schemas/friends.ts index 80bac7fe8951..cf884d726ae6 100644 --- a/packages/contracts/src/schemas/friends.ts +++ b/packages/contracts/src/schemas/friends.ts @@ -35,12 +35,15 @@ export const FriendSchema = UserSchema.pick({ timeTyping: true, xp: true, streak: true, + banned: true, + lbOptOut: true, }).extend({ addedAt: z.number().int().nonnegative().optional(), friendRequestId: IdSchema.optional(), top15: PersonalBestSchema.optional(), top60: PersonalBestSchema.optional(), badgeId: z.number().int().optional(), + isPremium: z.boolean().optional(), }); export type Friend = z.infer; From 68276b7cc1beed29aee62001fe75f63a414d5642 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 17 Jul 2025 11:42:03 +0200 Subject: [PATCH 12/28] fix PresetsDal test --- backend/__tests__/dal/preset.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/__tests__/dal/preset.spec.ts b/backend/__tests__/dal/preset.spec.ts index b97862a5654e..48c2bc763c8e 100644 --- a/backend/__tests__/dal/preset.spec.ts +++ b/backend/__tests__/dal/preset.spec.ts @@ -374,7 +374,7 @@ describe("PresetDal", () => { ).presetId; //WHEN - PresetDal.removePreset(uid, first); + await PresetDal.removePreset(uid, first); //THEN const read = await PresetDal.getPresets(uid); From 3ca2a333c0a61a8ce0439c9171eeb525c6408ce3 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 25 Jul 2025 11:41:28 +0200 Subject: [PATCH 13/28] fix schema split --- backend/src/api/controllers/friends.ts | 2 +- backend/src/dal/friends.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/api/controllers/friends.ts b/backend/src/api/controllers/friends.ts index ea742dac3041..b556c0f982bf 100644 --- a/backend/src/api/controllers/friends.ts +++ b/backend/src/api/controllers/friends.ts @@ -14,7 +14,7 @@ import * as UserDal from "../../dal/user"; import { replaceObjectId, replaceObjectIds } from "../../utils/misc"; import MonkeyError from "../../utils/error"; import { omit } from "lodash"; -import { FriendRequest } from "@monkeytype/contracts/schemas/friends"; +import { FriendRequest } from "@monkeytype/schemas/friends"; export async function getRequests( req: MonkeyRequest diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index e7b25350c1a0..07e0f3804967 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -4,7 +4,7 @@ import { Friend, FriendRequest, FriendRequestStatus, -} from "@monkeytype/contracts/schemas/friends"; +} from "@monkeytype/schemas/friends"; import MonkeyError from "../utils/error"; import { WithObjectId } from "../utils/misc"; From e0f2afffd4b9457eb62905b7da2fdb6b4e2fcda5 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sat, 26 Jul 2025 13:18:17 +0200 Subject: [PATCH 14/28] fix friend request deletion rule --- backend/__tests__/dal/friends.spec.ts | 22 ++++++++++++++++++---- backend/src/dal/friends.ts | 13 +++++-------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/backend/__tests__/dal/friends.spec.ts b/backend/__tests__/dal/friends.spec.ts index b4ef7dceb7de..a72b66b4ece8 100644 --- a/backend/__tests__/dal/friends.spec.ts +++ b/backend/__tests__/dal/friends.spec.ts @@ -217,19 +217,33 @@ describe("FriendsDal", () => { ).rejects.toThrow("Cannot be deleted"); }); - it("should fail if friend deletes blocked", async () => { + it("should fail if initiator deletes blocked by friend", async () => { //GIVEN const uid = new ObjectId().toHexString(); - const first = await createFriend({ - friendUid: uid, + const myRequestWasBlocked = await createFriend({ + initiatorName: uid, status: "blocked", }); //WHEN / THEN await expect( - FriendsDal.deleteById(uid, first._id.toHexString()) + FriendsDal.deleteById(uid, myRequestWasBlocked._id.toHexString()) ).rejects.toThrow("Cannot be deleted"); }); + it("allow friend to delete blocked", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const myBlockedUser = await createFriend({ + friendUid: uid, + status: "blocked", + }); + + //WHEN + await FriendsDal.deleteById(uid, myBlockedUser._id.toHexString()); + + //THEN + expect(await FriendsDal.getRequests({ friendUid: uid })).toEqual([]); + }); }); describe("deleteByUid", () => { diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index 07e0f3804967..add1663a5173 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -115,14 +115,11 @@ export async function updateStatus( /** * delete a friend by the id. - * @param initiatorUid + * @param uid * @param id - * @throws MonkeyError if the friend id is unknown or the initiatorUid does not match + * @throws MonkeyError if the friend id is unknown or uid does not match */ -export async function deleteById( - initiatorUid: string, - id: string -): Promise { +export async function deleteById(uid: string, id: string): Promise { const deletionResult = await getCollection().deleteOne({ $and: [ { @@ -130,8 +127,8 @@ export async function deleteById( }, { $or: [ - { initiatorUid }, - { status: { $in: ["accepted", "pending"] }, friendUid: initiatorUid }, + { friendUid: uid }, + { status: { $in: ["accepted", "pending"] }, initiatorUid: uid }, ], }, ], From 421df44a3c875591521dfeace9812d072e8fbfea Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 4 Aug 2025 22:43:00 +0200 Subject: [PATCH 15/28] vitest3 --- backend/__tests__/api/controllers/friends.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts index 113a8162453f..1edb2c71bbe4 100644 --- a/backend/__tests__/api/controllers/friends.spec.ts +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -24,7 +24,7 @@ describe("FriendsController", () => { const getFriendsMock = vi.spyOn(FriendsDal, "getRequests"); beforeEach(() => { - getFriendsMock.mockReset(); + getFriendsMock.mockClear(); }); it("should get for the current user", async () => { @@ -175,7 +175,7 @@ describe("FriendsController", () => { beforeEach(() => { [getUserByNameMock, getPartialUserMock, createUserMock].forEach((it) => - it.mockReset() + it.mockClear() ); }); @@ -289,7 +289,7 @@ describe("FriendsController", () => { const deleteByIdMock = vi.spyOn(FriendsDal, "deleteById"); beforeEach(() => { - deleteByIdMock.mockReset(); + deleteByIdMock.mockClear().mockResolvedValue(); }); it("should delete by id", async () => { @@ -319,7 +319,7 @@ describe("FriendsController", () => { const updateStatusMock = vi.spyOn(FriendsDal, "updateStatus"); beforeEach(() => { - updateStatusMock.mockReset(); + updateStatusMock.mockClear().mockResolvedValue(); }); it("should accept", async () => { @@ -379,7 +379,7 @@ describe("FriendsController", () => { const getFriendsMock = vi.spyOn(FriendsDal, "getFriends"); beforeEach(() => { - getFriendsMock.mockReset(); + getFriendsMock.mockClear(); }); it("gets with premium enabled", async () => { From 3e1df8fee39f5d524d6d8899ff4a9a7dbbe75c2e Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 18 Aug 2025 12:17:41 +0200 Subject: [PATCH 16/28] update tests --- .../__integration__/dal/friends.spec.ts | 17 +++++++++++++---- .../__tests__/api/controllers/friends.spec.ts | 1 + 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/__integration__/dal/friends.spec.ts b/backend/__tests__/__integration__/dal/friends.spec.ts index c86b9dd0f80d..e306f636a418 100644 --- a/backend/__tests__/__integration__/dal/friends.spec.ts +++ b/backend/__tests__/__integration__/dal/friends.spec.ts @@ -1,3 +1,12 @@ +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterEach, +} from "vitest"; import { ObjectId } from "mongodb"; import * as FriendsDal from "../../../src/dal/friends"; @@ -5,7 +14,7 @@ import { createUser, pb } from "../../__testData__/users"; describe("FriendsDal", () => { beforeAll(async () => { - FriendsDal.createIndicies(); + await FriendsDal.createIndicies(); }); describe("getRequests", () => { @@ -66,11 +75,11 @@ describe("FriendsDal", () => { describe("create", () => { const now = 1715082588; beforeEach(() => { - vitest.useFakeTimers(); - vitest.setSystemTime(now); + vi.useFakeTimers(); + vi.setSystemTime(now); }); afterEach(() => { - vitest.useRealTimers(); + vi.useRealTimers(); }); it("should fail creating duplicates", async () => { diff --git a/backend/__tests__/api/controllers/friends.spec.ts b/backend/__tests__/api/controllers/friends.spec.ts index 1edb2c71bbe4..b3590fa4a629 100644 --- a/backend/__tests__/api/controllers/friends.spec.ts +++ b/backend/__tests__/api/controllers/friends.spec.ts @@ -1,3 +1,4 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; import request, { Test as SuperTest } from "supertest"; import app from "../../../src/app"; import { mockBearerAuthentication } from "../../__testData__/auth"; From ba728a3e5e7c8b0f4048914f2ecf710eacd5cc00 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 27 Jul 2025 12:28:51 +0200 Subject: [PATCH 17/28] feat(leaderboard): add friends leaderboards (@fehmer) --- .../__integration__/dal/friends.spec.ts | 4 ++ .../dal/leaderboards.isolated.spec.ts | 64 +++++++++++++++++++ .../api/controllers/leaderboard.spec.ts | 53 ++++++++++++++- backend/src/api/controllers/leaderboard.ts | 19 +++++- backend/src/dal/friends.ts | 22 +++++++ backend/src/dal/leaderboards.ts | 31 ++++++--- frontend/src/html/pages/leaderboards.html | 7 ++ frontend/src/ts/pages/leaderboards.ts | 52 +++++++++++++-- packages/contracts/src/leaderboards.ts | 11 +++- 9 files changed, 244 insertions(+), 19 deletions(-) diff --git a/backend/__tests__/__integration__/dal/friends.spec.ts b/backend/__tests__/__integration__/dal/friends.spec.ts index e306f636a418..248d6d2b9ca2 100644 --- a/backend/__tests__/__integration__/dal/friends.spec.ts +++ b/backend/__tests__/__integration__/dal/friends.spec.ts @@ -417,6 +417,10 @@ describe("FriendsDal", () => { name: "Me", }, ]); + + expect((await FriendsDal.getFriendsUids(uid)).sort()).toEqual( + [me.uid, friendOne.uid, friendTwo.uid, friendThree.uid].sort() + ); }); }); }); diff --git a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts index afe64f556787..97ffa1d0e4fe 100644 --- a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts +++ b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts @@ -275,6 +275,70 @@ describe("LeaderboardsDal", () => { expect(result[0]?.isPremium).toBeUndefined(); }); }); + describe("get", () => { + it("should get for friends only", async () => { + //GIVEN + const rank1 = await createUser(lbBests(pb(90), pb(100, 90, 2))); + const _rank2 = await createUser(lbBests(undefined, pb(100, 90, 1))); + const _rank3 = await createUser(lbBests(undefined, pb(100, 80, 2))); + const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + + const result = (await LeaderboardsDal.get( + "time", + "60", + "english", + 0, + 50, + [rank1.uid, rank4.uid] + )) as LeaderboardsDal.DBLeaderboardEntry[]; + + //THEN + const lb = result.map((it) => _.omit(it, ["_id"])); + + expect(lb).toEqual([ + expectedLbEntry("60", { rank: 1, user: rank1 }), + expectedLbEntry("60", { rank: 4, user: rank4 }), + ]); + }); + }); + describe("getCount", () => { + it("should get count", async () => { + //GIVEN + await createUser(lbBests(undefined, pb(90))); + await createUser(lbBests(undefined, pb(90))); + await createUser(lbBests(undefined, pb(90))); + await createUser(lbBests(undefined, pb(90))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + + const result = await LeaderboardsDal.getCount("time", "60", "english"); + + //THEN + expect(result).toEqual(4); + }); + it("should get for friends only", async () => { + //GIVEN + const friendOne = await createUser(lbBests(undefined, pb(90))); + await createUser(lbBests(undefined, pb(90))); + await createUser(lbBests(undefined, pb(90))); + const friendTwo = await createUser(lbBests(undefined, pb(90))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + + const result = await LeaderboardsDal.getCount("time", "60", "english", [ + friendOne.uid, + friendTwo.uid, + ]); + + //THEN + expect(result).toEqual(2); + }); + }); }); function expectedLbEntry( diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index bce16e114ef7..9bb544bc6d8c 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -4,6 +4,7 @@ import { ObjectId } from "mongodb"; import request from "supertest"; import app from "../../../src/app"; import * as LeaderboardDal from "../../../src/dal/leaderboards"; +import * as FriendDal from "../../../src/dal/friends"; import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards"; import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard"; import * as Configuration from "../../../src/init/configuration"; @@ -38,10 +39,13 @@ describe("Loaderboard Controller", () => { describe("get leaderboard", () => { const getLeaderboardMock = vi.spyOn(LeaderboardDal, "get"); const getLeaderboardCountMock = vi.spyOn(LeaderboardDal, "getCount"); + const getFriendsUidsMock = vi.spyOn(FriendDal, "getFriendsUids"); beforeEach(() => { getLeaderboardMock.mockClear(); getLeaderboardCountMock.mockClear(); + getFriendsUidsMock.mockClear(); + getLeaderboardCountMock.mockResolvedValue(42); }); it("should get for english time 60", async () => { @@ -103,6 +107,15 @@ describe("Loaderboard Controller", () => { 0, 50, false + + ); + + expect(getLeaderboardCountMock).toHaveBeenCalledWith( + "time", + "60", + "english", + undefined + ); }); @@ -130,7 +143,7 @@ describe("Loaderboard Controller", () => { expect(body).toEqual({ message: "Leaderboard retrieved", data: { - count: 0, + count: 42, pageSize: 25, entries: [], }, @@ -146,6 +159,44 @@ describe("Loaderboard Controller", () => { ); }); + it("should get for friendsOnly", async () => { + //GIVEN + getLeaderboardMock.mockResolvedValue([]); + getFriendsUidsMock.mockResolvedValue(["uidOne", "uidTwo"]); + getLeaderboardCountMock.mockResolvedValue(2); + + //WHEN + + const { body } = await mockApp + .get("/leaderboards") + .query({ + language: "english", + mode: "time", + mode2: "60", + friendsOnly: true, + }) + .expect(200); + + //THEN + expect(body.data.count).toEqual(2); + + expect(getLeaderboardMock).toHaveBeenCalledWith( + "time", + "60", + "english", + 0, + 50, + ["uidOne", "uidTwo"] + ); + expect(getLeaderboardCountMock).toHaveBeenCalledWith( + "time", + "60", + "english", + ["uidOne", "uidTwo"] +>>>>>>> 7e96c814c (feat(leaderboard): add friends leaderboards (@fehmer)) + ); + }); + describe("should get for modes", async () => { beforeEach(() => { getLeaderboardMock.mockResolvedValue([]); diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 689a2341e6c4..d5734ccfff7a 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -1,6 +1,7 @@ import _ from "lodash"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as LeaderboardsDAL from "../../dal/leaderboards"; +import * as FriendsDAL from "../../dal/friends"; import MonkeyError from "../../utils/error"; import * as DailyLeaderboards from "../../utils/daily-leaderboards"; import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; @@ -30,7 +31,8 @@ import { MonkeyRequest } from "../types"; export async function getLeaderboard( req: MonkeyRequest ): Promise { - const { language, mode, mode2, page, pageSize } = req.query; + const { language, mode, mode2, page, pageSize, friendsOnly } = req.query; + const { uid } = req.ctx.decodedToken; if ( mode !== "time" || @@ -40,13 +42,19 @@ export async function getLeaderboard( throw new MonkeyError(404, "There is no leaderboard for this mode"); } + let friendUids: string[] | undefined; + if (friendsOnly === true) { + friendUids = await FriendsDAL.getFriendsUids(uid); + } + const leaderboard = await LeaderboardsDAL.get( mode, mode2, language, page, pageSize, - req.ctx.configuration.users.premium.enabled + req.ctx.configuration.users.premium.enabled, + friendUids ); if (leaderboard === false) { @@ -56,7 +64,12 @@ export async function getLeaderboard( ); } - const count = await LeaderboardsDAL.getCount(mode, mode2, language); + const count = await LeaderboardsDAL.getCount( + mode, + mode2, + language, + friendUids + ); const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"])); return new MonkeyResponse("Leaderboard retrieved", { diff --git a/backend/src/dal/friends.ts b/backend/src/dal/friends.ts index add1663a5173..9b85ad343211 100644 --- a/backend/src/dal/friends.ts +++ b/backend/src/dal/friends.ts @@ -385,6 +385,28 @@ export async function getFriends(uid: string): Promise { .toArray()) as DBFriend[]; } +export async function getFriendsUids(uid: string): Promise { + return Array.from( + new Set( + ( + await getCollection() + .find( + { + $and: [ + { + $or: [{ initiatorUid: uid }, { friendUid: uid }], + status: "accepted", + }, + ], + }, + { projection: { initiatorUid: true, friendUid: true } } + ) + .toArray() + ).flatMap((it) => [it.initiatorUid, it.friendUid]) + ) + ); +} + export async function createIndicies(): Promise { //index used for search await getCollection().createIndex({ initiatorUid: 1 }); diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 42594eb1baf1..98f18a3d8f36 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -9,7 +9,7 @@ import { } from "../init/configuration"; import { addLog } from "./logs"; -import { Collection, ObjectId } from "mongodb"; +import { Collection, Filter, ObjectId } from "mongodb"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; import { omit } from "lodash"; import { DBUser, getUsersCollection } from "./user"; @@ -34,7 +34,8 @@ export async function get( language: string, page: number, pageSize: number, - premiumFeaturesEnabled: boolean = false + premiumFeaturesEnabled: boolean = false, + userIds?: string[] ): Promise { if (page < 0 || pageSize < 0) { throw new MonkeyError(500, "Invalid page or pageSize"); @@ -43,9 +44,15 @@ export async function get( const skip = page * pageSize; const limit = pageSize; + let filter: Filter = {}; + + if (userIds !== undefined) { + filter.uid = { $in: userIds }; + } + try { const preset = await getCollection({ language, mode, mode2 }) - .find() + .find(filter) .sort({ rank: 1 }) .skip(skip) .limit(limit) @@ -71,19 +78,25 @@ const cachedCounts = new Map(); export async function getCount( mode: string, mode2: string, - language: string + language: string, + userIds?: string[] ): Promise { const key = `${language}_${mode}_${mode2}`; - if (cachedCounts.has(key)) { + if (userIds === undefined && cachedCounts.has(key)) { return cachedCounts.get(key) as number; } else { - const count = await getCollection({ + const lb = await getCollection({ language, mode, mode2, - }).estimatedDocumentCount(); - cachedCounts.set(key, count); - return count; + }); + if (userIds === undefined) { + const count = await lb.estimatedDocumentCount(); + cachedCounts.set(key, count); + return count; + } else { + return lb.countDocuments({ uid: { $in: userIds } }); + } } } diff --git a/frontend/src/html/pages/leaderboards.html b/frontend/src/html/pages/leaderboards.html index 1041691f3d0d..686d4a147204 100644 --- a/frontend/src/html/pages/leaderboards.html +++ b/frontend/src/html/pages/leaderboards.html @@ -173,6 +173,13 @@ daily + +
+ +
- +
+ + }
# name diff --git a/frontend/src/styles/leaderboards.scss b/frontend/src/styles/leaderboards.scss index f98e054aae8e..0505e2011ae5 100644 --- a/frontend/src/styles/leaderboards.scss +++ b/frontend/src/styles/leaderboards.scss @@ -161,7 +161,16 @@ padding: var(--padding); } + &.friendsOnly { + td:first-child { + display: table-cell; + } + } td:first-child { + display: none; + } + td:first-child, + td:nth-child(2) { // padding: 0; width: 0; text-align: center; diff --git a/frontend/src/ts/pages/leaderboards.ts b/frontend/src/ts/pages/leaderboards.ts index bfda3e1f95d5..b93b586b0708 100644 --- a/frontend/src/ts/pages/leaderboards.ts +++ b/frontend/src/ts/pages/leaderboards.ts @@ -438,10 +438,10 @@ function buildTableRow(entry: LeaderboardEntry, me = false): HTMLElement { } element.dataset["uid"] = entry.uid; element.innerHTML = ` - + ${entry.friendsRank ?? ""} ${ entry.rank === 1 ? '' : entry.rank - }
@@ -547,6 +547,12 @@ function fillTable(): void { const table = $(".page.pageLeaderboards table tbody"); table.empty(); + if (state.friendsOnly) { + table.parent().addClass("friendsOnly"); + } else { + table.parent().removeClass("friendsOnly"); + } + $(".page.pageLeaderboards table thead").addClass("hidden"); if (state.type === "allTime" || state.type === "daily") { $(".page.pageLeaderboards table thead.allTimeAndDaily").removeClass( From 8cac132d234e967f15e3170008bbc283b1327c40 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 1 Aug 2025 17:10:40 +0200 Subject: [PATCH 21/28] hide filter if friends feature is disabled, add friendsOnly to rank endpoint --- .../dal/leaderboards.isolated.spec.ts | 107 ++++++++++++++---- .../api/controllers/leaderboard.spec.ts | 18 ++- backend/src/api/controllers/leaderboard.ts | 43 +++++-- backend/src/dal/leaderboards.ts | 58 ++++++---- frontend/src/html/pages/leaderboards.html | 2 +- frontend/src/ts/pages/leaderboards.ts | 25 +++- packages/contracts/src/leaderboards.ts | 4 +- 7 files changed, 200 insertions(+), 57 deletions(-) diff --git a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts index 8187a5ee7832..c197f4427fe7 100644 --- a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts +++ b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts @@ -276,6 +276,32 @@ describe("LeaderboardsDal", () => { }); }); describe("get", () => { + it("should get for page", async () => { + //GIVEN + const _rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2))); + const _rank2 = await createUser(lbBests(undefined, pb(100, 90, 1))); + const rank3 = await createUser(lbBests(undefined, pb(95, 80, 2))); + const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN + + const result = (await LeaderboardsDal.get( + "time", + "60", + "english", + 1, + 2 + )) as LeaderboardsDal.DBLeaderboardEntry[]; + + //THEN + const lb = result.map((it) => _.omit(it, ["_id"])); + + expect(lb).toEqual([ + expectedLbEntry("60", { rank: 3, user: rank3 }), + expectedLbEntry("60", { rank: 4, user: rank4 }), + ]); + }); it("should get for friends only", async () => { //GIVEN const rank1 = await createUser(lbBests(pb(90), pb(100, 90, 2))); @@ -303,40 +329,83 @@ describe("LeaderboardsDal", () => { expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 2 }), ]); }); - }); - describe("getCount", () => { - it("should get count", async () => { + it("should get for friends only with page", async () => { //GIVEN - await createUser(lbBests(undefined, pb(90))); - await createUser(lbBests(undefined, pb(90))); - await createUser(lbBests(undefined, pb(90))); - await createUser(lbBests(undefined, pb(90))); + const rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2))); + const rank2 = await createUser(lbBests(undefined, pb(100, 90, 1))); + const _rank3 = await createUser(lbBests(undefined, pb(95, 80, 2))); + const rank4 = await createUser(lbBests(undefined, pb(90, 100, 1))); await LeaderboardsDal.update("time", "60", "english"); //WHEN - const result = await LeaderboardsDal.getCount("time", "60", "english"); + const result = (await LeaderboardsDal.get("time", "60", "english", 1, 2, [ + rank1.uid, + rank2.uid, + rank4.uid, + ])) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN - expect(result).toEqual(4); + const lb = result.map((it) => _.omit(it, ["_id"])); + + expect(lb).toEqual([ + expectedLbEntry("60", { rank: 4, user: rank4, friendsRank: 3 }), + ]); }); - it("should get for friends only", async () => { + }); + describe("getCount / getRank", () => { + it("should get count", async () => { //GIVEN - const friendOne = await createUser(lbBests(undefined, pb(90))); - await createUser(lbBests(undefined, pb(90))); + await createUser(lbBests(undefined, pb(105))); + await createUser(lbBests(undefined, pb(100))); + const me = await createUser(lbBests(undefined, pb(95))); await createUser(lbBests(undefined, pb(90))); + await LeaderboardsDal.update("time", "60", "english"); + + //WHEN / THEN + + expect(await LeaderboardsDal.getCount("time", "60", "english")) // + .toEqual(4); + expect(await LeaderboardsDal.getRank("time", "60", "english", me.uid)) // + .toEqual( + expect.objectContaining({ + wpm: 95, + rank: 3, + name: me.name, + uid: me.uid, + }) + ); + }); + it("should get for friends only", async () => { + //GIVEN + const friendOne = await createUser(lbBests(undefined, pb(105))); + await createUser(lbBests(undefined, pb(100))); + await createUser(lbBests(undefined, pb(95))); const friendTwo = await createUser(lbBests(undefined, pb(90))); + const me = await createUser(lbBests(undefined, pb(99))); + + console.log("me", me.uid); + await LeaderboardsDal.update("time", "60", "english"); - //WHEN + const friends = [friendOne.uid, friendTwo.uid, me.uid]; - const result = await LeaderboardsDal.getCount("time", "60", "english", [ - friendOne.uid, - friendTwo.uid, - ]); + //WHEN / THEN - //THEN - expect(result).toEqual(2); + expect(await LeaderboardsDal.getCount("time", "60", "english", friends)) // + .toEqual(3); + expect( + await LeaderboardsDal.getRank("time", "60", "english", me.uid, friends) + ) // + .toEqual( + expect.objectContaining({ + wpm: 99, + rank: 3, + friendsRank: 2, + name: me.name, + uid: me.uid, + }) + ); }); }); }); diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index 0d90aa925e1d..d9a0e647bf68 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -161,6 +161,7 @@ describe("Loaderboard Controller", () => { it("should get for friendsOnly", async () => { //GIVEN + await enableFriendsFeature(true); getLeaderboardMock.mockResolvedValue([]); getFriendsUidsMock.mockResolvedValue(["uidOne", "uidTwo"]); getLeaderboardCountMock.mockResolvedValue(2); @@ -311,7 +312,7 @@ describe("Loaderboard Controller", () => { const entryId = new ObjectId(); const resultEntry = { - _id: entryId.toHexString(), + _id: entryId, wpm: 10, acc: 80, timestamp: 1200, @@ -333,14 +334,15 @@ describe("Loaderboard Controller", () => { //THEN expect(body).toEqual({ message: "Rank retrieved", - data: resultEntry, + data: { ...resultEntry, _id: undefined }, }); expect(getLeaderboardRankMock).toHaveBeenCalledWith( "time", "60", "english", - uid + uid, + undefined ); }); it("should get with ape key", async () => { @@ -1305,3 +1307,13 @@ async function weeklyLeaderboardEnabled(enabled: boolean): Promise { mockConfig ); } + +async function enableFriendsFeature(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + friends: { enabled: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 9483a50c6549..a6703894ae63 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -33,6 +33,7 @@ export async function getLeaderboard( ): Promise { const { language, mode, mode2, page, pageSize, friendsOnly } = req.query; const { uid } = req.ctx.decodedToken; + const friendConfig = req.ctx.configuration.friends; if ( mode !== "time" || @@ -42,10 +43,11 @@ export async function getLeaderboard( throw new MonkeyError(404, "There is no leaderboard for this mode"); } - let friendUids: string[] | undefined; - if (uid !== "" && friendsOnly === true) { - friendUids = await FriendsDAL.getFriendsUids(uid); - } + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + friendConfig + ); const leaderboard = await LeaderboardsDAL.get( mode, @@ -82,10 +84,23 @@ export async function getLeaderboard( export async function getRankFromLeaderboard( req: MonkeyRequest ): Promise { - const { language, mode, mode2 } = req.query; + const { language, mode, mode2, friendsOnly } = req.query; const { uid } = req.ctx.decodedToken; + const friendConfig = req.ctx.configuration.friends; + + const friendUids = await getFriendsUids( + uid, + friendsOnly === true, + friendConfig + ); - const data = await LeaderboardsDAL.getRank(mode, mode2, language, uid); + const data = await LeaderboardsDAL.getRank( + mode, + mode2, + language, + uid, + friendUids + ); if (data === false) { throw new MonkeyError( 503, @@ -93,7 +108,7 @@ export async function getRankFromLeaderboard( ); } - return new MonkeyResponse("Rank retrieved", data); + return new MonkeyResponse("Rank retrieved", _.omit(data, "_id")); } function getDailyLeaderboardWithError( @@ -226,3 +241,17 @@ export async function getWeeklyXpLeaderboardRank( return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry); } + +async function getFriendsUids( + uid: string, + friendsOnly: boolean, + friendsConfig: Configuration["friends"] +): Promise { + if (uid !== "" && friendsOnly) { + if (!friendsConfig.enabled) { + throw new MonkeyError(503, "This feature is currently unavailable."); + } + return await FriendsDAL.getFriendsUids(uid); + } + return undefined; +} diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index d9bf9ab58070..66e8ce86bdb6 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -9,7 +9,7 @@ import { } from "../init/configuration"; import { addLog } from "./logs"; -import { Collection, Filter, ObjectId } from "mongodb"; +import { Collection, Document, ObjectId } from "mongodb"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; import { omit } from "lodash"; import { DBUser, getUsersCollection } from "./user"; @@ -44,29 +44,27 @@ export async function get( const skip = page * pageSize; const limit = pageSize; - let filter: Filter = {}; + const pipeline: Document[] = [{ $skip: skip }, { $limit: limit }]; if (userIds !== undefined) { - filter.uid = { $in: userIds }; + pipeline.splice(0, 0, { $match: { uid: { $in: userIds } } }); + pipeline.splice(1, 0, { + $setWindowFields: { + sortBy: { rank: 1 }, + output: { friendsRank: { $documentNumber: {} } }, + }, + }); } try { - let leaderboard = await getCollection({ language, mode, mode2 }) - .find(filter) - .sort({ rank: 1 }) - .skip(skip) - .limit(limit) - .toArray(); + let leaderboard = (await getCollection({ language, mode, mode2 }) + .aggregate(pipeline) + .toArray()) as DBLeaderboardEntry[]; if (!premiumFeaturesEnabled) { leaderboard = leaderboard.map((it) => omit(it, "isPremium")); } - if (userIds !== undefined) { - let friendsRank = skip + 1; - leaderboard.forEach((it) => (it.friendsRank = friendsRank++)); - } - return leaderboard; } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -109,14 +107,34 @@ export async function getRank( mode: string, mode2: string, language: string, - uid: string -): Promise { + uid: string, + userIds?: string[] +): Promise { try { - const entry = await getCollection({ language, mode, mode2 }).findOne({ - uid, - }); + if (userIds === undefined) { + const entry = await getCollection({ language, mode, mode2 }).findOne({ + uid, + }); + + return entry; + } else if (userIds.length === 0) { + return null; + } else { + const entry = await getCollection({ language, mode, mode2 }) + .aggregate([ + { $match: { uid: { $in: userIds } } }, + { + $setWindowFields: { + sortBy: { rank: 1 }, + output: { friendsRank: { $documentNumber: {} } }, + }, + }, + { $match: { uid } }, + ]) + .toArray(); - return entry; + return entry[0] as DBLeaderboardEntry; + } } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (e.error === 175) { diff --git a/frontend/src/html/pages/leaderboards.html b/frontend/src/html/pages/leaderboards.html index e33cfd1f5b79..455c30847c14 100644 --- a/frontend/src/html/pages/leaderboards.html +++ b/frontend/src/html/pages/leaderboards.html @@ -175,7 +175,7 @@
-
+