diff --git a/backend/__tests__/__integration__/dal/connections.spec.ts b/backend/__tests__/__integration__/dal/connections.spec.ts index 7aa62d99c..b7b9af47c 100644 --- a/backend/__tests__/__integration__/dal/connections.spec.ts +++ b/backend/__tests__/__integration__/dal/connections.spec.ts @@ -11,6 +11,7 @@ import { ObjectId } from "mongodb"; import * as ConnectionsDal from "../../../src/dal/connections"; import { createConnection } from "../../__testData__/connections"; +import { createUser } from "../../__testData__/users"; describe("ConnectionsDal", () => { beforeAll(async () => { @@ -401,4 +402,92 @@ describe("ConnectionsDal", () => { ]); }); }); + + describe("aggregateWithAcceptedConnections", () => { + it("should return friend uids", async () => { + //GIVE + const uid = (await createUser()).uid; + const friendOne = await createConnection({ + initiatorUid: uid, + receiverUid: (await createUser()).uid, + status: "accepted", + }); + const friendTwo = await createConnection({ + initiatorUid: (await createUser()).uid, + receiverUid: uid, + status: "accepted", + }); + const friendThree = await createConnection({ + initiatorUid: (await createUser()).uid, + receiverUid: uid, + status: "accepted", + }); + const _pending = await createConnection({ + initiatorUid: uid, + receiverUid: (await createUser()).uid, + status: "pending", + }); + const _blocked = await createConnection({ + initiatorUid: uid, + receiverUid: (await createUser()).uid, + status: "blocked", + }); + const _decoy = await createConnection({ + receiverUid: (await createUser()).uid, + status: "accepted", + }); + + //WHEN + const friendUids = await ConnectionsDal.aggregateWithAcceptedConnections<{ + uid: string; + }>({ collectionName: "users", uid }, [{ $project: { uid: true } }]); + + //THEN + expect(friendUids.flatMap((it) => it.uid).toSorted()).toEqual([ + uid, + friendOne.receiverUid, + friendTwo.initiatorUid, + friendThree.initiatorUid, + ]); + }); + it("should return friend uids and metaData", async () => { + //GIVE + const me = await createUser(); + const friend = await createUser(); + + const connection = await createConnection({ + initiatorUid: me.uid, + receiverUid: friend.uid, + status: "accepted", + }); + + //WHEN + const friendUids = await ConnectionsDal.aggregateWithAcceptedConnections( + { collectionName: "users", uid: me.uid, includeMetaData: true }, + [ + { + $project: { + uid: true, + lastModified: "$connectionMeta.lastModified", + connectionId: "$connectionMeta._id", + }, + }, + ] + ); + + //THEN + expect(friendUids).toEqual([ + { + _id: friend._id, + connectionId: connection._id, + lastModified: connection.lastModified, + uid: friend.uid, + }, + { + _id: me._id, + uid: me.uid, + }, + ]); + }); + }); }); diff --git a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts index 63a75af77..5b37a0877 100644 --- a/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts +++ b/backend/__tests__/__integration__/dal/leaderboards.isolated.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import _ from "lodash"; import { ObjectId } from "mongodb"; import * as UserDal from "../../../src/dal/user"; @@ -11,6 +11,7 @@ import * as DB from "../../../src/init/db"; import { LbPersonalBests } from "../../../src/utils/pb"; import { pb } from "../../__testData__/users"; +import { createConnection } from "../../__testData__/connections"; describe("LeaderboardsDal", () => { afterEach(async () => { @@ -307,9 +308,20 @@ describe("LeaderboardsDal", () => { it("should get for friends only", async () => { //GIVEN const rank1 = await createUser(lbBests(pb(90), pb(100, 90, 2))); + const uid = rank1.uid; 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))); + + //two friends, one is not on the leaderboard + await createConnection({ + initiatorUid: uid, + receiverUid: rank4.uid, + status: "accepted", + }); + + await createConnection({ initiatorUid: uid, status: "accepted" }); + await LeaderboardsDal.update("time", "60", "english"); //WHEN @@ -321,7 +333,7 @@ describe("LeaderboardsDal", () => { 0, 50, false, - [rank1.uid, rank4.uid] + uid )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN @@ -335,11 +347,23 @@ describe("LeaderboardsDal", () => { it("should get for friends only with page", async () => { //GIVEN const rank1 = await createUser(lbBests(pb(90), pb(105, 90, 2))); + const uid = rank1.uid; 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"); + await createConnection({ + initiatorUid: uid, + receiverUid: rank2.uid, + status: "accepted", + }); + await createConnection({ + initiatorUid: rank4.uid, + receiverUid: uid, + status: "accepted", + }); + //WHEN const results = (await LeaderboardsDal.get( "time", @@ -348,7 +372,7 @@ describe("LeaderboardsDal", () => { 1, 2, false, - [rank1.uid, rank2.uid, rank4.uid] + uid )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN @@ -360,6 +384,7 @@ describe("LeaderboardsDal", () => { }); it("should return empty list if no friends", async () => { //GIVEN + const uid = new ObjectId().toHexString(); //WHEN const results = (await LeaderboardsDal.get( @@ -369,7 +394,7 @@ describe("LeaderboardsDal", () => { 1, 2, false, - [] + uid )) as LeaderboardsDal.DBLeaderboardEntry[]; //THEN expect(results).toEqual([]); @@ -378,10 +403,10 @@ describe("LeaderboardsDal", () => { describe("getCount / getRank", () => { it("should get count", async () => { //GIVEN - 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 createUser(lbBests(undefined, pb(105)), { name: "One" }); + await createUser(lbBests(undefined, pb(100)), { name: "Two" }); + const me = await createUser(lbBests(undefined, pb(95)), { name: "Me" }); + await createUser(lbBests(undefined, pb(90)), { name: "Three" }); await LeaderboardsDal.update("time", "60", "english"); //WHEN / THEN @@ -405,19 +430,26 @@ describe("LeaderboardsDal", () => { 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"); - const friends = [friendOne.uid, friendTwo.uid, me.uid]; + await createConnection({ + initiatorUid: me.uid, + receiverUid: friendOne.uid, + status: "accepted", + }); + + await createConnection({ + initiatorUid: friendTwo.uid, + receiverUid: me.uid, + status: "accepted", + }); //WHEN / THEN - expect(await LeaderboardsDal.getCount("time", "60", "english", friends)) // + expect(await LeaderboardsDal.getCount("time", "60", "english", me.uid)) // .toEqual(3); expect( - await LeaderboardsDal.getRank("time", "60", "english", me.uid, friends) + await LeaderboardsDal.getRank("time", "60", "english", me.uid, true) ) // .toEqual( expect.objectContaining({ diff --git a/backend/__tests__/__testData__/connections.ts b/backend/__tests__/__testData__/connections.ts index acde3ca09..38a18f614 100644 --- a/backend/__tests__/__testData__/connections.ts +++ b/backend/__tests__/__testData__/connections.ts @@ -16,9 +16,8 @@ export async function createConnection( }, maxPerUser ); - await ConnectionsDal.getCollection().updateOne( - { _id: result._id }, - { $set: data } - ); + await ConnectionsDal.__testing + .getCollection() + .updateOne({ _id: result._id }, { $set: data }); return { ...result, ...data }; } diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index d06b906f3..12fd86ea2 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -30,12 +30,10 @@ describe("Loaderboard Controller", () => { describe("get leaderboard", () => { const getLeaderboardMock = vi.spyOn(LeaderboardDal, "get"); const getLeaderboardCountMock = vi.spyOn(LeaderboardDal, "getCount"); - const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids"); beforeEach(() => { getLeaderboardMock.mockClear(); getLeaderboardCountMock.mockClear(); - getFriendsUidsMock.mockClear(); getLeaderboardCountMock.mockResolvedValue(42); }); @@ -154,7 +152,6 @@ describe("Loaderboard Controller", () => { //GIVEN await enableConnectionsFeature(true); getLeaderboardMock.mockResolvedValue([]); - getFriendsUidsMock.mockResolvedValue(["uidOne", "uidTwo"]); getLeaderboardCountMock.mockResolvedValue(2); //WHEN @@ -180,13 +177,13 @@ describe("Loaderboard Controller", () => { 0, 50, false, - ["uidOne", "uidTwo"] + uid ); expect(getLeaderboardCountMock).toHaveBeenCalledWith( "time", "60", "english", - ["uidOne", "uidTwo"] + uid ); }); @@ -286,11 +283,9 @@ describe("Loaderboard Controller", () => { describe("get rank", () => { const getLeaderboardRankMock = vi.spyOn(LeaderboardDal, "getRank"); - const getFriendsUidsMock = vi.spyOn(ConnectionsDal, "getFriendsUids"); afterEach(() => { getLeaderboardRankMock.mockClear(); - getFriendsUidsMock.mockClear(); }); it("fails withouth authentication", async () => { @@ -335,14 +330,12 @@ describe("Loaderboard Controller", () => { "60", "english", uid, - undefined + false ); }); it("should get for english time 60 friends only", async () => { //GIVEN await enableConnectionsFeature(true); - const friends = ["friendOne", "friendTwo"]; - getFriendsUidsMock.mockResolvedValue(friends); getLeaderboardRankMock.mockResolvedValue({} as any); //WHEN @@ -363,9 +356,8 @@ describe("Loaderboard Controller", () => { "60", "english", uid, - friends + true ); - expect(getFriendsUidsMock).toHaveBeenCalledWith(uid); }); it("should get with ape key", async () => { await acceptApeKeys(true); diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index ae7c47a74..e754e38ba 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -43,11 +43,7 @@ export async function getLeaderboard( throw new MonkeyError(404, "There is no leaderboard for this mode"); } - const friendUids = await getFriendsUids( - uid, - friendsOnly === true, - connectionsConfig - ); + const friendsOnlyUid = getFriendsOnlyUid(uid, friendsOnly, connectionsConfig); const leaderboard = await LeaderboardsDAL.get( mode, @@ -56,7 +52,7 @@ export async function getLeaderboard( page, pageSize, req.ctx.configuration.users.premium.enabled, - friendUids + friendsOnlyUid ); if (leaderboard === false) { @@ -70,7 +66,7 @@ export async function getLeaderboard( mode, mode2, language, - friendUids + friendsOnlyUid ); const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"])); @@ -88,18 +84,12 @@ export async function getRankFromLeaderboard( const { uid } = req.ctx.decodedToken; const connectionsConfig = req.ctx.configuration.connections; - const friendUids = await getFriendsUids( - uid, - friendsOnly === true, - connectionsConfig - ); - const data = await LeaderboardsDAL.getRank( mode, mode2, language, uid, - friendUids + getFriendsOnlyUid(uid, friendsOnly, connectionsConfig) !== undefined ); if (data === false) { throw new MonkeyError( @@ -284,3 +274,17 @@ async function getFriendsUids( } return undefined; } + +function getFriendsOnlyUid( + uid: string, + friendsOnly: boolean | undefined, + friendsConfig: Configuration["connections"] +): string | undefined { + if (uid !== "" && friendsOnly === true) { + if (!friendsConfig.enabled) { + throw new MonkeyError(503, "This feature is currently unavailable."); + } + return uid; + } + return undefined; +} diff --git a/backend/src/dal/connections.ts b/backend/src/dal/connections.ts index a2924bd2c..75b2eeb0a 100644 --- a/backend/src/dal/connections.ts +++ b/backend/src/dal/connections.ts @@ -1,4 +1,4 @@ -import { Collection, Filter, ObjectId } from "mongodb"; +import { Collection, Document, Filter, ObjectId } from "mongodb"; import * as db from "../init/db"; import { Connection, ConnectionStatus } from "@monkeytype/schemas/connections"; import MonkeyError from "../utils/error"; @@ -10,7 +10,7 @@ export type DBConnection = WithObjectId< } >; -export const getCollection = (): Collection => +const getCollection = (): Collection => db.collection("connections"); export async function getConnections(options: { @@ -209,6 +209,125 @@ export async function getFriendsUids(uid: string): Promise { ); } +/** + * aggregate the given `pipeline` on the `collectionName` for each friendUid and the given `uid`. + + * @param pipeline + * @param options + * @returns + */ +export async function aggregateWithAcceptedConnections( + options: { + uid: string; + /** + * target collection + */ + collectionName: string; + /** + * uid field on the collection, defaults to `uid` + */ + uidField?: string; + /** + * add meta data `connectionMeta.lastModified` and *connectionMeta._id` to the document + */ + includeMetaData?: boolean; + }, + pipeline: Document[] +): Promise { + const metaData = options.includeMetaData + ? { + let: { + lastModified: "$lastModified", + connectionId: "$connectionId", + }, + pipeline: [ + { + $addFields: { + "connectionMeta.lastModified": "$$lastModified", + "connectionMeta._id": "$$connectionId", + }, + }, + ], + } + : {}; + const { uid, collectionName, uidField } = options; + const fullPipeline = [ + { + $match: { + status: "accepted", + //uid is friend or initiator + $or: [{ initiatorUid: uid }, { receiverUid: uid }], + }, + }, + { + $project: { + lastModified: true, + uid: { + //pick the other user, not 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" }, + { $replaceRoot: { newRoot: "$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 }] }], + }, + }, + */ + + { + //replace with $unionWith in MongoDB 6 or newer + $lookup: { + from: collectionName, + localField: "uid", + foreignField: uidField ?? "uid", + as: "result", + ...metaData, + }, + }, + + { $match: { result: { $ne: [] } } }, + { $replaceRoot: { newRoot: { $first: "$result" } } }, + ...pipeline, + ]; + + //console.log(JSON.stringify(fullPipeline, null, 4)); + return (await getCollection().aggregate(fullPipeline).toArray()) as T[]; +} + function getKey(initiatorUid: string, receiverUid: string): string { const ids = [initiatorUid, receiverUid]; ids.sort(); @@ -223,3 +342,7 @@ export async function createIndicies(): Promise { //make sure there is only one connection for each initiatorr/receiver await getCollection().createIndex({ key: 1 }, { unique: true }); } + +export const __testing = { + getCollection, +}; diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index 8de1b68bb..573d6c930 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -14,19 +14,25 @@ import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards"; import { omit } from "lodash"; import { DBUser, getUsersCollection } from "./user"; import MonkeyError from "../utils/error"; +import { aggregateWithAcceptedConnections } from "./connections"; export type DBLeaderboardEntry = LeaderboardEntry & { _id: ObjectId; }; +function getCollectionName(key: { + language: string; + mode: string; + mode2: string; +}): string { + return `leaderboards.${key.language}.${key.mode}.${key.mode2}`; +} export const getCollection = (key: { language: string; mode: string; mode2: string; }): Collection => - db.collection( - `leaderboards.${key.language}.${key.mode}.${key.mode2}` - ); + db.collection(getCollectionName(key)); export async function get( mode: string, @@ -35,42 +41,45 @@ export async function get( page: number, pageSize: number, premiumFeaturesEnabled: boolean = false, - userIds?: string[] + uid?: string ): Promise { if (page < 0 || pageSize < 0) { throw new MonkeyError(500, "Invalid page or pageSize"); } - if (userIds?.length === 0) { - return []; - } - const skip = page * pageSize; const limit = pageSize; + let leaderboard: DBLeaderboardEntry[] | false = []; + const pipeline: Document[] = [ { $sort: { rank: 1 } }, { $skip: skip }, { $limit: limit }, ]; - if (userIds !== undefined) { - pipeline.unshift( - { $match: { uid: { $in: userIds } } }, - { - $setWindowFields: { - sortBy: { rank: 1 }, - output: { friendsRank: { $documentNumber: {} } }, - }, - } - ); - } - try { - let leaderboard = (await getCollection({ language, mode, mode2 }) - .aggregate(pipeline) - .toArray()) as DBLeaderboardEntry[]; - + if (uid !== undefined) { + leaderboard = await aggregateWithAcceptedConnections( + { + uid, + collectionName: getCollectionName({ language, mode, mode2 }), + }, + [ + { + $setWindowFields: { + sortBy: { rank: 1 }, + output: { friendsRank: { $documentNumber: {} } }, + }, + }, + ...pipeline, + ] + ); + } else { + leaderboard = await getCollection({ language, mode, mode2 }) + .aggregate(pipeline) + .toArray(); + } if (!premiumFeaturesEnabled) { leaderboard = leaderboard.map((it) => omit(it, "isPremium")); } @@ -92,23 +101,30 @@ export async function getCount( mode: string, mode2: string, language: string, - userIds?: string[] + uid?: string ): Promise { const key = `${language}_${mode}_${mode2}`; - if (userIds === undefined && cachedCounts.has(key)) { + if (uid === undefined && cachedCounts.has(key)) { return cachedCounts.get(key) as number; } else { - const lb = getCollection({ - language, - mode, - mode2, - }); - if (userIds === undefined) { - const count = await lb.estimatedDocumentCount(); + if (uid === undefined) { + const count = await getCollection({ + language, + mode, + mode2, + }).estimatedDocumentCount(); cachedCounts.set(key, count); return count; } else { - return lb.countDocuments({ uid: { $in: userIds } }); + return ( + await aggregateWithAcceptedConnections( + { + collectionName: getCollectionName({ language, mode, mode2 }), + uid, + }, + [{ $project: { _id: true } }] + ) + ).length; } } } @@ -118,32 +134,33 @@ export async function getRank( mode2: string, language: string, uid: string, - userIds?: string[] + friendsOnly: boolean = false ): Promise { try { - if (userIds === undefined) { + if (!friendsOnly) { 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 } } }, + const results = + await aggregateWithAcceptedConnections( { - $setWindowFields: { - sortBy: { rank: 1 }, - output: { friendsRank: { $documentNumber: {} } }, - }, + collectionName: getCollectionName({ language, mode, mode2 }), + uid, }, - { $match: { uid } }, - ]) - .toArray(); - - return entry[0] as DBLeaderboardEntry; + [ + { + $setWindowFields: { + sortBy: { rank: 1 }, + output: { friendsRank: { $documentNumber: {} } }, + }, + }, + { $match: { uid } }, + ] + ); + return results[0] ?? null; } } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -164,7 +181,7 @@ export async function update( rank?: number; }> { const key = `lbPersonalBests.${mode}.${mode2}.${language}`; - const lbCollectionName = `leaderboards.${language}.${mode}.${mode2}`; + const lbCollectionName = getCollectionName({ language, mode, mode2 }); const minTimeTyping = (await getCachedConfiguration(true)).leaderboards .minTimeTyping; const lb = db.collection("users").aggregate( diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 756cd2793..a96bdc67a 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -34,7 +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"; +import { aggregateWithAcceptedConnections } from "./connections"; export type DBUserTag = WithObjectId; @@ -1228,210 +1228,118 @@ async function updateUser( } 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", - }, - ], - }, - }, + return await aggregateWithAcceptedConnections( + { + uid, + collectionName: "users", + includeMetaData: true, + }, + [ { $project: { - receiverUid: true, - initiatorUid: true, - lastModified: true, + _id: false, + uid: true, + connectionId: "$connectionMeta._id", + lastModified: "$connectionMeta.lastModified", + 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: { - //pick the other user, not uid - uid: { + 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: { $eq: ["$receiverUid", uid] }, + if: { + $or: [ + { $eq: ["$premium.expirationTimestamp", -1] }, + { + $gt: ["$premium.expirationTimestamp", { $toLong: "$$NOW" }], + }, + ], + }, // oxlint-disable-next-line no-thenable - then: "$initiatorUid", - else: "$receiverUid", + then: true, + else: "$$REMOVE", }, }, }, }, - // 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", - }, - }, + $addFields: { + //remove nulls + top15: { $ifNull: ["$top15", "$$REMOVE"] }, + top60: { $ifNull: ["$top60", "$$REMOVE"] }, + badgeId: { $ifNull: ["$badgeId", "$$REMOVE"] }, + lastModified: "$lastModified", }, }, { $project: { - data: { - $concatArrays: ["$data", [{ uid }]], - }, + personalBests: false, + inventory: false, + premium: false, }, }, - { - $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[]; + ] + ); }