mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-11-17 22:29:52 +08:00
parent
3b29ad4b1d
commit
6c6e1529a2
31 changed files with 691 additions and 577 deletions
338
backend/__tests__/api/controllers/preset.spec.ts
Normal file
338
backend/__tests__/api/controllers/preset.spec.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import request from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import * as PresetDal from "../../../src/dal/preset";
|
||||
import { ObjectId } from "mongodb";
|
||||
const mockApp = request(app);
|
||||
|
||||
describe("PresetController", () => {
|
||||
describe("get presets", () => {
|
||||
const getPresetsMock = vi.spyOn(PresetDal, "getPresets");
|
||||
|
||||
afterEach(() => {
|
||||
getPresetsMock.mockReset();
|
||||
});
|
||||
|
||||
it("should get the users presets", async () => {
|
||||
//GIVEN
|
||||
const presetOne = {
|
||||
_id: new ObjectId(),
|
||||
uid: "123456789",
|
||||
name: "test1",
|
||||
config: { language: "english" },
|
||||
};
|
||||
const presetTwo = {
|
||||
_id: new ObjectId(),
|
||||
uid: "123456789",
|
||||
name: "test2",
|
||||
config: { language: "polish" },
|
||||
};
|
||||
|
||||
getPresetsMock.mockResolvedValue([presetOne, presetTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Presets retrieved",
|
||||
data: [
|
||||
{
|
||||
_id: presetOne._id.toHexString(),
|
||||
name: "test1",
|
||||
config: { language: "english" },
|
||||
},
|
||||
{
|
||||
_id: presetTwo._id.toHexString(),
|
||||
name: "test2",
|
||||
config: { language: "polish" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(getPresetsMock).toHaveBeenCalledWith("123456789");
|
||||
});
|
||||
it("should return empty array if user has no presets", async () => {
|
||||
//GIVEN
|
||||
getPresetsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Presets retrieved",
|
||||
data: [],
|
||||
});
|
||||
|
||||
expect(getPresetsMock).toHaveBeenCalledWith("123456789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("add preset", () => {
|
||||
const addPresetMock = vi.spyOn(PresetDal, "addPreset");
|
||||
|
||||
afterEach(() => {
|
||||
addPresetMock.mockReset();
|
||||
});
|
||||
|
||||
it("should add the users preset", async () => {
|
||||
//GIVEN
|
||||
addPresetMock.mockResolvedValue({ presetId: "1" });
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.accept("application/json")
|
||||
.send({
|
||||
name: "new",
|
||||
config: {
|
||||
language: "english",
|
||||
tags: ["one", "two"],
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset created",
|
||||
data: { presetId: "1" },
|
||||
});
|
||||
|
||||
expect(addPresetMock).toHaveBeenCalledWith("123456789", {
|
||||
name: "new",
|
||||
config: { language: "english", tags: ["one", "two"] },
|
||||
});
|
||||
});
|
||||
it("should not fail with emtpy config", async () => {
|
||||
//GIVEN
|
||||
|
||||
addPresetMock.mockResolvedValue({ presetId: "1" });
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.accept("application/json")
|
||||
.send({ name: "new", config: {} })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset created",
|
||||
data: { presetId: "1" },
|
||||
});
|
||||
|
||||
expect(addPresetMock).toHaveBeenCalledWith("123456789", {
|
||||
name: "new",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
it("should fail with missing mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.accept("application/json")
|
||||
.send({})
|
||||
.expect(422);
|
||||
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"name" Required`, `"config" Required`],
|
||||
});
|
||||
expect(addPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should not fail with invalid preset", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.accept("application/json")
|
||||
.send({
|
||||
_id: "1",
|
||||
name: "update",
|
||||
extra: "extra",
|
||||
config: {
|
||||
extra: "extra",
|
||||
autoSwitchTheme: "yes",
|
||||
confidenceMode: "pretty",
|
||||
},
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"config.autoSwitchTheme" Expected boolean, received string`,
|
||||
`"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
|
||||
`"config" Unrecognized key(s) in object: 'extra'`,
|
||||
`Unrecognized key(s) in object: '_id', 'extra'`,
|
||||
],
|
||||
});
|
||||
|
||||
expect(addPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update preset", () => {
|
||||
const editPresetMock = vi.spyOn(PresetDal, "editPreset");
|
||||
|
||||
afterEach(() => {
|
||||
editPresetMock.mockReset();
|
||||
});
|
||||
|
||||
it("should update the users preset", async () => {
|
||||
//GIVEN
|
||||
editPresetMock.mockResolvedValue({} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.accept("application/json")
|
||||
.send({
|
||||
_id: "1",
|
||||
name: "new",
|
||||
config: {
|
||||
language: "english",
|
||||
tags: ["one", "two"],
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(editPresetMock).toHaveBeenCalledWith("123456789", {
|
||||
_id: "1",
|
||||
name: "new",
|
||||
config: { language: "english", tags: ["one", "two"] },
|
||||
});
|
||||
});
|
||||
it("should not fail with emtpy config", async () => {
|
||||
//GIVEN
|
||||
|
||||
editPresetMock.mockResolvedValue({} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.accept("application/json")
|
||||
.send({ _id: "1", name: "new", config: {} })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(editPresetMock).toHaveBeenCalledWith("123456789", {
|
||||
_id: "1",
|
||||
name: "new",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
it("should fail with missing mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.accept("application/json")
|
||||
.send({})
|
||||
.expect(422);
|
||||
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"_id" Required`,
|
||||
`"name" Required`,
|
||||
`"config" Required`,
|
||||
],
|
||||
});
|
||||
expect(editPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should not fail with invalid preset", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch("/presets")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.accept("application/json")
|
||||
.send({
|
||||
_id: "1",
|
||||
name: "update",
|
||||
extra: "extra",
|
||||
config: {
|
||||
extra: "extra",
|
||||
autoSwitchTheme: "yes",
|
||||
confidenceMode: "pretty",
|
||||
},
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"config.autoSwitchTheme" Expected boolean, received string`,
|
||||
`"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
|
||||
`"config" Unrecognized key(s) in object: 'extra'`,
|
||||
`Unrecognized key(s) in object: 'extra'`,
|
||||
],
|
||||
});
|
||||
|
||||
expect(editPresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("delete config", () => {
|
||||
const deletePresetMock = vi.spyOn(PresetDal, "removePreset");
|
||||
|
||||
afterEach(() => {
|
||||
deletePresetMock.mockReset();
|
||||
});
|
||||
|
||||
it("should delete the users preset", async () => {
|
||||
//GIVEN
|
||||
deletePresetMock.mockResolvedValue();
|
||||
|
||||
//WHEN
|
||||
|
||||
const { body } = await mockApp
|
||||
.delete("/presets/1")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Preset deleted",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(deletePresetMock).toHaveBeenCalledWith("123456789", "1");
|
||||
});
|
||||
it("should fail without preset _id", async () => {
|
||||
//GIVEN
|
||||
deletePresetMock.mockResolvedValue();
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.delete("/presets/")
|
||||
.set("authorization", "Uid 123456789")
|
||||
.expect(404);
|
||||
|
||||
expect(deletePresetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,18 +1,23 @@
|
|||
import { ObjectId } from "mongodb";
|
||||
import * as PresetDal from "../../src/dal/preset";
|
||||
import _ from "lodash";
|
||||
import { off } from "process";
|
||||
|
||||
describe("PresetDal", () => {
|
||||
describe("readPreset", () => {
|
||||
it("should read", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await PresetDal.addPreset(uid, "first", { ads: "sellout" });
|
||||
const second = await PresetDal.addPreset(uid, "second", {
|
||||
ads: "result",
|
||||
const first = await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
});
|
||||
await PresetDal.addPreset("unknown", "unknown", {});
|
||||
const second = await PresetDal.addPreset(uid, {
|
||||
name: "second",
|
||||
config: {
|
||||
ads: "result",
|
||||
},
|
||||
});
|
||||
await PresetDal.addPreset("unknown", { name: "unknown", config: {} });
|
||||
|
||||
//WHEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
|
|
@ -43,24 +48,27 @@ describe("PresetDal", () => {
|
|||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await PresetDal.addPreset(uid, "test", {} as any);
|
||||
await PresetDal.addPreset(uid, { name: "test", config: {} });
|
||||
}
|
||||
|
||||
//WHEN / THEN
|
||||
expect(() =>
|
||||
PresetDal.addPreset(uid, "max", {} as any)
|
||||
PresetDal.addPreset(uid, { name: "max", config: {} })
|
||||
).rejects.toThrowError("Too many presets");
|
||||
});
|
||||
it("should add preset", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await PresetDal.addPreset(uid, "test", {} as any);
|
||||
await PresetDal.addPreset(uid, { name: "test", config: {} });
|
||||
}
|
||||
|
||||
//WHEN
|
||||
const newPreset = await PresetDal.addPreset(uid, "new", {
|
||||
ads: "sellout",
|
||||
const newPreset = await PresetDal.addPreset(uid, {
|
||||
name: "new",
|
||||
config: {
|
||||
ads: "sellout",
|
||||
},
|
||||
});
|
||||
|
||||
//THEN
|
||||
|
|
@ -82,31 +90,44 @@ describe("PresetDal", () => {
|
|||
|
||||
describe("editPreset", () => {
|
||||
it("should not fail if preset is unknown", async () => {
|
||||
await PresetDal.editPreset(
|
||||
"uid",
|
||||
new ObjectId().toHexString(),
|
||||
"new",
|
||||
undefined
|
||||
);
|
||||
await PresetDal.editPreset("uid", {
|
||||
_id: new ObjectId().toHexString(),
|
||||
name: "new",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should edit", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, "first", { ads: "sellout" })
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
})
|
||||
).presetId;
|
||||
const second = (
|
||||
await PresetDal.addPreset(uid, "second", {
|
||||
ads: "result",
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "second",
|
||||
config: {
|
||||
ads: "result",
|
||||
},
|
||||
})
|
||||
).presetId;
|
||||
const decoy = (
|
||||
await PresetDal.addPreset(decoyUid, "unknown", { ads: "result" })
|
||||
await PresetDal.addPreset(decoyUid, {
|
||||
name: "unknown",
|
||||
config: { ads: "result" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
await PresetDal.editPreset(uid, first, "newName", { ads: "off" });
|
||||
await PresetDal.editPreset(uid, {
|
||||
_id: first,
|
||||
name: "newName",
|
||||
config: { ads: "off" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
|
|
@ -143,37 +164,18 @@ describe("PresetDal", () => {
|
|||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, "first", { ads: "sellout" })
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN undefined
|
||||
await PresetDal.editPreset(uid, first, "newName", undefined);
|
||||
expect(await PresetDal.getPresets(uid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first),
|
||||
uid: uid,
|
||||
name: "newName",
|
||||
config: { ads: "sellout" },
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
//WHEN null
|
||||
await PresetDal.editPreset(uid, first, "newName", null);
|
||||
expect(await PresetDal.getPresets(uid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: new ObjectId(first),
|
||||
uid: uid,
|
||||
name: "newName",
|
||||
config: { ads: "sellout" },
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
//WHEN empty
|
||||
await PresetDal.editPreset(uid, first, "newName", {});
|
||||
await PresetDal.editPreset(uid, {
|
||||
_id: first,
|
||||
name: "newName",
|
||||
config: {},
|
||||
});
|
||||
expect(await PresetDal.getPresets(uid)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
|
|
@ -190,11 +192,18 @@ describe("PresetDal", () => {
|
|||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, "first", { ads: "sellout" })
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
await PresetDal.editPreset(decoyUid, first, "newName", { ads: "off" });
|
||||
await PresetDal.editPreset(decoyUid, {
|
||||
_id: first,
|
||||
name: "newName",
|
||||
config: { ads: "off" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
|
|
@ -222,12 +231,20 @@ describe("PresetDal", () => {
|
|||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
const first = (await PresetDal.addPreset(uid, "first", {})).presetId;
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, { name: "first", config: {} })
|
||||
).presetId;
|
||||
const second = (
|
||||
await PresetDal.addPreset(uid, "second", { ads: "result" })
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "second",
|
||||
config: { ads: "result" },
|
||||
})
|
||||
).presetId;
|
||||
const decoy = (
|
||||
await PresetDal.addPreset(decoyUid, "unknown", { ads: "result" })
|
||||
await PresetDal.addPreset(decoyUid, {
|
||||
name: "unknown",
|
||||
config: { ads: "result" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
|
|
@ -262,7 +279,10 @@ describe("PresetDal", () => {
|
|||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
const first = (
|
||||
await PresetDal.addPreset(uid, "first", { ads: "sellout" })
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "first",
|
||||
config: { ads: "sellout" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
|
|
@ -294,10 +314,16 @@ describe("PresetDal", () => {
|
|||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const decoyUid = new ObjectId().toHexString();
|
||||
await PresetDal.addPreset(uid, "first", {});
|
||||
await PresetDal.addPreset(uid, "second", { ads: "result" });
|
||||
await PresetDal.addPreset(uid, { name: "first", config: {} });
|
||||
await PresetDal.addPreset(uid, {
|
||||
name: "second",
|
||||
config: { ads: "result" },
|
||||
});
|
||||
const decoy = (
|
||||
await PresetDal.addPreset(decoyUid, "unknown", { ads: "result" })
|
||||
await PresetDal.addPreset(decoyUid, {
|
||||
name: "unknown",
|
||||
config: { ads: "result" },
|
||||
})
|
||||
).presetId;
|
||||
|
||||
//WHEN
|
||||
|
|
|
|||
|
|
@ -621,4 +621,33 @@ describe("Misc Utils", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceObjectIds", () => {
|
||||
it("replaces objecIds with string", () => {
|
||||
const fromDatabase = {
|
||||
_id: new ObjectId(),
|
||||
test: "test",
|
||||
number: 1,
|
||||
};
|
||||
const fromDatabase2 = {
|
||||
_id: new ObjectId(),
|
||||
test: "bob",
|
||||
number: 2,
|
||||
};
|
||||
expect(
|
||||
misc.replaceObjectIds([fromDatabase, fromDatabase2])
|
||||
).toStrictEqual([
|
||||
{
|
||||
_id: fromDatabase._id.toHexString(),
|
||||
test: "test",
|
||||
number: 1,
|
||||
},
|
||||
{
|
||||
_id: fromDatabase2._id.toHexString(),
|
||||
test: "bob",
|
||||
number: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ export function getOpenApi(): OpenAPIObject {
|
|||
"User specific configurations like test settings, theme or tags.",
|
||||
"x-displayName": "User configuration",
|
||||
},
|
||||
{
|
||||
name: "presets",
|
||||
description: "User specific configuration presets.",
|
||||
"x-displayName": "User presets",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export async function saveConfig(
|
|||
|
||||
await ConfigDAL.saveConfig(uid, config);
|
||||
|
||||
return new MonkeyResponse2("Config updated");
|
||||
return new MonkeyResponse2("Config updated", null);
|
||||
}
|
||||
|
||||
export async function deleteConfig(
|
||||
|
|
@ -29,5 +29,5 @@ export async function deleteConfig(
|
|||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ConfigDAL.deleteConfig(uid);
|
||||
return new MonkeyResponse2("Config deleted");
|
||||
return new MonkeyResponse2("Config deleted", null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,56 @@
|
|||
import {
|
||||
AddPresetRequest,
|
||||
AddPresetResponse,
|
||||
DeletePresetsParams,
|
||||
GetPresetResponse,
|
||||
} from "@monkeytype/contracts/presets";
|
||||
import * as PresetDAL from "../../dal/preset";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyResponse2 } from "../../utils/monkey-response";
|
||||
import { replaceObjectId } from "../../utils/misc";
|
||||
import { Preset } from "@monkeytype/contracts/schemas/presets";
|
||||
|
||||
export async function getPresets(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetPresetResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const data = await PresetDAL.getPresets(uid);
|
||||
return new MonkeyResponse("Preset retrieved", data);
|
||||
const data = (await PresetDAL.getPresets(uid))
|
||||
.map((preset) => ({
|
||||
...preset,
|
||||
uid: undefined,
|
||||
}))
|
||||
.map(replaceObjectId);
|
||||
|
||||
return new MonkeyResponse2("Presets retrieved", data);
|
||||
}
|
||||
|
||||
export async function addPreset(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { name, config } = req.body;
|
||||
req: MonkeyTypes.Request2<undefined, AddPresetRequest>
|
||||
): Promise<AddPresetResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const data = await PresetDAL.addPreset(uid, name, config);
|
||||
const data = await PresetDAL.addPreset(uid, req.body);
|
||||
|
||||
return new MonkeyResponse("Preset created", data);
|
||||
return new MonkeyResponse2("Preset created", data);
|
||||
}
|
||||
|
||||
export async function editPreset(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { _id, name, config } = req.body;
|
||||
req: MonkeyTypes.Request2<undefined, Preset>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await PresetDAL.editPreset(uid, _id, name, config);
|
||||
await PresetDAL.editPreset(uid, req.body);
|
||||
|
||||
return new MonkeyResponse("Preset updated");
|
||||
return new MonkeyResponse2("Preset updated", null);
|
||||
}
|
||||
|
||||
export async function removePreset(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, undefined, DeletePresetsParams>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { presetId } = req.params;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await PresetDAL.removePreset(uid, presetId as string);
|
||||
await PresetDAL.removePreset(uid, presetId);
|
||||
|
||||
return new MonkeyResponse("Preset deleted");
|
||||
return new MonkeyResponse2("Preset deleted", null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ router.use("/v2/internal.json", (req, res) => {
|
|||
res.sendFile("api/openapi.json", { root });
|
||||
});
|
||||
|
||||
router.use("/v2/public", (req, res) => {
|
||||
router.use(["/v2/public", "/v2/"], (req, res) => {
|
||||
res.sendFile("api/public.html", { root });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ const APP_START_TIME = Date.now();
|
|||
const API_ROUTE_MAP = {
|
||||
"/users": users,
|
||||
"/results": results,
|
||||
"/presets": presets,
|
||||
"/psas": psas,
|
||||
"/public": publicStats,
|
||||
"/leaderboards": leaderboards,
|
||||
|
|
@ -57,6 +56,7 @@ const API_ROUTE_MAP = {
|
|||
const s = initServer();
|
||||
const router = s.router(contract, {
|
||||
configs,
|
||||
presets,
|
||||
});
|
||||
|
||||
export function addApiRoutes(app: Application): void {
|
||||
|
|
|
|||
|
|
@ -1,74 +1,25 @@
|
|||
import joi from "joi";
|
||||
import { authenticateRequest } from "../../middlewares/auth";
|
||||
import * as PresetController from "../controllers/preset";
|
||||
import { presetsContract } from "@monkeytype/contracts/presets";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import configSchema from "../schemas/config-schema";
|
||||
import { Router } from "express";
|
||||
import { asyncHandler } from "../../middlewares/utility";
|
||||
import { validateRequest } from "../../middlewares/validation";
|
||||
import * as PresetController from "../controllers/preset";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const presetNameSchema = joi
|
||||
.string()
|
||||
.required()
|
||||
.regex(/^[0-9a-zA-Z_-]+$/)
|
||||
.max(16)
|
||||
.messages({
|
||||
"string.pattern.base": "Invalid preset name",
|
||||
"string.max": "Preset name exceeds maximum of 16 characters",
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
authenticateRequest(),
|
||||
RateLimit.presetsGet,
|
||||
asyncHandler(PresetController.getPresets)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
authenticateRequest(),
|
||||
RateLimit.presetsAdd,
|
||||
validateRequest({
|
||||
body: {
|
||||
name: presetNameSchema,
|
||||
config: configSchema.keys({
|
||||
tags: joi.array().items(joi.string().token().max(50)),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
asyncHandler(PresetController.addPreset)
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/",
|
||||
authenticateRequest(),
|
||||
RateLimit.presetsEdit,
|
||||
validateRequest({
|
||||
body: {
|
||||
_id: joi.string().token().required(),
|
||||
name: presetNameSchema,
|
||||
config: configSchema
|
||||
.keys({
|
||||
tags: joi.array().items(joi.string().token().max(50)),
|
||||
})
|
||||
.allow(null),
|
||||
},
|
||||
}),
|
||||
asyncHandler(PresetController.editPreset)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:presetId",
|
||||
authenticateRequest(),
|
||||
RateLimit.presetsRemove,
|
||||
validateRequest({
|
||||
params: {
|
||||
presetId: joi.string().token().required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(PresetController.removePreset)
|
||||
);
|
||||
|
||||
export default router;
|
||||
const s = initServer();
|
||||
export default s.router(presetsContract, {
|
||||
get: {
|
||||
middleware: [RateLimit.presetsGet],
|
||||
handler: async (r) => callController(PresetController.getPresets)(r),
|
||||
},
|
||||
add: {
|
||||
middleware: [RateLimit.presetsAdd],
|
||||
handler: async (r) => callController(PresetController.addPreset)(r),
|
||||
},
|
||||
save: {
|
||||
middleware: [RateLimit.presetsEdit],
|
||||
handler: async (r) => callController(PresetController.editPreset)(r),
|
||||
},
|
||||
delete: {
|
||||
middleware: [RateLimit.presetsRemove],
|
||||
handler: async (r) => callController(PresetController.removePreset)(r),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
import _ from "lodash";
|
||||
import joi from "joi";
|
||||
|
||||
const CARET_STYLES = [
|
||||
"off",
|
||||
"default",
|
||||
"underline",
|
||||
"outline",
|
||||
"block",
|
||||
"carrot",
|
||||
"banana",
|
||||
];
|
||||
//TODO replaced, still used by presets
|
||||
const CONFIG_SCHEMA = joi.object({
|
||||
theme: joi.string().max(50).token(),
|
||||
themeLight: joi.string().max(50).token(),
|
||||
themeDark: joi.string().max(50).token(),
|
||||
autoSwitchTheme: joi.boolean(),
|
||||
customTheme: joi.boolean(),
|
||||
customThemeId: joi.string().min(0).max(24).token(),
|
||||
customThemeColors: joi
|
||||
.array()
|
||||
.items(joi.string().pattern(/^#([\da-f]{3}){1,2}$/i))
|
||||
.length(10),
|
||||
favThemes: joi.array().items(joi.string().max(50).token()),
|
||||
showKeyTips: joi.boolean(),
|
||||
smoothCaret: joi.string().valid("off", "slow", "medium", "fast"),
|
||||
quickRestart: joi.string().valid("off", "tab", "esc", "enter"),
|
||||
punctuation: joi.boolean(),
|
||||
numbers: joi.boolean(),
|
||||
words: joi.number().min(0),
|
||||
time: joi.number().min(0),
|
||||
mode: joi.string().valid("time", "words", "quote", "zen", "custom"),
|
||||
quoteLength: joi.array().items(joi.number()),
|
||||
language: joi
|
||||
.string()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_+]+$/),
|
||||
fontSize: joi.number().min(0),
|
||||
freedomMode: joi.boolean(),
|
||||
difficulty: joi.string().valid("normal", "expert", "master"),
|
||||
blindMode: joi.boolean(),
|
||||
quickEnd: joi.boolean(),
|
||||
caretStyle: joi.string().valid(...CARET_STYLES),
|
||||
paceCaretStyle: joi.string().valid(...CARET_STYLES),
|
||||
flipTestColors: joi.boolean(),
|
||||
layout: joi.string().max(50).token(),
|
||||
funbox: joi
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/[\w#]+/),
|
||||
confidenceMode: joi.string().valid("off", "on", "max"),
|
||||
indicateTypos: joi.string().valid("off", "below", "replace"),
|
||||
timerStyle: joi.string().valid("off", "bar", "text", "mini"),
|
||||
liveSpeedStyle: joi.string().valid("off", "text", "mini"),
|
||||
liveAccStyle: joi.string().valid("off", "text", "mini"),
|
||||
liveBurstStyle: joi.string().valid("off", "text", "mini"),
|
||||
colorfulMode: joi.boolean(),
|
||||
randomTheme: joi
|
||||
.string()
|
||||
.valid("off", "on", "fav", "light", "dark", "custom"),
|
||||
timerColor: joi.string().valid("black", "sub", "text", "main"),
|
||||
timerOpacity: joi.number().valid(0.25, 0.5, 0.75, 1),
|
||||
stopOnError: joi.string().valid("off", "word", "letter"),
|
||||
showAllLines: joi.boolean(),
|
||||
keymapMode: joi.string().valid("off", "static", "react", "next"),
|
||||
keymapStyle: joi
|
||||
.string()
|
||||
.valid(
|
||||
"staggered",
|
||||
"alice",
|
||||
"matrix",
|
||||
"split",
|
||||
"split_matrix",
|
||||
"steno",
|
||||
"steno_matrix"
|
||||
),
|
||||
keymapLegendStyle: joi
|
||||
.string()
|
||||
.valid("lowercase", "uppercase", "blank", "dynamic"),
|
||||
keymapLayout: joi
|
||||
.string()
|
||||
.regex(/[\w\-_]+/)
|
||||
.valid()
|
||||
.max(50),
|
||||
keymapShowTopRow: joi.string().valid("always", "layout", "never"),
|
||||
fontFamily: joi
|
||||
.string()
|
||||
.max(50)
|
||||
.regex(/^[a-zA-Z0-9_\-+.]+$/),
|
||||
smoothLineScroll: joi.boolean(),
|
||||
alwaysShowDecimalPlaces: joi.boolean(),
|
||||
alwaysShowWordsHistory: joi.boolean(),
|
||||
singleListCommandLine: joi.string().valid("manual", "on"),
|
||||
capsLockWarning: joi.boolean(),
|
||||
playSoundOnError: joi.string().valid("off", ..._.range(1, 5).map(_.toString)),
|
||||
playSoundOnClick: joi.alternatives().try(
|
||||
joi.boolean(), //todo remove soon
|
||||
joi.string().valid("off", ..._.range(1, 16).map(_.toString))
|
||||
),
|
||||
soundVolume: joi.string().valid("0.1", "0.5", "1.0"),
|
||||
startGraphsAtZero: joi.boolean(),
|
||||
showOutOfFocusWarning: joi.boolean(),
|
||||
paceCaret: joi
|
||||
.string()
|
||||
.valid("off", "average", "pb", "last", "daily", "custom"),
|
||||
paceCaretCustomSpeed: joi.number().min(0),
|
||||
repeatedPace: joi.boolean(),
|
||||
accountChart: joi
|
||||
.array()
|
||||
.items(joi.string().valid("on", "off"))
|
||||
.min(3)
|
||||
.max(4)
|
||||
.optional(), //replace min max with length 4 after a while
|
||||
minWpm: joi.string().valid("off", "custom"),
|
||||
minWpmCustomSpeed: joi.number().min(0),
|
||||
highlightMode: joi
|
||||
.string()
|
||||
.valid(
|
||||
"off",
|
||||
"letter",
|
||||
"word",
|
||||
"next_word",
|
||||
"next_two_words",
|
||||
"next_three_words"
|
||||
),
|
||||
tapeMode: joi.string().valid("off", "letter", "word"),
|
||||
typingSpeedUnit: joi.string().valid("wpm", "cpm", "wps", "cps", "wph"),
|
||||
enableAds: joi.string().valid("off", "on", "max"),
|
||||
ads: joi.string().valid("off", "result", "on", "sellout"),
|
||||
hideExtraLetters: joi.boolean(),
|
||||
strictSpace: joi.boolean(),
|
||||
minAcc: joi.string().valid("off", "custom"),
|
||||
minAccCustom: joi.number().min(0),
|
||||
monkey: joi.boolean(),
|
||||
repeatQuotes: joi.string().valid("off", "typing"),
|
||||
oppositeShiftMode: joi.string().valid("off", "on", "keymap"),
|
||||
customBackground: joi.string().uri().allow(""),
|
||||
customBackgroundSize: joi.string().valid("cover", "contain", "max"),
|
||||
customBackgroundFilter: joi.array().items(joi.number()),
|
||||
customLayoutfluid: joi.string().regex(/^[0-9a-zA-Z_#]+$/),
|
||||
monkeyPowerLevel: joi.string().valid("off", "1", "2", "3", "4"),
|
||||
minBurst: joi.string().valid("off", "fixed", "flex"),
|
||||
minBurstCustomSpeed: joi.number().min(0),
|
||||
burstHeatmap: joi.boolean(),
|
||||
britishEnglish: joi.boolean(),
|
||||
lazyMode: joi.boolean(),
|
||||
showAverage: joi.string().valid("off", "speed", "acc", "both"),
|
||||
maxLineWidth: joi.number().min(20).max(1000).allow(0),
|
||||
});
|
||||
|
||||
export default CONFIG_SCHEMA;
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
import MonkeyError from "../utils/error";
|
||||
import * as db from "../init/db";
|
||||
import { ObjectId, type Filter, Collection, type WithId } from "mongodb";
|
||||
import {
|
||||
ConfigPreset,
|
||||
DBConfigPreset as SharedDBConfigPreset,
|
||||
} from "@monkeytype/shared-types";
|
||||
import { Preset } from "@monkeytype/contracts/schemas/presets";
|
||||
|
||||
const MAX_PRESETS = 10;
|
||||
|
||||
type DBConfigPreset = MonkeyTypes.WithObjectId<SharedDBConfigPreset>;
|
||||
type DBConfigPreset = MonkeyTypes.WithObjectId<
|
||||
Preset & {
|
||||
uid: string;
|
||||
}
|
||||
>;
|
||||
|
||||
function getPresetKeyFilter(
|
||||
uid: string,
|
||||
|
|
@ -37,36 +38,32 @@ export async function getPresets(uid: string): Promise<DBConfigPreset[]> {
|
|||
|
||||
export async function addPreset(
|
||||
uid: string,
|
||||
name: string,
|
||||
config: ConfigPreset
|
||||
preset: Omit<Preset, "_id">
|
||||
): Promise<PresetCreationResult> {
|
||||
const presets = await getPresets(uid);
|
||||
if (presets.length >= MAX_PRESETS) {
|
||||
const presets = await getPresetsCollection().countDocuments({ uid });
|
||||
|
||||
if (presets >= MAX_PRESETS) {
|
||||
throw new MonkeyError(409, "Too many presets");
|
||||
}
|
||||
|
||||
const preset = await getPresetsCollection().insertOne({
|
||||
const result = await getPresetsCollection().insertOne({
|
||||
...preset,
|
||||
_id: new ObjectId(),
|
||||
uid,
|
||||
name,
|
||||
config,
|
||||
});
|
||||
return {
|
||||
presetId: preset.insertedId.toHexString(),
|
||||
presetId: result.insertedId.toHexString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function editPreset(
|
||||
uid: string,
|
||||
presetId: string,
|
||||
name: string,
|
||||
config: ConfigPreset | null | undefined
|
||||
): Promise<void> {
|
||||
export async function editPreset(uid: string, preset: Preset): Promise<void> {
|
||||
const config = preset.config;
|
||||
const presetUpdates =
|
||||
config !== undefined && config !== null && Object.keys(config).length > 0
|
||||
? { name, config }
|
||||
: { name };
|
||||
await getPresetsCollection().updateOne(getPresetKeyFilter(uid, presetId), {
|
||||
? { name: preset.name, config }
|
||||
: { name: preset.name };
|
||||
|
||||
await getPresetsCollection().updateOne(getPresetKeyFilter(uid, preset._id), {
|
||||
$set: presetUpdates,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "These are the set of `internal` endpoints dedicated to the Monkeytype web client. Authentication for these endpoints requires a user account.",
|
||||
"description": "These are the set of `internal` endpoints dedicated to the Monkeytype web client. Authentication for these endpoints requires a user account.\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/internal",
|
||||
"version": "1.0.0",
|
||||
"title": "Monkeytype",
|
||||
"termsOfService": "https://monkeytype.com/terms-of-service",
|
||||
|
|
@ -27,14 +27,6 @@
|
|||
"name": "psas",
|
||||
"description": "Public service announcements"
|
||||
},
|
||||
{
|
||||
"name": "presets",
|
||||
"description": "Preset data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "configs",
|
||||
"description": "User configuration data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "ape-keys",
|
||||
"description": "ApeKey data and related operations"
|
||||
|
|
@ -442,146 +434,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/presets": {
|
||||
"get": {
|
||||
"tags": ["presets"],
|
||||
"summary": "Gets saved preset configurations",
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["presets"],
|
||||
"summary": "Creates a preset configuration",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"config": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["presets"],
|
||||
"summary": "Updates an existing preset configuration",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"config": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/presets/{presetId}": {
|
||||
"delete": {
|
||||
"tags": ["presets"],
|
||||
"summary": "Deletes a preset configuration",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "presetId",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/configs": {
|
||||
"get": {
|
||||
"tags": ["configs"],
|
||||
"summary": "Gets the user's current configuration",
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["configs"],
|
||||
"summary": "Updates a user's configuration",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ape-keys": {
|
||||
"get": {
|
||||
"tags": ["ape-keys"],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.",
|
||||
"description": "Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.\n\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/public",
|
||||
"version": "1.0.0",
|
||||
"title": "Monkeytype API",
|
||||
"termsOfService": "https://monkeytype.com/terms-of-service",
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ export function isDevEnvironment(): boolean {
|
|||
/**
|
||||
* convert database object into api object
|
||||
* @param data database object with `_id: ObjectId`
|
||||
* @returns api obkect with `id: string`
|
||||
* @returns api object with `id: string`
|
||||
*/
|
||||
export function replaceObjectId<T extends { _id: ObjectId }>(
|
||||
data: T
|
||||
|
|
@ -321,3 +321,14 @@ export function replaceObjectId<T extends { _id: ObjectId }>(
|
|||
} as T & { _id: string };
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* convert database objects into api objects
|
||||
* @param data database objects with `_id: ObjectId`
|
||||
* @returns api objects with `id: string`
|
||||
*/
|
||||
export function replaceObjectIds<T extends { _id: ObjectId }>(
|
||||
data: T[]
|
||||
): (T & { _id: string })[] {
|
||||
return data.map((it) => replaceObjectId(it));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ export class MonkeyResponse2<T = null>
|
|||
implements MonkeyResponseType, MonkeyDataAware<T>
|
||||
{
|
||||
public message: string;
|
||||
public data: T | null;
|
||||
public data: T;
|
||||
|
||||
constructor(message: string, data: T | null = null) {
|
||||
constructor(message: string, data: T) {
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import Leaderboards from "./leaderboards";
|
||||
import Presets from "./presets";
|
||||
import Psas from "./psas";
|
||||
import Quotes from "./quotes";
|
||||
import Results from "./results";
|
||||
|
|
@ -11,7 +10,6 @@ import Dev from "./dev";
|
|||
|
||||
export default {
|
||||
Leaderboards,
|
||||
Presets,
|
||||
Psas,
|
||||
Public,
|
||||
Quotes,
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
const BASE_PATH = "/presets";
|
||||
|
||||
export default class Presets {
|
||||
constructor(private httpClient: Ape.HttpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
async get(): Ape.EndpointResponse<Ape.Presets.GetPresets> {
|
||||
return await this.httpClient.get(BASE_PATH);
|
||||
}
|
||||
|
||||
async add(
|
||||
presetName: string,
|
||||
configChanges: MonkeyTypes.ConfigChanges
|
||||
): Ape.EndpointResponse<Ape.Presets.PostPreset> {
|
||||
const payload = {
|
||||
name: presetName,
|
||||
config: configChanges,
|
||||
};
|
||||
|
||||
return await this.httpClient.post(BASE_PATH, { payload });
|
||||
}
|
||||
|
||||
async edit(
|
||||
presetId: string,
|
||||
presetName: string,
|
||||
configChanges: MonkeyTypes.ConfigChanges
|
||||
): Ape.EndpointResponse<Ape.Presets.PatchPreset> {
|
||||
const payload = {
|
||||
_id: presetId,
|
||||
name: presetName,
|
||||
config: configChanges,
|
||||
};
|
||||
|
||||
return await this.httpClient.patch(BASE_PATH, { payload });
|
||||
}
|
||||
|
||||
async delete(
|
||||
presetId: string
|
||||
): Ape.EndpointResponse<Ape.Presets.DeltePreset> {
|
||||
const encoded = encodeURIComponent(presetId);
|
||||
return await this.httpClient.delete(`${BASE_PATH}/${encoded}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { buildHttpClient } from "./adapters/axios-adapter";
|
|||
import { envConfig } from "../constants/env-config";
|
||||
import { buildClient } from "./adapters/ts-rest-adapter";
|
||||
import { configsContract } from "@monkeytype/contracts/configs";
|
||||
import { presetsContract } from "@monkeytype/contracts/presets";
|
||||
|
||||
const API_PATH = "";
|
||||
const BASE_URL = envConfig.backendUrl;
|
||||
|
|
@ -18,7 +19,7 @@ const Ape = {
|
|||
psas: new endpoints.Psas(httpClient),
|
||||
quotes: new endpoints.Quotes(httpClient),
|
||||
leaderboards: new endpoints.Leaderboards(httpClient),
|
||||
presets: new endpoints.Presets(httpClient),
|
||||
presets: buildClient(presetsContract, BASE_URL, 10_000),
|
||||
publicStats: new endpoints.Public(httpClient),
|
||||
apeKeys: new endpoints.ApeKeys(httpClient),
|
||||
configuration: new endpoints.Configuration(httpClient),
|
||||
|
|
|
|||
10
frontend/src/ts/ape/types/presets.d.ts
vendored
10
frontend/src/ts/ape/types/presets.d.ts
vendored
|
|
@ -1,10 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// for some reason when using the dot notaion, the types are not being recognized as used
|
||||
declare namespace Ape.Presets {
|
||||
type GetPresets = DBConfigPreset[];
|
||||
type PostPreset = {
|
||||
presetId: string;
|
||||
};
|
||||
type PatchPreset = null;
|
||||
type DeltePreset = null;
|
||||
}
|
||||
|
|
@ -92,14 +92,14 @@ export async function initSnapshot(): Promise<
|
|||
if (presetsResponse.status !== 200) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw {
|
||||
message: `${presetsResponse.message} (presets)`,
|
||||
message: `${presetsResponse.body.message} (presets)`,
|
||||
responseCode: presetsResponse.status,
|
||||
};
|
||||
}
|
||||
|
||||
const userData = userResponse.data;
|
||||
const configData = configResponse.body.data;
|
||||
const presetsData = presetsResponse.data;
|
||||
const presetsData = presetsResponse.body.data;
|
||||
|
||||
if (userData === null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { format as dateFormat } from "date-fns/format";
|
|||
import Format from "../../utils/format";
|
||||
import { PersonalBests } from "@monkeytype/shared-types/user";
|
||||
import { Mode2 } from "@monkeytype/shared-types/config";
|
||||
import { StringNumber } from "@monkeytype/shared-types/util";
|
||||
import { StringNumber } from "@monkeytype/contracts/schemas/util";
|
||||
|
||||
function clearTables(isProfile: boolean): void {
|
||||
const source = isProfile ? "Profile" : "Account";
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ settingsPage
|
|||
EditPresetPopup.show("add");
|
||||
} else if (target.classList.contains("editButton")) {
|
||||
const presetid = target.parentElement?.getAttribute("data-id");
|
||||
const name = target.parentElement?.getAttribute("data-name");
|
||||
const name = target.parentElement?.getAttribute("data-display");
|
||||
if (
|
||||
presetid === undefined ||
|
||||
name === undefined ||
|
||||
|
|
@ -53,7 +53,7 @@ settingsPage
|
|||
EditPresetPopup.show("edit", presetid, name);
|
||||
} else if (target.classList.contains("removeButton")) {
|
||||
const presetid = target.parentElement?.getAttribute("data-id");
|
||||
const name = target.parentElement?.getAttribute("data-name");
|
||||
const name = target.parentElement?.getAttribute("data-display");
|
||||
if (
|
||||
presetid === undefined ||
|
||||
name === undefined ||
|
||||
|
|
|
|||
|
|
@ -91,12 +91,14 @@ async function apply(): Promise<void> {
|
|||
Loader.show();
|
||||
|
||||
if (action === "add") {
|
||||
const response = await Ape.presets.add(presetName, configChanges);
|
||||
const response = await Ape.presets.add({
|
||||
body: { name: presetName, config: configChanges },
|
||||
});
|
||||
|
||||
if (response.status !== 200 || response.data === null) {
|
||||
if (response.status !== 200 || response.body.data === null) {
|
||||
Notifications.add(
|
||||
"Failed to add preset: " +
|
||||
response.message.replace(presetName, propPresetName),
|
||||
response.body.message.replace(presetName, propPresetName),
|
||||
-1
|
||||
);
|
||||
} else {
|
||||
|
|
@ -107,18 +109,20 @@ async function apply(): Promise<void> {
|
|||
name: presetName,
|
||||
config: configChanges,
|
||||
display: propPresetName,
|
||||
_id: response.data.presetId,
|
||||
_id: response.body.data.presetId,
|
||||
} as MonkeyTypes.SnapshotPreset);
|
||||
}
|
||||
} else if (action === "edit") {
|
||||
const response = await Ape.presets.edit(
|
||||
presetId,
|
||||
presetName,
|
||||
configChanges
|
||||
);
|
||||
const response = await Ape.presets.save({
|
||||
body: {
|
||||
_id: presetId,
|
||||
name: presetName,
|
||||
config: configChanges,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to edit preset: " + response.message, -1);
|
||||
Notifications.add("Failed to edit preset: " + response.body.message, -1);
|
||||
} else {
|
||||
Notifications.add("Preset updated", 1);
|
||||
const preset = snapshotPresets.filter(
|
||||
|
|
@ -131,10 +135,13 @@ async function apply(): Promise<void> {
|
|||
}
|
||||
}
|
||||
} else if (action === "remove") {
|
||||
const response = await Ape.presets.delete(presetId);
|
||||
const response = await Ape.presets.delete({ params: { presetId } });
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to remove preset: " + response.message, -1);
|
||||
Notifications.add(
|
||||
"Failed to remove preset: " + response.body.message,
|
||||
-1
|
||||
);
|
||||
} else {
|
||||
Notifications.add("Preset removed", 1);
|
||||
snapshotPresets.forEach(
|
||||
|
|
|
|||
7
frontend/src/ts/types/types.d.ts
vendored
7
frontend/src/ts/types/types.d.ts
vendored
|
|
@ -176,9 +176,10 @@ declare namespace MonkeyTypes {
|
|||
tags: string[];
|
||||
} & import("@monkeytype/shared-types/config").Config;
|
||||
|
||||
type SnapshotPreset = import("@monkeytype/shared-types").DBConfigPreset & {
|
||||
display: string;
|
||||
};
|
||||
type SnapshotPreset =
|
||||
import("@monkeytype/contracts/schemas/presets").Preset & {
|
||||
display: string;
|
||||
};
|
||||
|
||||
type RawCustomTheme = {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
import { configsContract } from "./configs";
|
||||
import { presetsContract } from "./presets";
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
configs: configsContract,
|
||||
presets: presetsContract,
|
||||
});
|
||||
|
|
|
|||
83
packages/contracts/src/presets.ts
Normal file
83
packages/contracts/src/presets.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CommonResponses,
|
||||
EndpointMetadata,
|
||||
MonkeyResponseSchema,
|
||||
responseWithData,
|
||||
} from "./schemas/api";
|
||||
import { PresetSchema } from "./schemas/presets";
|
||||
import { IdSchema } from "./schemas/util";
|
||||
|
||||
export const GetPresetResponseSchema = responseWithData(z.array(PresetSchema));
|
||||
export type GetPresetResponse = z.infer<typeof GetPresetResponseSchema>;
|
||||
|
||||
export const AddPresetRequestSchema = PresetSchema.omit({ _id: true });
|
||||
export type AddPresetRequest = z.infer<typeof AddPresetRequestSchema>;
|
||||
|
||||
export const AddPresetResponseSchemna = responseWithData(
|
||||
z.object({ presetId: IdSchema })
|
||||
);
|
||||
export type AddPresetResponse = z.infer<typeof AddPresetResponseSchemna>;
|
||||
|
||||
export const DeletePresetsParamsSchema = z.object({
|
||||
presetId: IdSchema,
|
||||
});
|
||||
export type DeletePresetsParams = z.infer<typeof DeletePresetsParamsSchema>;
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const presetsContract = c.router(
|
||||
{
|
||||
get: {
|
||||
summary: "get presets",
|
||||
description: "Get presets of the current user.",
|
||||
method: "GET",
|
||||
path: "/",
|
||||
responses: {
|
||||
200: GetPresetResponseSchema,
|
||||
},
|
||||
},
|
||||
add: {
|
||||
summary: "add preset",
|
||||
description: "Add a new preset for the current user.",
|
||||
method: "POST",
|
||||
path: "/",
|
||||
body: AddPresetRequestSchema.strict(),
|
||||
responses: {
|
||||
200: AddPresetResponseSchemna,
|
||||
},
|
||||
},
|
||||
save: {
|
||||
summary: "update preset",
|
||||
description: "Update an existing preset for the current user.",
|
||||
method: "PATCH",
|
||||
path: "/",
|
||||
body: PresetSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
method: "DELETE",
|
||||
path: "/:presetId",
|
||||
pathParams: DeletePresetsParamsSchema,
|
||||
body: c.noBody(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
summary: "delete preset",
|
||||
description: "Delete preset by id.",
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/presets",
|
||||
strictStatusCodes: true,
|
||||
metadata: {
|
||||
openApiTags: "presets",
|
||||
} as EndpointMetadata,
|
||||
|
||||
commonResponses: CommonResponses,
|
||||
}
|
||||
);
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { z, ZodSchema } from "zod";
|
||||
|
||||
export type OperationTag = "configs";
|
||||
export type OpenApiTag = "configs" | "presets";
|
||||
|
||||
export type EndpointMetadata = {
|
||||
/** Authentication options, by default a bearer token is required. */
|
||||
authenticationOptions?: RequestAuthenticationOptions;
|
||||
openApiTags?: OperationTag | OperationTag[];
|
||||
openApiTags?: OpenApiTag | OpenApiTag[];
|
||||
};
|
||||
|
||||
export type RequestAuthenticationOptions = {
|
||||
|
|
|
|||
18
packages/contracts/src/schemas/presets.ts
Normal file
18
packages/contracts/src/schemas/presets.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { z } from "zod";
|
||||
import { IdSchema, TagSchema } from "./util";
|
||||
import { PartialConfigSchema } from "./configs";
|
||||
|
||||
export const PresetNameSchema = z
|
||||
.string()
|
||||
.regex(/^[0-9a-zA-Z_-]+$/)
|
||||
.max(16);
|
||||
export type PresentName = z.infer<typeof PresetNameSchema>;
|
||||
|
||||
export const PresetSchema = z.object({
|
||||
_id: IdSchema,
|
||||
name: PresetNameSchema,
|
||||
config: PartialConfigSchema.extend({
|
||||
tags: z.array(TagSchema).optional(),
|
||||
}),
|
||||
});
|
||||
export type Preset = z.infer<typeof PresetSchema>;
|
||||
|
|
@ -7,3 +7,9 @@ export const StringNumberSchema = z.custom<`${number}`>((val) => {
|
|||
export type StringNumber = z.infer<typeof StringNumberSchema>;
|
||||
|
||||
export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/);
|
||||
|
||||
export const IdSchema = token();
|
||||
export type Id = z.infer<typeof IdSchema>;
|
||||
|
||||
export const TagSchema = token().max(50);
|
||||
export type Tag = z.infer<typeof TagSchema>;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
Config,
|
||||
Difficulty,
|
||||
Mode,
|
||||
} from "@monkeytype/contracts/schemas/configs";
|
||||
import { Difficulty, Mode } from "@monkeytype/contracts/schemas/configs";
|
||||
import { Mode2 } from "./config";
|
||||
import { PersonalBest, PersonalBests } from "./user";
|
||||
|
||||
|
|
@ -329,17 +325,6 @@ export type ApeKey = {
|
|||
lastUsedOn: number;
|
||||
};
|
||||
|
||||
export type ConfigPreset = Partial<Config> & {
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type DBConfigPreset = {
|
||||
_id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
config: ConfigPreset;
|
||||
};
|
||||
|
||||
export type LeaderboardEntry = {
|
||||
_id: string;
|
||||
wpm: number;
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
export type StringNumber =
|
||||
import("@monkeytype/contracts/schemas/util").StringNumber;
|
||||
Loading…
Add table
Reference in a new issue