mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-10-22 05:26:14 +08:00
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:
parent
111bf387e3
commit
ba843419a1
8 changed files with 376 additions and 0 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
|
|
74
backend/src/api/schemas/filter-schema.ts
Normal file
74
backend/src/api/schemas/filter-schema.ts
Normal 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;
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
67
backend/src/types/types.d.ts
vendored
67
backend/src/types/types.d.ts
vendored
|
@ -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>>;
|
||||
|
|
Loading…
Add table
Reference in a new issue