impr: use tsrest for quotes endpoints (@fehmer) (#5802)

!nuf
This commit is contained in:
Christian Fehmer 2024-08-29 15:42:42 +02:00 committed by GitHub
parent 1ba4be38d0
commit 1804ebcd8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1383 additions and 691 deletions

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

View file

@ -620,6 +620,9 @@ describe("Misc Utils", () => {
number: 1,
});
});
it("ignores null values", () => {
expect(misc.replaceObjectId(null)).toBeNull();
});
});
describe("replaceObjectIds", () => {

View file

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

View file

@ -19,7 +19,7 @@ export async function getPresets(
...preset,
uid: undefined,
}))
.map(replaceObjectId);
.map((it) => replaceObjectId(it));
return new MonkeyResponse2("Presets retrieved", data);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {

View file

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

View file

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

View file

@ -1,7 +1,5 @@
import Quotes from "./quotes";
import Users from "./users";
export default {
Quotes,
Users,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")

View file

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

View 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,
}
);

View file

@ -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. */

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

View file

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

View file

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