From 974e50ec48bc9e2df5e5fd0c705fb747377cf3ec Mon Sep 17 00:00:00 2001 From: Bruce Berrios <58147810+Bruception@users.noreply.github.com> Date: Sun, 30 Jan 2022 18:53:56 -0500 Subject: [PATCH] Add quote reporting feature (#2372) by Bruception * Add initial quote reporting logic * Remove report status * Add initial frontend * Add submit logic * Add report quote button to rating popup * Refactor quoteId argument * Remove console log * Add captcha to request validation schema * Use captcha id for value and reset * Update report data schema * Hide report popup on complete * quote search styling update * updated report quote styling * tofixed * tofixed * moved report button to the result screen styling changes * resetting captcha after hiding to avoid ugly animation * select2 styling update * lowercase Co-authored-by: Miodec --- backend/api/controllers/quotes.js | 40 ++++++++ backend/api/routes/quotes.js | 11 ++- backend/dao/report.js | 29 ++++++ backend/middlewares/apiUtils.js | 29 +++++- gulpfile.js | 1 + src/js/input-controller.js | 1 + src/js/popups/quote-report-popup.js | 138 ++++++++++++++++++++++++++++ src/js/popups/quote-search-popup.js | 50 ++++++++-- src/js/popups/rate-quote-popup.js | 23 ++++- src/js/test/result.js | 2 +- src/js/test/test-logic.js | 3 + src/js/test/test-ui.js | 8 ++ src/sass/inputs.scss | 12 +-- src/sass/popups.scss | 88 ++++++++++++++---- src/sass/test.scss | 11 ++- static/index.html | 37 +++++++- 16 files changed, 435 insertions(+), 48 deletions(-) create mode 100644 backend/api/controllers/quotes.js create mode 100644 backend/dao/report.js create mode 100644 src/js/popups/quote-report-popup.js diff --git a/backend/api/controllers/quotes.js b/backend/api/controllers/quotes.js new file mode 100644 index 000000000..7e6000831 --- /dev/null +++ b/backend/api/controllers/quotes.js @@ -0,0 +1,40 @@ +const { v4: uuidv4 } = require("uuid"); +const ReportDAO = require("../../dao/report"); +const UserDAO = require("../../dao/user"); +const MonkeyError = require("../../handlers/error"); +const Captcha = require("../../handlers/captcha"); + +class QuotesController { + static async reportQuote(req, res) { + const { uid } = req.decodedToken; + + const user = await UserDAO.getUser(uid); + if (user.cannotReport) { + throw new MonkeyError(403, "You don't have permission to do this."); + } + + const { quoteId, quoteLanguage, reason, comment, captcha } = req.body; + + if (!(await Captcha.verify(captcha))) { + throw new MonkeyError(400, "Captcha check failed."); + } + + const newReport = { + id: uuidv4(), + type: "quote", + timestamp: new Date().getTime(), + uid, + details: { + contentId: `${quoteLanguage}-${quoteId}`, + reason, + comment, + }, + }; + + await ReportDAO.createReport(newReport); + + res.sendStatus(200); + } +} + +module.exports = QuotesController; diff --git a/backend/api/routes/quotes.js b/backend/api/routes/quotes.js index d5e18b984..38fc18d05 100644 --- a/backend/api/routes/quotes.js +++ b/backend/api/routes/quotes.js @@ -3,8 +3,12 @@ const { authenticateRequest } = require("../../middlewares/auth"); const { Router } = require("express"); const NewQuotesController = require("../controllers/new-quotes"); const QuoteRatingsController = require("../controllers/quote-ratings"); +const QuotesController = require("../controllers/quotes"); const RateLimit = require("../../middlewares/rate-limit"); -const { requestValidation } = require("../../middlewares/apiUtils"); +const { + asyncHandlerWrapper, + requestValidation, +} = require("../../middlewares/apiUtils"); const SUPPORTED_QUOTE_LANGUAGES = require("../../constants/quoteLanguages"); const quotesRouter = Router(); @@ -71,11 +75,10 @@ quotesRouter.post( ) .required(), comment: joi.string().allow("").max(250).required(), + captcha: joi.string().required(), }, }), - (req, res) => { - res.sendStatus(200); - } + asyncHandlerWrapper(QuotesController.reportQuote) ); module.exports = quotesRouter; diff --git a/backend/dao/report.js b/backend/dao/report.js new file mode 100644 index 000000000..12a5f026b --- /dev/null +++ b/backend/dao/report.js @@ -0,0 +1,29 @@ +const MonkeyError = require("../handlers/error"); +const { mongoDB } = require("../init/mongodb"); + +const MAX_REPORTS = 100; + +class ReportDAO { + static async createReport(report) { + const reports = await mongoDB().collection("reports").find().toArray(); + + if (reports.length >= MAX_REPORTS) { + throw new MonkeyError( + 503, + "Reports are not being accepted at this time. Please try again later." + ); + } + + const reportAlreadyExists = reports.find((existingReport) => { + return existingReport.details.contentId === report.details.contentId; + }); + + if (reportAlreadyExists) { + throw new MonkeyError(409, "A report for this content already exists."); + } + + await mongoDB().collection("reports").insertOne(report); + } +} + +module.exports = ReportDAO; diff --git a/backend/middlewares/apiUtils.js b/backend/middlewares/apiUtils.js index f8aaaa91d..c4f0308e1 100644 --- a/backend/middlewares/apiUtils.js +++ b/backend/middlewares/apiUtils.js @@ -1,11 +1,33 @@ const joi = require("joi"); const MonkeyError = require("../handlers/error"); +/** + * This utility serves as an alternative to wrapping express handlers with try/catch statements. + * Any routes that use an async handler function should wrap the handler with this function. + * Without this, any errors thrown will not be caught by the error handling middleware, and + * the app will hang! + */ +function asyncHandlerWrapper(handler) { + return async (req, res, next) => { + try { + const handlerData = await handler(req, res); + if (!res.headersSent && handlerData) { + res.json(handlerData); + } + next(); + } catch (error) { + next(error); + } + }; +} + function requestValidation(validationSchema) { return (req, res, next) => { - // In dev environments, as an alternative to token authentication, - // you can pass the authentication middleware by having a user id in the body. - // Inject the user id into the schema so that validation will not fail. + /** + * In dev environments, as an alternative to token authentication, + * you can pass the authentication middleware by having a user id in the body. + * Inject the user id into the schema so that validation will not fail. + */ if (process.env.MODE === "dev") { validationSchema.body = { uid: joi.any(), @@ -28,5 +50,6 @@ function requestValidation(validationSchema) { } module.exports = { + asyncHandlerWrapper, requestValidation, }; diff --git a/gulpfile.js b/gulpfile.js index c1ce6e199..5b67b1fa9 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -142,6 +142,7 @@ const refactoredSrc = [ "./src/js/popups/quote-search-popup.js", "./src/js/popups/quote-submit-popup.js", "./src/js/popups/quote-approve-popup.js", + "./src/js/popups/quote-report-popup.js", "./src/js/popups/rate-quote-popup.js", "./src/js/popups/version-popup.js", "./src/js/popups/support-popup.js", diff --git a/src/js/input-controller.js b/src/js/input-controller.js index 702feb239..85181c477 100644 --- a/src/js/input-controller.js +++ b/src/js/input-controller.js @@ -666,6 +666,7 @@ $(document).keydown((event) => { !$("#quoteSearchPopupWrapper").hasClass("hidden") || !$("#quoteSubmitPopupWrapper").hasClass("hidden") || !$("#quoteApprovePopupWrapper").hasClass("hidden") || + !$("#quoteReportPopupWrapper").hasClass("hidden") || !$("#wordFilterPopupWrapper").hasClass("hidden"); const allowTyping = diff --git a/src/js/popups/quote-report-popup.js b/src/js/popups/quote-report-popup.js new file mode 100644 index 000000000..d17902ede --- /dev/null +++ b/src/js/popups/quote-report-popup.js @@ -0,0 +1,138 @@ +import * as Misc from "./misc"; +import * as Notifications from "./notifications"; +import axiosInstance from "./axios-instance"; +import Config from "./config"; +import * as Loader from "./loader"; + +const CAPTCHA_ID = 1; + +const state = { + previousPopupShowCallback: undefined, + quoteToReport: undefined, +}; + +const defaultOptions = { + quoteId: -1, + previousPopupShowCallback: () => {}, + noAnim: false, +}; + +export async function show(options = defaultOptions) { + if ($("#quoteReportPopupWrapper").hasClass("hidden")) { + const { quoteId, previousPopupShowCallback, noAnim } = options; + + state.previousPopupShowCallback = previousPopupShowCallback; + + const { quotes } = await Misc.getQuotes(Config.language); + state.quoteToReport = quotes.find((quote) => { + return quote.id === quoteId; + }); + + $("#quoteReportPopup .quote").text(state.quoteToReport.text); + $("#quoteReportPopup .reason").val("Grammatical error"); + $("#quoteReportPopup .comment").val(""); + $("#quoteReportPopup .characterCount").text("-"); + $("#quoteReportPopup .reason").select2({ + minimumResultsForSearch: Infinity, + }); + $("#quoteReportPopupWrapper") + .stop(true, true) + .css("opacity", 0) + .removeClass("hidden") + .animate({ opacity: 1 }, noAnim ? 0 : 100, (e) => { + $("#quoteReportPopup textarea").focus().select(); + }); + } +} + +export async function hide() { + if (!$("#quoteReportPopupWrapper").hasClass("hidden")) { + $("#quoteReportPopupWrapper") + .stop(true, true) + .css("opacity", 1) + .animate( + { + opacity: 0, + }, + 100, + (e) => { + grecaptcha.reset(CAPTCHA_ID); + $("#quoteReportPopupWrapper").addClass("hidden"); + if (state.previousPopupShowCallback) { + state.previousPopupShowCallback(); + } + } + ); + } +} + +async function submitReport() { + const captchaResponse = grecaptcha.getResponse(CAPTCHA_ID); + if (!captchaResponse) { + Notifications.add("Please complete the captcha."); + return; + } + + const requestBody = { + quoteId: state.quoteToReport.id.toString(), + quoteLanguage: Config.language, + reason: $("#quoteReportPopup .reason").val(), + comment: $("#quoteReportPopup .comment").val(), + captcha: captchaResponse, + }; + + if (!requestBody.reason) { + Notifications.add("Please select a valid report reason."); + return; + } + + const characterDifference = requestBody.comment.length - 250; + if (characterDifference > 0) { + Notifications.add( + `Report comment is ${characterDifference} character(s) too long.` + ); + return; + } + + Loader.show(); + + let response; + try { + response = await axiosInstance.post("/quotes/report", requestBody); + } catch (e) { + Loader.hide(); + let msg = e?.response?.data?.message ?? e.message; + Notifications.add("Failed to report quote: " + msg, -1); + return; + } + + Loader.hide(); + if (response.status !== 200) { + Notifications.add(response.data.message); + } else { + Notifications.add("Report submitted. Thank you!", 1); + hide(); + } +} + +$("#quoteReportPopupWrapper").on("mousedown", (e) => { + if ($(e.target).attr("id") === "quoteReportPopupWrapper") { + hide(); + } +}); + +$("#quoteReportPopup .comment").on("input", (e) => { + setTimeout(() => { + const len = $("#quoteReportPopup .comment").val().length; + $("#quoteReportPopup .characterCount").text(len); + if (len > 250) { + $("#quoteReportPopup .characterCount").addClass("red"); + } else { + $("#quoteReportPopup .characterCount").removeClass("red"); + } + }, 1); +}); + +$("#quoteReportPopup .submit").on("click", async (e) => { + await submitReport(); +}); diff --git a/src/js/popups/quote-search-popup.js b/src/js/popups/quote-search-popup.js index 28138853a..328a48e1d 100644 --- a/src/js/popups/quote-search-popup.js +++ b/src/js/popups/quote-search-popup.js @@ -5,6 +5,7 @@ import * as ManualRestart from "./manual-restart-tracker"; import * as TestLogic from "./test-logic"; import * as QuoteSubmitPopup from "./quote-submit-popup"; import * as QuoteApprovePopup from "./quote-approve-popup"; +import * as QuoteReportPopup from "./quote-report-popup"; import * as DB from "./db"; import * as TestUI from "./test-ui"; @@ -40,6 +41,8 @@ async function updateResults(searchText) { let resultsList = $("#quoteSearchResults"); let resultListLength = 0; + const isNotAuthed = !firebase.auth().currentUser; + found.forEach(async (quote) => { let lengthDesc; if (quote.length < 101) { @@ -55,10 +58,16 @@ async function updateResults(searchText) { resultsList.append(`
${quote.text}
-
id
${quote.id}
+
id
${ + quote.id + }
length
${lengthDesc}
source
${quote.source}
-
+
+ +
`); } @@ -73,9 +82,13 @@ async function updateResults(searchText) { } } -export async function show() { +export async function show(clearText = true) { if ($("#quoteSearchPopupWrapper").hasClass("hidden")) { - $("#quoteSearchPopup input").val(""); + if (clearText) { + $("#quoteSearchPopup input").val(""); + } + + const quoteSearchInputValue = $("#quoteSearchPopup input").val(); if (!firebase.auth().currentUser) { $("#quoteSearchPopup #gotoSubmitQuoteButton").addClass("hidden"); @@ -94,13 +107,15 @@ export async function show() { .css("opacity", 0) .removeClass("hidden") .animate({ opacity: 1 }, 100, (e) => { - $("#quoteSearchPopup input").focus().select(); - updateResults(""); + if (clearText) { + $("#quoteSearchPopup input").focus().select(); + } + updateResults(quoteSearchInputValue); }); } } -export function hide(noAnim = false) { +export function hide(noAnim = false, focusWords = true) { if (!$("#quoteSearchPopupWrapper").hasClass("hidden")) { $("#quoteSearchPopupWrapper") .stop(true, true) @@ -112,7 +127,9 @@ export function hide(noAnim = false) { noAnim ? 0 : 100, (e) => { $("#quoteSearchPopupWrapper").addClass("hidden"); - TestUI.focusWords(); + if (focusWords) { + TestUI.focusWords(); + } } ); } @@ -155,6 +172,9 @@ $(document).on( "click", "#quoteSearchPopup #quoteSearchResults .searchResult", (e) => { + if (e.target.classList.contains("report")) { + return; + } selectedId = parseInt($(e.currentTarget).attr("id")); apply(selectedId); } @@ -170,6 +190,20 @@ $(document).on("click", "#quoteSearchPopup #goToApproveQuotes", (e) => { QuoteApprovePopup.show(true); }); +$(document).on("click", "#quoteSearchPopup .report", async (e) => { + const quoteId = e.target.closest(".searchResult").id; + const quoteIdSelectedForReport = parseInt(quoteId); + + hide(true, false); + QuoteReportPopup.show({ + quoteId: quoteIdSelectedForReport, + noAnim: true, + previousPopupShowCallback: () => { + show(false); + }, + }); +}); + // $("#quoteSearchPopup input").keypress((e) => { // if (e.keyCode == 13) { // if (!isNaN(document.getElementById("searchBox").value)) { diff --git a/src/js/popups/rate-quote-popup.js b/src/js/popups/rate-quote-popup.js index 5a9310316..9db3b7532 100644 --- a/src/js/popups/rate-quote-popup.js +++ b/src/js/popups/rate-quote-popup.js @@ -1,6 +1,7 @@ import * as DB from "./db"; import * as Loader from "./loader"; import * as Notifications from "./notifications"; +import * as QuoteReportPopup from "./quote-report-popup"; import axiosInstance from "./axios-instance"; let rating = 0; @@ -80,9 +81,11 @@ function updateData() { updateRatingStats(); } -export function show(quote) { +export function show(quote, shouldReset = true) { if ($("#rateQuotePopupWrapper").hasClass("hidden")) { - reset(); + if (shouldReset) { + reset(); + } currentQuote = quote; rating = 0; @@ -92,6 +95,7 @@ export function show(quote) { if (alreadyRated) { rating = alreadyRated; } + refreshStars(); updateData(); $("#rateQuotePopupWrapper") @@ -181,7 +185,9 @@ async function submit() { quoteStats.average = ( Math.round((quoteStats.totalRating / quoteStats.ratings) * 10) / 10 ).toFixed(1); - $(".pageTest #result #rateQuoteButton .rating").text(quoteStats.average); + $(".pageTest #result #rateQuoteButton .rating").text( + quoteStats.average.toFixed(1) + ); $(".pageTest #result #rateQuoteButton .icon").removeClass("far"); $(".pageTest #result #rateQuoteButton .icon").addClass("fas"); } @@ -211,3 +217,14 @@ $("#rateQuotePopup .stars .star").mouseout((e) => { $("#rateQuotePopup .submitButton").click((e) => { submit(); }); + +$("#rateQuotePopup #reportQuoteButton").click((e) => { + hide(); + QuoteReportPopup.show({ + quoteId: parseInt(currentQuote.id), + noAnim: true, + previousPopupShowCallback: () => { + show(currentQuote, false); + }, + }); +}); diff --git a/src/js/test/result.js b/src/js/test/result.js index c16f0ba02..c922fcdf1 100644 --- a/src/js/test/result.js +++ b/src/js/test/result.js @@ -558,7 +558,7 @@ export function updateRateQuote(randomQuote) { } RateQuotePopup.getQuoteStats(randomQuote).then((quoteStats) => { $(".pageTest #result #rateQuoteButton .rating").text( - quoteStats.average ?? "" + quoteStats.average.toFixed(1) ?? "" ); $(".pageTest #result #rateQuoteButton") .css({ opacity: 0 }) diff --git a/src/js/test/test-logic.js b/src/js/test/test-logic.js index f23dfc164..f6be311a6 100644 --- a/src/js/test/test-logic.js +++ b/src/js/test/test-logic.js @@ -1596,6 +1596,7 @@ export async function finish(difficultyFailed = false) { if (firebase.auth().currentUser == null) { $(".pageTest #result #rateQuoteButton").addClass("hidden"); + $(".pageTest #result #reportQuoteButton").addClass("hidden"); try { firebase.analytics().logEvent("testCompletedNoLogin", completedEvent); } catch (e) { @@ -1603,6 +1604,8 @@ export async function finish(difficultyFailed = false) { } notSignedInLastResult = completedEvent; dontSave = true; + } else { + $(".pageTest #result #reportQuoteButton").removeClass("hidden"); } Result.update( diff --git a/src/js/test/test-ui.js b/src/js/test/test-ui.js index 518cc2d7c..2265a5278 100644 --- a/src/js/test/test-ui.js +++ b/src/js/test/test-ui.js @@ -21,6 +21,7 @@ import * as ChallengeController from "./challenge-controller"; import * as RateQuotePopup from "./rate-quote-popup"; import * as UI from "./ui"; import * as TestTimer from "./test-timer"; +import * as ReportQuotePopup from "./quote-report-popup"; export let currentWordElementIndex = 0; export let resultVisible = false; @@ -997,6 +998,13 @@ $(".pageTest #rateQuoteButton").click(async (event) => { RateQuotePopup.show(TestLogic.randomQuote); }); +$(".pageTest #reportQuoteButton").click(async (event) => { + ReportQuotePopup.show({ + quoteId: parseInt(TestLogic.randomQuote.id), + noAnim: false, + }); +}); + $(".pageTest #toggleBurstHeatmap").click(async (event) => { UpdateConfig.setBurstHeatmap(!Config.burstHeatmap); }); diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index 6f5d230c1..87a70faf8 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -89,13 +89,13 @@ input[type="number"] { .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { - background-color: var(--sub-color); - color: var(--text-color); + background-color: var(--text-color); + color: var(--bg-color); } .select2-container--default .select2-results__option--selected { - background-color: var(--main-color); - color: var(--bg-color); + background-color: var(--bg-color); + color: var(--sub-color); } .select2-container--open .select2-dropdown--below { @@ -144,14 +144,14 @@ input[type="number"] { .select2-selection--single .select2-selection__arrow b { - border-color: var(--main-color) transparent transparent transparent; + border-color: var(--sub-color) transparent transparent transparent; } .select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: var(--main-color) transparent; + border-color: var(--sub-color) transparent; } .select2-container--default .select2-search--dropdown .select2-search__field { diff --git a/src/sass/popups.scss b/src/sass/popups.scss index 0e8f0d923..ec5acefbb 100644 --- a/src/sass/popups.scss +++ b/src/sass/popups.scss @@ -400,6 +400,9 @@ .searchResult { display: grid; grid-template-columns: 1fr 1fr 3fr 0fr; + grid-template-areas: + "text text text text" + "id len source report"; grid-auto-rows: auto; width: 100%; gap: 0.5rem; @@ -411,48 +414,41 @@ height: min-content; .text { - grid-column-start: 1; - grid-column-end: 4; - grid-row-start: 1; - grid-row-end: 2; + grid-area: text; overflow: visible; color: var(--text-color); } .id { - grid-column-start: 1; - grid-column-end: 2; - grid-row-start: 2; - grid-row-end: 3; + grid-area: id; font-size: 0.8rem; color: var(--sub-color); } .length { - grid-column-start: 2; - grid-column-end: 3; - grid-row-start: 2; - grid-row-end: 3; + grid-area: len; font-size: 0.8rem; color: var(--sub-color); } .source { - grid-column-start: 3; - grid-column-end: 4; - grid-row-start: 2; - grid-row-end: 3; + grid-area: source; font-size: 0.8rem; color: var(--sub-color); } .resultChevron { - grid-column-start: 4; - grid-column-end: 5; - grid-row-start: 1; - grid-row-end: 3; + grid-area: chevron; display: flex; align-items: center; justify-items: center; color: var(--sub-color); font-size: 2rem; } + .report { + grid-area: report; + color: var(--sub-color); + transition: 0.25s; + &:hover { + color: var(--text-color); + } + } .sub { opacity: 0.5; } @@ -600,6 +596,58 @@ } } +#quoteReportPopupWrapper { + #quoteReportPopup { + background: var(--bg-color); + border-radius: var(--roundness); + padding: 2rem; + display: grid; + gap: 1rem; + width: 1000px; + grid-template-rows: auto auto auto auto auto auto auto auto auto; + height: auto; + max-height: 40rem; + overflow-y: scroll; + + label { + color: var(--sub-color); + margin-bottom: -1rem; + } + + .text { + color: var(--sub-color); + } + + .quote { + font-size: 1.5rem; + } + + .title { + font-size: 1.5rem; + color: var(--sub-color); + } + + textarea { + resize: vertical; + width: 100%; + padding: 10px; + line-height: 1.2rem; + min-height: 5rem; + } + + .characterCount { + position: absolute; + top: -1.25rem; + right: 0.25rem; + color: var(--sub-color); + user-select: none; + &.red { + color: var(--error-color); + } + } + } +} + #resultEditTagsPanelWrapper { #resultEditTagsPanel { background: var(--bg-color); diff --git a/src/sass/test.scss b/src/sass/test.scss index 1019d4a41..458bb4cbd 100644 --- a/src/sass/test.scss +++ b/src/sass/test.scss @@ -677,8 +677,15 @@ } } - &.source #rateQuoteButton { - padding: 0 0.5rem; + &.source { + #rateQuoteButton, + #reportQuoteButton { + padding: 0 0.25rem; + } + #rateQuoteButton { + display: inline-grid; + gap: 0.25rem; + } } } diff --git a/static/index.html b/static/index.html index 6e718c0ab..77d4e56bc 100644 --- a/static/index.html +++ b/static/index.html @@ -551,6 +551,33 @@
Submit
+