mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
parent
1ba4be38d0
commit
1804ebcd8a
894
backend/__tests__/api/controllers/quotes.spec.ts
Normal file
894
backend/__tests__/api/controllers/quotes.spec.ts
Normal file
|
@ -0,0 +1,894 @@
|
|||
import request from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import * as NewQuotesDal from "../../../src/dal/new-quotes";
|
||||
import type { DBNewQuote } from "../../../src/dal/new-quotes";
|
||||
import * as QuoteRatingsDal from "../../../src/dal/quote-ratings";
|
||||
import * as ReportDal from "../../../src/dal/report";
|
||||
import * as Captcha from "../../../src/utils/captcha";
|
||||
import { ObjectId } from "mongodb";
|
||||
import _ from "lodash";
|
||||
import { ApproveQuote } from "@monkeytype/contracts/schemas/quotes";
|
||||
|
||||
const mockApp = request(app);
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
|
||||
const uid = new ObjectId().toHexString();
|
||||
|
||||
describe("QuotesController", () => {
|
||||
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
|
||||
beforeEach(() => {
|
||||
enableQuotes(true);
|
||||
|
||||
const user = { quoteMod: true, name: "Bob" } as any;
|
||||
getPartialUserMock.mockReset().mockResolvedValue(user);
|
||||
});
|
||||
|
||||
describe("getQuotes", () => {
|
||||
const getQuotesMock = vi.spyOn(NewQuotesDal, "get");
|
||||
|
||||
beforeEach(() => {
|
||||
getQuotesMock.mockReset();
|
||||
getQuotesMock.mockResolvedValue([]);
|
||||
});
|
||||
it("should return quotes", async () => {
|
||||
//GIVEN
|
||||
const quoteOne: DBNewQuote = {
|
||||
_id: new ObjectId(),
|
||||
text: "test",
|
||||
source: "Bob",
|
||||
language: "english",
|
||||
submittedBy: "Kevin",
|
||||
timestamp: 1000,
|
||||
approved: true,
|
||||
};
|
||||
const quoteTwo: DBNewQuote = {
|
||||
_id: new ObjectId(),
|
||||
text: "test2",
|
||||
source: "Stuart",
|
||||
language: "english",
|
||||
submittedBy: "Kevin",
|
||||
timestamp: 2000,
|
||||
approved: false,
|
||||
};
|
||||
getQuotesMock.mockResolvedValue([quoteOne, quoteTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Quote submissions retrieved");
|
||||
expect(body.data).toEqual([
|
||||
{ ...quoteOne, _id: quoteOne._id.toHexString() },
|
||||
{
|
||||
...quoteTwo,
|
||||
_id: quoteTwo._id.toHexString(),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getQuotesMock).toHaveBeenCalledWith("all");
|
||||
});
|
||||
it("should return quotes with quoteMod", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock
|
||||
.mockReset()
|
||||
.mockResolvedValue({ quoteMod: "english" } as any);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/quotes")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
|
||||
expect(getQuotesMock).toHaveBeenCalledWith("english");
|
||||
});
|
||||
it("should fail with quoteMod false", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock
|
||||
.mockReset()
|
||||
.mockResolvedValue({ quoteMod: false } as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
|
||||
expect(getQuotesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail with quoteMod empty", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockReset().mockResolvedValue({ quoteMod: "" } as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
|
||||
expect(getQuotesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.get("/quotes").expect(401);
|
||||
});
|
||||
});
|
||||
describe("isSubmissionsEnabled", () => {
|
||||
it("should return for quotes enabled without authentication", async () => {
|
||||
//GIVEN
|
||||
enableQuotes(true);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/isSubmissionEnabled")
|
||||
.expect(200);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Quote submission enabled",
|
||||
data: { isEnabled: true },
|
||||
});
|
||||
});
|
||||
it("should return for quotes disabled without authentication", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/isSubmissionEnabled")
|
||||
.expect(200);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Quote submission enabled",
|
||||
data: { isEnabled: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("addQuote", () => {
|
||||
const addQuoteMock = vi.spyOn(NewQuotesDal, "add");
|
||||
const verifyCaptchaMock = vi.spyOn(Captcha, "verify");
|
||||
|
||||
beforeEach(() => {
|
||||
addQuoteMock.mockReset();
|
||||
addQuoteMock.mockResolvedValue({} as any);
|
||||
|
||||
verifyCaptchaMock.mockReset();
|
||||
verifyCaptchaMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should add quote", async () => {
|
||||
//GIVEN
|
||||
const newQuote = {
|
||||
text: new Array(60).fill("a").join(""),
|
||||
source: "Bob",
|
||||
language: "english",
|
||||
captcha: "captcha",
|
||||
};
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send(newQuote)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Quote submission added",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(addQuoteMock).toHaveBeenCalledWith(
|
||||
newQuote.text,
|
||||
newQuote.source,
|
||||
newQuote.language,
|
||||
uid
|
||||
);
|
||||
|
||||
expect(verifyCaptchaMock).toHaveBeenCalledWith(newQuote.captcha);
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.post("/quotes").expect(401);
|
||||
});
|
||||
it("should fail if feature is disabled", async () => {
|
||||
//GIVEN
|
||||
enableQuotes(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(503);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual(
|
||||
"Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up."
|
||||
);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"text" Required',
|
||||
'"source" Required',
|
||||
'"language" Required',
|
||||
'"captcha" Required',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.send({
|
||||
text: new Array(60).fill("a").join(""),
|
||||
source: "Bob",
|
||||
language: "english",
|
||||
captcha: "captcha",
|
||||
extra: "value",
|
||||
})
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail with invalid capture", async () => {
|
||||
//GIVEN
|
||||
verifyCaptchaMock.mockResolvedValue(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes")
|
||||
.send({
|
||||
text: new Array(60).fill("a").join(""),
|
||||
source: "Bob",
|
||||
language: "english",
|
||||
captcha: "captcha",
|
||||
})
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Captcha check failed");
|
||||
});
|
||||
});
|
||||
describe("approveQuote", () => {
|
||||
const approveQuoteMock = vi.spyOn(NewQuotesDal, "approve");
|
||||
|
||||
beforeEach(() => {
|
||||
approveQuoteMock.mockReset();
|
||||
});
|
||||
|
||||
it("should approve", async () => {
|
||||
//GiVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
const quote: ApproveQuote = {
|
||||
id: 100,
|
||||
text: "text",
|
||||
source: "source",
|
||||
length: 10,
|
||||
approvedBy: "Kevin",
|
||||
};
|
||||
approveQuoteMock.mockResolvedValue({
|
||||
message: "ok",
|
||||
quote,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({
|
||||
quoteId,
|
||||
editText: "editedText",
|
||||
editSource: "editedSource",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "ok",
|
||||
data: quote,
|
||||
});
|
||||
|
||||
expect(approveQuoteMock).toHaveBeenCalledWith(
|
||||
quoteId,
|
||||
"editedText",
|
||||
"editedSource",
|
||||
"Bob"
|
||||
);
|
||||
});
|
||||
it("should approve with optional parameters as null", async () => {
|
||||
//GiVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
approveQuoteMock.mockResolvedValue({
|
||||
message: "ok",
|
||||
quote: {} as any,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId, editText: null, editSource: null })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "ok",
|
||||
data: {},
|
||||
});
|
||||
|
||||
expect(approveQuoteMock).toHaveBeenCalledWith(
|
||||
quoteId,
|
||||
undefined,
|
||||
undefined,
|
||||
"Bob"
|
||||
);
|
||||
});
|
||||
it("should approve without optional parameters", async () => {
|
||||
//GiVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
approveQuoteMock.mockResolvedValue({
|
||||
message: "ok",
|
||||
quote: {} as any,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "ok",
|
||||
data: {},
|
||||
});
|
||||
|
||||
expect(approveQuoteMock).toHaveBeenCalledWith(
|
||||
quoteId,
|
||||
undefined,
|
||||
undefined,
|
||||
"Bob"
|
||||
);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"quoteId" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId: new ObjectId().toHexString(), extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no quote mod", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockReset().mockResolvedValue({} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/approve")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId: new ObjectId().toHexString() })
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp
|
||||
.post("/quotes/approve")
|
||||
.send({ quoteId: new ObjectId().toHexString() })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
describe("refuseQuote", () => {
|
||||
const refuseQuoteMock = vi.spyOn(NewQuotesDal, "refuse");
|
||||
|
||||
beforeEach(() => {
|
||||
refuseQuoteMock.mockReset();
|
||||
});
|
||||
|
||||
it("should refuse quote", async () => {
|
||||
//GIVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/reject")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId })
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Quote refused",
|
||||
data: null,
|
||||
});
|
||||
expect(refuseQuoteMock).toHaveBeenCalledWith(quoteId);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/reject")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"quoteId" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//GIVEN
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/reject")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId, extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no quote mod", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockReset().mockResolvedValue({} as any);
|
||||
const quoteId = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/reject")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId })
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp
|
||||
.post("/quotes/reject")
|
||||
.send({ quoteId: new ObjectId().toHexString() })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
describe("getRating", () => {
|
||||
const getRatingMock = vi.spyOn(QuoteRatingsDal, "get");
|
||||
|
||||
beforeEach(() => {
|
||||
getRatingMock.mockReset();
|
||||
});
|
||||
|
||||
it("should get", async () => {
|
||||
//GIVEN
|
||||
const quoteRating = {
|
||||
_id: new ObjectId(),
|
||||
average: 2,
|
||||
language: "english",
|
||||
quoteId: 23,
|
||||
ratings: 100,
|
||||
totalRating: 122,
|
||||
};
|
||||
getRatingMock.mockResolvedValue(quoteRating);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/rating")
|
||||
.query({ quoteId: 42, language: "english" })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Rating retrieved",
|
||||
data: { ...quoteRating, _id: quoteRating._id.toHexString() },
|
||||
});
|
||||
|
||||
expect(getRatingMock).toHaveBeenCalledWith(42, "english");
|
||||
});
|
||||
it("should fail without mandatory query parameters", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ['"quoteId" Invalid input', '"language" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown query parameters", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.query({ quoteId: 42, language: "english", extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp
|
||||
.get("/quotes/rating")
|
||||
.query({ quoteId: 42, language: "english" })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
describe("submitRating", () => {
|
||||
const updateQuotesRatingsMock = vi.spyOn(UserDal, "updateQuoteRatings");
|
||||
const submitQuoteRating = vi.spyOn(QuoteRatingsDal, "submit");
|
||||
|
||||
beforeEach(() => {
|
||||
getPartialUserMock
|
||||
.mockReset()
|
||||
.mockResolvedValue({ quoteRatings: null } as any);
|
||||
|
||||
updateQuotesRatingsMock.mockReset();
|
||||
submitQuoteRating.mockReset();
|
||||
});
|
||||
it("should submit new rating", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({
|
||||
quoteId: 23,
|
||||
rating: 4,
|
||||
language: "english",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Rating submitted",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(submitQuoteRating).toHaveBeenCalledWith(23, "english", 4, false);
|
||||
|
||||
expect(updateQuotesRatingsMock).toHaveBeenCalledWith(uid, {
|
||||
english: { "23": 4 },
|
||||
});
|
||||
});
|
||||
it("should update existing rating", async () => {
|
||||
//GIVEN
|
||||
|
||||
getPartialUserMock.mockReset().mockResolvedValue({
|
||||
quoteRatings: { german: { "4": 1 }, english: { "5": 5, "23": 4 } },
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({
|
||||
quoteId: 23,
|
||||
rating: 2,
|
||||
language: "english",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Rating updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(submitQuoteRating).toHaveBeenCalledWith(23, "english", -2, true);
|
||||
|
||||
expect(updateQuotesRatingsMock).toHaveBeenCalledWith(uid, {
|
||||
german: { "4": 1 },
|
||||
english: { "5": 5, "23": 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("should update existing rating with same rating", async () => {
|
||||
//GIVEN
|
||||
|
||||
getPartialUserMock.mockReset().mockResolvedValue({
|
||||
quoteRatings: { german: { "4": 1 }, english: { "5": 5, "23": 4 } },
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({
|
||||
quoteId: 23,
|
||||
rating: 4,
|
||||
language: "english",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Rating updated",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(submitQuoteRating).toHaveBeenCalledWith(23, "english", 0, true);
|
||||
|
||||
expect(updateQuotesRatingsMock).toHaveBeenCalledWith(uid, {
|
||||
german: { "4": 1 },
|
||||
english: { "5": 5, "23": 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail with missing mandatory parameter", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"quoteId" Invalid input',
|
||||
'"language" Required',
|
||||
'"rating" Required',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown parameter", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId: 23, language: "english", rating: 5, extra: "value" })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail with zero rating", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId: 23, language: "english", rating: 0 })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"rating" Number must be greater than or equal to 1',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with rating bigger than 5", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId: 23, language: "english", rating: 6 })
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"rating" Number must be less than or equal to 5'],
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail with non-integer rating", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/rating")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ quoteId: 23, language: "english", rating: 2.5 })
|
||||
.expect(422);
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"rating" Expected integer, received float'],
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.post("/quotes/rating").expect(401);
|
||||
});
|
||||
});
|
||||
describe("reportQuote", () => {
|
||||
const verifyCaptchaMock = vi.spyOn(Captcha, "verify");
|
||||
const createReportMock = vi.spyOn(ReportDal, "createReport");
|
||||
|
||||
beforeEach(() => {
|
||||
enableQuoteReporting(true);
|
||||
|
||||
verifyCaptchaMock.mockReset();
|
||||
verifyCaptchaMock.mockResolvedValue(true);
|
||||
|
||||
createReportMock.mockReset();
|
||||
});
|
||||
|
||||
it("should report quote", async () => {
|
||||
//GIVEN
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({
|
||||
quoteId: "23", //quoteId is string on this endpoint
|
||||
quoteLanguage: "english",
|
||||
reason: "Inappropriate content",
|
||||
comment: "I don't like this.",
|
||||
captcha: "captcha",
|
||||
});
|
||||
//.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Quote reported",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(verifyCaptchaMock).toHaveBeenCalledWith("captcha");
|
||||
|
||||
expect(createReportMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "quote",
|
||||
uid,
|
||||
contentId: "english-23",
|
||||
reason: "Inappropriate content",
|
||||
comment: "I don't like this.",
|
||||
}),
|
||||
10, //configuration maxReport
|
||||
20 //configuration contentReportLimit
|
||||
);
|
||||
});
|
||||
|
||||
it("should report quote without comment", async () => {
|
||||
await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({
|
||||
quoteId: "23", //quoteId is string on this endpoint
|
||||
quoteLanguage: "english",
|
||||
reason: "Inappropriate content",
|
||||
captcha: "captcha",
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
it("should report quote with empty comment", async () => {
|
||||
await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({
|
||||
quoteId: "23", //quoteId is string on this endpoint
|
||||
quoteLanguage: "english",
|
||||
reason: "Inappropriate content",
|
||||
comment: "",
|
||||
captcha: "captcha",
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"quoteId" Invalid input',
|
||||
'"quoteLanguage" Required',
|
||||
'"reason" Required',
|
||||
'"captcha" Required',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail if feature is disabled", async () => {
|
||||
//GIVEN
|
||||
enableQuoteReporting(false);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(503);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Quote reporting is unavailable.");
|
||||
});
|
||||
it("should fail if user cannot report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock
|
||||
.mockReset()
|
||||
.mockResolvedValue({ canReport: false } as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/quotes/report")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(403);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function enableQuotes(enabled: boolean): Promise<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
quotes: { submissionsEnabled: enabled },
|
||||
});
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig
|
||||
);
|
||||
}
|
||||
|
||||
async function enableQuoteReporting(enabled: boolean): Promise<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
quotes: { reporting: { enabled, maxReports: 10, contentReportLimit: 20 } },
|
||||
});
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig
|
||||
);
|
||||
}
|
|
@ -620,6 +620,9 @@ describe("Misc Utils", () => {
|
|||
number: 1,
|
||||
});
|
||||
});
|
||||
it("ignores null values", () => {
|
||||
expect(misc.replaceObjectId(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceObjectIds", () => {
|
||||
|
|
|
@ -92,6 +92,12 @@ export function getOpenApi(): OpenAPIObject {
|
|||
"x-displayName": "PSAs",
|
||||
"x-public": "yes",
|
||||
},
|
||||
{
|
||||
name: "quotes",
|
||||
description: "Quote ratings and new quote submissions",
|
||||
"x-displayName": "Quotes",
|
||||
"x-public": "yes",
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
description:
|
||||
|
|
|
@ -19,7 +19,7 @@ export async function getPresets(
|
|||
...preset,
|
||||
uid: undefined,
|
||||
}))
|
||||
.map(replaceObjectId);
|
||||
.map((it) => replaceObjectId(it));
|
||||
|
||||
return new MonkeyResponse2("Presets retrieved", data);
|
||||
}
|
||||
|
|
|
@ -6,9 +6,22 @@ import * as NewQuotesDAL from "../../dal/new-quotes";
|
|||
import * as QuoteRatingsDAL from "../../dal/quote-ratings";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { verify } from "../../utils/captcha";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyResponse2 } from "../../utils/monkey-response";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { addLog } from "../../dal/logs";
|
||||
import {
|
||||
AddQuoteRatingRequest,
|
||||
AddQuoteRequest,
|
||||
ApproveQuoteRequest,
|
||||
ApproveQuoteResponse,
|
||||
GetQuoteRatingQuery,
|
||||
GetQuoteRatingResponse,
|
||||
GetQuotesResponse,
|
||||
IsSubmissionEnabledResponse,
|
||||
RejectQuoteRequest,
|
||||
ReportQuoteRequest,
|
||||
} from "@monkeytype/contracts/quotes";
|
||||
import { replaceObjectId, replaceObjectIds } from "../../utils/misc";
|
||||
|
||||
async function verifyCaptcha(captcha: string): Promise<void> {
|
||||
if (!(await verify(captcha))) {
|
||||
|
@ -17,49 +30,45 @@ async function verifyCaptcha(captcha: string): Promise<void> {
|
|||
}
|
||||
|
||||
export async function getQuotes(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetQuotesResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const quoteMod: boolean | undefined | string = (
|
||||
await getPartialUser(uid, "get quotes", ["quoteMod"])
|
||||
).quoteMod;
|
||||
let quoteModString: string;
|
||||
if (quoteMod === true) {
|
||||
quoteModString = "all";
|
||||
} else if (quoteMod !== false && quoteMod !== undefined) {
|
||||
quoteModString = quoteMod;
|
||||
} else {
|
||||
throw new MonkeyError(403, "You are not allowed to view submitted quotes");
|
||||
}
|
||||
const quoteMod = (await getPartialUser(uid, "get quotes", ["quoteMod"]))
|
||||
.quoteMod;
|
||||
const quoteModString = quoteMod === true ? "all" : (quoteMod as string);
|
||||
|
||||
const data = await NewQuotesDAL.get(quoteModString);
|
||||
return new MonkeyResponse("Quote submissions retrieved", data);
|
||||
return new MonkeyResponse2(
|
||||
"Quote submissions retrieved",
|
||||
replaceObjectIds(data)
|
||||
);
|
||||
}
|
||||
|
||||
export async function isSubmissionEnabled(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<IsSubmissionEnabledResponse> {
|
||||
const { submissionsEnabled } = req.ctx.configuration.quotes;
|
||||
return new MonkeyResponse(
|
||||
return new MonkeyResponse2(
|
||||
"Quote submission " + (submissionsEnabled ? "enabled" : "disabled"),
|
||||
{ isEnabled: submissionsEnabled }
|
||||
);
|
||||
}
|
||||
|
||||
export async function addQuote(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, AddQuoteRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { text, source, language, captcha } = req.body;
|
||||
|
||||
await verifyCaptcha(captcha);
|
||||
|
||||
await NewQuotesDAL.add(text, source, language, uid);
|
||||
return new MonkeyResponse("Quote submission added");
|
||||
return new MonkeyResponse2("Quote submission added", null);
|
||||
}
|
||||
|
||||
export async function approveQuote(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, ApproveQuoteRequest>
|
||||
): Promise<ApproveQuoteResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { quoteId, editText, editSource } = req.body;
|
||||
|
||||
|
@ -72,46 +81,40 @@ export async function approveQuote(
|
|||
const data = await NewQuotesDAL.approve(quoteId, editText, editSource, name);
|
||||
void addLog("system_quote_approved", data, uid);
|
||||
|
||||
return new MonkeyResponse(data.message, data.quote);
|
||||
return new MonkeyResponse2(data.message, data.quote);
|
||||
}
|
||||
|
||||
export async function refuseQuote(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, RejectQuoteRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { quoteId } = req.body;
|
||||
|
||||
await NewQuotesDAL.refuse(quoteId);
|
||||
return new MonkeyResponse("Quote refused");
|
||||
return new MonkeyResponse2("Quote refused", null);
|
||||
}
|
||||
|
||||
export async function getRating(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<GetQuoteRatingQuery>
|
||||
): Promise<GetQuoteRatingResponse> {
|
||||
const { quoteId, language } = req.query;
|
||||
|
||||
const data = await QuoteRatingsDAL.get(
|
||||
parseInt(quoteId as string, 10),
|
||||
language as string
|
||||
);
|
||||
const data = await QuoteRatingsDAL.get(quoteId, language);
|
||||
|
||||
return new MonkeyResponse("Rating retrieved", data);
|
||||
return new MonkeyResponse2("Rating retrieved", replaceObjectId(data));
|
||||
}
|
||||
|
||||
export async function submitRating(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, AddQuoteRatingRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { quoteId, rating, language } = req.body;
|
||||
|
||||
const user = await getPartialUser(uid, "submit rating", ["quoteRatings"]);
|
||||
|
||||
const normalizedQuoteId = parseInt(quoteId as string, 10);
|
||||
const normalizedRating = Math.round(parseInt(rating as string, 10));
|
||||
|
||||
const userQuoteRatings = user.quoteRatings ?? {};
|
||||
const currentRating = userQuoteRatings[language]?.[normalizedQuoteId] ?? 0;
|
||||
const currentRating = userQuoteRatings[language]?.[quoteId] ?? 0;
|
||||
|
||||
const newRating = normalizedRating - currentRating;
|
||||
const newRating = rating - currentRating;
|
||||
const shouldUpdateRating = currentRating !== 0;
|
||||
|
||||
await QuoteRatingsDAL.submit(
|
||||
|
@ -121,24 +124,19 @@ export async function submitRating(
|
|||
shouldUpdateRating
|
||||
);
|
||||
|
||||
_.setWith(
|
||||
userQuoteRatings,
|
||||
`[${language}][${normalizedQuoteId}]`,
|
||||
normalizedRating,
|
||||
Object
|
||||
);
|
||||
_.setWith(userQuoteRatings, `[${language}][${quoteId}]`, rating, Object);
|
||||
|
||||
await updateQuoteRatings(uid, userQuoteRatings);
|
||||
|
||||
const responseMessage = `Rating ${
|
||||
shouldUpdateRating ? "updated" : "submitted"
|
||||
}`;
|
||||
return new MonkeyResponse(responseMessage);
|
||||
return new MonkeyResponse2(responseMessage, null);
|
||||
}
|
||||
|
||||
export async function reportQuote(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, ReportQuoteRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const {
|
||||
reporting: { maxReports, contentReportLimit },
|
||||
|
@ -156,10 +154,10 @@ export async function reportQuote(
|
|||
uid,
|
||||
contentId: `${quoteLanguage}-${quoteId}`,
|
||||
reason,
|
||||
comment,
|
||||
comment: comment ?? "",
|
||||
};
|
||||
|
||||
await ReportDAL.createReport(newReport, maxReports, contentReportLimit);
|
||||
|
||||
return new MonkeyResponse("Quote reported");
|
||||
return new MonkeyResponse2("Quote reported", null);
|
||||
}
|
||||
|
|
|
@ -41,7 +41,6 @@ const APP_START_TIME = Date.now();
|
|||
|
||||
const API_ROUTE_MAP = {
|
||||
"/users": users,
|
||||
"/quotes": quotes,
|
||||
"/webhooks": webhooks,
|
||||
"/docs": docs,
|
||||
};
|
||||
|
@ -58,6 +57,7 @@ const router = s.router(contract, {
|
|||
results,
|
||||
configuration,
|
||||
dev,
|
||||
quotes,
|
||||
});
|
||||
|
||||
export function addApiRoutes(app: Application): void {
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
import joi from "joi";
|
||||
import { authenticateRequest } from "../../middlewares/auth";
|
||||
import { Router } from "express";
|
||||
import * as QuoteController from "../controllers/quote";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import { checkUserPermissions } from "../../middlewares/permission";
|
||||
import { asyncHandler } from "../../middlewares/utility";
|
||||
import { quotesContract } from "@monkeytype/contracts/quotes";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { validate } from "../../middlewares/configuration";
|
||||
import { validateRequest } from "../../middlewares/validation";
|
||||
|
||||
const router = Router();
|
||||
import { checkUserPermissions } from "../../middlewares/permission";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import * as QuoteController from "../controllers/quote";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const checkIfUserIsQuoteMod = checkUserPermissions(["quoteMod"], {
|
||||
criteria: (user) => {
|
||||
|
@ -19,164 +15,61 @@ const checkIfUserIsQuoteMod = checkUserPermissions(["quoteMod"], {
|
|||
},
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
authenticateRequest(),
|
||||
RateLimit.newQuotesGet,
|
||||
checkIfUserIsQuoteMod,
|
||||
asyncHandler(QuoteController.getQuotes)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/isSubmissionEnabled",
|
||||
authenticateRequest({
|
||||
isPublic: true,
|
||||
}),
|
||||
RateLimit.newQuotesIsSubmissionEnabled,
|
||||
asyncHandler(QuoteController.isSubmissionEnabled)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
validate({
|
||||
criteria: (configuration) => {
|
||||
return configuration.quotes.submissionsEnabled;
|
||||
},
|
||||
invalidMessage:
|
||||
"Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.",
|
||||
}),
|
||||
authenticateRequest(),
|
||||
RateLimit.newQuotesAdd,
|
||||
validateRequest(
|
||||
{
|
||||
body: {
|
||||
text: joi.string().min(60).required(),
|
||||
source: joi.string().required(),
|
||||
language: joi
|
||||
.string()
|
||||
.regex(/^[\w+]+$/)
|
||||
.required(),
|
||||
captcha: joi
|
||||
.string()
|
||||
.regex(/[\w-_]+/)
|
||||
.required(),
|
||||
},
|
||||
},
|
||||
{ validationErrorMessage: "Please fill all the fields" }
|
||||
),
|
||||
asyncHandler(QuoteController.addQuote)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/approve",
|
||||
authenticateRequest(),
|
||||
RateLimit.newQuotesAction,
|
||||
validateRequest(
|
||||
{
|
||||
body: {
|
||||
quoteId: joi.string().required(),
|
||||
editText: joi.string().allow(null),
|
||||
editSource: joi.string().allow(null),
|
||||
},
|
||||
},
|
||||
{ validationErrorMessage: "Please fill all the fields" }
|
||||
),
|
||||
checkIfUserIsQuoteMod,
|
||||
asyncHandler(QuoteController.approveQuote)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/reject",
|
||||
authenticateRequest(),
|
||||
RateLimit.newQuotesAction,
|
||||
validateRequest({
|
||||
body: {
|
||||
quoteId: joi.string().required(),
|
||||
},
|
||||
}),
|
||||
checkIfUserIsQuoteMod,
|
||||
asyncHandler(QuoteController.refuseQuote)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/rating",
|
||||
authenticateRequest(),
|
||||
RateLimit.quoteRatingsGet,
|
||||
validateRequest({
|
||||
query: {
|
||||
quoteId: joi.string().regex(/^\d+$/).required(),
|
||||
language: joi
|
||||
.string()
|
||||
.regex(/^[\w+]+$/)
|
||||
.required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(QuoteController.getRating)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/rating",
|
||||
authenticateRequest(),
|
||||
RateLimit.quoteRatingsSubmit,
|
||||
validateRequest({
|
||||
body: {
|
||||
quoteId: joi.number().required(),
|
||||
rating: joi.number().min(1).max(5).required(),
|
||||
language: joi
|
||||
.string()
|
||||
.regex(/^[\w+]+$/)
|
||||
.max(50)
|
||||
.required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(QuoteController.submitRating)
|
||||
);
|
||||
|
||||
const withCustomMessages = joi.string().messages({
|
||||
"string.pattern.base": "Invalid parameter format",
|
||||
const s = initServer();
|
||||
export default s.router(quotesContract, {
|
||||
get: {
|
||||
middleware: [checkIfUserIsQuoteMod, RateLimit.newQuotesGet],
|
||||
handler: async (r) => callController(QuoteController.getQuotes)(r),
|
||||
},
|
||||
isSubmissionEnabled: {
|
||||
middleware: [RateLimit.newQuotesIsSubmissionEnabled],
|
||||
handler: async (r) =>
|
||||
callController(QuoteController.isSubmissionEnabled)(r),
|
||||
},
|
||||
add: {
|
||||
middleware: [
|
||||
validate({
|
||||
criteria: (configuration) => {
|
||||
return configuration.quotes.submissionsEnabled;
|
||||
},
|
||||
invalidMessage:
|
||||
"Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.",
|
||||
}),
|
||||
RateLimit.newQuotesAdd,
|
||||
],
|
||||
handler: async (r) => callController(QuoteController.addQuote)(r),
|
||||
},
|
||||
approveSubmission: {
|
||||
middleware: [checkIfUserIsQuoteMod, RateLimit.newQuotesAction],
|
||||
handler: async (r) => callController(QuoteController.approveQuote)(r),
|
||||
},
|
||||
rejectSubmission: {
|
||||
middleware: [checkIfUserIsQuoteMod, RateLimit.newQuotesAction],
|
||||
handler: async (r) => callController(QuoteController.refuseQuote)(r),
|
||||
},
|
||||
getRating: {
|
||||
middleware: [RateLimit.quoteRatingsGet],
|
||||
handler: async (r) => callController(QuoteController.getRating)(r),
|
||||
},
|
||||
addRating: {
|
||||
middleware: [RateLimit.quoteRatingsSubmit],
|
||||
handler: async (r) => callController(QuoteController.submitRating)(r),
|
||||
},
|
||||
report: {
|
||||
middleware: [
|
||||
validate({
|
||||
criteria: (configuration) => {
|
||||
return configuration.quotes.reporting.enabled;
|
||||
},
|
||||
invalidMessage: "Quote reporting is unavailable.",
|
||||
}),
|
||||
RateLimit.quoteReportSubmit,
|
||||
checkUserPermissions(["canReport"], {
|
||||
criteria: (user) => {
|
||||
return user.canReport !== false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
handler: async (r) => callController(QuoteController.reportQuote)(r),
|
||||
},
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/report",
|
||||
validate({
|
||||
criteria: (configuration) => {
|
||||
return configuration.quotes.reporting.enabled;
|
||||
},
|
||||
invalidMessage: "Quote reporting is unavailable.",
|
||||
}),
|
||||
authenticateRequest(),
|
||||
RateLimit.quoteReportSubmit,
|
||||
validateRequest({
|
||||
body: {
|
||||
quoteId: withCustomMessages.regex(/\d+/).required(),
|
||||
quoteLanguage: withCustomMessages
|
||||
.regex(/^[\w+]+$/)
|
||||
.max(50)
|
||||
.required(),
|
||||
reason: joi
|
||||
.string()
|
||||
.valid(
|
||||
"Grammatical error",
|
||||
"Duplicate quote",
|
||||
"Inappropriate content",
|
||||
"Low quality content",
|
||||
"Incorrect source"
|
||||
)
|
||||
.required(),
|
||||
comment: withCustomMessages
|
||||
.allow("")
|
||||
.regex(/^([.]|[^/<>])+$/)
|
||||
.max(250)
|
||||
.required(),
|
||||
captcha: withCustomMessages.regex(/[\w-_]+/).required(),
|
||||
},
|
||||
}),
|
||||
checkUserPermissions(["canReport"], {
|
||||
criteria: (user) => {
|
||||
return user.canReport !== false;
|
||||
},
|
||||
}),
|
||||
asyncHandler(QuoteController.reportQuote)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { simpleGit } from "simple-git";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { Collection, ObjectId } from "mongodb";
|
||||
import path from "path";
|
||||
import { existsSync, writeFileSync } from "fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import * as db from "../init/db";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { compareTwoStrings } from "string-similarity";
|
||||
import { ApproveQuote, Quote } from "@monkeytype/contracts/schemas/quotes";
|
||||
|
||||
const PATH_TO_REPO = "../../../../monkeytype-new-quotes";
|
||||
|
||||
|
@ -23,6 +24,12 @@ type AddQuoteReturn = {
|
|||
similarityScore?: number;
|
||||
};
|
||||
|
||||
export type DBNewQuote = MonkeyTypes.WithObjectId<Quote>;
|
||||
|
||||
// Export for use in tests
|
||||
export const getNewQuoteCollection = (): Collection<DBNewQuote> =>
|
||||
db.collection<DBNewQuote>("new-quotes");
|
||||
|
||||
export async function add(
|
||||
text: string,
|
||||
source: string,
|
||||
|
@ -44,9 +51,9 @@ export async function add(
|
|||
throw new MonkeyError(500, `Invalid language name`, language);
|
||||
}
|
||||
|
||||
const count = await db
|
||||
.collection("new-quotes")
|
||||
.countDocuments({ language: language });
|
||||
const count = await getNewQuoteCollection().countDocuments({
|
||||
language: language,
|
||||
});
|
||||
|
||||
if (count >= 100) {
|
||||
throw new MonkeyError(
|
||||
|
@ -83,7 +90,7 @@ export async function add(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export async function get(language: string): Promise<MonkeyTypes.NewQuote[]> {
|
||||
export async function get(language: string): Promise<DBNewQuote[]> {
|
||||
if (git === undefined) throw new MonkeyError(500, "Git not available.");
|
||||
const where: {
|
||||
approved: boolean;
|
||||
|
@ -99,38 +106,29 @@ export async function get(language: string): Promise<MonkeyTypes.NewQuote[]> {
|
|||
if (language !== "all") {
|
||||
where.language = language;
|
||||
}
|
||||
return await db
|
||||
.collection<MonkeyTypes.NewQuote>("new-quotes")
|
||||
return await getNewQuoteCollection()
|
||||
.find(where)
|
||||
.sort({ timestamp: 1 })
|
||||
.limit(10)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
type Quote = {
|
||||
id?: number;
|
||||
text: string;
|
||||
source: string;
|
||||
length: number;
|
||||
approvedBy: string;
|
||||
};
|
||||
|
||||
type ApproveReturn = {
|
||||
quote: Quote;
|
||||
quote: ApproveQuote;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function approve(
|
||||
quoteId: string,
|
||||
editQuote: string,
|
||||
editSource: string,
|
||||
editQuote: string | undefined,
|
||||
editSource: string | undefined,
|
||||
name: string
|
||||
): Promise<ApproveReturn> {
|
||||
if (git === undefined) throw new MonkeyError(500, "Git not available.");
|
||||
//check mod status
|
||||
const targetQuote = await db
|
||||
.collection<MonkeyTypes.NewQuote>("new-quotes")
|
||||
.findOne({ _id: new ObjectId(quoteId) });
|
||||
const targetQuote = await getNewQuoteCollection().findOne({
|
||||
_id: new ObjectId(quoteId),
|
||||
});
|
||||
if (!targetQuote) {
|
||||
throw new MonkeyError(
|
||||
404,
|
||||
|
@ -138,9 +136,9 @@ export async function approve(
|
|||
);
|
||||
}
|
||||
const language = targetQuote.language;
|
||||
const quote: Quote = {
|
||||
text: editQuote ? editQuote : targetQuote.text,
|
||||
source: editSource ? editSource : targetQuote.source,
|
||||
const quote: ApproveQuote = {
|
||||
text: editQuote ?? targetQuote.text,
|
||||
source: editSource ?? targetQuote.source,
|
||||
length: targetQuote.text.length,
|
||||
approvedBy: name,
|
||||
};
|
||||
|
@ -194,11 +192,11 @@ export async function approve(
|
|||
await git.add([`frontend/static/quotes/${language}.json`]);
|
||||
await git.commit(`Added quote to ${language}.json`);
|
||||
await git.push("origin", "master");
|
||||
await db.collection("new-quotes").deleteOne({ _id: new ObjectId(quoteId) });
|
||||
await getNewQuoteCollection().deleteOne({ _id: new ObjectId(quoteId) });
|
||||
return { quote, message };
|
||||
}
|
||||
|
||||
export async function refuse(quoteId: string): Promise<void> {
|
||||
if (git === undefined) throw new MonkeyError(500, "Git not available.");
|
||||
await db.collection("new-quotes").deleteOne({ _id: new ObjectId(quoteId) });
|
||||
await getNewQuoteCollection().deleteOne({ _id: new ObjectId(quoteId) });
|
||||
}
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
import { QuoteRating } from "@monkeytype/contracts/schemas/quotes";
|
||||
import * as db from "../init/db";
|
||||
import { Collection } from "mongodb";
|
||||
|
||||
type DBQuoteRating = MonkeyTypes.WithObjectId<QuoteRating>;
|
||||
|
||||
// Export for use in tests
|
||||
export const getQuoteRatingCollection = (): Collection<DBQuoteRating> =>
|
||||
db.collection<DBQuoteRating>("quote-rating");
|
||||
|
||||
export async function submit(
|
||||
quoteId: number,
|
||||
|
@ -7,21 +15,17 @@ export async function submit(
|
|||
update: boolean
|
||||
): Promise<void> {
|
||||
if (update) {
|
||||
await db
|
||||
.collection<MonkeyTypes.QuoteRating>("quote-rating")
|
||||
.updateOne(
|
||||
{ quoteId, language },
|
||||
{ $inc: { totalRating: rating } },
|
||||
{ upsert: true }
|
||||
);
|
||||
await getQuoteRatingCollection().updateOne(
|
||||
{ quoteId, language },
|
||||
{ $inc: { totalRating: rating } },
|
||||
{ upsert: true }
|
||||
);
|
||||
} else {
|
||||
await db
|
||||
.collection<MonkeyTypes.QuoteRating>("quote-rating")
|
||||
.updateOne(
|
||||
{ quoteId, language },
|
||||
{ $inc: { ratings: 1, totalRating: rating } },
|
||||
{ upsert: true }
|
||||
);
|
||||
await getQuoteRatingCollection().updateOne(
|
||||
{ quoteId, language },
|
||||
{ $inc: { ratings: 1, totalRating: rating } },
|
||||
{ upsert: true }
|
||||
);
|
||||
}
|
||||
|
||||
const quoteRating = await get(quoteId, language);
|
||||
|
@ -34,16 +38,15 @@ export async function submit(
|
|||
).toFixed(1)
|
||||
);
|
||||
|
||||
await db
|
||||
.collection<MonkeyTypes.QuoteRating>("quote-rating")
|
||||
.updateOne({ quoteId, language }, { $set: { average } });
|
||||
await getQuoteRatingCollection().updateOne(
|
||||
{ quoteId, language },
|
||||
{ $set: { average } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function get(
|
||||
quoteId: number,
|
||||
language: string
|
||||
): Promise<MonkeyTypes.QuoteRating | null> {
|
||||
return await db
|
||||
.collection<MonkeyTypes.QuoteRating>("quote-rating")
|
||||
.findOne({ quoteId, language });
|
||||
): Promise<DBQuoteRating | null> {
|
||||
return await getQuoteRatingCollection().findOne({ quoteId, language });
|
||||
}
|
||||
|
|
|
@ -22,10 +22,6 @@
|
|||
{
|
||||
"name": "users",
|
||||
"description": "User data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "quotes",
|
||||
"description": "Quote data and related operations"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
|
@ -403,226 +399,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/quotes": {
|
||||
"get": {
|
||||
"tags": ["quotes"],
|
||||
"summary": "Gets a list of quote submissions",
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["quotes"],
|
||||
"summary": "Creates a quote submission",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"captcha": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/quotes/approve": {
|
||||
"post": {
|
||||
"tags": ["quotes"],
|
||||
"summary": "Approves a quote submission",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"quoteId": {
|
||||
"type": "string"
|
||||
},
|
||||
"editText": {
|
||||
"type": "string"
|
||||
},
|
||||
"editSource": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/quotes/reject": {
|
||||
"post": {
|
||||
"tags": ["quotes"],
|
||||
"summary": "Rejects a quote submission",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"quoteId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/quotes/rating": {
|
||||
"get": {
|
||||
"tags": ["quotes"],
|
||||
"summary": "Gets a rating for a quote",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"quoteId": {
|
||||
"type": "string"
|
||||
},
|
||||
"language": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["quotes"],
|
||||
"summary": "Adds a rating for a quote",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"quoteId": {
|
||||
"type": "string"
|
||||
},
|
||||
"rating": {
|
||||
"type": "string"
|
||||
},
|
||||
"language": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/quotes/report": {
|
||||
"post": {
|
||||
"tags": ["quotes"],
|
||||
"summary": "Reports a quote",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"quoteId": {
|
||||
"type": "string"
|
||||
},
|
||||
"quoteLanguage": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"comment": {
|
||||
"type": "string"
|
||||
},
|
||||
"captcha": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
|
|
19
backend/src/types/types.d.ts
vendored
19
backend/src/types/types.d.ts
vendored
|
@ -83,16 +83,6 @@ declare namespace MonkeyTypes {
|
|||
useCount: number;
|
||||
};
|
||||
|
||||
type NewQuote = {
|
||||
_id: ObjectId;
|
||||
text: string;
|
||||
source: string;
|
||||
language: string;
|
||||
submittedBy: string;
|
||||
timestamp: number;
|
||||
approved: boolean;
|
||||
};
|
||||
|
||||
type ReportTypes = "quote" | "user";
|
||||
|
||||
type Report = {
|
||||
|
@ -106,15 +96,6 @@ declare namespace MonkeyTypes {
|
|||
comment: string;
|
||||
};
|
||||
|
||||
type QuoteRating = {
|
||||
_id: string;
|
||||
average: number;
|
||||
language: string;
|
||||
quoteId: number;
|
||||
ratings: number;
|
||||
totalRating: number;
|
||||
};
|
||||
|
||||
type FunboxMetadata = {
|
||||
name: string;
|
||||
canGetPb: boolean;
|
||||
|
|
|
@ -315,7 +315,16 @@ export function isDevEnvironment(): boolean {
|
|||
*/
|
||||
export function replaceObjectId<T extends { _id: ObjectId }>(
|
||||
data: T
|
||||
): T & { _id: string } {
|
||||
): T & { _id: string };
|
||||
export function replaceObjectId<T extends { _id: ObjectId }>(
|
||||
data: T | null
|
||||
): (T & { _id: string }) | null;
|
||||
export function replaceObjectId<T extends { _id: ObjectId }>(
|
||||
data: T | null
|
||||
): (T & { _id: string }) | null {
|
||||
if (data === null) {
|
||||
return null;
|
||||
}
|
||||
const result = {
|
||||
_id: data._id.toString(),
|
||||
...omit(data, "_id"),
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import Quotes from "./quotes";
|
||||
import Users from "./users";
|
||||
|
||||
export default {
|
||||
Quotes,
|
||||
Users,
|
||||
};
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
const BASE_PATH = "/quotes";
|
||||
|
||||
export default class Quotes {
|
||||
constructor(private httpClient: Ape.HttpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
async get(): Ape.EndpointResponse<Ape.Quotes.GetQuotes> {
|
||||
return await this.httpClient.get(BASE_PATH);
|
||||
}
|
||||
|
||||
async isSubmissionEnabled(): Ape.EndpointResponse<Ape.Quotes.GetIsSubmissionEnabled> {
|
||||
return await this.httpClient.get(`${BASE_PATH}/isSubmissionEnabled`);
|
||||
}
|
||||
|
||||
async submit(
|
||||
text: string,
|
||||
source: string,
|
||||
language: string,
|
||||
captcha: string
|
||||
): Ape.EndpointResponse<Ape.Quotes.PostQuotes> {
|
||||
const payload = {
|
||||
text,
|
||||
source,
|
||||
language,
|
||||
captcha,
|
||||
};
|
||||
|
||||
return await this.httpClient.post(BASE_PATH, { payload });
|
||||
}
|
||||
|
||||
async approveSubmission(
|
||||
quoteSubmissionId: string,
|
||||
editText?: string,
|
||||
editSource?: string
|
||||
): Ape.EndpointResponse<Ape.Quotes.PostApprove> {
|
||||
const payload = {
|
||||
quoteId: quoteSubmissionId,
|
||||
editText,
|
||||
editSource,
|
||||
};
|
||||
|
||||
return await this.httpClient.post(`${BASE_PATH}/approve`, { payload });
|
||||
}
|
||||
|
||||
async rejectSubmission(
|
||||
quoteSubmissionId: string
|
||||
): Ape.EndpointResponse<Ape.Quotes.PostReject> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/reject`, {
|
||||
payload: { quoteId: quoteSubmissionId },
|
||||
});
|
||||
}
|
||||
|
||||
async getRating(
|
||||
quote: MonkeyTypes.Quote
|
||||
): Ape.EndpointResponse<Ape.Quotes.GetRating> {
|
||||
const searchQuery = {
|
||||
quoteId: quote.id,
|
||||
language: quote.language,
|
||||
};
|
||||
|
||||
return await this.httpClient.get(`${BASE_PATH}/rating`, { searchQuery });
|
||||
}
|
||||
|
||||
async addRating(
|
||||
quote: MonkeyTypes.Quote,
|
||||
rating: number
|
||||
): Ape.EndpointResponse<Ape.Quotes.PostRating> {
|
||||
const payload = {
|
||||
quoteId: quote.id,
|
||||
rating,
|
||||
language: quote.language,
|
||||
};
|
||||
|
||||
return await this.httpClient.post(`${BASE_PATH}/rating`, { payload });
|
||||
}
|
||||
|
||||
async report(
|
||||
quoteId: string,
|
||||
quoteLanguage: string,
|
||||
reason: string,
|
||||
comment: string,
|
||||
captcha: string
|
||||
): Ape.EndpointResponse<Ape.Quotes.PostReport> {
|
||||
const payload = {
|
||||
quoteId,
|
||||
quoteLanguage,
|
||||
reason,
|
||||
comment,
|
||||
captcha,
|
||||
};
|
||||
|
||||
return await this.httpClient.post(`${BASE_PATH}/report`, { payload });
|
||||
}
|
||||
}
|
|
@ -17,7 +17,6 @@ const devClient = buildClient(devContract, BASE_URL, 240_000);
|
|||
const Ape = {
|
||||
...tsRestClient,
|
||||
users: new endpoints.Users(httpClient),
|
||||
quotes: new endpoints.Quotes(httpClient),
|
||||
dev: devClient,
|
||||
};
|
||||
|
||||
|
|
43
frontend/src/ts/ape/types/quotes.d.ts
vendored
43
frontend/src/ts/ape/types/quotes.d.ts
vendored
|
@ -1,43 +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.Quotes {
|
||||
type Quote = {
|
||||
_id: string;
|
||||
text: string;
|
||||
source: string;
|
||||
language: string;
|
||||
submittedBy: string;
|
||||
timestamp: number;
|
||||
approved: boolean;
|
||||
};
|
||||
|
||||
type QuoteRating = {
|
||||
_id: string;
|
||||
average: number;
|
||||
language: string;
|
||||
quoteId: number;
|
||||
ratings: number;
|
||||
totalRating: number;
|
||||
};
|
||||
|
||||
type ApproveReturn = {
|
||||
quote: {
|
||||
id?: number;
|
||||
text: string;
|
||||
source: string;
|
||||
length: number;
|
||||
approvedBy: string;
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
|
||||
type GetQuotes = Quote[];
|
||||
type GetIsSubmissionEnabled = { isEnabled: boolean };
|
||||
type GetRating = QuoteRating | null;
|
||||
|
||||
type PostQuotes = null;
|
||||
type PostApprove = ApproveReturn;
|
||||
type PostReject = null;
|
||||
type PostRating = QuoteRating | null;
|
||||
type PostReport = null;
|
||||
}
|
|
@ -46,7 +46,8 @@ window.onerror = function (message, url, line, column, error): void {
|
|||
|
||||
window.onunhandledrejection = function (e): void {
|
||||
if (Misc.isDevEnvironment()) {
|
||||
Notifications.add(e.reason.message, -1, {
|
||||
const message = e.reason.message ?? e.reason;
|
||||
Notifications.add(message, -1, {
|
||||
customTitle: "DEV: Unhandled rejection",
|
||||
duration: 5,
|
||||
});
|
||||
|
|
|
@ -3,8 +3,9 @@ import * as Loader from "../elements/loader";
|
|||
import * as Notifications from "../elements/notifications";
|
||||
import { format } from "date-fns/format";
|
||||
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
|
||||
import { Quote } from "@monkeytype/contracts/schemas/quotes";
|
||||
|
||||
let quotes: Ape.Quotes.Quote[] = [];
|
||||
let quotes: Quote[] = [];
|
||||
|
||||
function updateList(): void {
|
||||
$("#quoteApproveModal .quotes").empty();
|
||||
|
@ -96,11 +97,11 @@ async function getQuotes(): Promise<void> {
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to get new quotes: " + response.message, -1);
|
||||
Notifications.add("Failed to get new quotes: " + response.body.message, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
quotes = response.data ?? [];
|
||||
quotes = response.body.data ?? [];
|
||||
updateList();
|
||||
}
|
||||
|
||||
|
@ -150,17 +151,19 @@ async function approveQuote(index: number, dbid: string): Promise<void> {
|
|||
quote.find("textarea, input").prop("disabled", true);
|
||||
|
||||
Loader.show();
|
||||
const response = await Ape.quotes.approveSubmission(dbid);
|
||||
const response = await Ape.quotes.approveSubmission({
|
||||
body: { quoteId: dbid },
|
||||
});
|
||||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
resetButtons(index);
|
||||
quote.find("textarea, input").prop("disabled", false);
|
||||
Notifications.add("Failed to approve quote: " + response.message, -1);
|
||||
Notifications.add("Failed to approve quote: " + response.body.message, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
Notifications.add(`Quote approved. ${response.message ?? ""}`, 1);
|
||||
Notifications.add(`Quote approved. ${response.body.message ?? ""}`, 1);
|
||||
quotes.splice(index, 1);
|
||||
updateList();
|
||||
}
|
||||
|
@ -172,13 +175,15 @@ async function refuseQuote(index: number, dbid: string): Promise<void> {
|
|||
quote.find("textarea, input").prop("disabled", true);
|
||||
|
||||
Loader.show();
|
||||
const response = await Ape.quotes.rejectSubmission(dbid);
|
||||
const response = await Ape.quotes.rejectSubmission({
|
||||
body: { quoteId: dbid },
|
||||
});
|
||||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
resetButtons(index);
|
||||
quote.find("textarea, input").prop("disabled", false);
|
||||
Notifications.add("Failed to refuse quote: " + response.message, -1);
|
||||
Notifications.add("Failed to refuse quote: " + response.body.message, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -200,21 +205,26 @@ async function editQuote(index: number, dbid: string): Promise<void> {
|
|||
quote.find("textarea, input").prop("disabled", true);
|
||||
|
||||
Loader.show();
|
||||
const response = await Ape.quotes.approveSubmission(
|
||||
dbid,
|
||||
editText,
|
||||
editSource
|
||||
);
|
||||
const response = await Ape.quotes.approveSubmission({
|
||||
body: {
|
||||
quoteId: dbid,
|
||||
editText,
|
||||
editSource,
|
||||
},
|
||||
});
|
||||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
resetButtons(index);
|
||||
quote.find("textarea, input").prop("disabled", false);
|
||||
Notifications.add("Failed to approve quote: " + response.message, -1);
|
||||
Notifications.add("Failed to approve quote: " + response.body.message, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
Notifications.add(`Quote edited and approved. ${response.message ?? ""}`, 1);
|
||||
Notifications.add(
|
||||
`Quote edited and approved. ${response.body.message ?? ""}`,
|
||||
1
|
||||
);
|
||||
quotes.splice(index, 1);
|
||||
updateList();
|
||||
}
|
||||
|
|
|
@ -46,19 +46,24 @@ export async function getQuoteStats(
|
|||
}
|
||||
|
||||
currentQuote = quote;
|
||||
const response = await Ape.quotes.getRating(currentQuote);
|
||||
const response = await Ape.quotes.getRating({
|
||||
query: { quoteId: currentQuote.id, language: currentQuote.language },
|
||||
});
|
||||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to get quote ratings: " + response.message, -1);
|
||||
Notifications.add(
|
||||
"Failed to get quote ratings: " + response.body.message,
|
||||
-1
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data === null) {
|
||||
if (response.body.data === null) {
|
||||
return {} as QuoteStats;
|
||||
}
|
||||
|
||||
quoteStats = response.data as QuoteStats;
|
||||
quoteStats = response.body.data as QuoteStats;
|
||||
if (quoteStats !== undefined && !quoteStats.average) {
|
||||
quoteStats.average = getRatingAverage(quoteStats);
|
||||
}
|
||||
|
@ -140,11 +145,16 @@ async function submit(): Promise<void> {
|
|||
|
||||
hide(true);
|
||||
|
||||
const response = await Ape.quotes.addRating(currentQuote, rating);
|
||||
const response = await Ape.quotes.addRating({
|
||||
body: { quoteId: currentQuote.id, language: currentQuote.language, rating },
|
||||
});
|
||||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to submit quote rating: " + response.message, -1);
|
||||
Notifications.add(
|
||||
"Failed to submit quote rating: " + response.body.message,
|
||||
-1
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { removeLanguageSize } from "../utils/strings";
|
|||
import SlimSelect from "slim-select";
|
||||
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
|
||||
import { CharacterCounter } from "../elements/character-counter";
|
||||
import { QuoteReportReason } from "@monkeytype/contracts/schemas/quotes";
|
||||
|
||||
type State = {
|
||||
quoteToReport?: MonkeyTypes.Quote;
|
||||
|
@ -77,7 +78,7 @@ async function submitReport(): Promise<void> {
|
|||
|
||||
const quoteId = state.quoteToReport?.id.toString();
|
||||
const quoteLanguage = removeLanguageSize(Config.language);
|
||||
const reason = $("#quoteReportModal .reason").val() as string;
|
||||
const reason = $("#quoteReportModal .reason").val() as QuoteReportReason;
|
||||
const comment = $("#quoteReportModal .comment").val() as string;
|
||||
const captcha = captchaResponse;
|
||||
|
||||
|
@ -105,17 +106,19 @@ async function submitReport(): Promise<void> {
|
|||
}
|
||||
|
||||
Loader.show();
|
||||
const response = await Ape.quotes.report(
|
||||
quoteId,
|
||||
quoteLanguage,
|
||||
reason,
|
||||
comment,
|
||||
captcha
|
||||
);
|
||||
const response = await Ape.quotes.report({
|
||||
body: {
|
||||
quoteId,
|
||||
quoteLanguage,
|
||||
reason,
|
||||
comment,
|
||||
captcha,
|
||||
},
|
||||
});
|
||||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to report quote: " + response.message, -1);
|
||||
Notifications.add("Failed to report quote: " + response.body.message, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -420,8 +420,11 @@ async function setup(modalEl: HTMLElement): Promise<void> {
|
|||
.querySelector(".goToQuoteSubmit")
|
||||
?.addEventListener("click", async (e) => {
|
||||
Loader.show();
|
||||
const getSubmissionEnabled = await Ape.quotes.isSubmissionEnabled();
|
||||
const isSubmissionEnabled =
|
||||
(await Ape.quotes.isSubmissionEnabled()).data?.isEnabled ?? false;
|
||||
(getSubmissionEnabled.status === 200 &&
|
||||
getSubmissionEnabled.body.data?.isEnabled) ??
|
||||
false;
|
||||
Loader.hide();
|
||||
if (!isSubmissionEnabled) {
|
||||
Notifications.add(
|
||||
|
|
|
@ -37,11 +37,13 @@ async function submitQuote(): Promise<void> {
|
|||
}
|
||||
|
||||
Loader.show();
|
||||
const response = await Ape.quotes.submit(text, source, language, captcha);
|
||||
const response = await Ape.quotes.add({
|
||||
body: { text, source, language, captcha },
|
||||
});
|
||||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to submit quote: " + response.message, -1);
|
||||
Notifications.add("Failed to submit quote: " + response.body.message, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -176,6 +176,9 @@ export function escapeRegExp(str: string): string {
|
|||
}
|
||||
|
||||
export function escapeHTML(str: string): string {
|
||||
if (str === null || str === undefined) {
|
||||
return str;
|
||||
}
|
||||
str = str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
|
|
|
@ -9,6 +9,7 @@ import { leaderboardsContract } from "./leaderboards";
|
|||
import { resultsContract } from "./results";
|
||||
import { configurationContract } from "./configuration";
|
||||
import { devContract } from "./dev";
|
||||
import { quotesContract } from "./quotes";
|
||||
|
||||
const c = initContract();
|
||||
|
||||
|
@ -23,4 +24,5 @@ export const contract = c.router({
|
|||
results: resultsContract,
|
||||
configuration: configurationContract,
|
||||
dev: devContract,
|
||||
quotes: quotesContract,
|
||||
});
|
||||
|
|
181
packages/contracts/src/quotes.ts
Normal file
181
packages/contracts/src/quotes.ts
Normal file
|
@ -0,0 +1,181 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CommonResponses,
|
||||
EndpointMetadata,
|
||||
MonkeyResponseSchema,
|
||||
responseWithData,
|
||||
responseWithNullableData,
|
||||
} from "./schemas/api";
|
||||
import {
|
||||
ApproveQuoteSchema,
|
||||
QuoteIdSchema,
|
||||
QuoteRatingSchema,
|
||||
QuoteReportReasonSchema,
|
||||
QuoteSchema,
|
||||
} from "./schemas/quotes";
|
||||
import { IdSchema, LanguageSchema, NullableStringSchema } from "./schemas/util";
|
||||
|
||||
export const GetQuotesResponseSchema = responseWithData(z.array(QuoteSchema));
|
||||
export type GetQuotesResponse = z.infer<typeof GetQuotesResponseSchema>;
|
||||
|
||||
export const IsSubmissionEnabledResponseSchema = responseWithData(
|
||||
z.object({
|
||||
isEnabled: z.boolean(),
|
||||
})
|
||||
);
|
||||
export type IsSubmissionEnabledResponse = z.infer<
|
||||
typeof IsSubmissionEnabledResponseSchema
|
||||
>;
|
||||
|
||||
export const AddQuoteRequestSchema = z.object({
|
||||
text: z.string().min(60),
|
||||
source: z.string(),
|
||||
language: LanguageSchema,
|
||||
captcha: z.string(), //we don't generate the captcha so there should be no validation
|
||||
});
|
||||
export type AddQuoteRequest = z.infer<typeof AddQuoteRequestSchema>;
|
||||
|
||||
export const ApproveQuoteRequestSchema = z.object({
|
||||
quoteId: IdSchema,
|
||||
editText: NullableStringSchema,
|
||||
editSource: NullableStringSchema,
|
||||
});
|
||||
export type ApproveQuoteRequest = z.infer<typeof ApproveQuoteRequestSchema>;
|
||||
|
||||
export const ApproveQuoteResponseSchema = responseWithData(ApproveQuoteSchema);
|
||||
export type ApproveQuoteResponse = z.infer<typeof ApproveQuoteResponseSchema>;
|
||||
|
||||
export const RejectQuoteRequestSchema = z.object({
|
||||
quoteId: IdSchema,
|
||||
});
|
||||
export type RejectQuoteRequest = z.infer<typeof RejectQuoteRequestSchema>;
|
||||
|
||||
export const GetQuoteRatingQuerySchema = z.object({
|
||||
quoteId: QuoteIdSchema,
|
||||
language: LanguageSchema,
|
||||
});
|
||||
export type GetQuoteRatingQuery = z.infer<typeof GetQuoteRatingQuerySchema>;
|
||||
|
||||
export const GetQuoteRatingResponseSchema =
|
||||
responseWithNullableData(QuoteRatingSchema);
|
||||
export type GetQuoteRatingResponse = z.infer<
|
||||
typeof GetQuoteRatingResponseSchema
|
||||
>;
|
||||
|
||||
export const AddQuoteRatingRequestSchema = z.object({
|
||||
quoteId: QuoteIdSchema,
|
||||
language: LanguageSchema,
|
||||
rating: z.number().int().min(1).max(5),
|
||||
});
|
||||
export type AddQuoteRatingRequest = z.infer<typeof AddQuoteRatingRequestSchema>;
|
||||
|
||||
export const ReportQuoteRequestSchema = z.object({
|
||||
quoteId: QuoteIdSchema,
|
||||
quoteLanguage: LanguageSchema,
|
||||
reason: QuoteReportReasonSchema,
|
||||
comment: z
|
||||
.string()
|
||||
.regex(/^([.]|[^/<>])+$/)
|
||||
.max(250)
|
||||
.optional()
|
||||
.or(z.string().length(0)),
|
||||
captcha: z.string(), //we don't generate the captcha so there should be no validation
|
||||
});
|
||||
export type ReportQuoteRequest = z.infer<typeof ReportQuoteRequestSchema>;
|
||||
|
||||
const c = initContract();
|
||||
export const quotesContract = c.router(
|
||||
{
|
||||
get: {
|
||||
summary: "get quote submissions",
|
||||
description: "Get list of quote submissions",
|
||||
method: "GET",
|
||||
path: "",
|
||||
responses: {
|
||||
200: GetQuotesResponseSchema,
|
||||
},
|
||||
},
|
||||
isSubmissionEnabled: {
|
||||
summary: "is submission enabled",
|
||||
description: "Check if submissions are enabled.",
|
||||
method: "GET",
|
||||
path: "/isSubmissionEnabled",
|
||||
responses: {
|
||||
200: IsSubmissionEnabledResponseSchema,
|
||||
},
|
||||
metadata: {
|
||||
authenticationOptions: { isPublic: true },
|
||||
} as EndpointMetadata,
|
||||
},
|
||||
add: {
|
||||
summary: "submit quote",
|
||||
description: "Add a quote submission",
|
||||
method: "POST",
|
||||
path: "",
|
||||
body: AddQuoteRequestSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
approveSubmission: {
|
||||
summary: "submit quote",
|
||||
description: "Add a quote submission",
|
||||
method: "POST",
|
||||
path: "/approve",
|
||||
body: ApproveQuoteRequestSchema.strict(),
|
||||
responses: {
|
||||
200: ApproveQuoteResponseSchema,
|
||||
},
|
||||
},
|
||||
rejectSubmission: {
|
||||
summary: "reject quote",
|
||||
description: "Reject a quote submission",
|
||||
method: "POST",
|
||||
path: "/reject",
|
||||
body: RejectQuoteRequestSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
getRating: {
|
||||
summary: "get rating",
|
||||
description: "Get quote rating",
|
||||
method: "GET",
|
||||
path: "/rating",
|
||||
query: GetQuoteRatingQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetQuoteRatingResponseSchema,
|
||||
},
|
||||
},
|
||||
addRating: {
|
||||
summary: "add rating",
|
||||
description: "Add a quote rating",
|
||||
method: "POST",
|
||||
path: "/rating",
|
||||
body: AddQuoteRatingRequestSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
report: {
|
||||
summary: "report quote",
|
||||
description: "Report a quote",
|
||||
method: "POST",
|
||||
path: "/report",
|
||||
body: ReportQuoteRequestSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/quotes",
|
||||
strictStatusCodes: true,
|
||||
metadata: {
|
||||
openApiTags: "quotes",
|
||||
} as EndpointMetadata,
|
||||
commonResponses: CommonResponses,
|
||||
}
|
||||
);
|
|
@ -10,7 +10,8 @@ export type OpenApiTag =
|
|||
| "leaderboards"
|
||||
| "results"
|
||||
| "configuration"
|
||||
| "dev";
|
||||
| "dev"
|
||||
| "quotes";
|
||||
|
||||
export type EndpointMetadata = {
|
||||
/** Authentication options, by default a bearer token is required. */
|
||||
|
|
48
packages/contracts/src/schemas/quotes.ts
Normal file
48
packages/contracts/src/schemas/quotes.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { z } from "zod";
|
||||
import { IdSchema, LanguageSchema } from "./util";
|
||||
|
||||
export const QuoteIdSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.or(z.string().regex(/^\d+$/).transform(Number));
|
||||
export type QuoteId = z.infer<typeof QuoteIdSchema>;
|
||||
|
||||
export const ApproveQuoteSchema = z.object({
|
||||
id: QuoteIdSchema.optional(),
|
||||
text: z.string(),
|
||||
source: z.string(),
|
||||
length: z.number().int().positive(),
|
||||
approvedBy: z.string().describe("The approvers name"),
|
||||
});
|
||||
export type ApproveQuote = z.infer<typeof ApproveQuoteSchema>;
|
||||
|
||||
export const QuoteSchema = z.object({
|
||||
_id: IdSchema,
|
||||
text: z.string(),
|
||||
source: z.string(),
|
||||
language: LanguageSchema,
|
||||
submittedBy: IdSchema.describe("uid of the submitter"),
|
||||
timestamp: z.number().int().nonnegative(),
|
||||
approved: z.boolean(),
|
||||
});
|
||||
export type Quote = z.infer<typeof QuoteSchema>;
|
||||
|
||||
export const QuoteRatingSchema = z.object({
|
||||
_id: IdSchema,
|
||||
language: LanguageSchema,
|
||||
quoteId: QuoteIdSchema,
|
||||
average: z.number().nonnegative(),
|
||||
ratings: z.number().int().nonnegative(),
|
||||
totalRating: z.number().nonnegative(),
|
||||
});
|
||||
export type QuoteRating = z.infer<typeof QuoteRatingSchema>;
|
||||
|
||||
export const QuoteReportReasonSchema = z.enum([
|
||||
"Grammatical error",
|
||||
"Duplicate quote",
|
||||
"Inappropriate content",
|
||||
"Low quality content",
|
||||
"Incorrect source",
|
||||
]);
|
||||
export type QuoteReportReason = z.infer<typeof QuoteReportReasonSchema>;
|
|
@ -23,6 +23,13 @@ export const LanguageSchema = z
|
|||
.regex(/^[a-zA-Z0-9_+]+$/, "Can only contain letters [a-zA-Z0-9_+]");
|
||||
export type Language = z.infer<typeof LanguageSchema>;
|
||||
|
||||
export const NullableStringSchema = z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? undefined);
|
||||
export type NullableString = z.infer<typeof NullableStringSchema>;
|
||||
|
||||
export const PercentageSchema = z.number().nonnegative().max(100);
|
||||
export type Percentage = z.infer<typeof PercentageSchema>;
|
||||
|
||||
|
|
|
@ -48,6 +48,7 @@ export type PremiumInfo = {
|
|||
expirationTimestamp: number;
|
||||
};
|
||||
|
||||
// Record<Language, Record<QuoteIdString, Rating>>
|
||||
export type UserQuoteRatings = Record<string, Record<string, number>>;
|
||||
|
||||
export type UserLbMemory = Record<
|
||||
|
|
Loading…
Reference in a new issue