diff --git a/backend/__tests__/dal/user.spec.ts b/backend/__tests__/dal/user.spec.ts index 9c2ada593..c82e3e4f5 100644 --- a/backend/__tests__/dal/user.spec.ts +++ b/backend/__tests__/dal/user.spec.ts @@ -1,5 +1,7 @@ import _ from "lodash"; +import { ObjectId } from "mongodb"; import { + addResultFilter, addUser, clearPb, getUser, @@ -20,6 +22,67 @@ const mockPersonalBest = { timestamp: 13123123, }; +const mockResultFilter = { + _id: new ObjectId(), + name: "sfdkjhgdf", + difficulty: { + normal: true, + expert: false, + master: false, + }, + mode: { + words: false, + time: true, + quote: false, + zen: false, + custom: false, + }, + words: { + "10": false, + "25": false, + "50": false, + "100": false, + custom: false, + }, + time: { + "15": false, + "30": true, + "60": false, + "120": false, + custom: false, + }, + quoteLength: { + short: false, + medium: false, + long: false, + thicc: false, + }, + punctuation: { + on: false, + off: true, + }, + numbers: { + on: false, + off: true, + }, + date: { + last_day: false, + last_week: false, + last_month: false, + last_3months: false, + all: true, + }, + tags: { + none: true, + }, + language: { + english: true, + }, + funbox: { + none: true, + }, +}; + describe("UserDal", () => { it("should be able to insert users", async () => { // given @@ -254,4 +317,39 @@ describe("UserDal", () => { expect(updatedUser.banned).toBe(undefined); expect(updatedUser.autoBanTimestamps).toEqual([36000000]); }); + + it("addResultFilters should return error if uuid not found", async () => { + // given + await addUser("test name", "test email", "TestID"); + + // when, then + await expect( + addResultFilter("non existing uid", mockResultFilter, 5) + ).rejects.toThrow("User not found"); + }); + + it("addResultFilters should return error if user has reached maximum", async () => { + // given + await addUser("test name", "test email", "TestID"); + await addResultFilter("TestID", mockResultFilter, 1); + + // when, then + await expect( + addResultFilter("TestID", mockResultFilter, 1) + ).rejects.toThrow("Maximum number of custom filters reached for user."); + }); + + it("addResultFilters success", async () => { + // given + await addUser("test name", "test email", "TestID"); + + // when + const result = await addResultFilter("TestID", mockResultFilter, 1); + + // then + const user = await getUser("TestID", "test add result filters"); + const createdFilter = user.customFilters ?? []; + + expect(result).toStrictEqual(createdFilter[0]._id); + }); }); diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 13c365b2d..26d79e698 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -186,6 +186,31 @@ export async function unlinkDiscord( return new MonkeyResponse("Discord account unlinked"); } +export async function addResultFilter( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + const filter = req.body; + const { maxFiltersPerUser } = req.ctx.configuration.customFilters; + + const createdId = await UserDAL.addResultFilter( + uid, + filter, + maxFiltersPerUser + ); + return new MonkeyResponse("Result filter created", createdId); +} + +export async function removeResultFilter( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + const { filterId } = req.params; + + await UserDAL.removeResultFilter(uid, filterId); + return new MonkeyResponse("Result filter deleted"); +} + export async function addTag( req: MonkeyTypes.Request ): Promise { diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 3480f039f..b16a3442d 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -10,6 +10,7 @@ import { import * as RateLimit from "../../middlewares/rate-limit"; import apeRateLimit from "../../middlewares/ape-rate-limit"; import { isUsernameValid } from "../../utils/validation"; +import filterSchema from "../schemas/filter-schema"; const router = Router(); @@ -168,6 +169,37 @@ router.delete( asyncHandler(UserController.clearPb) ); +const requireResultFiltersEnabled = validateConfiguration({ + criteria: (configuration) => { + return configuration.customFilters.enabled; + }, + invalidMessage: "Custom Filters are not available at this time.", +}); + +router.post( + "/resultFilters", + RateLimit.userCustomFilterAdd, + requireResultFiltersEnabled, + authenticateRequest(), + validateRequest({ + body: filterSchema, + }), + asyncHandler(UserController.addResultFilter) +); + +router.delete( + "/resultFilters/:filterId", + RateLimit.userCustomFilterRemove, + requireResultFiltersEnabled, + authenticateRequest(), + validateRequest({ + params: { + filterId: joi.string().required(), + }, + }), + asyncHandler(UserController.removeResultFilter) +); + router.get( "/tags", RateLimit.userTagsGet, diff --git a/backend/src/api/schemas/filter-schema.ts b/backend/src/api/schemas/filter-schema.ts new file mode 100644 index 000000000..48712cc58 --- /dev/null +++ b/backend/src/api/schemas/filter-schema.ts @@ -0,0 +1,74 @@ +import joi from "joi"; + +const FILTER_SCHEMA = { + _id: joi.string().required(), + name: joi.string().required(), + difficulty: joi + .object({ + normal: joi.bool().required(), + expert: joi.bool().required(), + master: joi.bool().required(), + }) + .required(), + mode: joi + .object({ + words: joi.bool().required(), + time: joi.bool().required(), + quote: joi.bool().required(), + zen: joi.bool().required(), + custom: joi.bool().required(), + }) + .required(), + words: joi + .object({ + 10: joi.bool().required(), + 25: joi.bool().required(), + 50: joi.bool().required(), + 100: joi.bool().required(), + custom: joi.bool().required(), + }) + .required(), + time: joi + .object({ + 15: joi.bool().required(), + 30: joi.bool().required(), + 60: joi.bool().required(), + 120: joi.bool().required(), + custom: joi.bool().required(), + }) + .required(), + quoteLength: joi + .object({ + short: joi.bool().required(), + medium: joi.bool().required(), + long: joi.bool().required(), + thicc: joi.bool().required(), + }) + .required(), + punctuation: joi + .object({ + on: joi.bool().required(), + off: joi.bool().required(), + }) + .required(), + numbers: joi + .object({ + on: joi.bool().required(), + off: joi.bool().required(), + }) + .required(), + date: joi + .object({ + last_day: joi.bool().required(), + last_week: joi.bool().required(), + last_month: joi.bool().required(), + last_3months: joi.bool().required(), + all: joi.bool().required(), + }) + .required(), + tags: joi.object().required(), + language: joi.object().required(), + funbox: joi.object().required(), +}; + +export default FILTER_SCHEMA; diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index 8fc3533ce..7f2cc6ea8 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -42,6 +42,10 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = { discordIntegration: { enabled: false, }, + customFilters: { + enabled: true, + maxFiltersPerUser: 0, + }, }; export const CONFIGURATION_FORM_SCHEMA = { @@ -209,5 +213,20 @@ export const CONFIGURATION_FORM_SCHEMA = { }, }, }, + customFilters: { + type: "object", + label: "Custom Filters", + fields: { + enabled: { + type: "boolean", + label: "Enabled", + }, + maxFiltersPerUser: { + type: "number", + label: "Max Filters per user", + min: 0, + }, + }, + }, }, }; diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 37fffe246..fdbda736b 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -150,6 +150,53 @@ export async function isDiscordIdAvailable( return _.isNil(user); } +export async function addResultFilter( + uid: string, + filter: MonkeyTypes.ResultFilters, + maxFiltersPerUser: number +): Promise { + // ensure limit not reached + const filtersCount = ( + (await getUser(uid, "Add Result filter")).customFilters ?? [] + ).length; + + if (filtersCount >= maxFiltersPerUser) { + throw new MonkeyError( + 409, + "Maximum number of custom filters reached for user." + ); + } + + const _id = new ObjectId(); + await getUsersCollection().updateOne( + { uid }, + { $push: { customFilters: { ...filter, _id } } } + ); + return _id; +} + +export async function removeResultFilter( + uid: string, + _id: string +): Promise { + const user = await getUser(uid, "remove result filter"); + const filterId = new ObjectId(_id); + if ( + user.customFilters === undefined || + user.customFilters.filter((t) => t._id.toHexString() === _id).length === 0 + ) { + throw new MonkeyError(404, "Custom filter not found"); + } + + await getUsersCollection().updateOne( + { + uid, + "customFilters._id": filterId, + }, + { $pull: { customFilters: { _id: filterId } } } + ); +} + export async function addTag( uid: string, name: string diff --git a/backend/src/middlewares/rate-limit.ts b/backend/src/middlewares/rate-limit.ts index 176689977..ead12c341 100644 --- a/backend/src/middlewares/rate-limit.ts +++ b/backend/src/middlewares/rate-limit.ts @@ -249,6 +249,20 @@ export const userClearPB = rateLimit({ handler: customHandler, }); +export const userCustomFilterAdd = rateLimit({ + windowMs: ONE_HOUR, + max: 60 * REQUEST_MULTIPLIER, + keyGenerator: getAddress, + handler: customHandler, +}); + +export const userCustomFilterRemove = rateLimit({ + windowMs: ONE_HOUR, + max: 60 * REQUEST_MULTIPLIER, + keyGenerator: getAddress, + handler: customHandler, +}); + export const userTagsGet = rateLimit({ windowMs: ONE_HOUR, max: 60 * REQUEST_MULTIPLIER, diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 43f5a2d2c..458e7454e 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -47,6 +47,10 @@ declare namespace MonkeyTypes { discordIntegration: { enabled: boolean; }; + customFilters: { + enabled: boolean; + maxFiltersPerUser: number; + }; } interface DecodedToken { @@ -93,6 +97,69 @@ declare namespace MonkeyTypes { needsToChangeName?: boolean; discordAvatar?: string; badgeIds?: number[]; + customFilters?: ResultFilters[]; + } + + interface ResultFilters { + _id: ObjectId; + name: string; + difficulty: { + normal: boolean; + expert: boolean; + master: boolean; + }; + mode: { + words: boolean; + time: boolean; + quote: boolean; + zen: boolean; + custom: boolean; + }; + words: { + 10: boolean; + 25: boolean; + 50: boolean; + 100: boolean; + custom: boolean; + }; + time: { + 15: boolean; + 30: boolean; + 60: boolean; + 120: boolean; + custom: boolean; + }; + quoteLength: { + short: boolean; + medium: boolean; + long: boolean; + thicc: boolean; + }; + punctuation: { + on: boolean; + off: boolean; + }; + numbers: { + on: boolean; + off: boolean; + }; + date: { + last_day: boolean; + last_week: boolean; + last_month: boolean; + last_3months: boolean; + all: boolean; + }; + tags: { + [tagId: string]: boolean; + }; + language: { + [language: string]: boolean; + }; + funbox: { + none?: boolean; + [funbox: string]: boolean; + }; } type UserQuoteRatings = Record>;