diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index f08957408..e56c76044 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -1,11 +1,11 @@ import request from "supertest"; import app from "../../../src/app"; +import _ from "lodash"; import * as Configuration from "../../../src/init/configuration"; import * as ResultDal from "../../../src/dal/result"; import * as UserDal from "../../../src/dal/user"; import * as AuthUtils from "../../../src/utils/auth"; import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier"; -import { messaging } from "firebase-admin"; const uid = "123456"; const mockDecodedToken: DecodedIdToken = { @@ -24,8 +24,9 @@ const configuration = Configuration.getCachedConfiguration(); describe("result controller test", () => { describe("getResults", () => { - beforeEach(() => { + beforeEach(async () => { resultMock.mockResolvedValue([]); + await enablePremiumFeatures(true); }); afterEach(() => { resultMock.mockReset(); @@ -74,7 +75,7 @@ describe("result controller test", () => { //WHEN await mockApp .get("/results") - .query({ offset: 500, limit: 250 }) + .query({ limit: 250, offset: 500 }) .set("Authorization", "Bearer 123456789") .send() .expect(200); @@ -93,7 +94,7 @@ describe("result controller test", () => { //WHEN await mockApp .get("/results") - .query({ limit: 600, offset: 800 }) + .query({ limit: 100, offset: 1000 }) .set("Authorization", "Bearer 123456789") .send() .expect(422) @@ -116,7 +117,7 @@ describe("result controller test", () => { //WHEN await mockApp .get("/results") - .query({ offset: 600, limit: 800 }) + .query({ limit: 800, offset: 600 }) .set("Authorization", "Bearer 123456789") .send() .expect(200); @@ -129,6 +130,26 @@ describe("result controller test", () => { onOrAfterTimestamp: NaN, }); }); + it("should get results if offset/limit is partly outside the max limit", async () => { + //GIVEN + jest.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); + + //WHEN + await mockApp + .get("/results") + .query({ limit: 20, offset: 990 }) + .set("Authorization", "Bearer 123456789") + .send() + .expect(200); + + //THEN + + expect(resultMock).toHaveBeenCalledWith(mockDecodedToken.uid, { + limit: 10, //limit is reduced to stay within max limit + offset: 990, + onOrAfterTimestamp: NaN, + }); + }); it("should fail exceeding 1k limit", async () => { //GIVEN jest.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); @@ -155,7 +176,7 @@ describe("result controller test", () => { //WHEN await mockApp .get("/results") - .query({ limit: 1000, offset: 24900 }) + .query({ limit: 1000, offset: 25000 }) .set("Authorization", "Bearer 123456789") .send() .expect(422) @@ -171,9 +192,74 @@ describe("result controller test", () => { //THEN }); + it("should get results within regular limits for premium users even if premium is globally disabled", async () => { + //GIVEN + jest.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); + enablePremiumFeatures(false); + + //WHEN + await mockApp + .get("/results") + .query({ limit: 100, offset: 900 }) + .set("Authorization", "Bearer 123456789") + .send() + .expect(200); + + //THEN + expect(resultMock).toHaveBeenCalledWith(mockDecodedToken.uid, { + limit: 100, + offset: 900, + onOrAfterTimestamp: NaN, + }); + }); + it("should fail exceeding max limit for premium user if premium is globally disabled", async () => { + //GIVEN + jest.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); + enablePremiumFeatures(false); + + //WHEN + await mockApp + .get("/results") + .query({ limit: 200, offset: 900 }) + .set("Authorization", "Bearer 123456789") + .send() + .expect(503) + .expect(expectErrorMessage("Premium feature disabled.")); + + //THEN + }); + it("should get results with regular limit as default for premium users if premium is globally disabled", async () => { + //GIVEN + jest.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); + enablePremiumFeatures(false); + + //WHEN + await mockApp + .get("/results") + .set("Authorization", "Bearer 123456789") + .send() + .expect(200); + + //THEN + expect(resultMock).toHaveBeenCalledWith(mockDecodedToken.uid, { + limit: 1000, //the default limit for regular users + offset: 0, + onOrAfterTimestamp: NaN, + }); + }); }); }); function expectErrorMessage(message: string): (res: request.Response) => void { return (res) => expect(res.body).toHaveProperty("message", message); } + +async function enablePremiumFeatures(premium: boolean): Promise { + const mockConfig = _.merge(await configuration, { + users: { premium: { enabled: premium } }, + }); + + jest + .spyOn(Configuration, "getCachedConfiguration") + .mockResolvedValue(mockConfig); +} diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index e46775dd9..a2a47e1b0 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -63,24 +63,40 @@ export async function getResults( req: MonkeyTypes.Request ): Promise { const { uid } = req.ctx.decodedToken; - const isPremium = await UserDAL.checkIfUserIsPremium(uid); + const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled; + const userHasPremium = await UserDAL.checkIfUserIsPremium(uid); - const maxLimit = isPremium - ? req.ctx.configuration.results.limits.premiumUser - : req.ctx.configuration.results.limits.regularUser; + const maxLimit = + premiumFeaturesEnabled && userHasPremium + ? req.ctx.configuration.results.limits.premiumUser + : req.ctx.configuration.results.limits.regularUser; const onOrAfterTimestamp = parseInt( req.query.onOrAfterTimestamp as string, 10 ); - const limit = stringToNumberOrDefault( + let limit = stringToNumberOrDefault( req.query.limit as string, - Math.min(1000, maxLimit) + Math.min(req.ctx.configuration.results.maxBatchSize, maxLimit) ); const offset = stringToNumberOrDefault(req.query.offset as string, 0); + //check if premium features are disabled and current call exceeds the limit for regular users + if ( + userHasPremium && + premiumFeaturesEnabled === false && + limit + offset > req.ctx.configuration.results.limits.regularUser + ) { + throw new MonkeyError(503, "Premium feature disabled."); + } + if (limit + offset > maxLimit) { - throw new MonkeyError(422, `Max results limit of ${maxLimit} exceeded.`); + if (offset < maxLimit) { + //batch is partly in the allowed ranged. Set the limit to the max allowed and return partly results. + limit = maxLimit - offset; + } else { + throw new MonkeyError(422, `Max results limit of ${maxLimit} exceeded.`); + } } const results = await ResultDAL.getResults(uid, { @@ -88,6 +104,16 @@ export async function getResults( limit, offset, }); + Logger.logToDb( + "user_results_requested", + { + limit, + offset, + onOrAfterTimestamp, + isPremium: userHasPremium, + }, + uid + ); return new MonkeyResponse("Results retrieved", results); } diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 4006d8f2e..c072fb631 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -383,9 +383,12 @@ export async function getUser( UserDAL.flagForNameChange(uid); } + const isPremium = await UserDAL.checkIfUserIsPremium(uid); + const userData = { ...getRelevantUserInfo(userInfo), inboxUnreadSize: inboxUnreadSize, + isPremium, }; return new MonkeyResponse("User data retrieved", userData); diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index 210b556b3..e21c7e71d 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -16,6 +16,7 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = { regularUser: 1000, premiumUser: 10000, }, + maxBatchSize: 1000, }, quotes: { reporting: { @@ -69,6 +70,9 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = { enabled: false, maxMail: 0, }, + premium: { + enabled: false, + }, }, rateLimiting: { badAuthentication: { @@ -189,6 +193,11 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema }, }, }, + maxBatchSize: { + type: "number", + label: "results endpoint max batch size", + min: 1, + }, }, }, quotes: { @@ -266,6 +275,16 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema type: "object", label: "Users", fields: { + premium: { + type: "object", + label: "Premium", + fields: { + enabled: { + type: "boolean", + label: "Enabled", + }, + }, + }, signUp: { type: "boolean", label: "Sign Up Enabled", diff --git a/backend/src/types/shared.ts b/backend/src/types/shared.ts index d3152f654..f48c5be12 100644 --- a/backend/src/types/shared.ts +++ b/backend/src/types/shared.ts @@ -33,6 +33,7 @@ export interface Configuration { regularUser: number; premiumUser: number; }; + maxBatchSize: number; }; users: { signUp: boolean; @@ -67,6 +68,9 @@ export interface Configuration { enabled: boolean; maxMail: number; }; + premium: { + enabled: boolean; + }; }; admin: { endpointsEnabled: boolean; diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 31bfeffe2..02f038832 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -1,3 +1,5 @@ +type Configuration = import("../types/shared").Configuration; + type ObjectId = import("mongodb").ObjectId; type ExpressRequest = import("express").Request; diff --git a/frontend/src/styles/account.scss b/frontend/src/styles/account.scss index 055e88627..5b9da93e9 100644 --- a/frontend/src/styles/account.scss +++ b/frontend/src/styles/account.scss @@ -128,6 +128,74 @@ } } + &.resultBatches { + display: grid; + grid-template-areas: "bar button" "text text"; + grid-template-columns: 2fr 1fr; + column-gap: 1rem; + .title { + grid-area: title; + margin-bottom: 0; + } + & > .text { + grid-area: text; + text-align: center; + } + button { + grid-area: button; + } + .leftText, + button, + .rightText { + align-self: center; + } + .bars { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.25rem 1rem; + } + .rightText { + color: var(--sub-color); + font-size: 0.8em; + line-height: 1.25em; + } + .bar { + height: 0.5rem; + border-radius: var(--roundness); + background: var(--sub-alt-color); + position: relative; + align-self: center; + .fill { + transition: width 0.125s; + height: 100%; + width: 0%; + background: var(--main-color); + border-radius: var(--roundness); + } + + //not used for now + .indicator { + position: absolute; + width: max-content; + bottom: 0; + .line { + width: 0.1em; + height: 1.5em; + background: var(--sub-color); + border-radius: var(--roundness); + right: 0; + position: absolute; + top: 0; + } + .text { + font-size: 0.5em; + color: var(--sub-color); + margin-right: 1em; + } + } + } + } + &.noDataError { margin: 20rem 0; text-align: center; @@ -192,6 +260,7 @@ .title { color: var(--sub-color); + margin-bottom: 0.5em; } .val { @@ -336,7 +405,6 @@ display: grid; gap: 0.25rem; color: var(--sub-color); - line-height: 1rem; font-size: 1rem; &.testDate .buttons, diff --git a/frontend/src/ts/ape/endpoints/results.ts b/frontend/src/ts/ape/endpoints/results.ts index eb3dc5a91..c6dbd6268 100644 --- a/frontend/src/ts/ape/endpoints/results.ts +++ b/frontend/src/ts/ape/endpoints/results.ts @@ -5,8 +5,8 @@ export default class Results { this.httpClient = httpClient; } - async get(): Ape.EndpointResponse { - return await this.httpClient.get(BASE_PATH); + async get(offset?: number): Ape.EndpointResponse { + return await this.httpClient.get(BASE_PATH, { searchQuery: { offset } }); } async save( diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index bf4b6f52d..3cca5448b 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -131,6 +131,7 @@ export async function initSnapshot(): Promise< snap.streak = userData?.streak?.length ?? 0; snap.maxStreak = userData?.streak?.maxLength ?? 0; snap.filterPresets = userData.resultFilterPresets ?? []; + snap.isPremium = userData?.isPremium; const hourOffset = userData?.streak?.hourOffset; snap.streakHourOffset = @@ -225,51 +226,67 @@ export async function initSnapshot(): Promise< } } -export async function getUserResults(): Promise { +export async function getUserResults(offset?: number): Promise { const user = Auth?.currentUser; if (!user) return false; if (!dbSnapshot) return false; + if ( + dbSnapshot.results !== undefined && + (offset === undefined || dbSnapshot.results.length > offset) + ) { + return false; + } if (!ConnectionState.get()) { return false; } - if (dbSnapshot.results !== undefined) { - return true; - } else { + if (dbSnapshot.results === undefined) { LoadingPage.updateText("Downloading results..."); LoadingPage.updateBar(90); - - const response = await Ape.results.get(); - - if (response.status !== 200) { - Notifications.add("Error getting results: " + response.message, -1); - return false; - } - - const results = response.data as MonkeyTypes.Result[]; - results.forEach((result) => { - if (result.bailedOut === undefined) result.bailedOut = false; - if (result.blindMode === undefined) result.blindMode = false; - if (result.lazyMode === undefined) result.lazyMode = false; - if (result.difficulty === undefined) result.difficulty = "normal"; - if (result.funbox === undefined) result.funbox = "none"; - if (result.language === undefined || result.language === null) { - result.language = "english"; - } - if (result.numbers === undefined) result.numbers = false; - if (result.punctuation === undefined) result.punctuation = false; - if (result.quoteLength === undefined) result.quoteLength = -1; - if (result.restartCount === undefined) result.restartCount = 0; - if (result.incompleteTestSeconds === undefined) { - result.incompleteTestSeconds = 0; - } - if (result.afkDuration === undefined) result.afkDuration = 0; - if (result.tags === undefined) result.tags = []; - }); - dbSnapshot.results = results?.sort((a, b) => b.timestamp - a.timestamp); - return true; } + + const response = await Ape.results.get(offset); + + if (response.status !== 200) { + Notifications.add("Error getting results: " + response.message, -1); + return false; + } + + const results = response.data as MonkeyTypes.Result[]; + results?.sort((a, b) => b.timestamp - a.timestamp); + results.forEach((result) => { + if (result.bailedOut === undefined) result.bailedOut = false; + if (result.blindMode === undefined) result.blindMode = false; + if (result.lazyMode === undefined) result.lazyMode = false; + if (result.difficulty === undefined) result.difficulty = "normal"; + if (result.funbox === undefined) result.funbox = "none"; + if (result.language === undefined || result.language === null) { + result.language = "english"; + } + if (result.numbers === undefined) result.numbers = false; + if (result.punctuation === undefined) result.punctuation = false; + if (result.quoteLength === undefined) result.quoteLength = -1; + if (result.restartCount === undefined) result.restartCount = 0; + if (result.incompleteTestSeconds === undefined) { + result.incompleteTestSeconds = 0; + } + if (result.afkDuration === undefined) result.afkDuration = 0; + if (result.tags === undefined) result.tags = []; + }); + + if (dbSnapshot.results !== undefined) { + //merge + const oldestTimestamp = + dbSnapshot.results[dbSnapshot.results.length - 1].timestamp; + const resultsWithoutDuplicates = results.filter( + (it) => it.timestamp < oldestTimestamp + ); + dbSnapshot.results.push(...resultsWithoutDuplicates); + } else { + dbSnapshot.results = results; + } + return true; } function _getCustomThemeById( diff --git a/frontend/src/ts/elements/result-batches.ts b/frontend/src/ts/elements/result-batches.ts new file mode 100644 index 000000000..861ce905b --- /dev/null +++ b/frontend/src/ts/elements/result-batches.ts @@ -0,0 +1,122 @@ +import * as DB from "../db"; +import * as ServerConfiguration from "../ape/server-configuration"; +import { blendTwoHexColors, mapRange } from "../utils/misc"; +import * as ThemeColors from "../elements/theme-colors"; + +export function hide(): void { + $(".pageAccount .resultBatches").addClass("hidden"); +} + +export function show(): void { + $(".pageAccount .resultBatches").removeClass("hidden"); +} + +export async function update(): Promise { + const results = DB.getSnapshot()?.results; + + if (results === undefined) { + console.error( + "(Result batches) Results are missing but they should be available at the time of drawing the account page?" + ); + hide(); + return; + } + + enableButton(); + + const completedTests = DB.getSnapshot()?.typingStats?.completedTests ?? 0; + const percentageDownloaded = Math.round( + (results.length / completedTests) * 100 + ); + const limits = ServerConfiguration.get()?.results.limits ?? { + regularUser: 0, + premiumUser: 0, + }; + const currentLimit = DB.getSnapshot()?.isPremium + ? limits.premiumUser + : limits.regularUser; + const percentageLimit = Math.round((results?.length / currentLimit) * 100); + + const barsWrapper = $(".pageAccount .resultBatches .bars"); + + const bars = { + downloaded: { + fill: barsWrapper.find(".downloaded .fill"), + rightText: barsWrapper.find(".downloaded.rightText"), + }, + limit: { + fill: barsWrapper.find(".limit .fill"), + rightText: barsWrapper.find(".limit.rightText"), + }, + }; + + bars.downloaded.fill.css("width", Math.min(percentageDownloaded, 100) + "%"); + bars.downloaded.rightText.text( + `${results?.length} / ${completedTests} (${percentageDownloaded}%)` + ); + + const colors = await ThemeColors.getAll(); + + bars.limit.fill.css({ + width: Math.min(percentageLimit, 100) + "%", + background: blendTwoHexColors( + colors.sub, + colors.error, + mapRange(percentageLimit, 50, 100, 0, 1) + ), + }); + bars.limit.rightText.text( + `${results?.length} / ${currentLimit} (${percentageLimit}%)` + ); + + const text = $(".pageAccount .resultBatches > .text"); + text.text(""); + + if (results.length >= completedTests) { + disableButton(); + updateButtonText("all results loaded"); + } + + if (results.length >= currentLimit) { + disableButton(); + updateButtonText("limit reached"); + + // if (DB.getSnapshot()?.isPremium === false) { + // text.html( + // `
Want to load up to ${limits?.premiumUser} results and gain access to more perks? Join Monkeytype Premium.
` + // ); + // } + } +} + +export function disableButton(): void { + $(".pageAccount .resultBatches button").prop("disabled", true); +} + +export function enableButton(): void { + $(".pageAccount .resultBatches button").prop("disabled", false); +} + +export function updateButtonText(text: string): void { + $(".pageAccount .resultBatches button").text(text); +} + +export function showOrHideIfNeeded(): void { + //for now, just hide if not premium - will show this to everyone later + const isPremium = DB.getSnapshot()?.isPremium ?? false; + if (!isPremium) { + hide(); + return; + } + + const completed = DB.getSnapshot()?.typingStats?.completedTests ?? 0; + const batchSize = ServerConfiguration.get()?.results.maxBatchSize ?? 0; + + //no matter if premium or not, if the user is below the initial batch, hide the element + if (completed <= batchSize) { + hide(); + return; + } + + show(); +} diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index e91e4ed48..c95e88ff7 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -21,6 +21,8 @@ import type { ScaleChartOptions, LinearScaleOptions } from "chart.js"; import * as ConfigEvent from "../observables/config-event"; import * as ActivePage from "../states/active-page"; import { Auth } from "../firebase"; +import * as Loader from "../elements/loader"; +import * as ResultBatches from "../elements/result-batches"; let filterDebug = false; //toggle filterdebug @@ -222,6 +224,8 @@ async function fillContent(): Promise { PbTables.update(snapshot.personalBests); Profile.update("account", snapshot); + ResultBatches.update(); + chartData = []; accChartData = []; const wpmChartData: number[] = []; @@ -1047,15 +1051,15 @@ async function fillContent(): Promise { ); } -export async function downloadResults(): Promise { - if (DB.getSnapshot()?.results !== undefined) return; - const results = await DB.getUserResults(); +export async function downloadResults(offset?: number): Promise { + const results = await DB.getUserResults(offset); if (results === false && !ConnectionState.get()) { Notifications.add("Could not get results - you are offline", -1, { duration: 5, }); return; } + TodayTracker.addAllFromToday(); if (results) { ResultFilters.updateActive(); @@ -1299,6 +1303,17 @@ $(".pageAccount .profile").on("click", ".details .copyLink", () => { ); }); +$(".pageAccount button.loadMoreResults").on("click", async () => { + const offset = DB.getSnapshot()?.results?.length || 0; + + Loader.show(); + ResultBatches.disableButton(); + + await downloadResults(offset); + await fillContent(); + Loader.hide(); +}); + ConfigEvent.subscribe((eventKey) => { if (ActivePage.get() === "account" && eventKey === "typingSpeedUnit") { update(); @@ -1327,6 +1342,7 @@ export const page = new Page( $(".pageAccount .content").addClass("hidden"); $(".pageAccount .preloader").removeClass("hidden"); } + await update(); await Misc.sleep(0); updateChartColors(); @@ -1336,6 +1352,8 @@ export const page = new Page( `

