mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-10-10 07:36:09 +08:00
Add inbox (#3458) Bruception
* Add basic inbox * Changes * Remove export * Move condition * Use for each
This commit is contained in:
parent
59fa77fe18
commit
4b49900d57
11 changed files with 361 additions and 5 deletions
|
@ -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!",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,6 +21,7 @@ const dailyLeaderboardsConfig = {
|
|||
],
|
||||
dailyLeaderboardCacheSize: 3,
|
||||
topResultsToAnnounce: 3,
|
||||
xpReward: 0,
|
||||
};
|
||||
|
||||
describe("Daily Leaderboards", () => {
|
||||
|
|
22
backend/__tests__/utils/monkey-mail.spec.ts
Normal file
22
backend/__tests__/utils/monkey-mail.spec.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -117,6 +117,19 @@ export async function updateEmail(
|
|||
return new MonkeyResponse("Email updated");
|
||||
}
|
||||
|
||||
function getRelevantUserInfo(
|
||||
user: MonkeyTypes.User
|
||||
): Partial<MonkeyTypes.User> {
|
||||
return _.omit(user, [
|
||||
"bananas",
|
||||
"lbPersonalBests",
|
||||
"quoteMod",
|
||||
"inbox",
|
||||
"nameHistory",
|
||||
"lastNameChange",
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getUser(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
|
@ -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<MonkeyResponse> {
|
||||
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<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { mailIdsToMarkRead, mailIdsToDelete } = req.body;
|
||||
|
||||
await UserDAL.updateInbox(uid, mailIdsToMarkRead, mailIdsToDelete);
|
||||
|
||||
return new MonkeyResponse("Inbox updated");
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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<MonkeyTypes.User["inbox"]> {
|
||||
const user = await getUser(uid, "get inventory");
|
||||
return user.inbox ?? [];
|
||||
}
|
||||
|
||||
export async function addToInbox(
|
||||
uid: string,
|
||||
mail: MonkeyMailWithTemplate[],
|
||||
maxInboxSize: number
|
||||
): Promise<void> {
|
||||
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<WithId<MonkeyTypes.User>> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
32
backend/src/types/types.d.ts
vendored
32
backend/src/types/types.d.ts
vendored
|
@ -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<T> {
|
||||
type: string;
|
||||
item: T;
|
||||
}
|
||||
|
||||
interface XpReward extends Reward<number> {
|
||||
type: "xp";
|
||||
item: number;
|
||||
}
|
||||
|
||||
interface BadgeReward extends Reward<Badge> {
|
||||
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 {
|
||||
|
|
20
backend/src/utils/monkey-mail.ts
Normal file
20
backend/src/utils/monkey-mail.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { v4 } from "uuid";
|
||||
|
||||
type MonkeyMailOptions = Partial<Omit<MonkeyMailWithTemplate, "id" | "read">>;
|
||||
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,
|
||||
};
|
||||
}
|
Loading…
Add table
Reference in a new issue