Custom Filters [Backend] (#3105)

* Added `ResultFilters` to types
Added customFilters field to user
This field is an optional array of `ResultFilters`
It will store a user's custom filters

* Added Add and Remove functions for `ResultFilters` in user DAL

Also added unit tests

* Added Custom Filter configuration

Can now enable/disable the custom filters feature
Can also set a cap on the number of filters per user

* Add and Remove functions for `ResultFilters` in user controller
This commit is contained in:
Malo Hamon 2022-06-12 10:32:06 +10:00 committed by GitHub
parent 111bf387e3
commit ba843419a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 376 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -186,6 +186,31 @@ export async function unlinkDiscord(
return new MonkeyResponse("Discord account unlinked");
}
export async function addResultFilter(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
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<MonkeyResponse> {
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<MonkeyResponse> {

View file

@ -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,

View file

@ -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;

View file

@ -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,
},
},
},
},
};

View file

@ -150,6 +150,53 @@ export async function isDiscordIdAvailable(
return _.isNil(user);
}
export async function addResultFilter(
uid: string,
filter: MonkeyTypes.ResultFilters,
maxFiltersPerUser: number
): Promise<ObjectId> {
// 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<void> {
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

View file

@ -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,

View file

@ -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<string, Record<string, number>>;