diff --git a/backend/__tests__/dal/user.spec.ts b/backend/__tests__/dal/user.spec.ts index 15aa2bd9b..dda26520e 100644 --- a/backend/__tests__/dal/user.spec.ts +++ b/backend/__tests__/dal/user.spec.ts @@ -489,4 +489,32 @@ describe("UserDal", () => { expect(resetUser.bananas).toStrictEqual(0); expect(resetUser.xp).toStrictEqual(0); }); + + it("getInbox should return the user's inbox", async () => { + await UserDAL.addUser("test name", "test email", "TestID"); + + const emptyInbox = await UserDAL.getInbox("TestID"); + + expect(emptyInbox).toStrictEqual([]); + + await UserDAL.addToInbox( + "TestID", + [ + { + getTemplate: (user) => ({ + subject: `Hello ${user.name}!`, + }), + } as any, + ], + 0 + ); + + const inbox = await UserDAL.getInbox("TestID"); + + expect(inbox).toStrictEqual([ + { + subject: "Hello test name!", + }, + ]); + }); }); diff --git a/backend/__tests__/utils/daily-leaderboards.spec.ts b/backend/__tests__/utils/daily-leaderboards.spec.ts index 294801e52..737a4b531 100644 --- a/backend/__tests__/utils/daily-leaderboards.spec.ts +++ b/backend/__tests__/utils/daily-leaderboards.spec.ts @@ -21,6 +21,7 @@ const dailyLeaderboardsConfig = { ], dailyLeaderboardCacheSize: 3, topResultsToAnnounce: 3, + xpReward: 0, }; describe("Daily Leaderboards", () => { diff --git a/backend/__tests__/utils/monkey-mail.spec.ts b/backend/__tests__/utils/monkey-mail.spec.ts new file mode 100644 index 000000000..4746c731c --- /dev/null +++ b/backend/__tests__/utils/monkey-mail.spec.ts @@ -0,0 +1,22 @@ +import { buildMonkeyMail } from "../../src/utils/monkey-mail"; + +describe("Monkey Mail", () => { + it("should properly create a mail object", () => { + const mailConfig = { + subject: "", + body: "", + timestamp: Date.now(), + getTemplate: (): any => ({}), + }; + + const mail = buildMonkeyMail(mailConfig) as any; + + expect(mail.id).toBeDefined(); + expect(mail.subject).toBe(""); + expect(mail.body).toBe(""); + expect(mail.timestamp).toBeDefined(); + expect(mail.read).toBe(false); + expect(mail.rewards).toEqual([]); + expect(mail.getTemplate).toBeDefined(); + }); +}); diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 441037f9c..49adc9ad8 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -117,6 +117,19 @@ export async function updateEmail( return new MonkeyResponse("Email updated"); } +function getRelevantUserInfo( + user: MonkeyTypes.User +): Partial { + return _.omit(user, [ + "bananas", + "lbPersonalBests", + "quoteMod", + "inbox", + "nameHistory", + "lastNameChange", + ]); +} + export async function getUser( req: MonkeyTypes.Request ): Promise { @@ -142,7 +155,12 @@ export async function getUser( const agentLog = buildAgentLog(req); Logger.logToDb("user_data_requested", agentLog, uid); - return new MonkeyResponse("User data retrieved", userInfo); + const userData = { + ...getRelevantUserInfo(userInfo), + inboxUnreadSize: _.filter(userInfo.inbox, { read: false }).length, + }; + + return new MonkeyResponse("User data retrieved", userData); } export async function linkDiscord( @@ -487,3 +505,24 @@ export async function updateProfile( return new MonkeyResponse("Profile updated"); } + +export async function getInbox( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + + const inbox = await UserDAL.getInbox(uid); + + return new MonkeyResponse("Inbox retrieved", inbox); +} + +export async function updateInbox( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + const { mailIdsToMarkRead, mailIdsToDelete } = req.body; + + await UserDAL.updateInbox(uid, mailIdsToMarkRead, mailIdsToDelete); + + return new MonkeyResponse("Inbox updated"); +} diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 5152b3197..a36f849ad 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -470,4 +470,35 @@ router.patch( asyncHandler(UserController.updateProfile) ); +const mailIdSchema = joi.array().items(joi.string().guid()).min(1).default([]); + +const requireInboxEnabled = validateConfiguration({ + criteria: (configuration) => { + return configuration.users.inbox.enabled; + }, + invalidMessage: "Your inbox is not available at this time.", +}); + +router.get( + "/inbox", + requireInboxEnabled, + authenticateRequest(), + RateLimit.userMailGet, + asyncHandler(UserController.getInbox) +); + +router.patch( + "/inbox", + requireInboxEnabled, + authenticateRequest(), + RateLimit.userMailUpdate, + validateRequest({ + body: { + mailIdsToDelete: mailIdSchema, + mailIdsToMarkRead: mailIdSchema, + }, + }), + asyncHandler(UserController.updateInbox) +); + export default router; diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index 4b4d82fdd..dbf060892 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -47,6 +47,10 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = { maxDailyBonus: 0, minDailyBonus: 0, }, + inbox: { + enabled: false, + maxMail: 0, + }, }, rateLimiting: { badAuthentication: { @@ -63,6 +67,7 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = { // GOTCHA! MUST ATLEAST BE 1, LRUCache module will make process crash and die dailyLeaderboardCacheSize: 1, topResultsToAnnounce: 1, // This should never be 0. Setting to zero will announce all results. + xpReward: 0, }, }; @@ -255,6 +260,21 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { }, }, }, + inbox: { + type: "object", + label: "Inbox", + fields: { + enabled: { + type: "boolean", + label: "Enabled", + }, + maxMail: { + type: "number", + label: "Max Messages", + min: 0, + }, + }, + }, profiles: { type: "object", label: "User Profiles", @@ -347,6 +367,11 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { label: "Top Results To Announce", min: 1, }, + xpReward: { + type: "number", + label: "XP Reward", + min: 0, + }, }, }, }, diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 7f0e01d9f..837034322 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -4,9 +4,10 @@ import { updateUserEmail } from "../utils/auth"; import { checkAndUpdatePb } from "../utils/pb"; import * as db from "../init/db"; import MonkeyError from "../utils/error"; -import { Collection, ObjectId, WithId, Long } from "mongodb"; +import { Collection, ObjectId, WithId, Long, UpdateFilter } from "mongodb"; import Logger from "../utils/logger"; import { flattenObjectDeep } from "../utils/misc"; +import { MonkeyMailWithTemplate } from "../utils/monkey-mail"; const SECONDS_PER_HOUR = 3600; @@ -732,3 +733,110 @@ export async function updateProfile( } ); } + +export async function getInbox( + uid: string +): Promise { + const user = await getUser(uid, "get inventory"); + return user.inbox ?? []; +} + +export async function addToInbox( + uid: string, + mail: MonkeyMailWithTemplate[], + maxInboxSize: number +): Promise { + const user = await getUser(uid, "add to inbox"); + + const inbox = user.inbox ?? []; + + for (let i = 0; i < inbox.length + mail.length - maxInboxSize; i++) { + inbox.pop(); + } + + const evaluatedMail: MonkeyTypes.MonkeyMail[] = mail.map((mail) => { + return _.omit( + { + ...mail, + ...(mail.getTemplate && mail.getTemplate(user)), + }, + "getTemplate" + ); + }); + + inbox.unshift(...evaluatedMail); + const newInbox = inbox.sort((a, b) => b.timestamp - a.timestamp); + + await getUsersCollection().updateOne( + { uid }, + { + $set: { + inbox: newInbox, + }, + } + ); +} + +function buildRewardUpdates( + rewards: MonkeyTypes.AllRewards[] +): UpdateFilter> { + let totalXp = 0; + const newBadges: MonkeyTypes.Badge[] = []; + + rewards.forEach((reward) => { + if (reward.type === "xp") { + totalXp += reward.item; + } else if (reward.type === "badge") { + newBadges.push(reward.item); + } + }); + + return { + $inc: { + xp: totalXp, + }, + $push: { + "inventory.badges": { $each: newBadges }, + }, + }; +} + +export async function updateInbox( + uid: string, + mailToRead: string[], + mailToDelete: string[] +): Promise { + const user = await getUser(uid, "update inbox"); + + const inbox = user.inbox ?? []; + + const mailToReadSet = new Set(mailToRead); + const mailToDeleteSet = new Set(mailToDelete); + + const allRewards: MonkeyTypes.AllRewards[] = []; + + const newInbox = inbox + .filter((mail) => { + const { id, rewards } = mail; + + if (mailToReadSet.has(id) && !mail.read) { + mail.read = true; + if (rewards.length > 0) { + allRewards.push(...rewards); + } + } + + return !mailToDeleteSet.has(id); + }) + .sort((a, b) => b.timestamp - a.timestamp); + + const baseUpdate = { + $set: { + inbox: newInbox, + }, + }; + const rewardUpdates = buildRewardUpdates(allRewards); + const mergedUpdates = _.merge(baseUpdate, rewardUpdates); + + await getUsersCollection().updateOne({ uid }, mergedUpdates); +} diff --git a/backend/src/jobs/announce-daily-leaderboards.ts b/backend/src/jobs/announce-daily-leaderboards.ts index 784332915..81728bc4a 100644 --- a/backend/src/jobs/announce-daily-leaderboards.ts +++ b/backend/src/jobs/announce-daily-leaderboards.ts @@ -3,6 +3,8 @@ import { getCurrentDayTimestamp } from "../utils/misc"; import { getCachedConfiguration } from "../init/configuration"; import { DailyLeaderboard } from "../utils/daily-leaderboards"; import { announceDailyLeaderboardTopResults } from "../tasks/george"; +import { addToInbox } from "../dal/user"; +import { buildMonkeyMail } from "../utils/monkey-mail"; const CRON_SCHEDULE = "1 0 * * *"; // At 00:01. const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; @@ -24,7 +26,8 @@ async function announceDailyLeaderboard( language: string, mode: string, mode2: string, - dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"] + dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"], + inboxConfig: MonkeyTypes.Configuration["users"]["inbox"] ): Promise { const yesterday = getCurrentDayTimestamp() - ONE_DAY_IN_MILLISECONDS; const dailyLeaderboard = new DailyLeaderboard( @@ -43,6 +46,29 @@ async function announceDailyLeaderboard( return; } + if (inboxConfig.enabled) { + const { xpReward } = dailyLeaderboardsConfig; + + const inboxPromises = topResults.map(async (entry) => { + const mail = buildMonkeyMail({ + rewards: [ + { + type: "xp", + item: xpReward, + }, + ], + getTemplate: (user) => ({ + subject: `${xpReward} XP for top placement in the daily leaderboard!`, + body: `Congratulations ${user.name} on placing top ${entry.rank} in the ${language} ${mode} ${mode2} daily leaderboard! Claim your ${xpReward} xp!`, + }), + }); + + return await addToInbox(entry.uid, [mail], inboxConfig.maxMail); + }); + + await Promise.allSettled(inboxPromises); + } + const leaderboardId = `${mode} ${mode2} ${language}`; await announceDailyLeaderboardTopResults( leaderboardId, @@ -52,14 +78,24 @@ async function announceDailyLeaderboard( } async function announceDailyLeaderboards(): Promise { - const { dailyLeaderboards, maintenance } = await getCachedConfiguration(); + const { + dailyLeaderboards, + users: { inbox }, + maintenance, + } = await getCachedConfiguration(); if (!dailyLeaderboards.enabled || maintenance) { return; } await Promise.allSettled( leaderboardsToAnnounce.map(({ language, mode, mode2 }) => { - return announceDailyLeaderboard(language, mode, mode2, dailyLeaderboards); + return announceDailyLeaderboard( + language, + mode, + mode2, + dailyLeaderboards, + inbox + ); }) ); } diff --git a/backend/src/middlewares/rate-limit.ts b/backend/src/middlewares/rate-limit.ts index 43c8dedf9..93bf3ca24 100644 --- a/backend/src/middlewares/rate-limit.ts +++ b/backend/src/middlewares/rate-limit.ts @@ -439,6 +439,20 @@ export const userProfileUpdate = rateLimit({ handler: customHandler, }); +export const userMailGet = rateLimit({ + windowMs: ONE_HOUR_MS, + max: 60 * REQUEST_MULTIPLIER, + keyGenerator: getKeyWithUid, + handler: customHandler, +}); + +export const userMailUpdate = rateLimit({ + windowMs: ONE_HOUR_MS, + max: 60 * REQUEST_MULTIPLIER, + keyGenerator: getKeyWithUid, + handler: customHandler, +}); + // ApeKeys Routing export const apeKeysGet = rateLimit({ windowMs: ONE_HOUR_MS, diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 0646ecf7c..7f86dfcb2 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -46,6 +46,10 @@ declare namespace MonkeyTypes { maxDailyBonus: number; minDailyBonus: number; }; + inbox: { + enabled: boolean; + maxMail: number; + }; }; apeKeys: { endpointsEnabled: boolean; @@ -68,6 +72,7 @@ declare namespace MonkeyTypes { validModeRules: ValidModeRule[]; dailyLeaderboardCacheSize: number; topResultsToAnnounce: number; + xpReward: number; }; } @@ -98,6 +103,32 @@ declare namespace MonkeyTypes { }; } + interface Reward { + type: string; + item: T; + } + + interface XpReward extends Reward { + type: "xp"; + item: number; + } + + interface BadgeReward extends Reward { + type: "badge"; + item: Badge; + } + + type AllRewards = XpReward | BadgeReward; + + interface MonkeyMail { + id: string; + subject: string; + body: string; + timestamp: number; + read: boolean; + rewards: AllRewards[]; + } + interface User { autoBanTimestamps?: number[]; addedAt: number; @@ -128,6 +159,7 @@ declare namespace MonkeyTypes { profileDetails?: UserProfileDetails; inventory?: UserInventory; xp?: number; + inbox?: MonkeyMail[]; } interface UserInventory { diff --git a/backend/src/utils/monkey-mail.ts b/backend/src/utils/monkey-mail.ts new file mode 100644 index 000000000..4e669536c --- /dev/null +++ b/backend/src/utils/monkey-mail.ts @@ -0,0 +1,20 @@ +import { v4 } from "uuid"; + +type MonkeyMailOptions = Partial>; +export interface MonkeyMailWithTemplate extends MonkeyTypes.MonkeyMail { + getTemplate?: (user: MonkeyTypes.User) => MonkeyMailOptions; +} + +export function buildMonkeyMail( + options: MonkeyMailOptions +): MonkeyMailWithTemplate { + return { + id: v4(), + subject: options.subject || "", + body: options.body || "", + timestamp: options.timestamp || Date.now(), + read: false, + rewards: options.rewards || [], + getTemplate: options.getTemplate, + }; +}