feat: Allow more results for premium users (fehmer, Miodec) (#4778)

* feat: Unlimited history for premium users UI

* disable button while loading, show spinner

* optional chaining

* add isPremium to /users, remove check from frontend

* add /results max batch size to configuration

* result batching ui v1

* rename variables, update button based on state, update text

* Return allowed results in /results if limit is partly overshooting the max limit

* use br instead of margin

* let result batches code handle button disabling

* hide title

* limit max width

* hide section when below batch size

* update limit color based on the % of limit used

* bring back loaded bar

* remove unused css

* fix alignemtn

* remove text for now

* add result getting log

* always hiding for non premium users for now

* Add server configuration users.premium.enabled, throw error on /results if premium user exceeds regular limit and premium is globally disabled

* Disable premium feature globally by default

* cleanup open todos

* Don't use premium user max limit on /results if premium feature is disabled on server

* fix merge issue

---------

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2023-11-20 17:17:20 +01:00 committed by GitHub
parent 643451b9cc
commit 1d4d7dab87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 442 additions and 54 deletions

View file

@ -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<void> {
const mockConfig = _.merge(await configuration, {
users: { premium: { enabled: premium } },
});
jest
.spyOn(Configuration, "getCachedConfiguration")
.mockResolvedValue(mockConfig);
}

View file

@ -63,24 +63,40 @@ export async function getResults(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
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);
}

View file

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

View file

@ -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<MonkeyTypes.Configuration>
},
},
},
maxBatchSize: {
type: "number",
label: "results endpoint max batch size",
min: 1,
},
},
},
quotes: {
@ -266,6 +275,16 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema<MonkeyTypes.Configuration>
type: "object",
label: "Users",
fields: {
premium: {
type: "object",
label: "Premium",
fields: {
enabled: {
type: "boolean",
label: "Enabled",
},
},
},
signUp: {
type: "boolean",
label: "Sign Up Enabled",

View file

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

View file

@ -1,3 +1,5 @@
type Configuration = import("../types/shared").Configuration;
type ObjectId = import("mongodb").ObjectId;
type ExpressRequest = import("express").Request;

View file

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

View file

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

View file

@ -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<boolean> {
export async function getUserResults(offset?: number): Promise<boolean> {
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<MonkeyTypes.Mode>[];
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<MonkeyTypes.Mode>[];
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(

View file

@ -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<void> {
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(
// `<br>Want to load up to ${limits?.premiumUser} results and gain access to more perks? Join Monkeytype Premium.<br>`
// );
// }
}
}
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();
}

View file

@ -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<void> {
PbTables.update(snapshot.personalBests);
Profile.update("account", snapshot);
ResultBatches.update();
chartData = [];
accChartData = [];
const wpmChartData: number[] = [];
@ -1047,15 +1051,15 @@ async function fillContent(): Promise<void> {
);
}
export async function downloadResults(): Promise<void> {
if (DB.getSnapshot()?.results !== undefined) return;
const results = await DB.getUserResults();
export async function downloadResults(offset?: number): Promise<void> {
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(
`<p class="accountVerificatinNotice" style="text-align:center">Your account is not verified. <a class="sendVerificationEmail">Send the verification email again</a>.`
);
}
ResultBatches.showOrHideIfNeeded();
},
async () => {
//

View file

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

View file

@ -607,6 +607,7 @@ declare namespace MonkeyTypes {
maxStreak: number;
streakHourOffset?: number;
lbOptOut?: boolean;
isPremium?: boolean;
}
interface UserDetails {

View file

@ -304,6 +304,28 @@
<div id="ad-account-1-small"></div>
</div>
<div class="group resultBatches hidden">
<!-- <div class="title">result history</div> -->
<div class="bars">
<div class="leftText downloaded">Result history:</div>
<div class="bar downloaded">
<div class="fill"></div>
</div>
<div class="rightText downloaded">-</div>
<div class="leftText limit">Result limit:</div>
<div class="bar limit">
<div class="fill"></div>
<!-- <div class="indicator">
<div class="text">completed tests</div>
<div class="line"></div>
</div> -->
</div>
<div class="rightText limit">-</div>
</div>
<div class="text"></div>
<button class="loadMoreResults">load more</button>
</div>
<div class="group presetFilterButtons hidden">
<div class="buttonsAndTitle">
<div class="title">filter presets</div>