diff --git a/backend/__tests__/__integration__/dal/connections.spec.ts b/backend/__tests__/__integration__/dal/connections.spec.ts new file mode 100644 index 000000000..709c987b2 --- /dev/null +++ b/backend/__tests__/__integration__/dal/connections.spec.ts @@ -0,0 +1,365 @@ +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterEach, +} from "vitest"; +import { ObjectId } from "mongodb"; + +import * as ConnectionsDal from "../../../src/dal/connections"; +import { createConnection } from "../../__testData__/connections"; + +describe("ConnectionsDal", () => { + beforeAll(async () => { + await ConnectionsDal.createIndicies(); + }); + + describe("getRequests", () => { + it("get by uid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const initOne = await createConnection({ initiatorUid: uid }); + const initTwo = await createConnection({ initiatorUid: uid }); + const friendOne = await createConnection({ receiverUid: uid }); + const _decoy = await createConnection({}); + + //WHEN / THEM + + expect( + await ConnectionsDal.getConnections({ + initiatorUid: uid, + receiverUid: uid, + }) + ).toStrictEqual([initOne, initTwo, friendOne]); + }); + + it("get by uid and status", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const initAccepted = await createConnection({ + initiatorUid: uid, + status: "accepted", + }); + const _initPending = await createConnection({ + initiatorUid: uid, + status: "pending", + }); + const initBlocked = await createConnection({ + initiatorUid: uid, + status: "blocked", + }); + + const friendAccepted = await createConnection({ + receiverUid: uid, + status: "accepted", + }); + const _friendPending = await createConnection({ + receiverUid: uid, + status: "pending", + }); + + const _decoy = await createConnection({ status: "accepted" }); + + //WHEN / THEN + + expect( + await ConnectionsDal.getConnections({ + initiatorUid: uid, + receiverUid: uid, + status: ["accepted", "blocked"], + }) + ).toStrictEqual([initAccepted, initBlocked, friendAccepted]); + }); + }); + + describe("create", () => { + const now = 1715082588; + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("should fail creating duplicates", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createConnection({ + initiatorUid: uid, + }); + + //WHEN/THEN + await expect( + createConnection({ + initiatorUid: first.receiverUid, + receiverUid: uid, + }) + ).rejects.toThrow("Connection request already sent"); + }); + + it("should create", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const receiverUid = new ObjectId().toHexString(); + + //WHEN + const created = await ConnectionsDal.create( + { uid, name: "Bob" }, + { uid: receiverUid, name: "Kevin" }, + 2 + ); + + //THEN + expect(created).toEqual({ + _id: created._id, + initiatorUid: uid, + initiatorName: "Bob", + receiverUid: receiverUid, + receiverName: "Kevin", + lastModified: now, + status: "pending", + key: `${uid}/${receiverUid}`, + }); + }); + + it("should fail if maximum connections are reached", async () => { + //GIVEN + const initiatorUid = new ObjectId().toHexString(); + await createConnection({ initiatorUid }); + await createConnection({ initiatorUid }); + + //WHEN / THEM + await expect(createConnection({ initiatorUid }, 2)).rejects.toThrow( + "Maximum number of connections reached\nStack: create connection request" + ); + }); + + it("should fail creating if blocked", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createConnection({ + initiatorUid: uid, + status: "blocked", + }); + + //WHEN/THEN + await expect( + createConnection({ + initiatorUid: first.receiverUid, + receiverUid: uid, + }) + ).rejects.toThrow("Connection blocked"); + }); + }); + describe("updateStatus", () => { + const now = 1715082588; + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it("should update the status", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createConnection({ + receiverUid: uid, + lastModified: 100, + }); + const second = await createConnection({ + receiverUid: uid, + lastModified: 200, + }); + + //WHEN + await ConnectionsDal.updateStatus( + uid, + first._id.toHexString(), + "accepted" + ); + + //THEN + expect(await ConnectionsDal.getConnections({ receiverUid: uid })).toEqual( + [{ ...first, status: "accepted", lastModified: now }, second] + ); + + //can update twice to the same status + await ConnectionsDal.updateStatus( + uid, + first._id.toHexString(), + "accepted" + ); + }); + it("should fail if uid does not match the reeceiverUid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createConnection({ + initiatorUid: uid, + }); + + //WHEN / THEN + await expect( + ConnectionsDal.updateStatus(uid, first._id.toHexString(), "accepted") + ).rejects.toThrow("No permission or connection not found"); + }); + }); + + describe("deleteById", () => { + it("should delete by initiator", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createConnection({ + initiatorUid: uid, + }); + const second = await createConnection({ + initiatorUid: uid, + }); + + //WHEN + await ConnectionsDal.deleteById(uid, first._id.toHexString()); + + //THEN + expect( + await ConnectionsDal.getConnections({ initiatorUid: uid }) + ).toStrictEqual([second]); + }); + + it("should delete by receiver", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createConnection({ + receiverUid: uid, + }); + const second = await createConnection({ + receiverUid: uid, + status: "accepted", + }); + + //WHEN + await ConnectionsDal.deleteById(uid, first._id.toHexString()); + + //THEN + expect( + await ConnectionsDal.getConnections({ + initiatorUid: second.initiatorUid, + }) + ).toStrictEqual([second]); + }); + + it("should fail if uid does not match", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const first = await createConnection({ + initiatorUid: uid, + }); + + //WHEN / THEN + await expect( + ConnectionsDal.deleteById("Bob", first._id.toHexString()) + ).rejects.toThrow("No permission or connection not found"); + }); + + it("should fail if initiator deletes blocked by receiver", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const myRequestWasBlocked = await createConnection({ + initiatorName: uid, + status: "blocked", + }); + + //WHEN / THEN + await expect( + ConnectionsDal.deleteById(uid, myRequestWasBlocked._id.toHexString()) + ).rejects.toThrow("No permission or connection not found"); + }); + it("allow receiver to delete blocked", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const myBlockedUser = await createConnection({ + receiverUid: uid, + status: "blocked", + }); + + //WHEN + await ConnectionsDal.deleteById(uid, myBlockedUser._id.toHexString()); + + //THEN + expect(await ConnectionsDal.getConnections({ receiverUid: uid })).toEqual( + [] + ); + }); + }); + + describe("deleteByUid", () => { + it("should delete by uid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const _initOne = await createConnection({ initiatorUid: uid }); + const _initTwo = await createConnection({ initiatorUid: uid }); + const _friendOne = await createConnection({ receiverUid: uid }); + const decoy = await createConnection({}); + + //WHEN + await ConnectionsDal.deleteByUid(uid); + + //THEN + expect( + await ConnectionsDal.getConnections({ + initiatorUid: uid, + receiverUid: uid, + }) + ).toEqual([]); + + expect( + await ConnectionsDal.getConnections({ + initiatorUid: decoy.initiatorUid, + }) + ).toEqual([decoy]); + }); + }); + describe("updateName", () => { + it("should update the name", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const initOne = await createConnection({ + initiatorUid: uid, + initiatorName: "Bob", + }); + const initTwo = await createConnection({ + initiatorUid: uid, + initiatorName: "Bob", + }); + const friendOne = await createConnection({ + receiverUid: uid, + receiverName: "Bob", + }); + const decoy = await createConnection({}); + + //WHEN + await ConnectionsDal.updateName(uid, "King Bob"); + + //THEN + expect( + await ConnectionsDal.getConnections({ + initiatorUid: uid, + receiverUid: uid, + }) + ).toEqual([ + { ...initOne, initiatorName: "King Bob" }, + { ...initTwo, initiatorName: "King Bob" }, + { ...friendOne, receiverName: "King Bob" }, + ]); + + expect( + await ConnectionsDal.getConnections({ + initiatorUid: decoy.initiatorUid, + }) + ).toEqual([decoy]); + }); + }); +}); diff --git a/backend/__tests__/__integration__/dal/preset.spec.ts b/backend/__tests__/__integration__/dal/preset.spec.ts index bffa5160f..3b087b888 100644 --- a/backend/__tests__/__integration__/dal/preset.spec.ts +++ b/backend/__tests__/__integration__/dal/preset.spec.ts @@ -382,7 +382,7 @@ describe("PresetDal", () => { ).presetId; //WHEN - PresetDal.removePreset(uid, first); + await PresetDal.removePreset(uid, first); //THEN const read = await PresetDal.getPresets(uid); diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index c42b31d86..c6f84fa85 100644 --- a/backend/__tests__/__integration__/dal/user.spec.ts +++ b/backend/__tests__/__integration__/dal/user.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import _ from "lodash"; import * as UserDAL from "../../../src/dal/user"; import * as UserTestData from "../../__testData__/users"; +import { createConnection as createFriend } from "../../__testData__/connections"; import { ObjectId } from "mongodb"; import { MonkeyMail, ResultFilters } from "@monkeytype/schemas/users"; import { PersonalBest, PersonalBests } from "@monkeytype/schemas/shared"; @@ -1980,4 +1981,121 @@ describe("UserDal", () => { expect(read.streak?.hourOffset).toBeUndefined(); }); }); + + describe("getFriends", () => { + it("get list of friends", async () => { + //GIVEN + const me = await UserTestData.createUser({ name: "Me" }); + const uid = me.uid; + + const friendOne = await UserTestData.createUser({ + name: "One", + personalBests: { + time: { + "15": [UserTestData.pb(100)], + "60": [UserTestData.pb(85), UserTestData.pb(90)], + }, + } as any, + 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, + receiverUid: friendOne.uid, + status: "accepted", + lastModified: 100, + }); + const friendTwo = await UserTestData.createUser({ + name: "Two", + discordId: "discordId", + discordAvatar: "discordAvatar", + timeTyping: 600, + startedTests: 150, + completedTests: 125, + streak: { + length: 10, + maxLength: 50, + lastResultTimestamp: 0, + hourOffset: -1, + } as any, + xp: 42, + inventory: { + badges: [{ id: 23 }, { id: 5 }], + }, + premium: { + expirationTimestamp: vi.getRealSystemTime() + 5000, + } as any, + }); + const friendTwoRequest = await createFriend({ + initiatorUid: uid, + receiverUid: friendTwo.uid, + status: "accepted", + lastModified: 200, + }); + + const friendThree = await UserTestData.createUser({ name: "Three" }); + const friendThreeRequest = await createFriend({ + receiverUid: uid, + initiatorUid: friendThree.uid, + status: "accepted", + lastModified: 300, + }); + + //non accepted + await createFriend({ receiverUid: uid, status: "pending" }); + await createFriend({ initiatorUid: uid, status: "blocked" }); + + //WHEN + const friends = await UserDAL.getFriends(uid); + + //THEN + expect(friends).toEqual([ + { + uid: friendOne.uid, + name: "One", + lastModified: 100, + connectionId: 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", + lastModified: 200, + connectionId: friendTwoRequest._id, + discordId: friendTwo.discordId, + discordAvatar: friendTwo.discordAvatar, + timeTyping: friendTwo.timeTyping, + startedTests: friendTwo.startedTests, + completedTests: friendTwo.completedTests, + streak: { + length: friendTwo.streak?.length, + maxLength: friendTwo.streak?.maxLength, + }, + xp: friendTwo.xp, + isPremium: true, + }, + { + uid: friendThree.uid, + name: "Three", + lastModified: 300, + connectionId: friendThreeRequest._id, + }, + { + uid: me.uid, + name: "Me", + }, + ]); + }); + }); }); diff --git a/backend/__tests__/__integration__/setup-integration-tests.ts b/backend/__tests__/__integration__/setup-integration-tests.ts index 3b73bb9e2..b71258cfe 100644 --- a/backend/__tests__/__integration__/setup-integration-tests.ts +++ b/backend/__tests__/__integration__/setup-integration-tests.ts @@ -24,6 +24,9 @@ beforeAll(async () => { })); setupCommonMocks(); + + //we compare the time in mongodb to calculate premium status, so we have to use real time here + vi.useRealTimers(); }); afterEach(async () => { diff --git a/backend/__tests__/__testData__/connections.ts b/backend/__tests__/__testData__/connections.ts new file mode 100644 index 000000000..acde3ca09 --- /dev/null +++ b/backend/__tests__/__testData__/connections.ts @@ -0,0 +1,24 @@ +import { ObjectId } from "mongodb"; +import * as ConnectionsDal from "../../src/dal/connections"; + +export async function createConnection( + data: Partial, + maxPerUser = 25 +): Promise { + const result = await ConnectionsDal.create( + { + uid: data.initiatorUid ?? new ObjectId().toHexString(), + name: data.initiatorName ?? "user" + new ObjectId().toHexString(), + }, + { + uid: data.receiverUid ?? new ObjectId().toHexString(), + name: data.receiverName ?? "user" + new ObjectId().toHexString(), + }, + maxPerUser + ); + await ConnectionsDal.getCollection().updateOne( + { _id: result._id }, + { $set: data } + ); + return { ...result, ...data }; +} diff --git a/backend/__tests__/api/controllers/connections.spec.ts b/backend/__tests__/api/controllers/connections.spec.ts new file mode 100644 index 000000000..a13e64de2 --- /dev/null +++ b/backend/__tests__/api/controllers/connections.spec.ts @@ -0,0 +1,398 @@ +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"; +import * as Configuration from "../../../src/init/configuration"; +import { ObjectId } from "mongodb"; +import _ from "lodash"; +import * as ConnectionsDal from "../../../src/dal/connections"; +import * as UserDal from "../../../src/dal/user"; + +const mockApp = request(app); +const configuration = Configuration.getCachedConfiguration(); +const uid = new ObjectId().toHexString(); +const mockAuth = mockBearerAuthentication(uid); + +describe("ConnectionsController", () => { + beforeEach(async () => { + await enableConnectionsEndpoints(true); + vi.useFakeTimers(); + vi.setSystemTime(1000); + mockAuth.beforeEach(); + }); + + describe("get connections", () => { + const getConnectionsMock = vi.spyOn(ConnectionsDal, "getConnections"); + + beforeEach(() => { + getConnectionsMock.mockClear(); + }); + + it("should get for the current user", async () => { + //GIVEN + const friend: ConnectionsDal.DBConnection = { + _id: new ObjectId(), + lastModified: 42, + initiatorUid: new ObjectId().toHexString(), + initiatorName: "Bob", + receiverUid: new ObjectId().toHexString(), + receiverName: "Kevin", + status: "pending", + key: "key", + }; + + getConnectionsMock.mockResolvedValue([friend]); + + //WHEN + const { body } = await mockApp + .get("/connections") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual([ + { ...friend, _id: friend._id.toHexString(), key: undefined }, + ]); + expect(getConnectionsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + receiverUid: uid, + }); + }); + + it("should filter by status", async () => { + //GIVEN + getConnectionsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/connections") + .query({ status: "accepted" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getConnectionsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + receiverUid: uid, + status: ["accepted"], + }); + }); + + it("should filter by multiple status", async () => { + //GIVEN + getConnectionsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/connections") + .query({ status: ["accepted", "blocked"] }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getConnectionsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + receiverUid: uid, + status: ["accepted", "blocked"], + }); + }); + + it("should filter by type incoming", async () => { + //GIVEN + getConnectionsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/connections") + .query({ type: "incoming" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getConnectionsMock).toHaveBeenCalledWith({ + receiverUid: uid, + }); + }); + + it("should filter by type outgoing", async () => { + //GIVEN + getConnectionsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/connections") + .query({ type: "outgoing" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getConnectionsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + }); + }); + + it("should filter by multiple types", async () => { + //GIVEN + getConnectionsMock.mockResolvedValue([]); + + //WHEN + await mockApp + .get("/connections") + .query({ type: ["incoming", "outgoing"] }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(getConnectionsMock).toHaveBeenCalledWith({ + initiatorUid: uid, + receiverUid: uid, + }); + }); + + it("should fail if connections endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp.get("/connections").set("Authorization", `Bearer ${uid}`) + ); + }); + it("should fail without authentication", async () => { + await mockApp.get("/connections").expect(401); + }); + it("should fail for unknown query parameter", async () => { + const { body } = await mockApp + .get("/connections") + .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 connection", () => { + const getUserByNameMock = vi.spyOn(UserDal, "getUserByName"); + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + const createUserMock = vi.spyOn(ConnectionsDal, "create"); + + beforeEach(() => { + [getUserByNameMock, getPartialUserMock, createUserMock].forEach((it) => + it.mockClear() + ); + }); + + it("should create", 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: ConnectionsDal.DBConnection = { + _id: new ObjectId(), + lastModified: 42, + initiatorUid: me.uid, + initiatorName: me.name, + receiverUid: myFriend.uid, + receiverName: myFriend.name, + key: "test", + status: "pending", + }; + createUserMock.mockResolvedValue(result); + + //WHEN + const { body } = await mockApp + .post("/connections") + .send({ receiverName: "Kevin" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(body.data).toEqual({ + _id: result._id.toHexString(), + lastModified: 42, + initiatorUid: me.uid, + initiatorName: me.name, + receiverUid: myFriend.uid, + receiverName: myFriend.name, + status: "pending", + }); + + expect(getUserByNameMock).toHaveBeenCalledWith( + "Kevin", + "create connection" + ); + expect(getPartialUserMock).toHaveBeenCalledWith( + uid, + "create connection", + ["uid", "name"] + ); + expect(createUserMock).toHaveBeenCalledWith(me, myFriend, 100); + }); + + it("should fail if user and receiver 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("/connections") + .send({ receiverName: "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 + .post("/connections") + .send({}) + .set("Authorization", `Bearer ${uid}`) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: [`"receiverName" Required`], + }); + }); + it("should fail with extra properties", async () => { + //WHEN + const { body } = await mockApp + .post("/connections") + .send({ receiverName: "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 connections endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp + .post("/connections") + .send({ receiverName: "1" }) + .set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp.post("/connections").expect(401); + }); + }); + + describe("delete connection", () => { + const deleteByIdMock = vi.spyOn(ConnectionsDal, "deleteById"); + + beforeEach(() => { + deleteByIdMock.mockClear().mockResolvedValue(); + }); + + it("should delete by id", async () => { + //WHEN + await mockApp + .delete("/connections/1") + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(deleteByIdMock).toHaveBeenCalledWith(uid, "1"); + }); + it("should fail if connections endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp.delete("/connections/1").set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp.delete("/connections/1").expect(401); + }); + }); + + describe("update connection", () => { + const updateStatusMock = vi.spyOn(ConnectionsDal, "updateStatus"); + + beforeEach(() => { + updateStatusMock.mockClear().mockResolvedValue(); + }); + + it("should accept", async () => { + //WHEN + await mockApp + .patch("/connections/1") + .send({ status: "accepted" }) + .set("Authorization", `Bearer ${uid}`) + .expect(200); + + //THEN + expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "accepted"); + }); + it("should block", async () => { + //WHEN + await mockApp + .patch("/connections/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 + .patch("/connections/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' | 'blocked', received 'invalid'`, + ], + }); + }); + it("should fail if connections endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp + .patch("/connections/1") + .send({ status: "accepted" }) + .set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp + .patch("/connections/1") + .send({ status: "accepted" }) + .expect(401); + }); + }); +}); + +async function enableConnectionsEndpoints(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + connections: { enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} +async function expectFailForDisabledEndpoint(call: SuperTest): Promise { + await enableConnectionsEndpoints(false); + const { body } = await call.expect(503); + expect(body.message).toEqual("Connections are not available at this time."); +} diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 1a7634270..61bd8a31a 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -36,7 +36,9 @@ import { MonkeyMail, UserStreak } from "@monkeytype/schemas/users"; import MonkeyError, { isFirebaseError } from "../../../src/utils/error"; import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard"; +import * as ConnectionsDal from "../../../src/dal/connections"; import { pb } from "../../__testData__/users"; +import { SuperTest } from "supertest"; const { mockApp, uid, mockAuth } = setup(); const configuration = Configuration.getCachedConfiguration(); @@ -224,7 +226,7 @@ describe("user controller test", () => { userIsNameAvailableMock.mockClear(); }); - it("returns ok if name is available", async () => { + it("returns available if name is available", async () => { //GIVEN userIsNameAvailableMock.mockResolvedValue(true); @@ -236,13 +238,13 @@ describe("user controller test", () => { //THEN expect(body).toEqual({ - message: "Username available", - data: null, + message: "Check username", + data: { available: true }, }); expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", ""); }); - it("returns 409 if name is not available", async () => { + it("returns taken if name is not available", async () => { //GIVEN userIsNameAvailableMock.mockResolvedValue(false); @@ -250,10 +252,13 @@ describe("user controller test", () => { const { body } = await mockApp .get("/users/checkName/bob") //no authentication required - .expect(409); + .expect(200); //THEN - expect(body.message).toEqual("Username unavailable"); + expect(body).toEqual({ + message: "Check username", + data: { available: false }, + }); expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", ""); }); @@ -269,11 +274,17 @@ describe("user controller test", () => { //THEN expect(body).toEqual({ - message: "Username available", - data: null, + message: "Check username", + data: { available: true }, }); expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", uid); }); + it("returns 422 if username contains profanity", async () => { + await mockApp + .get("/users/checkName/newMiodec") + //no authentication required + .expect(422); + }); }); describe("sendVerificationEmail", () => { const adminGetUserMock = vi.fn(); @@ -624,6 +635,7 @@ describe("user controller test", () => { "purgeUserFromXpLeaderboards" ); const blocklistAddMock = vi.spyOn(BlocklistDal, "add"); + const connectionsDeletebyUidMock = vi.spyOn(ConnectionsDal, "deleteByUid"); const logsDeleteUserMock = vi.spyOn(LogDal, "deleteUserLogs"); beforeEach(() => { @@ -636,6 +648,7 @@ describe("user controller test", () => { deleteConfigMock, purgeUserFromDailyLeaderboardsMock, purgeUserFromXpLeaderboardsMock, + connectionsDeletebyUidMock, logsDeleteUserMock, ].forEach((it) => it.mockResolvedValue(undefined)); @@ -654,6 +667,7 @@ describe("user controller test", () => { deleteAllPresetsMock, purgeUserFromDailyLeaderboardsMock, purgeUserFromXpLeaderboardsMock, + connectionsDeletebyUidMock, logsDeleteUserMock, ].forEach((it) => it.mockClear()); }); @@ -684,6 +698,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -719,6 +734,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -749,6 +765,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -778,6 +795,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).not.toHaveBeenCalledWith(uid); expect(deleteConfigMock).not.toHaveBeenCalledWith(uid); expect(deleteAllResultMock).not.toHaveBeenCalledWith(uid); + expect(connectionsDeletebyUidMock).not.toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).not.toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -818,6 +836,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -858,6 +877,7 @@ describe("user controller test", () => { expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid); expect(deleteConfigMock).toHaveBeenCalledWith(uid); expect(deleteAllResultMock).toHaveBeenCalledWith(uid); + expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid); expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( uid, (await configuration).dailyLeaderboards @@ -978,6 +998,7 @@ describe("user controller test", () => { const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); const updateNameMock = vi.spyOn(UserDal, "updateName"); + const connectionsUpdateNameMock = vi.spyOn(ConnectionsDal, "updateName"); const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(() => { @@ -985,6 +1006,7 @@ describe("user controller test", () => { blocklistContainsMock, getPartialUserMock, updateNameMock, + connectionsUpdateNameMock, addImportantLogMock, ].forEach((it) => { it.mockClear().mockResolvedValue(null as never); @@ -1016,6 +1038,7 @@ describe("user controller test", () => { "changed name from Bob to newName", uid ); + expect(connectionsUpdateNameMock).toHaveBeenCalledWith(uid, "newName"); }); it("should fail if username is blocked", async () => { @@ -1032,6 +1055,7 @@ describe("user controller test", () => { //THEN expect(body.message).toEqual("Username blocked"); expect(updateNameMock).not.toHaveBeenCalled(); + expect(connectionsUpdateNameMock).not.toHaveBeenCalled(); }); it("should fail for banned users", async () => { @@ -3875,6 +3899,62 @@ describe("user controller test", () => { }); }); }); + describe("get friends", () => { + const getFriendsMock = vi.spyOn(UserDal, "getFriends"); + + beforeEach(() => { + enableConnectionsEndpoints(true); + getFriendsMock.mockClear(); + }); + + it("gets with premium enabled", async () => { + //GIVEN + enablePremiumFeatures(true); + const friend: UserDal.DBFriend = { + name: "Bob", + isPremium: true, + } as any; + getFriendsMock.mockResolvedValue([friend]); + + //WHEN + const { body } = await mockApp + .get("/users/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: UserDal.DBFriend = { + name: "Bob", + isPremium: true, + } as any; + getFriendsMock.mockResolvedValue([friend]); + + //WHEN + const { body } = await mockApp + .get("/users/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("/users/friends").set("Authorization", `Bearer ${uid}`) + ); + }); + + it("should fail without authentication", async () => { + await mockApp.get("/users/friends").expect(401); + }); + }); }); function fillYearWithDay(days: number): number[] { @@ -3974,3 +4054,18 @@ async function enableReporting(enabled: boolean): Promise { mockConfig ); } + +async function enableConnectionsEndpoints(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + connections: { enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} +async function expectFailForDisabledEndpoint(call: SuperTest): Promise { + await enableConnectionsEndpoints(false); + const { body } = await call.expect(503); + expect(body.message).toEqual("Connections are not available at this time."); +} diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index d42e06d05..53eaf52eb 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -99,6 +99,12 @@ export function getOpenApi(): OpenAPIObject { description: "All-time and daily leaderboards of the fastest typers.", "x-displayName": "Leaderboards", }, + { + name: "connections", + description: "Connections between users.", + "x-displayName": "Connections", + "x-public": "no", + }, { name: "psas", description: "Public service announcements.", diff --git a/backend/src/api/controllers/connections.ts b/backend/src/api/controllers/connections.ts new file mode 100644 index 000000000..906f98665 --- /dev/null +++ b/backend/src/api/controllers/connections.ts @@ -0,0 +1,85 @@ +import { + CreateConnectionRequest, + CreateConnectionResponse, + GetConnectionsQuery, + GetConnectionsResponse, + IdPathParams, + UpdateConnectionRequest, +} from "@monkeytype/contracts/connections"; +import { MonkeyRequest } from "../types"; +import { MonkeyResponse } from "../../utils/monkey-response"; +import * as ConnectionsDal from "../../dal/connections"; +import * as UserDal from "../../dal/user"; +import { replaceObjectId } from "../../utils/misc"; +import MonkeyError from "../../utils/error"; +import { omit } from "lodash"; +import { Connection } from "@monkeytype/schemas/connections"; + +function convert(db: ConnectionsDal.DBConnection): Connection { + return replaceObjectId(omit(db, "key")); +} +export async function getConnections( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const { status, type } = req.query; + + const results = await ConnectionsDal.getConnections({ + initiatorUid: + type === undefined || type.includes("outgoing") ? uid : undefined, + receiverUid: + type === undefined || type?.includes("incoming") ? uid : undefined, + status: status, + }); + + return new MonkeyResponse("Connections retrieved", results.map(convert)); +} + +export async function createConnection( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const { receiverName } = req.body; + const { maxPerUser } = req.ctx.configuration.connections; + + const receiver = await UserDal.getUserByName( + receiverName, + "create connection" + ); + + if (uid === receiver.uid) { + throw new MonkeyError(400, "You cannot be your own friend, sorry."); + } + + const initiator = await UserDal.getPartialUser(uid, "create connection", [ + "uid", + "name", + ]); + + const result = await ConnectionsDal.create(initiator, receiver, maxPerUser); + + return new MonkeyResponse("Connection created", convert(result)); +} + +export async function deleteConnection( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const { id } = req.params; + + await ConnectionsDal.deleteById(uid, id); + + return new MonkeyResponse("Connection deleted", null); +} + +export async function updateConnection( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const { id } = req.params; + const { status } = req.body; + + await ConnectionsDal.updateStatus(uid, id, status); + + return new MonkeyResponse("Connection updated", null); +} diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 994d7ad72..93b7b5f18 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -51,6 +51,7 @@ import { AddTagRequest, AddTagResponse, CheckNamePathParameters, + CheckNameResponse, CreateUserRequest, DeleteCustomThemeRequest, EditCustomThemeRequst, @@ -60,6 +61,7 @@ import { GetCustomThemesResponse, GetDiscordOauthLinkResponse, GetFavoriteQuotesResponse, + GetFriendsResponse, GetPersonalBestsQuery, GetPersonalBestsResponse, GetProfilePathParams, @@ -89,6 +91,7 @@ import { import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time"; import { MonkeyRequest } from "../types"; import { tryCatch } from "@monkeytype/util/trycatch"; +import * as ConnectionsDal from "../../dal/connections"; async function verifyCaptcha(captcha: string): Promise { const { data: verified, error } = await tryCatch(verify(captcha)); @@ -292,6 +295,7 @@ export async function deleteUser(req: MonkeyRequest): Promise { uid, req.ctx.configuration.leaderboards.weeklyXp ), + ConnectionsDal.deleteByUid(uid), ]); try { @@ -382,6 +386,8 @@ export async function updateName( } await UserDAL.updateName(uid, name, user.name); + + await ConnectionsDal.updateName(uid, name); void addImportantLog( "user_name_updated", `changed name from ${user.name} to ${name}`, @@ -425,16 +431,15 @@ export async function optOutOfLeaderboards( export async function checkName( req: MonkeyRequest -): Promise { +): Promise { const { name } = req.params; const { uid } = req.ctx.decodedToken; const available = await UserDAL.isNameAvailable(name, uid); - if (!available) { - throw new MonkeyError(409, "Username unavailable"); - } - return new MonkeyResponse("Username available", null); + return new MonkeyResponse("Check username", { + available, + }); } export async function updateEmail( @@ -1261,3 +1266,19 @@ export async function getStreak( return new MonkeyResponse("Streak data retrieved", user.streak ?? null); } + +export async function getFriends( + req: MonkeyRequest +): Promise { + const { uid } = req.ctx.decodedToken; + const premiumEnabled = req.ctx.configuration.users.premium.enabled; + const data = await UserDAL.getFriends(uid); + + if (!premiumEnabled) { + for (const friend of data) { + delete friend.isPremium; + } + } + + return new MonkeyResponse("Friends retrieved", data); +} diff --git a/backend/src/api/routes/connections.ts b/backend/src/api/routes/connections.ts new file mode 100644 index 000000000..59a73f179 --- /dev/null +++ b/backend/src/api/routes/connections.ts @@ -0,0 +1,25 @@ +import { connectionsContract } from "@monkeytype/contracts/connections"; +import { initServer } from "@ts-rest/express"; +import { callController } from "../ts-rest-adapter"; + +import * as ConnectionsController from "../controllers/connections"; + +const s = initServer(); +export default s.router(connectionsContract, { + get: { + handler: async (r) => + callController(ConnectionsController.getConnections)(r), + }, + create: { + handler: async (r) => + callController(ConnectionsController.createConnection)(r), + }, + delete: { + handler: async (r) => + callController(ConnectionsController.deleteConnection)(r), + }, + update: { + handler: async (r) => + callController(ConnectionsController.updateConnection)(r), + }, +}); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index d416c9f3b..aed51685e 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 connections from "./connections"; import addSwaggerMiddlewares from "./swagger"; import { MonkeyResponse } from "../../utils/monkey-response"; import { @@ -61,6 +62,7 @@ const router = s.router(contract, { users, quotes, webhooks, + connections, }); export function addApiRoutes(app: Application): void { diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 8e34831a8..86878d38b 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -137,4 +137,7 @@ export default s.router(usersContract, { getStreak: { handler: async (r) => callController(UserController.getStreak)(r), }, + getFriends: { + handler: async (r) => callController(UserController.getFriends)(r), + }, }); diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index d69f74e46..7c135062d 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -103,6 +103,7 @@ export const BASE_CONFIGURATION: Configuration = { xpRewardBrackets: [], }, }, + connections: { enabled: false, maxPerUser: 100 }, }; type BaseSchema = { @@ -603,5 +604,16 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { }, }, }, + connections: { + type: "object", + label: "Connections", + fields: { + enabled: { type: "boolean", label: "Enabled" }, + maxPerUser: { + type: "number", + label: "Max Connections per user", + }, + }, + }, }, }; diff --git a/backend/src/dal/connections.ts b/backend/src/dal/connections.ts new file mode 100644 index 000000000..b6448aa46 --- /dev/null +++ b/backend/src/dal/connections.ts @@ -0,0 +1,202 @@ +import { Collection, Filter, ObjectId } from "mongodb"; +import * as db from "../init/db"; +import { Connection, ConnectionStatus } from "@monkeytype/schemas/connections"; +import MonkeyError from "../utils/error"; +import { WithObjectId } from "../utils/misc"; + +export type DBConnection = WithObjectId< + Connection & { + key: string; //sorted uid + } +>; + +export const getCollection = (): Collection => + db.collection("connections"); + +export async function getConnections(options: { + initiatorUid?: string; + receiverUid?: string; + status?: ConnectionStatus[]; +}): Promise { + const { initiatorUid, receiverUid, status } = options; + + if (initiatorUid === undefined && receiverUid === undefined) + throw new Error("Missing filter"); + + let filter: Filter = { $or: [] }; + + if (initiatorUid !== undefined) { + filter.$or?.push({ initiatorUid }); + } + + if (receiverUid !== undefined) { + filter.$or?.push({ receiverUid }); + } + + if (status !== undefined) { + filter.status = { $in: status }; + } + + return await getCollection().find(filter).toArray(); +} + +export async function create( + initiator: { uid: string; name: string }, + receiver: { uid: string; name: string }, + maxPerUser: number +): Promise { + const count = await getCollection().countDocuments({ + initiatorUid: initiator.uid, + }); + + if (count >= maxPerUser) { + throw new MonkeyError( + 409, + "Maximum number of connections reached", + "create connection request" + ); + } + const key = getKey(initiator.uid, receiver.uid); + try { + const created: DBConnection = { + _id: new ObjectId(), + key, + initiatorUid: initiator.uid, + initiatorName: initiator.name, + receiverUid: receiver.uid, + receiverName: receiver.name, + lastModified: Date.now(), + status: "pending", + }; + + 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) { + const existing = await getCollection().findOne( + { key }, + { projection: { status: 1 } } + ); + + let message = ""; + + if (existing?.status === "accepted") { + message = "Connection already exists"; + } else if (existing?.status === "pending") { + message = "Connection request already sent"; + } else if (existing?.status === "blocked") { + if (existing.initiatorUid === initiator.uid) { + message = "Connection blocked by initiator"; + } else { + message = "Connection blocked by receiver"; + } + } else { + message = "Duplicate connection"; + } + + throw new MonkeyError(409, message); + } + + throw e; + } +} + +/** + *Update the status of a connection by id + * @param receiverUid + * @param id + * @param status + * @throws MonkeyError if the connection id is unknown or the recieverUid does not match + */ +export async function updateStatus( + receiverUid: string, + id: string, + status: ConnectionStatus +): Promise { + const updateResult = await getCollection().updateOne( + { + _id: new ObjectId(id), + receiverUid, + }, + { $set: { status, lastModified: Date.now() } } + ); + + if (updateResult.matchedCount === 0) { + throw new MonkeyError(404, "No permission or connection not found"); + } +} + +/** + * delete a connection by the id. + * @param uid + * @param id + * @throws MonkeyError if the connection id is unknown or uid does not match + */ +export async function deleteById(uid: string, id: string): Promise { + const deletionResult = await getCollection().deleteOne({ + $and: [ + { + _id: new ObjectId(id), + }, + { + $or: [ + { receiverUid: uid }, + { status: { $in: ["accepted", "pending"] }, initiatorUid: uid }, + ], + }, + ], + }); + + if (deletionResult.deletedCount === 0) { + throw new MonkeyError(404, "No permission or connection not found"); + } +} + +/** + * Update all connections for the uid (initiator or receiver) with the given name. + * @param uid + * @param newName + */ +export async function updateName(uid: string, newName: string): Promise { + await getCollection().bulkWrite([ + { + updateMany: { + filter: { initiatorUid: uid }, + update: { $set: { initiatorName: newName } }, + }, + }, + { + updateMany: { + filter: { receiverUid: uid }, + update: { $set: { receiverName: newName } }, + }, + }, + ]); +} + +/** + * Remove all connections containing the uid as initiatorUid or receiverUid + * @param uid + */ +export async function deleteByUid(uid: string): Promise { + await getCollection().deleteMany({ + $or: [{ initiatorUid: uid }, { receiverUid: uid }], + }); +} + +function getKey(initiatorUid: string, receiverUid: string): string { + const ids = [initiatorUid, receiverUid]; + ids.sort(); + return ids.join("/"); +} + +export async function createIndicies(): Promise { + //index used for search + await getCollection().createIndex({ initiatorUid: 1, status: 1 }); + await getCollection().createIndex({ receiverUid: 1, status: 1 }); + + //make sure there is only one connection for each initiatorr/receiver + await getCollection().createIndex({ key: 1 }, { unique: true }); +} diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 6b534b6f4..756cd2793 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -26,6 +26,7 @@ import { UserTag, User, CountByYearAndDay, + Friend, } from "@monkeytype/schemas/users"; import { Mode, Mode2, PersonalBest } from "@monkeytype/schemas/shared"; import { addImportantLog } from "./logs"; @@ -33,6 +34,7 @@ import { Result as ResultType } from "@monkeytype/schemas/results"; import { Configuration } from "@monkeytype/schemas/configuration"; import { isToday, isYesterday } from "@monkeytype/util/date-and-time"; import GeorgeQueue from "../queues/george-queue"; +import { getCollection as getConnectionCollection } from "./connections"; export type DBUserTag = WithObjectId; @@ -65,6 +67,8 @@ const SECONDS_PER_HOUR = 3600; type Result = Omit, "_id" | "name">; +export type DBFriend = Friend; + // Export for use in tests export const getUsersCollection = (): Collection => db.collection("users"); @@ -1222,3 +1226,212 @@ async function updateUser( error.stack ); } + +export async function getFriends(uid: string): Promise { + return (await getConnectionCollection() + .aggregate([ + { + $match: { + //uid is friend or initiator + $and: [ + { + $or: [{ initiatorUid: uid }, { receiverUid: uid }], + status: "accepted", + }, + ], + }, + }, + { + $project: { + receiverUid: true, + initiatorUid: true, + lastModified: true, + }, + }, + { + $addFields: { + //pick the other user, not uid + uid: { + $cond: { + if: { $eq: ["$receiverUid", uid] }, + // oxlint-disable-next-line no-thenable + then: "$initiatorUid", + else: "$receiverUid", + }, + }, + }, + }, + // 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", + lastModified: "$lastModified", + connectionId: "$_id", + }, + }, + }, + }, + { + $project: { + data: { + $concatArrays: ["$data", [{ uid }]], + }, + }, + }, + { + $unwind: "$data", + }, + + /* end of workaround, this is the replacement for >= 5.1 + + { $addFields: { connectionId: "$_id" } }, + { $project: { uid: true, lastModified: true, connectionId: 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: { + lastModified: "$data.lastModified", //just $lastModified if we remove the workaround above + connectionId: "$data.connectionId", //just $connectionId if we remove the workaround above + }, + pipeline: [ + { + $project: { + _id: false, + uid: true, + connectionId: true, + name: true, + discordId: true, + discordAvatar: true, + startedTests: true, + completedTests: true, + timeTyping: true, + xp: true, + "streak.length": true, + "streak.maxLength": true, + personalBests: true, + "inventory.badges": true, + "premium.expirationTimestamp": true, + banned: 1, + lbOptOut: 1, + }, + }, + { + $addFields: { + lastModified: "$$lastModified", + connectionId: "$$connectionId", + 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", + ], + }, + }, + }, + badgeId: { + $ifNull: [ + { + $first: { + $map: { + input: { + $filter: { + input: "$inventory.badges", + as: "badge", + cond: { $eq: ["$$badge.selected", true] }, + }, + }, + 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", + }, + }, + }, + }, + { + $addFields: { + //remove nulls + top15: { $ifNull: ["$top15", "$$REMOVE"] }, + top60: { $ifNull: ["$top60", "$$REMOVE"] }, + badgeId: { $ifNull: ["$badgeId", "$$REMOVE"] }, + lastModified: "$lastModified", + }, + }, + { + $project: { + personalBests: false, + inventory: false, + premium: 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[]; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 7570078a0..68c7ca3e5 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 connectionsDbSetup } from "./dal/connections"; 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 connections indicies..."); + await connectionsDbSetup(); + recordServerVersion(version); } catch (error) { Logger.error("Failed to boot server"); diff --git a/frontend/src/html/header.html b/frontend/src/html/header.html index 4cbd88403..d49241005 100644 --- a/frontend/src/html/header.html +++ b/frontend/src/html/header.html @@ -116,7 +116,7 @@
- + +