Your account is not verified. Send the verification email again.` ); } + + ResultBatches.showOrHideIfNeeded(); }, async () => { // diff --git a/frontend/src/ts/pages/loading.ts b/frontend/src/ts/pages/loading.ts index e55339ec8..d287a9078 100644 --- a/frontend/src/ts/pages/loading.ts +++ b/frontend/src/ts/pages/loading.ts @@ -4,7 +4,7 @@ import * as Skeleton from "../popups/skeleton"; export function updateBar(percentage: number, fast = false): void { const speed = fast ? 100 : 1000; - $(".pageLoading .fill, .pageAccount .fill") + $(".pageLoading .fill, .pageAccount .preloader .fill") .stop(true, fast) .animate( { diff --git a/frontend/src/ts/types/types.d.ts b/frontend/src/ts/types/types.d.ts index e661cabfb..6f6e3edb3 100644 --- a/frontend/src/ts/types/types.d.ts +++ b/frontend/src/ts/types/types.d.ts @@ -607,6 +607,7 @@ declare namespace MonkeyTypes { maxStreak: number; streakHourOffset?: number; lbOptOut?: boolean; + isPremium?: boolean; } interface UserDetails { diff --git a/frontend/static/html/pages/account.html b/frontend/static/html/pages/account.html index 9dd4b8206..537f9fb6c 100644 --- a/frontend/static/html/pages/account.html +++ b/frontend/static/html/pages/account.html @@ -304,6 +304,28 @@

+ +