diff --git a/server/package.json b/server/package.json index f5171977a..dc567c1c5 100644 --- a/server/package.json +++ b/server/package.json @@ -6,6 +6,7 @@ "private": true, "scripts": { "test": "tap", + "snap": "TAP_SNAPSHOT=1 tap", "watchtest": "tap --watch", "build": "tsc --project tsconfig.build.json", "lint": "eslint ./src --ext .ts --fix", @@ -84,7 +85,7 @@ "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", "seq-logging": "1.1.1", - "tachi-common": "0.7.3-real-exceed", + "tachi-common": "0.7.15", "ts-node": "10.4.0", "tsconfig-paths": "3.12.0", "typescript": "4.5.5", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index e7ed753e8..d279f9574 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -65,7 +65,7 @@ specifiers: safe-json-stringify: 1.2.0 seq-logging: 1.1.1 supertest: 6.2.2 - tachi-common: 0.7.3-real-exceed + tachi-common: 0.7.15 tap: 15.1.6 ts-node: 10.4.0 tsconfig-paths: 3.12.0 @@ -95,7 +95,7 @@ dependencies: fast-json-stable-hash: 1.0.3 fast-xml-parser: 4.0.2 helmet: 5.0.2 - html-entities: 2.3.3 + html-entities: 2.3.2 json5: 2.2.0 mkdirp: 1.0.4 mongodb: 3.6.3 @@ -112,7 +112,7 @@ dependencies: rimraf: 3.0.2 safe-json-stringify: 1.2.0 seq-logging: 1.1.1 - tachi-common: 0.7.3-real-exceed + tachi-common: 0.7.15 ts-node: 10.4.0_2615db9039ce432b4abf2fc39ef336ec tsconfig-paths: 3.12.0 typescript: 4.5.5 @@ -2408,18 +2408,18 @@ packages: resolution: {integrity: sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==} dev: false - /css-select/4.3.0: - resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + /css-select/4.2.1: + resolution: {integrity: sha512-/aUslKhzkTNCQUB2qTX84lVmfia9NyjP3WpDGtj/WxhwBzWBYUV3DgUpurHTme8UTPcPlAD1DJ+b0nN/t50zDQ==} dependencies: boolbase: 1.0.0 - css-what: 6.1.0 + css-what: 5.1.0 domhandler: 4.3.1 domutils: 2.8.0 nth-check: 2.0.1 dev: false - /css-what/6.1.0: - resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + /css-what/5.1.0: + resolution: {integrity: sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==} engines: {node: '>= 6'} dev: false @@ -3459,8 +3459,8 @@ packages: engines: {node: '>=8'} dev: true - /html-entities/2.3.3: - resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + /html-entities/2.3.2: + resolution: {integrity: sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==} dev: false /html-escaper/2.0.2: @@ -4354,7 +4354,7 @@ packages: /node-html-parser/5.3.3: resolution: {integrity: sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==} dependencies: - css-select: 4.3.0 + css-select: 4.2.1 he: 1.2.0 dev: false @@ -5363,8 +5363,8 @@ packages: engines: {node: '>= 0.4'} dev: true - /tachi-common/0.7.3-real-exceed: - resolution: {integrity: sha512-rwIRnK3vr7Mq/YqDIZjQOYM5iJl47ssjBS8xLWQMpXjf36RPPqUSFbi0jJenMgMV3n/Le/KUZR62+iP34qptfQ==} + /tachi-common/0.7.15: + resolution: {integrity: sha512-rZNTYZFmDVUDcqaVJZAwLhH3Sskoks6Votc62JAOIrxVKEGu9JwGClEpU7YXtD4Uq2Qwt9ZesMtRatQ9rgcMcg==} dependencies: '@types/mongodb': 3.6.18 dev: false diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index 9a0011851..92fba9e9c 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -21,9 +21,9 @@ import { InviteCodeDocument, KaiAuthDocument, MilestoneDocument, + MilestoneSetDocument, OrphanChart, PBScoreDocument, - PrivateUserInfoDocument, PublicUserDocument, RecentlyViewedFolderDocument, ScoreDocument, @@ -35,11 +35,12 @@ import { UGPTSettings, UserGameStats, UserGameStatsSnapshot, - UserGoalDocument, - UserMilestoneDocument, + GoalSubscriptionDocument, + MilestoneSubscriptionDocument, UserSettings, } from "tachi-common"; import { GetMillisecondsSince } from "utils/misc"; +import { PrivateUserInfoDocument } from "utils/types"; const logger = CreateLogCtx(__filename); let dbName = ServerConfig.MONGO_DATABASE_NAME; @@ -151,9 +152,9 @@ const db = { folders: monkDB.get("folders"), "folder-chart-lookup": monkDB.get("folder-chart-lookup"), goals: monkDB.get("goals"), - "user-goals": monkDB.get("user-goals"), + "goal-subs": monkDB.get("goal-subs"), milestones: monkDB.get("milestones"), - "user-milestones": monkDB.get("user-milestones"), + "milestone-subs": monkDB.get("milestone-subs"), users: monkDB.get("users"), imports: monkDB.get("imports"), "import-timings": monkDB.get("import-timings"), @@ -190,6 +191,7 @@ const db = { "verify-email-codes": monkDB.get<{ userID: integer; code: string; email: string }>("verify-email-codes"), "recent-folder-views": monkDB.get("recent-folder-views"), + "milestone-sets": monkDB.get("milestone-sets"), }; export type StaticDatabases = @@ -202,8 +204,8 @@ export type StaticDatabases = | "imports" | "import-timings" | "goals" - | "user-goals" - | "user-milestones" + | "goal-subs" + | "milestone-subs" | "milestones" | "game-stats" | "game-settings" @@ -226,7 +228,8 @@ export type StaticDatabases = | "score-blacklist" | "verify-email-codes" | "class-achievements" - | "recent-folder-views"; + | "recent-folder-views" + | "milestone-sets"; export type Databases = StaticDatabases | `songs-${Game}` | `charts-${Game}`; diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index e4de52a3f..b570a3b86 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -41,13 +41,23 @@ const staticIndexes: Partial> = { "game-stats": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE)], "game-settings": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE)], "folder-chart-lookup": [index({ chartID: 1, folderID: 1 }, UNIQUE)], - goals: [index({ goalID: 1 }, UNIQUE)], - "user-goals": [index({ goalID: 1, userID: 1 }, UNIQUE), index({ goalID: 1 })], - milestones: [index({ milestoneID: 1 }, UNIQUE), index({ group: 1, game: 1, playtype: 1 })], - "user-milestones": [ + goals: [index({ goalID: 1 }, UNIQUE), index({ game: 1, playtype: 1 }), index({ name: "text" })], + "goal-subs": [index({ goalID: 1, userID: 1 }, UNIQUE), index({ goalID: 1 })], + milestones: [ + index({ milestoneID: 1 }, UNIQUE), + index({ game: 1, playtype: 1 }), + index({ name: "text" }), + ], + "milestone-subs": [ index({ milestoneID: 1, userID: 1 }, UNIQUE), index({ userID: 1, game: 1, playtype: 1 }), ], + "milestone-sets": [ + index({ setID: 1 }, UNIQUE), + index({ game: 1, playtype: 1 }), + index({ milestones: 1 }), + index({ name: "text" }), + ], imports: [index({ importID: 1 }, UNIQUE)], "import-timings": [ index({ importID: 1 }, UNIQUE), @@ -66,7 +76,6 @@ const staticIndexes: Partial> = { index({ title: "text", searchTerms: "text" }), ], "kai-auth-tokens": [index({ userID: 1, service: 1 }, UNIQUE)], - "bms-course-lookup": [index({ md5sums: 1 }, UNIQUE)], "api-tokens": [index({ token: 1 }, UNIQUE), index({ userID: 1 })], tables: [index({ tableID: 1, game: 1, playtype: 1 }, UNIQUE)], @@ -193,7 +202,9 @@ export function SetIndexesIfNoneSet() { // This means that there are likely to be no indexes // configured in the database. if (Object.keys(r).length === 1) { - logger.info(`First-time Mongo startup detected. Running SetIndexes.`); + logger.info( + `No indexes on users, First-time Tachi-Server startup assumed. Running SetIndexes.` + ); SetIndexesWithDB(monkDB, true); } }) diff --git a/server/src/external/mongo/schemas.ts b/server/src/external/mongo/schemas.ts index 41bb623c2..1cf4ce981 100644 --- a/server/src/external/mongo/schemas.ts +++ b/server/src/external/mongo/schemas.ts @@ -634,7 +634,7 @@ export const DatabaseSchemas: Record< value: "number", }, { - mode: p.isIn("abs", "proportion"), + mode: p.isIn("absolute", "proportion"), countNum: p.isPositive, key: p.isIn( "scoreData.percent", @@ -683,12 +683,12 @@ export const DatabaseSchemas: Record< type: "all", }, { - type: p.isIn("abs", "proportion"), + type: p.isIn("absolute", "proportion"), value: p.isPositive, } ), }), - "user-goals": prSchemaify({ + "goal-subs": prSchemaify({ goalID: "string", userID: p.isPositiveNonZeroInteger, game: p.isIn(games), @@ -702,7 +702,7 @@ export const DatabaseSchemas: Record< outOf: "number", outOfHuman: "string", }), - "user-milestones": prSchemaify({ + "milestone-subs": prSchemaify({ milestoneID: "string", userID: p.isPositiveNonZeroInteger, game: p.isIn(games), @@ -719,4 +719,10 @@ export const DatabaseSchemas: Record< folderID: "string", lastViewed: "number", }), + "milestone-sets": prSchemaify({ + setID: "string", + game: p.isIn(games), + playtype: isValidPlaytype, + milestones: ["string"], + }), }; diff --git a/server/src/external/redis/redis.ts b/server/src/external/redis/redis.ts index 03566aed8..05555690f 100644 --- a/server/src/external/redis/redis.ts +++ b/server/src/external/redis/redis.ts @@ -35,5 +35,13 @@ RedisClient.on("connect", () => { }); export function CloseRedisConnection() { - return RedisClient.quit(); + return new Promise((resolve, reject) => + RedisClient.quit((err, reply) => { + if (err) { + reject(err); + } else { + resolve(reply); + } + }) + ); } diff --git a/server/src/lib/constants/err-codes.ts b/server/src/lib/constants/err-codes.ts new file mode 100644 index 000000000..1b3ddbe32 --- /dev/null +++ b/server/src/lib/constants/err-codes.ts @@ -0,0 +1,4 @@ +export enum SubscribeFailReasons { + ALREADY_SUBSCRIBED, + ALREADY_ACHIEVED, +} diff --git a/server/src/lib/score-import/framework/goals/goals.test.ts b/server/src/lib/score-import/framework/goals/goals.test.ts index ad96bcdbd..9d37b44a2 100644 --- a/server/src/lib/score-import/framework/goals/goals.test.ts +++ b/server/src/lib/score-import/framework/goals/goals.test.ts @@ -2,7 +2,7 @@ import t from "tap"; import db from "external/mongo/db"; import ResetDBState from "test-utils/resets"; import { GetRelevantFolderGoals, GetRelevantGoals, UpdateGoalsForUser, ProcessGoal } from "./goals"; -import { GoalDocument, UserGoalDocument } from "tachi-common"; +import { GoalDocument, GoalSubscriptionDocument } from "tachi-common"; import { CreateFolderChartLookup } from "utils/folder"; import { GetKTDataJSON, @@ -30,7 +30,7 @@ t.test("#GetRelevantFolderGoals", (t) => { goalID: "fake_goal_id", playtype: "SP", timeAdded: 0, - title: "get > 1 ex score on any level 10.", + name: "get > 1 ex score on any level 10.", criteria: { mode: "single", value: 1, @@ -47,7 +47,7 @@ t.test("#GetRelevantFolderGoals", (t) => { goalID: "fake_bad_goal_id", playtype: "SP", timeAdded: 0, - title: "get > 1 ex score on some other folder.", + name: "get > 1 ex score on some other folder.", criteria: { mode: "single", value: 1, @@ -65,8 +65,8 @@ t.test("#GetRelevantFolderGoals", (t) => { await db.folders.insert(sp11folder); await db.goals.insert(fakeFolderGoalDocument); await db.goals.insert(notFolderGoalDocument); - await CreateFolderChartLookup(TestingIIDXFolderSP10); - await CreateFolderChartLookup(sp11folder); + await CreateFolderChartLookup(TestingIIDXFolderSP10, true); + await CreateFolderChartLookup(sp11folder, true); }); t.test("Should correctly find the goals on this folder.", async (t) => { @@ -106,7 +106,7 @@ t.test("#GetRelevantGoals", (t) => { goalID: crypto.randomBytes(20).toString("hex"), playtype: "SP", timeAdded: 0, - title: "get > 1 ex score on some other folder.", + name: "get > 1 ex score on some other folder.", criteria: { mode: "single", value: 1, @@ -116,7 +116,7 @@ t.test("#GetRelevantGoals", (t) => { await db.goals.insert(goals); - await db["user-goals"].insert( + await db["goal-subs"].insert( goals.map((e) => ({ achieved: false, wasInstantlyAchieved: false, @@ -131,9 +131,6 @@ t.test("#GetRelevantGoals", (t) => { progressHuman: "NO DATA", timeSet: Date.now(), userID: 1, - from: { - origin: "manual", - }, })) ); }); @@ -153,7 +150,7 @@ t.test("#GetRelevantGoals", (t) => { const res = await GetRelevantGoals("iidx", 1, chartIDs, logger); t.equal(res.goals.length, 5, "Should correctly resolve to 5 goals."); - t.equal(res.userGoalsMap.size, 5, "Should also return 5 userGoals."); + t.equal(res.goalSubsMap.size, 5, "Should also return 5 goalSubs."); t.end(); } @@ -174,7 +171,7 @@ t.test("#UpdateGoalsForUser", (t) => { goalID: "FAKE_GOAL_ID", playtype: "SP", timeAdded: 0, - title: "get > 1 ex score on some other folder.", + name: "get > 1 ex score on some other folder.", criteria: { mode: "single", value: 1, @@ -182,7 +179,7 @@ t.test("#UpdateGoalsForUser", (t) => { }, }; - const baseUserGoalDocument: UserGoalDocument = { + const baseGoalSubscriptionDocument: GoalSubscriptionDocument = { achieved: false, wasInstantlyAchieved: false, game: "iidx", @@ -196,23 +193,20 @@ t.test("#UpdateGoalsForUser", (t) => { timeAchieved: null, timeSet: 0, userID: 1, - from: { - origin: "manual", - }, }; t.test("Should correctly update goals when user achieves goal.", async (t) => { await db.goals.insert(baseGoalDocument); delete baseGoalDocument._id; - await db["user-goals"].insert(baseUserGoalDocument); + await db["goal-subs"].insert(baseGoalSubscriptionDocument); // we dont delete _id here because updategoalsforuser // depends on usergoal _id await db["personal-bests"].insert(TestingIIDXSPScorePB); delete TestingIIDXSPScorePB._id; - const ugMap = new Map([["FAKE_GOAL_ID", baseUserGoalDocument]]); + const ugMap = new Map([["FAKE_GOAL_ID", baseGoalSubscriptionDocument]]); const res = await UpdateGoalsForUser([baseGoalDocument], ugMap, 1, logger); @@ -236,7 +230,7 @@ t.test("#UpdateGoalsForUser", (t) => { }, ]); - const r = await db["user-goals"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 }); + const r = await db["goal-subs"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 }); t.hasStrict( r, @@ -250,7 +244,7 @@ t.test("#UpdateGoalsForUser", (t) => { "Should update goals in the database." ); - delete baseUserGoalDocument._id; + delete baseGoalSubscriptionDocument._id; t.end(); }); @@ -259,12 +253,12 @@ t.test("#UpdateGoalsForUser", (t) => { const goal = deepmerge(baseGoalDocument, { criteria: { value: 2 } }); await db.goals.insert(goal); - const userGoal = deepmerge(baseUserGoalDocument, { + const goalSub = deepmerge(baseGoalSubscriptionDocument, { outOf: 2, outOfHuman: "2", - }) as unknown as UserGoalDocument; + }) as unknown as GoalSubscriptionDocument; - await db["user-goals"].insert(userGoal); + await db["goal-subs"].insert(goalSub); // we dont delete _id here because updategoalsforuser // depends on usergoal _id @@ -272,7 +266,7 @@ t.test("#UpdateGoalsForUser", (t) => { deepmerge(TestingIIDXSPScorePB, { scoreData: { score: 1 } }) ); - const ugMap = new Map([["FAKE_GOAL_ID", userGoal]]); + const ugMap = new Map([["FAKE_GOAL_ID", goalSub]]); const res = await UpdateGoalsForUser([goal], ugMap, 1, logger); @@ -296,7 +290,7 @@ t.test("#UpdateGoalsForUser", (t) => { }, ]); - const r = await db["user-goals"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 }); + const r = await db["goal-subs"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 }); t.hasStrict( r, @@ -310,7 +304,7 @@ t.test("#UpdateGoalsForUser", (t) => { "Should update goals in the database." ); - delete baseUserGoalDocument._id; + delete baseGoalSubscriptionDocument._id; t.end(); }); @@ -334,7 +328,7 @@ t.test("#UpdateGoalsForUser", (t) => { t.test("Should handle (skip) invalid goals.", async (t) => { const res = await UpdateGoalsForUser( [deepmerge(baseGoalDocument, { charts: { type: "INVALID" } })], - new Map([["FAKE_GOAL_ID", baseUserGoalDocument]]), + new Map([["FAKE_GOAL_ID", baseGoalSubscriptionDocument]]), 1, logger ); @@ -360,7 +354,7 @@ t.test("#ProcessGoal", (t) => { }); t.test("Should process the users goal if a score has changed.", async (t) => { - await db["user-goals"].insert(HC511UserGoal); + await db["goal-subs"].insert(HC511UserGoal); await db["personal-bests"].insert(TestingIIDXSPScorePB); // score is EX HARD CLEAR by default. const res = await ProcessGoal(HC511Goal, HC511UserGoal, 1, logger); @@ -368,7 +362,7 @@ t.test("#ProcessGoal", (t) => { t.not(res, undefined, "Should NOT return undefined."); t.strictSame( - res!.import, + res?.import, { goalID: "mock_goalID", old: { @@ -392,8 +386,58 @@ t.test("#ProcessGoal", (t) => { t.end(); }); + t.test("Should unset wasInstantlyAchieved if the goal became unachieved.", async (t) => { + const achievedGoalSub: GoalSubscriptionDocument = { + achieved: true, + game: "iidx", + goalID: "mock_goalID", + lastInteraction: null, + outOf: 5, + outOfHuman: "HARD CLEAR", + playtype: "SP", + progress: 6, + progressHuman: "EX HARD CLEAR", + timeAchieved: 1000, + timeSet: 1000, + wasInstantlyAchieved: true, + userID: 1, + }; + + await db["goal-subs"].insert(achievedGoalSub); + + const res = await ProcessGoal(HC511Goal, achievedGoalSub, 1, logger); + + t.not(res, undefined, "Should NOT return undefined."); + + t.hasStrict( + res?.import, + { + goalID: "mock_goalID", + old: { + progress: 6, + outOf: 5, + achieved: true, + }, + new: { + progress: null, + outOf: 5, + achieved: false, + }, + }, + "Should unachieve the goal." + ); + + t.equal( + res?.bwrite.updateOne.update.$set.wasInstantlyAchieved, + false, + "Goal is to be set as not instantly achieved." + ); + + t.end(); + }); + t.test("Should return undefined if there's no score.", async (t) => { - await db["user-goals"].insert(HC511UserGoal); + await db["goal-subs"].insert(HC511UserGoal); const res = await ProcessGoal(HC511Goal, HC511UserGoal, 1, logger); @@ -403,7 +447,7 @@ t.test("#ProcessGoal", (t) => { }); t.test("Should return undefined if the progress has not changed.", async (t) => { - await db["user-goals"].insert(HC511UserGoal); + await db["goal-subs"].insert(HC511UserGoal); await db["personal-bests"].insert(TestingIIDXSPScorePB); const firstUpdate = await ProcessGoal(HC511Goal, HC511UserGoal, 1, logger); @@ -411,11 +455,11 @@ t.test("#ProcessGoal", (t) => { // ignore this one t.not(firstUpdate, undefined, "Should NOT return undefined."); - await db["user-goals"].bulkWrite([firstUpdate!.bwrite]); + await db["goal-subs"].bulkWrite([firstUpdate!.bwrite]); - const userGoal = await db["user-goals"].findOne({ userID: 1, goalID: HC511Goal.goalID }); + const goalSub = await db["goal-subs"].findOne({ userID: 1, goalID: HC511Goal.goalID }); - const secondUpdate = await ProcessGoal(HC511Goal, userGoal!, 1, logger); + const secondUpdate = await ProcessGoal(HC511Goal, goalSub!, 1, logger); t.equal(secondUpdate, undefined, "Should return undefined."); diff --git a/server/src/lib/score-import/framework/goals/goals.ts b/server/src/lib/score-import/framework/goals/goals.ts index 4377473f5..234721477 100644 --- a/server/src/lib/score-import/framework/goals/goals.ts +++ b/server/src/lib/score-import/framework/goals/goals.ts @@ -1,5 +1,5 @@ -import { integer, Game, GoalDocument, UserGoalDocument } from "tachi-common"; -import { EvaluateGoalForUser } from "lib/achievables/goals"; +import { integer, Game, GoalDocument, GoalSubscriptionDocument } from "tachi-common"; +import { EvaluateGoalForUser } from "lib/targets/goals"; import db from "external/mongo/db"; import { KtLogger } from "lib/logger/logger"; import { EmitWebhookEvent } from "lib/webhooks/webhooks"; @@ -13,7 +13,7 @@ export async function GetAndUpdateUsersGoals( chartIDs: Set, logger: KtLogger ) { - const { goals, userGoalsMap } = await GetRelevantGoals(game, userID, chartIDs, logger); + const { goals, goalSubsMap } = await GetRelevantGoals(game, userID, chartIDs, logger); if (!goals.length) { // if we hit the below code with an empty array mongodb will flip out on the bulkwrite op @@ -22,27 +22,27 @@ export async function GetAndUpdateUsersGoals( logger.verbose(`Found ${goals.length} relevant goals.`); - return UpdateGoalsForUser(goals, userGoalsMap, userID, logger); + return UpdateGoalsForUser(goals, goalSubsMap, userID, logger); } export async function UpdateGoalsForUser( goals: GoalDocument[], - userGoalsMap: Map, + goalSubsMap: Map, userID: integer, logger: KtLogger ) { const returns = await Promise.all( goals.map((goal: GoalDocument) => { - const userGoal = userGoalsMap.get(goal.goalID); + const goalSub = goalSubsMap.get(goal.goalID); - if (!userGoal) { + if (!goalSub) { logger.error( - `UserGoal:GoalID mismatch ${goal.goalID} - this user has no userGoal for this, yet it is set.` + `UserGoal:GoalID mismatch ${goal.goalID} - this user has no goalSub for this, yet it is set.` ); return; } - return ProcessGoal(goal, userGoal, userID, logger); + return ProcessGoal(goal, goalSub, userID, logger); }) ); @@ -76,7 +76,7 @@ export async function UpdateGoalsForUser( }); } - await db["user-goals"].bulkWrite(bulkWrite, { ordered: false }); + await db["goal-subs"].bulkWrite(bulkWrite, { ordered: false }); return importInfo; } @@ -89,7 +89,7 @@ export async function UpdateGoalsForUser( */ export async function ProcessGoal( goal: GoalDocument, - userGoal: UserGoalDocument, + goalSub: GoalSubscriptionDocument, userID: integer, logger: KtLogger ) { @@ -101,7 +101,7 @@ export async function ProcessGoal( } // nothing has changed - if (userGoal.progress === res.progress && userGoal.outOf === res.outOf) { + if (goalSub.progress === res.progress && goalSub.outOf === res.outOf) { return; } @@ -114,16 +114,16 @@ export async function ProcessGoal( }; const oldData = { - progress: userGoal.progress, - progressHuman: userGoal.progressHuman, - outOf: userGoal.outOf, - outOfHuman: userGoal.outOfHuman, - achieved: userGoal.achieved, + progress: goalSub.progress, + progressHuman: goalSub.progressHuman, + outOf: goalSub.outOf, + outOfHuman: goalSub.outOfHuman, + achieved: goalSub.achieved, }; let webhookEvent = null; // if this is a newly-achieved goal - if (res.achieved && !userGoal.achieved) { + if (res.achieved && !goalSub.achieved) { webhookEvent = { goalID: goal.goalID, old: oldData, @@ -132,18 +132,33 @@ export async function ProcessGoal( }; } + const setData = { + ...newData, + timeAchieved: newData.achieved ? Date.now() : null, + // we're guaranteed that this works, because things + // that haven't changed return nothing instead of + // getting to this point. + lastInteraction: Date.now(), + } as Partial; + + // If this goal was achieved, and is now *not* achieved, we need to unset + // some things. + if (goalSub.achieved && !res.achieved) { + logger.info(`User ${userID} lost their achieved status on ${goal.name}.`, { + goal, + res, + goalSub, + }); + + // This goal can't be marked as instantly achieved, since it was lost. + setData.wasInstantlyAchieved = false; + } + const bulkWrite = { updateOne: { - filter: { _id: userGoal._id! }, + filter: { _id: goalSub._id! }, update: { - $set: { - ...newData, - timeAchieved: newData.achieved ? Date.now() : null, - // we're guaranteed that this works, because things - // that haven't changed return nothing instead of - // getting to this point. - lastInteraction: Date.now(), - } as Partial, + $set: setData, }, }, }; @@ -165,23 +180,23 @@ export async function ProcessGoal( * * This optimisation allows users to have *lots* of goals, but only ever * evaluate the ones we need to. - * @returns An array of Goals, and an array of userGoals. + * @returns An array of Goals, and an array of goalSubs. */ export async function GetRelevantGoals( game: Game, userID: integer, chartIDs: Set, logger: KtLogger -): Promise<{ goals: GoalDocument[]; userGoalsMap: Map }> { - const userGoals = await db["user-goals"].find({ game, userID }, { projectID: true }); +): Promise<{ goals: GoalDocument[]; goalSubsMap: Map }> { + const goalSubs = await db["goal-subs"].find({ game, userID }, { projectID: true }); - logger.verbose(`Found user has ${userGoals.length} goals.`); + logger.verbose(`Found user has ${goalSubs.length} goals.`); - if (!userGoals.length) { - return { goals: [], userGoalsMap: new Map() }; + if (!goalSubs.length) { + return { goals: [], goalSubsMap: new Map() }; } - const goalIDs = userGoals.map((e) => e.goalID); + const goalIDs = goalSubs.map((e) => e.goalID); const chartIDsArr: string[] = []; for (const c of chartIDs) { @@ -204,20 +219,20 @@ export async function GetRelevantGoals( const goalSet = new Set(goals.map((e) => e.goalID)); - const userGoalsMap: Map = new Map(); + const goalSubsMap: Map = new Map(); - for (const userGoal of userGoals) { - if (!goalSet.has(userGoal.goalID)) { + for (const goalSub of goalSubs) { + if (!goalSet.has(goalSub.goalID)) { continue; } - // since these are guaranteed to be unique, lets make a hot map of goalID -> userGoalDocument, so we can - // pull them in for post-processing and filter out the userGoalDocuments that aren't relevant. - userGoalsMap.set(userGoal.goalID, userGoal); + // since these are guaranteed to be unique, lets make a hot map of goalID -> goalSubDocument, so we can + // pull them in for post-processing and filter out the goalSubDocuments that aren't relevant. + goalSubsMap.set(goalSub.goalID, goalSub); } return { goals, - userGoalsMap, + goalSubsMap, }; } diff --git a/server/src/lib/score-import/framework/milestones/milestones.test.ts b/server/src/lib/score-import/framework/milestones/milestones.test.ts index 83acbec62..0e49b7d8b 100644 --- a/server/src/lib/score-import/framework/milestones/milestones.test.ts +++ b/server/src/lib/score-import/framework/milestones/milestones.test.ts @@ -21,7 +21,7 @@ t.test("#UpdateUsersMilestones", (t) => { t.beforeEach(ResetDBState); t.beforeEach(() => db.milestones.insert(TestingIIDXSPMilestone)); t.beforeEach(() => - db["user-milestones"].insert({ + db["milestone-subs"].insert({ achieved: false, wasInstantlyAchieved: false, game: "iidx", @@ -29,6 +29,7 @@ t.test("#UpdateUsersMilestones", (t) => { playtype: "SP", progress: 0, timeAchieved: null, + lastInteraction: null, timeSet: 0, userID: 1, }) @@ -153,7 +154,7 @@ t.test("#UpdateUsersMilestones", (t) => { milestoneID: "some_other_milestone_with_mutual_goals", }) ); - await db["user-milestones"].insert({ + await db["milestone-subs"].insert({ achieved: false, wasInstantlyAchieved: false, game: "iidx", @@ -161,6 +162,7 @@ t.test("#UpdateUsersMilestones", (t) => { playtype: "SP", progress: 0, timeAchieved: null, + lastInteraction: null, timeSet: 0, userID: 1, }); @@ -262,7 +264,7 @@ t.test("#UpdateUsersMilestones", (t) => { ), ]); - await db["user-milestones"].insert([ + await db["milestone-subs"].insert([ { achieved: false, wasInstantlyAchieved: false, @@ -271,6 +273,7 @@ t.test("#UpdateUsersMilestones", (t) => { playtype: "SP", progress: 0, timeAchieved: null, + lastInteraction: null, timeSet: 0, userID: 1, }, @@ -283,6 +286,7 @@ t.test("#UpdateUsersMilestones", (t) => { playtype: "DP", progress: 0, timeAchieved: null, + lastInteraction: null, timeSet: 0, userID: 1, }, @@ -295,6 +299,7 @@ t.test("#UpdateUsersMilestones", (t) => { playtype: "Single", progress: 0, timeAchieved: null, + lastInteraction: null, timeSet: 0, userID: 1, }, @@ -306,6 +311,7 @@ t.test("#UpdateUsersMilestones", (t) => { playtype: "SP", progress: 0, timeAchieved: null, + lastInteraction: null, timeSet: 0, userID: 1, }, diff --git a/server/src/lib/score-import/framework/milestones/milestones.ts b/server/src/lib/score-import/framework/milestones/milestones.ts index 824bdbdef..da49ee84a 100644 --- a/server/src/lib/score-import/framework/milestones/milestones.ts +++ b/server/src/lib/score-import/framework/milestones/milestones.ts @@ -9,9 +9,9 @@ import { MilestoneDocument, MilestoneImportInfo, Playtypes, - UserMilestoneDocument, + MilestoneSubscriptionDocument, } from "tachi-common"; -import { CalculateMilestoneOutOf, GetGoalIDsFromMilestone } from "utils/milestone"; +import { CalculateMilestoneOutOf, GetGoalIDsFromMilestone } from "lib/targets/milestones"; /** * Processes and updates a user's milestones from their Goal Import Info (i.e. what is returned @@ -39,7 +39,7 @@ export function ProcessMilestoneFromGII( progress++; } - const outOf = CalculateMilestoneOutOf(milestone, goalIDs); + const outOf = CalculateMilestoneOutOf(milestone); // milestone achieved! if (progress >= outOf) { @@ -56,15 +56,15 @@ export async function UpdateUsersMilestones( userID: integer, logger: KtLogger ) { - const userGoalInfoMap: Map = new Map(); + const goalSubInfoMap: Map = new Map(); const goalIDs = []; for (const e of importGoalInfo) { - userGoalInfoMap.set(e.goalID, e.new); + goalSubInfoMap.set(e.goalID, e.new); goalIDs.push(e.goalID); } - const { milestones, userMilestones } = await GetRelevantMilestones( + const { milestones, milestoneSubs } = await GetRelevantMilestones( goalIDs, game, playtypes, @@ -74,9 +74,9 @@ export async function UpdateUsersMilestones( // create a map here to avoid linear searching when // co-iterating - const userMilestoneMap = new Map(); - for (const um of userMilestones) { - userMilestoneMap.set(um.milestoneID, um); + const milestoneSubMap = new Map(); + for (const um of milestoneSubs) { + milestoneSubMap.set(um.milestoneID, um); } const importGoalMap = new Map(); @@ -85,16 +85,16 @@ export async function UpdateUsersMilestones( importGoalMap.set(ig.goalID, ig.new); } - const bwrite: BulkWriteUpdateOneOperation[] = []; + const bwrite: BulkWriteUpdateOneOperation[] = []; const importMilestoneInfo: MilestoneImportInfo[] = []; for (const milestone of milestones) { const { achieved, progress } = ProcessMilestoneFromGII(milestone, importGoalMap); - const userMilestone = userMilestoneMap.get(milestone.milestoneID); + const milestoneSub = milestoneSubMap.get(milestone.milestoneID); - if (!userMilestone) { + if (!milestoneSub) { logger.severe( `Invalid state achieved in milestone processing - processed milestone that user did not have? ${milestone.milestoneID}` ); @@ -102,7 +102,7 @@ export async function UpdateUsersMilestones( throw new Error("Invalid state achieved in milestone processing."); } - bwrite.push({ + const bwriteOp: BulkWriteUpdateOneOperation = { updateOne: { filter: { milestoneID: milestone.milestoneID }, update: { @@ -112,13 +112,13 @@ export async function UpdateUsersMilestones( }, }, }, - }); + }; const milestoneInfo = { - milestoneID: userMilestone.milestoneID, + milestoneID: milestoneSub.milestoneID, old: { - progress: userMilestone.progress, - achieved: userMilestone.achieved, + progress: milestoneSub.progress, + achieved: milestoneSub.achieved, }, new: { progress, @@ -126,11 +126,15 @@ export async function UpdateUsersMilestones( }, }; - if (progress !== userMilestone.progress) { + if (progress !== milestoneSub.progress) { importMilestoneInfo.push(milestoneInfo); + // @ts-expect-error This property isn't read only, because I said so. + bwriteOp.updateOne.update.$set!.lastInteraction = Date.now(); } - if (achieved && !userMilestone.achieved) { + bwrite.push(bwriteOp); + + if (achieved && !milestoneSub.achieved) { EmitWebhookEvent({ type: "milestone-achieved/v1", content: { @@ -144,7 +148,7 @@ export async function UpdateUsersMilestones( } if (bwrite.length !== 0) { - await db["user-milestones"].bulkWrite(bwrite, { ordered: false }); + await db["milestone-subs"].bulkWrite(bwrite, { ordered: false }); } return importMilestoneInfo; @@ -157,20 +161,20 @@ async function GetRelevantMilestones( userID: integer, logger: KtLogger ) { - const userMilestones = await db["user-milestones"].find({ + const milestoneSubs = await db["milestone-subs"].find({ game, playtype: { $in: playtypes }, userID, }); - logger.debug(`Found ${userMilestones.length} user-milestones.`); + logger.debug(`Found ${milestoneSubs.length} milestone-subs.`); const milestones = await db.milestones.find({ - milestoneID: { $in: userMilestones.map((e) => e.milestoneID) }, + milestoneID: { $in: milestoneSubs.map((e) => e.milestoneID) }, "milestoneData.goals.goalID": { $in: goalIDs }, }); logger.debug(`Found ${milestones.length} relevant milestones.`); - return { userMilestones, milestones }; + return { milestoneSubs, milestones }; } diff --git a/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/server/src/lib/score-import/framework/pb/create-pb-doc.ts index 444c687ed..46c323e6c 100644 --- a/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -62,7 +62,7 @@ export async function CreatePBDoc(userID: integer, chartID: string, logger: KtLo } /** - * Updates users' rankings on a given chart. + * Updates user's rankings on a given chart. */ export async function UpdateChartRanking(chartID: string) { const scores = await db["personal-bests"].find( diff --git a/server/src/lib/score-import/framework/user-game-stats/classes.ts b/server/src/lib/score-import/framework/user-game-stats/classes.ts index 10e424ed3..6a538687f 100644 --- a/server/src/lib/score-import/framework/user-game-stats/classes.ts +++ b/server/src/lib/score-import/framework/user-game-stats/classes.ts @@ -62,13 +62,13 @@ const STATIC_CLASS_HANDLERS: ClassHandlerMap = { }; /** - * Calculates a Users' Game Stats Classes. This function is rather complex, because the reality is rather complex. + * Calculates a User's Game Stats Classes. This function is rather complex, because the reality is rather complex. * * A class is simply a hard bounded division dependent on a user. Such as a Dan or a skill level dependent on a statistic. * Not all services expose this information in the same way, so this function takes an async resolve function, * which is allowed to return its own classes. These will be merged with the classes that *we* can calculate. * - * As an example, we are always able to calculate things like Gitadora's colours. We know the users' skill statistic, + * As an example, we are always able to calculate things like Gitadora's colours. We know the user's skill statistic, * and a colour is just between X-Y skill. However, we cannot always calculate something like IIDX's dans. Infact, * there's no calculation involved. We need to instead request this information from a service. For things like ARC * they expose this on a dedicated endpoint. diff --git a/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.ts b/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.ts index d3734f9e2..f5a5dbc43 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/iidx/converter.ts @@ -1,5 +1,5 @@ import p from "prudence"; -import { Lamps, integer } from "tachi-common"; +import { integer, Lamps } from "tachi-common"; import { FormatPrError } from "utils/prudence"; import { FindIIDXChartOnInGameIDVersion } from "utils/queries/charts"; import { FindSongOnID } from "utils/queries/songs"; diff --git a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts index bb33785a1..2a6bd8469 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts @@ -32,7 +32,7 @@ const MAX_ITERATIONS = 500; * Traverses a Kai-like personal_bests api. * @param baseUrl The base URL to fetch requests from. - like https://google.com * @param subUrl The endpoint for the scores - /api/v1/personal_bests - * @param authDoc The users' authentication document. + * @param authDoc The user's authentication document. * @param fetch This is so `fetch` can be mocked with something * that wont make http requests during tests. */ diff --git a/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts b/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts index fb85d3e98..5ab5b0d59 100644 --- a/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts +++ b/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts @@ -1,6 +1,6 @@ import deepmerge from "deepmerge"; import CreateLogCtx from "lib/logger/logger"; -import { Game, BatchManualScore } from "tachi-common"; +import { BatchManualScore, Game } from "tachi-common"; import t from "tap"; import ResetDBState from "test-utils/resets"; import { GetKTDataJSON, Testing511Song, Testing511SPA } from "test-utils/test-data"; diff --git a/server/src/lib/score-import/import-types/common/batch-manual/converter.ts b/server/src/lib/score-import/import-types/common/batch-manual/converter.ts index 8256c82f1..fa5be9fea 100644 --- a/server/src/lib/score-import/import-types/common/batch-manual/converter.ts +++ b/server/src/lib/score-import/import-types/common/batch-manual/converter.ts @@ -3,10 +3,10 @@ import { KtLogger } from "lib/logger/logger"; import { BatchManualScore, ChartDocument, - ImportTypes, - SongDocument, Grades, IDStrings, + ImportTypes, + SongDocument, } from "tachi-common"; import { FindBMSChartOnHash, diff --git a/server/src/lib/score-import/import-types/common/batch-manual/parser.test.ts b/server/src/lib/score-import/import-types/common/batch-manual/parser.test.ts index 584bc34ad..f954066f0 100644 --- a/server/src/lib/score-import/import-types/common/batch-manual/parser.test.ts +++ b/server/src/lib/score-import/import-types/common/batch-manual/parser.test.ts @@ -1,9 +1,9 @@ import deepmerge from "deepmerge"; +import { IIDXDans } from "lib/constants/classes"; import CreateLogCtx from "lib/logger/logger"; import { BatchManual } from "tachi-common"; import t from "tap"; import { EscapeStringRegexp } from "utils/misc"; -import { IIDXDans } from "lib/constants/classes"; import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import { ParseBatchManualFromObject as ParserFn } from "./parser"; diff --git a/server/src/lib/score-import/import-types/common/batch-manual/parser.ts b/server/src/lib/score-import/import-types/common/batch-manual/parser.ts index 9efbcdbef..0850b442c 100644 --- a/server/src/lib/score-import/import-types/common/batch-manual/parser.ts +++ b/server/src/lib/score-import/import-types/common/batch-manual/parser.ts @@ -1,4 +1,5 @@ import deepmerge from "deepmerge"; +import { IIDXDans, SDVXDans, WACCA_STAGEUPS } from "lib/constants/classes"; import { KtLogger } from "lib/logger/logger"; import { TachiConfig } from "lib/setup/config"; import p, { PrudenceSchema, ValidSchemaValue } from "prudence"; @@ -12,7 +13,6 @@ import { Playtypes, } from "tachi-common"; import { FormatPrError } from "utils/prudence"; -import { IIDXDans, SDVXDans, WACCA_STAGEUPS } from "lib/constants/classes"; import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import { ParserFunctionReturns } from "../types"; import { BatchManualContext } from "./types"; diff --git a/server/src/lib/search/search.ts b/server/src/lib/search/search.ts index a1942d152..dfb299ca1 100644 --- a/server/src/lib/search/search.ts +++ b/server/src/lib/search/search.ts @@ -49,6 +49,7 @@ export function SearchCollection( // hide nonsense { $match: { __textScore: { $gt: 0.25 } } }, { $limit: limit }, + { $unset: "_id" }, ]) .catch((err) => { logger.error( diff --git a/server/src/lib/setup/config.ts b/server/src/lib/setup/config.ts index afbee29fe..d2de97f81 100644 --- a/server/src/lib/setup/config.ts +++ b/server/src/lib/setup/config.ts @@ -79,6 +79,8 @@ export interface TachiServerConfig { }; USC_QUEUE_SIZE: integer; BEATORAJA_QUEUE_SIZE: integer; + MAX_GOAL_SUBSCRIPTIONS: integer; + MAX_MILESTONE_SUBSCRIPTIONS: integer; OUR_URL: string; INVITE_CODE_CONFIG?: { BATCH_SIZE: integer; @@ -152,6 +154,8 @@ const err = p(config, { }), USC_QUEUE_SIZE: p.optional(p.gteInt(2)), BEATORAJA_QUEUE_SIZE: p.optional(p.gteInt(2)), + MAX_GOAL_SUBSCRIPTIONS: p.optional(p.isPositiveInteger), + MAX_MILESTONE_SUBSCRIPTIONS: p.optional(p.isPositiveInteger), OUR_URL: "string", INVITE_CODE_CONFIG: p.optional({ BATCH_SIZE: p.isPositiveInteger, @@ -209,6 +213,8 @@ tachiServerConfig.RATE_LIMIT ??= 500; tachiServerConfig.OAUTH_CLIENT_CAP ??= 15; tachiServerConfig.USC_QUEUE_SIZE ??= 3; tachiServerConfig.BEATORAJA_QUEUE_SIZE ??= 3; +tachiServerConfig.MAX_GOAL_SUBSCRIPTIONS ??= 1_000; +tachiServerConfig.MAX_MILESTONE_SUBSCRIPTIONS ??= 100; // Assign sane defaults to the logger config. tachiServerConfig.LOGGER_CONFIG = Object.assign( diff --git a/server/src/lib/showcase/evaluator.test.ts b/server/src/lib/showcase/evaluator.test.ts index fb0dba4df..315f52e65 100644 --- a/server/src/lib/showcase/evaluator.test.ts +++ b/server/src/lib/showcase/evaluator.test.ts @@ -13,7 +13,7 @@ import { IIDX_LAMPS } from "tachi-common"; t.test("#EvaluateShowcaseStat", (t) => { t.beforeEach(ResetDBState); - t.beforeEach(async () => await CreateFolderChartLookup(TestingIIDXFolderSP10)); + t.beforeEach(async () => await CreateFolderChartLookup(TestingIIDXFolderSP10, true)); t.beforeEach( async () => await db["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})) ); diff --git a/server/src/lib/showcase/get-stats.test.ts b/server/src/lib/showcase/get-stats.test.ts index 78a54238f..e3b49f258 100644 --- a/server/src/lib/showcase/get-stats.test.ts +++ b/server/src/lib/showcase/get-stats.test.ts @@ -14,7 +14,7 @@ t.test("#EvalulateUsersStatsShowcase", (t) => { t.beforeEach(async () => { await db.folders.insert(TestingIIDXFolderSP10); - await CreateFolderChartLookup(TestingIIDXFolderSP10); + await CreateFolderChartLookup(TestingIIDXFolderSP10, true); await db["game-settings"].remove({}); diff --git a/server/src/lib/achievables/goal-utils.ts b/server/src/lib/targets/goal-utils.ts similarity index 94% rename from server/src/lib/achievables/goal-utils.ts rename to server/src/lib/targets/goal-utils.ts index fa4823e7f..3141534bf 100644 --- a/server/src/lib/achievables/goal-utils.ts +++ b/server/src/lib/targets/goal-utils.ts @@ -35,7 +35,7 @@ export async function CreateGoalTitle( return `${formattedCriteria} any chart in ${datasetName}`; } // eslint-disable-next-line no-fallthrough - case "abs": + case "absolute": switch (charts.type) { case "any": return `${formattedCriteria} ${criteria.countNum} charts`; @@ -159,7 +159,8 @@ export async function ValidateGoalChartsAndCriteria( } chartCount = multiCharts.length; - } else if (charts.type === "any") { + } else { + // (charts.type === "any") chartCount = await db.charts[game].count({ playtype }); } @@ -173,17 +174,17 @@ export async function ValidateGoalChartsAndCriteria( if (Math.floor(chartCount * criteria.countNum) === 0) { throw new Error( - `countNum is too small for goal. Would result in requiring 0 charts to achieve the goal.` + `countNum (${criteria.countNum}) is too small for a goal with ${chartCount} charts. Would result in requiring 0 charts to achieve the goal.` ); } } else if ( - criteria.mode === "abs" && + criteria.mode === "absolute" && (criteria.countNum > chartCount || !Number.isInteger(criteria.countNum) || criteria.countNum < 2) ) { throw new Error( - `Invalid countNum for goal with criteria.mode of 'abs'. Expected a whole number less than the total amount of charts available and greater than 1.` + `Invalid countNum for goal with criteria.mode of 'absolute'. Expected a whole number less than the total amount of charts available and greater than 1. (Got ${criteria.countNum}, while total charts was ${chartCount}.)` ); } diff --git a/server/src/lib/achievables/goals.test.ts b/server/src/lib/targets/goals.test.ts similarity index 99% rename from server/src/lib/achievables/goals.test.ts rename to server/src/lib/targets/goals.test.ts index 98c0e82f8..28bae6d0d 100644 --- a/server/src/lib/achievables/goals.test.ts +++ b/server/src/lib/targets/goals.test.ts @@ -226,7 +226,7 @@ t.test("#EvaluateGoalForUser", (t) => { }), ]); - await CreateFolderChartLookup(TestingIIDXFolderSP10); + await CreateFolderChartLookup(TestingIIDXFolderSP10, true); }); t.test("Should work if 511 is >= HARD CLEAR", async (t) => { diff --git a/server/src/lib/achievables/goals.ts b/server/src/lib/targets/goals.ts similarity index 76% rename from server/src/lib/achievables/goals.ts rename to server/src/lib/targets/goals.ts index ff1de9193..cb4b87d3b 100644 --- a/server/src/lib/achievables/goals.ts +++ b/server/src/lib/targets/goals.ts @@ -1,19 +1,21 @@ import db from "external/mongo/db"; import fjsh from "fast-json-stable-hash"; +import { SubscribeFailReasons } from "lib/constants/err-codes"; import CreateLogCtx, { KtLogger } from "lib/logger/logger"; import { FilterQuery } from "mongodb"; import { Game, GetGamePTConfig, GoalDocument, - GoalOrigin, integer, PBScoreDocument, Playtypes, - UserGoalDocument, + GoalSubscriptionDocument, + MilestoneSubscriptionDocument, + MilestoneDocument, } from "tachi-common"; import { GetFolderChartIDs } from "utils/folder"; -import { CreateGoalTitle, ValidateGoalChartsAndCriteria } from "./goal-utils"; +import { CreateGoalTitle as CreateGoalName, ValidateGoalChartsAndCriteria } from "./goal-utils"; const logger = CreateLogCtx(__filename); @@ -148,11 +150,11 @@ export async function EvaluateGoalForUser( nextBestScore ), }; - } else if (goal.criteria.mode === "abs" || goal.criteria.mode === "proportion") { + } else if (goal.criteria.mode === "absolute" || goal.criteria.mode === "proportion") { let count; // abs -> Absolute mode, such as clear 10 charts. - if (goal.criteria.mode === "abs") { + if (goal.criteria.mode === "absolute") { count = goal.criteria.countNum; } else { // proportion -> Proportional mode, the value @@ -225,8 +227,20 @@ export function HumaniseGoalProgress( switch (key) { case "scoreData.gradeIndex": - return `${gptConfig.grades[value]} (${userPB?.scoreData.percent ?? "0"}%)`; + if (!gptConfig.grades[value]) { + throw new Error( + `Corrupt goal -- requested a grade of ${value}, which doesn't exist for this game.` + ); + } + + return `${gptConfig.grades[value]} (${userPB?.scoreData.percent.toFixed(2) ?? "0"}%)`; case "scoreData.lampIndex": + if (!gptConfig.lamps[value]) { + throw new Error( + `Corrupt goal -- requested a lamp of ${value}, which doesn't exist for this game.` + ); + } + if (userPB && (game === "iidx" || game === "bms" || game === "pms")) { return `${gptConfig.lamps[value]} (BP: ${ (userPB as PBWithBadPoor).scoreData.hitMeta.bp ?? "N/A" @@ -266,15 +280,10 @@ export async function ConstructGoal( criteria, charts, goalID: CreateGoalID(charts, criteria, game, playtype), - title: await CreateGoalTitle(charts, criteria, game, playtype), + name: await CreateGoalName(charts, criteria, game, playtype), } as GoalDocument; } -export enum SubscribeFailReasons { - ALREADY_SUBSCRIBED, - ALREADY_ACHIEVED, -} - /** * Subscribes a user to the provided goal document. Handles deduping goals naturally * and general good stuff. @@ -288,17 +297,16 @@ export enum SubscribeFailReasons { export async function SubscribeToGoal( userID: integer, goalDocument: GoalDocument, - origin: GoalOrigin, cancelIfAchieved = true ) { const goalExists = await db.goals.findOne({ goalID: goalDocument.goalID }); if (!goalExists) { await db.goals.insert(goalDocument); - logger.info(`Inserting new goal ${goalDocument.title}.`); + logger.info(`Inserting new goal '${goalDocument.name}'.`); } - const userAlreadySubscribed = await db["user-goals"].findOne({ + const userAlreadySubscribed = await db["goal-subs"].findOne({ userID, goalID: goalDocument.goalID, }); @@ -317,26 +325,78 @@ export async function SubscribeToGoal( return SubscribeFailReasons.ALREADY_ACHIEVED; } - const userGoal: UserGoalDocument = { + // @ts-expect-error TS can't resolve this. + // because it can't explode out the types. + const goalSub: GoalSubscriptionDocument = { outOf: result.outOf, outOfHuman: result.outOfHuman, progress: result.progress, progressHuman: result.progressHuman, userID, - lastInteraction: Date.now(), + lastInteraction: null, timeAchieved: result.achieved ? Date.now() : null, timeSet: Date.now(), - from: origin, game: goalDocument.game, playtype: goalDocument.playtype, goalID: goalDocument.goalID, - // Typescript gets pretty irritated at this because it can't explode out - // the types. That's whatever. - achieved: result.achieved as any, + achieved: result.achieved, wasInstantlyAchieved: result.achieved, }; - await db["user-goals"].insert(userGoal); + await db["goal-subs"].insert(goalSub); - return userGoal; + return goalSub; +} + +export function GetMilestonesThatContainGoal(goalID: string) { + return db.milestones.find({ + "milestoneData.goals.goalID": goalID, + }); +} + +/** + * Unsubscribing from a goal may not be legal, because the goal might be part of + * a milestone the user is subscribed to. This function returns all milestones + * and milestoneSubs that a goal is attached to. + * + * If this query matches none, an empty array is returned. + */ +export async function GetBlockingParentMilestoneSubs( + goalSub: GoalSubscriptionDocument +): Promise<(MilestoneSubscriptionDocument & { milestone: MilestoneDocument })[]> { + const blockers = await db["milestone-subs"].aggregate([ + { + // find all milestones that this user is subscribed to + $match: { + userID: goalSub.userID, + game: goalSub.game, + playtype: goalSub.playtype, + }, + }, + { + // look up the parent milestones + $lookup: { + from: "milestones", + localField: "milestoneID", + foreignField: "milestoneID", + as: "parentMilestoneSubs", + }, + }, + { + // then project it onto the $milestone field. This will be null + // if the milestone has no parent, which we hopefully won't have + // to consider (illegal) + $set: { + milestone: { $arrayElemAt: ["$parentMilestoneSubs", 0] }, + }, + }, + { + // then finally, filter to only milestones that pertain to this goal. + $match: { + "milestone.milestoneData.goals.goalID": goalSub.goalID, + }, + }, + ]); + + return blockers; } diff --git a/server/src/lib/targets/milestones.ts b/server/src/lib/targets/milestones.ts new file mode 100644 index 000000000..07350e683 --- /dev/null +++ b/server/src/lib/targets/milestones.ts @@ -0,0 +1,263 @@ +import db from "external/mongo/db"; +import { SubscribeFailReasons } from "lib/constants/err-codes"; +import CreateLogCtx from "lib/logger/logger"; +import { + GoalDocument, + GoalSubscriptionDocument, + integer, + MilestoneDocument, + MilestoneSubscriptionDocument, +} from "tachi-common"; +import { EvaluatedGoalReturn, EvaluateGoalForUser, SubscribeToGoal } from "./goals"; + +const logger = CreateLogCtx(__filename); + +/** + * Retrieves the goalID documents in a single array from the + * nested structure of milestones. + */ +export function GetGoalIDsFromMilestone(milestone: MilestoneDocument) { + // this sucks - maybe a nicer way to do this, because nested + // maps are just ugly + return milestone.milestoneData.map((e) => e.goals.map((e) => e.goalID)).flat(1); +} + +/** + * Return all the goals inside this milestone. + */ +export async function GetGoalsInMilestone(milestone: MilestoneDocument) { + const goalIDs = GetGoalIDsFromMilestone(milestone); + + const goals = await db.goals.find({ + goalID: { $in: goalIDs }, + }); + + if (goals.length !== goalIDs.length) { + logger.error( + `Milestone ${milestone.name} has ${goalIDs.length} goals registered, but we could only find ${goals.length} in the database?`, + { goals: goals.length, milestone, goalIDs: goalIDs.length } + ); + throw new Error(`Milestone is corrupt. Not the right amount of goals in db?`); + } + + if (goalIDs.length < 2) { + logger.error( + `Milestone ${milestone.name} resolves to less than 2 goals. Isn't a valid milestone?`, + { milestone } + ); + throw new Error(`Milestone is corrupt. Doesn't have enough goals.`); + } + + return goals; +} + +/** + * Work out how many goals need to be achieved for this + * milestone to be considered completed. + */ +export function CalculateMilestoneOutOf(milestone: MilestoneDocument) { + const goalIDs = GetGoalIDsFromMilestone(milestone); + + if (milestone.criteria.type === "all") { + return goalIDs.length; + } else if (milestone.criteria.type === "total") { + if (milestone.criteria.value === null) { + throw new Error( + `Invalid milestone ${milestone.milestoneID} - abs and null are not compatible.` + ); + } + + return milestone.criteria.value!; + } + + throw new Error( + // @ts-expect-error Yeah obviously this shouldn't happen. Mayaswell throw though. + `Invalid milestone.criteria.type of ${milestone.criteria.type} -- milestoneID ${milestone.milestoneID}` + ); +} + +type EvaluatedGoalResult = EvaluatedGoalReturn & { goalID: string }; + +/** + * Evaluate a user's progress on a milestone, regardless of whether they have it + * assigned or not. + * + * @returns All of the goals in the milestone. The users progress on each individual goal, + * their overall progress, what the milestone was outOf, and whether they achieved it or + * not. + */ +export async function EvaluateMilestoneProgress(userID: integer, milestone: MilestoneDocument) { + const goals = await GetGoalsInMilestone(milestone); + + const isSubscribedToMilestone = await db["milestone-subs"].findOne({ + milestoneID: milestone.milestoneID, + }); + + // If the user is subscribed the milestone, we don't need to calculate + // their progress on each goal. + const goalSubMap = new Map(); + if (isSubscribedToMilestone) { + const goalSubs = await db["goal-subs"].find({ + goalID: { $in: goals.map((e) => e.goalID) }, + userID, + }); + + for (const sub of goalSubs) { + goalSubMap.set(sub.goalID, sub); + } + } + + const goalResults: EvaluatedGoalResult[] = await Promise.all( + goals.map(async (goal) => { + if (isSubscribedToMilestone) { + const goalSub = goalSubMap.get(goal.goalID); + + if (!goalSub) { + logger.error( + `User ${userID} has a corrupt subscription to milestone '${milestone.name}', They do not have all the goals in this milestone assigned.` + ); + + throw new Error( + `User has corrupt subscription to milestone. Cannot calculate.` + ); + } + + return { + achieved: goalSub.achieved, + progress: goalSub.progress, + outOf: goalSub.outOf, + progressHuman: goalSub.progressHuman, + outOfHuman: goalSub.outOfHuman, + goalID: goal.goalID, + }; + } + + const result = await EvaluateGoalForUser(goal, userID, logger); + + if (!result) { + logger.error( + `Failed to calculate ${userID} result for goal '${goal.name}'. Is the goal valid?`, + { goal, milestone } + ); + + throw new Error(`Goal inside milestone is corrupt.`); + } + + return { + achieved: result.achieved, + progress: result.progress, + outOf: result.outOf, + progressHuman: result.progressHuman, + outOfHuman: result.outOfHuman, + goalID: goal.goalID, + }; + }) + ); + + const progress = goalResults.filter((e) => e.achieved).length; + const outOf = CalculateMilestoneOutOf(milestone); + + const achieved = progress >= outOf; + + return { + goals, + goalResults, + achieved, + progress, + outOf, + }; +} + +interface MilestoneSubscriptionReturns { + milestoneSub: MilestoneSubscriptionDocument; + goals: GoalDocument[]; + goalResults: EvaluatedGoalResult[]; +} + +/** + * Subscribes the given user to a provided milestone. If the user is already subscribed, + * null is returned. + * + * @param cancelIfAchieved - Don't subscribe to the milestone if subscribing would cause + * the user to immediately achieve it. + */ +export async function SubscribeToMilestone( + userID: integer, + milestone: MilestoneDocument, + cancelIfAchieved: false +): Promise; +export async function SubscribeToMilestone( + userID: integer, + milestone: MilestoneDocument, + cancelIfAchieved = true +): Promise< + | MilestoneSubscriptionReturns + | SubscribeFailReasons.ALREADY_SUBSCRIBED + | SubscribeFailReasons.ALREADY_ACHIEVED +> { + const isSubscribedToMilestone = await db["milestone-subs"].findOne({ + userID, + milestoneID: milestone.milestoneID, + }); + + if (isSubscribedToMilestone) { + return SubscribeFailReasons.ALREADY_SUBSCRIBED; + } + + const result = await EvaluateMilestoneProgress(userID, milestone); + + if (result.achieved && cancelIfAchieved) { + return SubscribeFailReasons.ALREADY_ACHIEVED; + } + + // @ts-expect-error TS can't resolve this. + // because it can't explode out the types. + const milestoneSub: MilestoneSubscriptionDocument = { + progress: result.progress, + userID, + milestoneID: milestone.milestoneID, + wasInstantlyAchieved: result.achieved, + timeSet: Date.now(), + game: milestone.game, + playtype: milestone.playtype, + achieved: result.achieved, + timeAchieved: result.achieved ? Date.now() : null, + lastInteraction: null, + }; + + // @optimisable, EvaluateMilestoneProgress calculates the users progress + // on each goal. We could probably shorten this by directly inserting the records + // from result.goalResults ourselves. + // evaluating goals is fairly cheap though. + await Promise.all( + result.goals.map((goal) => { + SubscribeToGoal(userID, goal, false); + }) + ); + + await db["milestone-subs"].insert(milestoneSub); + + logger.info(`User ${userID} subscribed to '${milestone.name}'.`); + + return { milestoneSub, goals: result.goals, goalResults: result.goalResults }; +} + +export async function UnsubscribeFromMilestone(userID: integer, milestone: MilestoneDocument) { + const goalIDs = GetGoalIDsFromMilestone(milestone); + + // TODO COME BACK HERE + + // then, remove all of the ones that now have no parent blocking their demise. + // that's pretty morbid, jesus christ. + await db["goal-subs"].remove({ + goalID: { $in: goalIDs }, + userID, + parentMilestones: { $size: 0 }, + }); + + // remove the user's milestone sub, aswell. + await db["milestone-subs"].remove({ + userID, + milestoneID: milestone.milestoneID, + }); +} diff --git a/server/src/lib/targets/types.ts b/server/src/lib/targets/types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/lib/webhooks/webhooks.ts b/server/src/lib/webhooks/webhooks.ts index f55fb4aaa..58d6129f2 100644 --- a/server/src/lib/webhooks/webhooks.ts +++ b/server/src/lib/webhooks/webhooks.ts @@ -12,7 +12,7 @@ export async function GetWebhookUrlInfo() { { projection: { webhookUri: 1, clientSecret: 1 } } ); - return urls; + return urls as { webhookUri: string; clientSecret: string }[]; } /** @@ -26,13 +26,16 @@ export async function EmitWebhookEvent(content: WebhookEvents) { // We don't actually care about the response of these. Just fire them and forget. for (const client of webhookUrls) { // we know this to be non-null because of GetWebhookUrlInfo. - fetch(client.webhookUri!, { + fetch(client.webhookUri, { method: "POST", body: JSON.stringify(content), headers: { "Content-Type": "application/json", Authorization: `Bearer ${client.clientSecret}`, }, + }).catch((err) => { + // We don't care about errors. It's probably on their end. + logger.info(err.message); }); } } diff --git a/server/src/scripts/state-sync/dedupe-score-ids.ts b/server/src/scripts/state-sync/dedupe-score-ids.ts index e5ef0eb74..8289b91e8 100644 --- a/server/src/scripts/state-sync/dedupe-score-ids.ts +++ b/server/src/scripts/state-sync/dedupe-score-ids.ts @@ -26,6 +26,7 @@ async function DedupeScoreIDs() { for (const dup of dups) { dup.dups.shift(); + // eslint-disable-next-line no-await-in-loop await db.scores.remove({ _id: { $in: dup.dups } }); } diff --git a/server/src/scripts/sync-database.ts b/server/src/scripts/sync-database.ts index 27af3cd43..1e4dad979 100644 --- a/server/src/scripts/sync-database.ts +++ b/server/src/scripts/sync-database.ts @@ -24,7 +24,10 @@ import { InitaliseFolderChartLookup } from "utils/folder"; interface SyncInstructions { pattern: RegExp; handler: ( + // These 'any's are necessary because generifying here kinda sucks. + // eslint-disable-next-line @typescript-eslint/no-explicit-any c: any[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any collection: ICollection, logger: KtLogger, collectionName: string diff --git a/server/src/server/middleware/auth.ts b/server/src/server/middleware/auth.ts index c2dff5af4..84a606983 100644 --- a/server/src/server/middleware/auth.ts +++ b/server/src/server/middleware/auth.ts @@ -1,10 +1,10 @@ import { RequestHandler } from "express"; import db from "external/mongo/db"; import { SYMBOL_TachiAPIAuth } from "lib/constants/tachi"; -import { SplitAuthorizationHeader } from "utils/misc"; -import { APITokenDocument, APIPermissions, UserAuthLevels } from "tachi-common"; import CreateLogCtx from "lib/logger/logger"; import { TachiConfig } from "lib/setup/config"; +import { APIPermissions, APITokenDocument, UserAuthLevels } from "tachi-common"; +import { SplitAuthorizationHeader } from "utils/misc"; const logger = CreateLogCtx(__filename); @@ -22,8 +22,7 @@ export const AllPermissions: Record = { customise_session: true, customise_score: true, delete_score: true, - set_goals: true, - unset_goals: true, + manage_targets: true, }; export const SetRequestPermissions: RequestHandler = CreateSetRequestPermissions("description"); diff --git a/server/src/server/middleware/multer-upload.ts b/server/src/server/middleware/multer-upload.ts index 4b454dfab..d852394e5 100644 --- a/server/src/server/middleware/multer-upload.ts +++ b/server/src/server/middleware/multer-upload.ts @@ -1,10 +1,10 @@ // @todo #118 import { RequestHandler } from "express"; -import multer, { MulterError } from "multer"; -import CreateLogCtx from "lib/logger/logger"; -import { integer } from "tachi-common"; import { SIXTEEN_MEGABTYES } from "lib/constants/filesize"; +import CreateLogCtx from "lib/logger/logger"; +import multer, { MulterError } from "multer"; +import { integer } from "tachi-common"; const defaultLogger = CreateLogCtx(__filename); diff --git a/server/src/server/middleware/prudence-validate.test.ts b/server/src/server/middleware/prudence-validate.test.ts index 38c272df9..f7bec380b 100644 --- a/server/src/server/middleware/prudence-validate.test.ts +++ b/server/src/server/middleware/prudence-validate.test.ts @@ -1,7 +1,7 @@ -import t from "tap"; import expMiddlewareMock from "express-request-mock"; -import prValidate from "./prudence-validate"; import Prudence from "prudence"; +import t from "tap"; +import prValidate from "./prudence-validate"; t.test("#PrudenceMiddleware", (t) => { const mw = prValidate({ foo: Prudence.regex(/^baz$/u) }, { foo: "example error message" }); diff --git a/server/src/server/middleware/prudence-validate.ts b/server/src/server/middleware/prudence-validate.ts index e45f50f31..c65020a6d 100644 --- a/server/src/server/middleware/prudence-validate.ts +++ b/server/src/server/middleware/prudence-validate.ts @@ -1,11 +1,11 @@ -import Prudence, { - MiddlewareErrorHandler, - PrudenceSchema, - ErrorMessages, - PrudenceOptions, -} from "prudence"; import { RequestHandler } from "express-serve-static-core"; import CreateLogCtx from "lib/logger/logger"; +import Prudence, { + ErrorMessages, + MiddlewareErrorHandler, + PrudenceOptions, + PrudenceSchema, +} from "prudence"; const logger = CreateLogCtx(__filename); diff --git a/server/src/server/middleware/request-logger.ts b/server/src/server/middleware/request-logger.ts index 657bf4ea5..6bbb2c58a 100644 --- a/server/src/server/middleware/request-logger.ts +++ b/server/src/server/middleware/request-logger.ts @@ -1,6 +1,6 @@ -import CreateLogCtx from "lib/logger/logger"; import { RequestHandler, Response } from "express-serve-static-core"; import { SYMBOL_TachiAPIAuth } from "lib/constants/tachi"; +import CreateLogCtx from "lib/logger/logger"; import { TachiConfig } from "lib/setup/config"; const logger = CreateLogCtx(__filename); diff --git a/server/src/server/middleware/sanitise-qstring.test.ts b/server/src/server/middleware/sanitise-qstring.test.ts index bd8d4d3ae..efab76a62 100644 --- a/server/src/server/middleware/sanitise-qstring.test.ts +++ b/server/src/server/middleware/sanitise-qstring.test.ts @@ -1,6 +1,6 @@ -import SanitiseQString from "./sanitise-qstring"; -import t from "tap"; import expMiddlewareMock from "express-request-mock"; +import t from "tap"; +import SanitiseQString from "./sanitise-qstring"; t.test("#SanitiseQString", (t) => { t.test("Should allow GET requests with valid data.", async (t) => { diff --git a/server/src/server/router/api/v1/auth/auth.ts b/server/src/server/router/api/v1/auth/auth.ts index 9b89fff0e..ea721356b 100644 --- a/server/src/server/router/api/v1/auth/auth.ts +++ b/server/src/server/router/api/v1/auth/auth.ts @@ -1,18 +1,13 @@ import bcrypt from "bcryptjs"; -import { - integer, - PrivateUserInfoDocument, - PublicUserDocument, - UserAuthLevels, - UserSettings, -} from "tachi-common"; import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; -import { FormatUserDoc } from "utils/user"; +import { Environment, ServerConfig } from "lib/setup/config"; +import { integer, PublicUserDocument, UserAuthLevels, UserSettings } from "tachi-common"; import nodeFetch from "utils/fetch"; import { Random20Hex } from "utils/misc"; -import { Environment, ServerConfig } from "lib/setup/config"; +import { PrivateUserInfoDocument } from "utils/types"; import { CreateURLWithParams } from "utils/url"; +import { FormatUserDoc } from "utils/user"; const logger = CreateLogCtx(__filename); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/middleware.ts b/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/middleware.ts index 4995a9d8e..b823d6c54 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/middleware.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/middleware.ts @@ -1,11 +1,9 @@ import { RequestHandler } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; -import { AssignToReqTachiData } from "utils/req-tachi-data"; +import { AssignToReqTachiData, GetGPT } from "utils/req-tachi-data"; export const ValidateAndGetChart: RequestHandler = async (req, res, next) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const chart = await db.charts[game].findOne({ chartID: req.params.chartID, diff --git a/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.test.ts index f9624d332..01a278769 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.test.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.test.ts @@ -1,11 +1,10 @@ -import t from "tap"; +import deepmerge from "deepmerge"; import db from "external/mongo/db"; - +import { PBScoreDocument, PublicUserDocument } from "tachi-common"; +import t from "tap"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; import { Testing511SPA, TestingIIDXSPScorePB } from "test-utils/test-data"; -import deepmerge from "deepmerge"; -import { PBScoreDocument, PublicUserDocument } from "tachi-common"; t.test("GET /api/v1/games/:game/:playtype/charts/:chartID", (t) => { t.beforeEach(ResetDBState); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/charts/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/charts/router.test.ts index f5a89edf7..138515154 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/charts/router.test.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/charts/router.test.ts @@ -1,9 +1,9 @@ -import t from "tap"; import db from "external/mongo/db"; +import { PBScoreDocument } from "tachi-common"; +import t from "tap"; +import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; import { LoadTachiIIDXData, Testing511SPA } from "test-utils/test-data"; -import { PBScoreDocument } from "tachi-common"; -import mockApi from "test-utils/mock-api"; t.test("GET /api/v1/games/:game/:playtype/charts", (t) => { t.beforeEach(ResetDBState); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts index 3f5abc322..c77db0a58 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts @@ -1,10 +1,11 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiAPIAuth, SYMBOL_TachiData } from "lib/constants/tachi"; +import { SYMBOL_TachiAPIAuth } from "lib/constants/tachi"; import { SearchGameSongs } from "lib/search/search"; import { ChartDocument, UGPTSettings } from "tachi-common"; import { IsString } from "utils/misc"; import { FindChartsOnPopularity } from "utils/queries/charts"; +import { GetGPT } from "utils/req-tachi-data"; import chartIDRouter from "./_chartID/router"; const router: Router = Router({ mergeParams: true }); @@ -20,8 +21,7 @@ const router: Router = Router({ mergeParams: true }); * @name GET /api/v1/games/:game/:playtype/charts */ router.get("/", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); let songIDs = undefined; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/folders/middleware.ts b/server/src/server/router/api/v1/games/_game/_playtype/folders/middleware.ts index 59bc41bcf..6cba90bfe 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/folders/middleware.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/folders/middleware.ts @@ -1,11 +1,9 @@ import { RequestHandler } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; -import { AssignToReqTachiData } from "utils/req-tachi-data"; +import { AssignToReqTachiData, GetGPT } from "utils/req-tachi-data"; export const GetFolderFromParam: RequestHandler = async (req, res, next) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const folder = await db.folders.findOne({ folderID: req.params.folderID, game, playtype }); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/folders/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/folders/router.test.ts index 2bec59c98..a1f11efde 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/folders/router.test.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/folders/router.test.ts @@ -1,10 +1,9 @@ -import t from "tap"; +import deepmerge from "deepmerge"; import db from "external/mongo/db"; +import { FolderDocument } from "tachi-common"; +import t from "tap"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; -import deepmerge from "deepmerge"; -import { FolderDocument } from "tachi-common"; - import { Testing511SPA } from "test-utils/test-data"; import { CreateFolderChartLookup } from "utils/folder"; @@ -55,7 +54,7 @@ t.test("GET /api/v1/games/:game/:playtype/folders/:folderID", (t) => { t.test("Should return the folder at this ID.", async (t) => { await db.folders.insert(deepmerge(mockFolder, {})); - await CreateFolderChartLookup(mockFolder); + await CreateFolderChartLookup(mockFolder, true); const res = await mockApi.get("/api/v1/games/iidx/SP/folders/foo"); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/folders/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/folders/router.ts index d81b18bd4..a7e11e8ed 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/folders/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/folders/router.ts @@ -4,12 +4,13 @@ import { SYMBOL_TachiData } from "lib/constants/tachi"; import { SearchCollection } from "lib/search/search"; import { GetFolderCharts } from "utils/folder"; import { IsString } from "utils/misc"; +import { GetGPT } from "utils/req-tachi-data"; import { GetFolderFromParam } from "./middleware"; const router: Router = Router({ mergeParams: true }); /** - * Search the folders for this game. + * Search the folders for this GPT. * * @param search - The query to search for. * @param inactive - Also show inactive folders, such as those on old versions. @@ -17,8 +18,7 @@ const router: Router = Router({ mergeParams: true }); * @name GET /api/v1/games/:game/:playtype/folders */ router.get("/", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); if (!IsString(req.query.search)) { return res.status(400).json({ diff --git a/server/src/server/router/api/v1/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/router.ts index 8ddc5da4e..6ea578348 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/router.ts @@ -1,6 +1,5 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; import { ONE_HOUR } from "lib/constants/time"; import { FindOptions } from "monk"; import NodeCache from "node-cache"; @@ -10,6 +9,7 @@ import { FormatGame, Game, GetGamePTConfig, integer, Playtypes, UserGameStats } import { GetRelevantSongsAndCharts } from "utils/db"; import { IsString } from "utils/misc"; import { GetClassDistribution } from "utils/queries/stats"; +import { GetGPT } from "utils/req-tachi-data"; import { CheckStrProfileAlg, CheckStrScoreAlg, @@ -18,11 +18,11 @@ import { import { GetUsersWithIDs } from "utils/user"; import chartsRouter from "./charts/router"; import foldersRouter from "./folders/router"; -import goalsRouter from "./targets/goals/router"; import { ValidatePlaytypeFromParam } from "./middleware"; import scoresRouter from "./scores/router"; import songIDRouter from "./songs/_songID/router"; import tablesRouter from "./tables/router"; +import targetsRouter from "./targets/router"; const router: Router = Router({ mergeParams: true }); @@ -63,8 +63,7 @@ async function GetGameStats( * @name GET /api/v1/games/:game/:playtype */ router.get("/", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const { scoreCount, playerCount, chartCount } = await GetGameStats(game, playtype); @@ -90,8 +89,7 @@ router.get("/", async (req, res) => { * @name GET /api/v1/games/:game/:playtype/leaderboard */ router.get("/leaderboard", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const gptConfig = GetGamePTConfig(game, playtype); const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 100; @@ -155,8 +153,7 @@ router.get("/leaderboard", async (req, res) => { * @name GET /api/v1/games/:game/:playtype/score-leaderboard */ router.get("/score-leaderboard", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const gptConfig = GetGamePTConfig(game, playtype); const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 50; @@ -226,8 +223,7 @@ router.get( class: "string", }), async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const gptConfig = GetGamePTConfig(game, playtype); const stat = req.query.class as string; @@ -268,8 +264,7 @@ router.get( limit: p.optional((self) => p.isBoundedInteger(1, 50)(Number(self))), }), async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const limit = req.query.limit ? Number(req.query.limit) : 10; @@ -299,6 +294,6 @@ router.use("/songs/:songID", songIDRouter); router.use("/folders", foldersRouter); router.use("/tables", tablesRouter); router.use("/scores", scoresRouter); -router.use("/goals", goalsRouter); +router.use("/targets", targetsRouter); export default router; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts index 78b309cfb..7852f62fe 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts @@ -1,30 +1,29 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; -import prValidate from "server/middleware/prudence-validate"; import p from "prudence"; -import { GetUsersWithIDs } from "utils/user"; +import prValidate from "server/middleware/prudence-validate"; import { GetRelevantSongsAndCharts } from "utils/db"; +import { GetGPT } from "utils/req-tachi-data"; +import { GetUsersWithIDs } from "utils/user"; const router: Router = Router({ mergeParams: true }); /** * Return the most recent highlighted scores for this game. * - * @param limit - Return up to this amount. Caps at 100, defaults to 10. + * @param limit - Return up to this amount. Caps at 100, defaults to 100. * * @name GET /api/v1/games/:game/:playtype/scores/highlighted */ router.get( "/highlighted", prValidate({ - limit: p.optional((self) => p.isBoundedInteger(1, 500)(Number(self))), + limit: p.optional((self) => p.isBoundedInteger(1, 100)(Number(self))), }), async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); - const limit = req.query.limit ? Number(req.query.limit) : 10; + const limit = req.query.limit ? Number(req.query.limit) : 100; const scores = await db.scores.find( { diff --git a/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts index dce568c4b..d0a2d3144 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import db from "external/mongo/db"; import { SYMBOL_TachiData } from "lib/constants/tachi"; +import { GetGPT } from "utils/req-tachi-data"; import { ValidateAndGetSong } from "./middleware"; const router: Router = Router({ mergeParams: true }); @@ -14,8 +15,7 @@ router.use(ValidateAndGetSong); */ router.get("/", async (req, res) => { const song = req[SYMBOL_TachiData]!.songDoc!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const charts = await db.charts[game].find({ songID: song.id, diff --git a/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts index 37a45c69b..3e420b134 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts @@ -4,6 +4,7 @@ import { SYMBOL_TachiData } from "lib/constants/tachi"; import { FilterQuery } from "mongodb"; import { TableDocument } from "tachi-common"; import { GetFoldersFromTable } from "utils/folder"; +import { GetGPT } from "utils/req-tachi-data"; import { GetTableFromParam } from "./middleware"; const router: Router = Router({ mergeParams: true }); @@ -16,8 +17,7 @@ const router: Router = Router({ mergeParams: true }); * @name GET /api/v1/games/:game/:playtype/tables */ router.get("/", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const query: FilterQuery = { game, playtype }; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts new file mode 100644 index 000000000..294238e49 --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts @@ -0,0 +1,147 @@ +import db from "external/mongo/db"; +import t from "tap"; +import ResetDBState from "test-utils/resets"; +import { + FakeOtherUser, + IIDXSPMilestoneGoals, + IIDXSPMilestoneGoalSubs, + TestingIIDXSPMilestone, +} from "test-utils/test-data"; +import dm from "deepmerge"; +import { GoalDocument, GoalSubscriptionDocument } from "tachi-common"; +import mockApi from "test-utils/mock-api"; + +// this is my lazy sample data for these tests. +const LoadLazySampleData = async () => { + await db.users.insert(FakeOtherUser); + await db.goals.insert(IIDXSPMilestoneGoals); + await db["goal-subs"].insert([ + ...IIDXSPMilestoneGoalSubs, + dm(IIDXSPMilestoneGoalSubs[0], { + userID: 2, + }), + ] as GoalSubscriptionDocument[]); +}; + +t.test("GET /api/v1/games/:game/:playtype/targets/goals/popular", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadLazySampleData); + + t.test("Should return the most popular subscribed goals for this game.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/goals/popular"); + + t.equal(res.statusCode, 200, "Should return 200."); + + for (const goalSub of IIDXSPMilestoneGoals) { + // i hate this monk 'feature'!!! + delete goalSub._id; + } + + // note: we have to sort the output here such that it's deterministic. + t.strictSame( + (res.body.body as GoalDocument[]).sort((a, b) => a.goalID.localeCompare(b.goalID)), + ( + [ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dm(IIDXSPMilestoneGoals[0] as any, { __subscriptions: 2 }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dm(IIDXSPMilestoneGoals[1] as any, { __subscriptions: 1 }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dm(IIDXSPMilestoneGoals[2] as any, { __subscriptions: 1 }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dm(IIDXSPMilestoneGoals[3] as any, { __subscriptions: 1 }), + ] as unknown as GoalDocument[] + ).sort((a, b) => a.goalID.localeCompare(b.goalID)), + "Should return the most subscribed goals." + ); + + t.end(); + }); + + t.test("Should return an empty array if nobody has done anything.", async (t) => { + const res = await mockApi.get("/api/v1/games/chunithm/Single/targets/goals/popular"); + + t.equal(res.statusCode, 200, "Should return 200."); + t.strictSame(res.body.body, []); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/:game/:playtype/targets/goals/:goalID", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadLazySampleData); + + t.test("Should return information about the specified goal.", async (t) => { + await db.milestones.insert(TestingIIDXSPMilestone); + + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/goals/eg_goal_1"); + + t.hasStrict(res.body.body, { + goal: { + goalID: "eg_goal_1", + }, + goalSubs: [ + { userID: 1, goalID: "eg_goal_1" }, + { userID: 2, goalID: "eg_goal_1" }, + ], + users: [{ id: 1 }, { id: 2 }], + parentMilestones: [{ milestoneID: TestingIIDXSPMilestone.milestoneID }], + }); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/:game/:playtype/targets/goals/:goalID/evaluate-for", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadLazySampleData); + + t.test("Should evaluate a goal upon a user that exists.", async (t) => { + const res = await mockApi.get( + "/api/v1/games/iidx/SP/targets/goals/eg_goal_1/evaluate-for?userID=1" + ); + + t.equal(res.statusCode, 200, "Should return a status code of 200."); + + t.strictSame( + res.body.body, + { + achieved: false, + outOf: 5, + outOfHuman: "HARD CLEAR", + progress: null, + progressHuman: "NO DATA", + }, + "Should evaluate the goal for the user." + ); + + t.end(); + }); + + t.test("Should return 404 if the user doesn't exist.", async (t) => { + const res = await mockApi.get( + "/api/v1/games/iidx/SP/targets/goals/eg_goal_1/evaluate-for?userID=unknown" + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 400 if the user exists but hasn't played this game.", async (t) => { + const res = await mockApi.get( + "/api/v1/games/iidx/SP/targets/goals/eg_goal_1/evaluate-for?userID=2" + ); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts index 1d79578f4..e828ac353 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts @@ -1,11 +1,16 @@ import { RequestHandler, Router } from "express"; import db from "external/mongo/db"; -import { EvaluatedGoalReturn, EvaluateGoalForUser } from "lib/achievables/goals"; import { SYMBOL_TachiData } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; +import { + EvaluatedGoalReturn, + EvaluateGoalForUser, + GetMilestonesThatContainGoal, +} from "lib/targets/goals"; import prValidate from "server/middleware/prudence-validate"; import { FormatGame } from "tachi-common"; -import { AssignToReqTachiData } from "utils/req-tachi-data"; +import { GetMostSubscribedGoals } from "utils/db"; +import { AssignToReqTachiData, GetGPT } from "utils/req-tachi-data"; import { GetUsersWithIDs, ResolveUser } from "utils/user"; const logger = CreateLogCtx(__filename); @@ -13,51 +18,24 @@ const logger = CreateLogCtx(__filename); const router: Router = Router({ mergeParams: true }); /** - * Retrieve goals that have been recently achieved for this game. + * Get the most popular goals for this GPT. * - * @name GET /api/v1/games/:game/:playtype/targets/goals/recently-achieved + * @name GET /api/v1/games/:game/:playtype/targets/goals/popular */ -router.get("/recently-achieved", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; +router.get("/popular", async (req, res) => { + const { game, playtype } = GetGPT(req); - const recentlyAchievedUserGoals = await db["user-goals"].find( - { - game, - playtype, - achieved: true, - // exclude stuff where timeAchieved == timeSet, as they're not really - // goals but rather consequences of milestone assignment. - $expr: { $ne: ["$timeAchieved", "$timeSet"] }, - }, - { - sort: { - timeAchieved: -1, - }, - limit: 10, - } - ); - - const goals = await db.goals.find({ - goalID: { $in: recentlyAchievedUserGoals.map((e) => e.goalID) }, - }); - - const users = await GetUsersWithIDs(recentlyAchievedUserGoals.map((e) => e.userID)); + const goals = await GetMostSubscribedGoals({ game, playtype }); return res.status(200).json({ success: true, - description: `Retrieved ${recentlyAchievedUserGoals.length} recently achieved goals.`, - body: { - userGoals: recentlyAchievedUserGoals, - goals, - users, - }, + description: `Returned ${goals.length} goals.`, + body: goals, }); }); const ResolveGoalID: RequestHandler = async (req, res, next) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const goalID = req.params.goalID; const goal = await db.goals.findOne({ @@ -86,19 +64,22 @@ const ResolveGoalID: RequestHandler = async (req, res, next) => { router.get("/:goalID", ResolveGoalID, async (req, res) => { const goal = req[SYMBOL_TachiData]!.goalDoc!; - const userGoals = await db["user-goals"].find({ + const goalSubs = await db["goal-subs"].find({ goalID: goal.goalID, }); - const users = await GetUsersWithIDs(userGoals.map((e) => e.userID)); + const users = await GetUsersWithIDs(goalSubs.map((e) => e.userID)); + + const parentMilestones = await GetMilestonesThatContainGoal(goal.goalID); return res.status(200).json({ success: true, - description: `Retrieved information about ${goal.title}.`, + description: `Retrieved information about ${goal.name}.`, body: { goal, - userGoals, + goalSubs, users, + parentMilestones, }, }); }); @@ -108,7 +89,7 @@ router.get("/:goalID", ResolveGoalID, async (req, res) => { * * @param userID - The userID to evaluate this goal against. Must be a player of this GPT. * - * @name GET /api/v1/games/:game/:playtype/targets/goals/:goalID/evaluate + * @name GET /api/v1/games/:game/:playtype/targets/goals/:goalID/evaluate-for */ router.get( "/:goalID/evaluate-for", @@ -117,8 +98,7 @@ router.get( userID: "string", }), async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype } = GetGPT(req); const userID = req.query.userID as string; @@ -150,27 +130,27 @@ router.get( const goal = req[SYMBOL_TachiData]!.goalDoc!; const goalID = goal.goalID; - const userGoal = await db["user-goals"].findOne({ + const goalSub = await db["goal-subs"].findOne({ userID: user.id, goalID, }); let goalResults: EvaluatedGoalReturn; // shortcut evaluation by using the user goal - if (userGoal) { + if (goalSub) { goalResults = { - achieved: userGoal.achieved, - outOf: userGoal.outOf, - outOfHuman: userGoal.outOfHuman, - progress: userGoal.progress, - progressHuman: userGoal.progressHuman, + achieved: goalSub.achieved, + outOf: goalSub.outOf, + outOfHuman: goalSub.outOfHuman, + progress: goalSub.progress, + progressHuman: goalSub.progressHuman, }; } else { const results = await EvaluateGoalForUser(goal, user.id, logger); if (!results) { throw new Error( - `Failed to evaluate goal ${goal.title} (${goal.goalID}) for user ${user.id}. More information above.` + `Failed to evaluate goal ${goal.name} (${goal.goalID}) for user ${user.id}. More information above.` ); } @@ -179,7 +159,7 @@ router.get( return res.status(200).json({ success: true, - description: `Evaluated ${goal.title} for ${user.username}.`, + description: `Evaluated ${goal.name} for ${user.username}.`, body: goalResults, }); } diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.test.ts new file mode 100644 index 000000000..a97dc3c65 --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.test.ts @@ -0,0 +1,102 @@ +import db from "external/mongo/db"; +import t from "tap"; +import ResetDBState from "test-utils/resets"; +import { TestingIIDXSPMilestone } from "test-utils/test-data"; +import dm from "deepmerge"; +import { MilestoneDocument, MilestoneSetDocument } from "tachi-common"; +import mockApi from "test-utils/mock-api"; + +const TestingIIDXSPMilestoneSet: MilestoneSetDocument = { + name: "Testing Milestone Set", + desc: "foo", + game: "iidx", + milestones: [TestingIIDXSPMilestone.milestoneID, "other_milestone"], + playtype: "SP", + setID: "milestone_set", +}; + +t.test("GET /api/v1/games/:game/:playtype/targets/milestone-sets", (t) => { + t.beforeEach(ResetDBState); + + function mkSet(merge: any) { + return dm(TestingIIDXSPMilestoneSet, merge) as MilestoneSetDocument; + } + + t.test("Should search the loaded milestone sets for this game.", async (t) => { + await db["milestone-sets"].insert([ + mkSet({ name: "Testing Set", setID: "name" }), + mkSet({ name: "Testing Other Set", setID: "similar_name" }), + mkSet({ name: "Different Name", setID: "radically_different_name" }), + mkSet({ + game: "chunithm", + playtype: "Single", + setID: "matching name but different gpt", + }), + mkSet({ playtype: "DP", setID: "matching name but different playtype" }), + ]); + + const res = await mockApi.get( + "/api/v1/games/iidx/SP/targets/milestone-sets?search=Testing" + ); + + t.hasStrict( + (res.body.body as MilestoneSetDocument[]).sort((a, b) => a.name.localeCompare(b.name)), + [{ setID: "similar_name" }, { setID: "name" }] + ); + + t.end(); + }); + + t.test("Should mandate a search field.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestone-sets"); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/:game/:playtype/targets/milestone-sets/:setID", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await db["milestone-sets"].insert(TestingIIDXSPMilestoneSet); + await db.milestones.insert([ + TestingIIDXSPMilestone, + dm(TestingIIDXSPMilestone, { milestoneID: "other_milestone" }) as MilestoneDocument, + ]); + }); + + t.test("Should return the milestone set and its milestones.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestone-sets/milestone_set"); + + t.equal(res.statusCode, 200, "Should return 200."); + + t.hasStrict(res.body.body.milestoneSet, { + setID: TestingIIDXSPMilestoneSet.setID, + }); + + t.hasStrict( + (res.body.body.milestones as MilestoneDocument[]).sort((a, b) => + a.milestoneID.localeCompare(b.milestoneID) + ), + [ + { milestoneID: TestingIIDXSPMilestone.milestoneID }, + { milestoneID: "other_milestone" }, + ] + ); + + t.end(); + }); + + t.test("Should return 404 if the milestone set doesn't exist.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestone-sets/foobar"); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.ts new file mode 100644 index 000000000..cbf741e41 --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestone-sets/router.ts @@ -0,0 +1,84 @@ +import { RequestHandler, Router } from "express"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import { SearchCollection } from "lib/search/search"; +import { GetChildMilestones } from "utils/db"; +import { IsString } from "utils/misc"; +import { AssignToReqTachiData, GetGPT } from "utils/req-tachi-data"; + +const router: Router = Router({ mergeParams: true }); + +const ResolveMilestoneSetID: RequestHandler = async (req, res, next) => { + const { game, playtype } = GetGPT(req); + const setID = req.params.setID; + + const set = await db["milestone-sets"].findOne({ + setID, + game, + playtype, + }); + + if (!set) { + return res.status(404).json({ + success: false, + description: `A milestone set with ID ${setID} doesn't exist.`, + }); + } + + AssignToReqTachiData(req, { milestoneSetDoc: set }); + + return next(); +}; + +/** + * Search milestone sets. + * + * @param search - The milestone set to search for. + * + * @name GET /api/v1/games/:game/:playtype/targets/milestone-sets + */ +router.get("/", async (req, res) => { + const { game, playtype } = GetGPT(req); + + if (!IsString(req.query.search)) { + return res.status(400).json({ + success: false, + description: `Invalid value for search.`, + }); + } + + const milestoneSets = await SearchCollection( + db["milestone-sets"], + req.query.search, + { game, playtype }, + 50 + ); + + return res.status(200).json({ + success: true, + description: `Returned ${milestoneSets.length} milestone sets.`, + body: milestoneSets, + }); +}); + +/** + * Retrieve a specific milestone set. + * + * @name GET /api/v1/games/:game/:playtype/targets/milestone-sets/:setID + */ +router.get("/:setID", ResolveMilestoneSetID, async (req, res) => { + const milestoneSet = req[SYMBOL_TachiData]!.milestoneSetDoc!; + + const milestones = await GetChildMilestones(milestoneSet); + + return res.status(200).json({ + success: true, + description: `Retrieved milestone set '${milestoneSet.name}'.`, + body: { + milestones, + milestoneSet, + }, + }); +}); + +export default router; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.test.ts new file mode 100644 index 000000000..9eebb3722 --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.test.ts @@ -0,0 +1,267 @@ +import dm from "deepmerge"; +import db from "external/mongo/db"; +import { + MilestoneDocument, + MilestoneSetDocument, + MilestoneSubscriptionDocument, +} from "tachi-common"; +import t from "tap"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { + FakeOtherUser, + IIDXSPMilestoneGoals, + IIDXSPMilestoneGoalSubs, + TestingIIDXSPMilestone, + TestingIIDXSPMilestoneSub, + TestingIIDXSPScorePB, +} from "test-utils/test-data"; + +t.test("GET /api/v1/games/:game/:playtype/targets/milestones", (t) => { + t.beforeEach(ResetDBState); + + function mkMilestone(merge: any) { + return dm(TestingIIDXSPMilestone, merge) as MilestoneDocument; + } + + t.test("Should search milestones.", async (t) => { + await db.milestones.insert([ + mkMilestone({ name: "Testing Set", milestoneID: "name" }), + mkMilestone({ name: "Testing Other Set", milestoneID: "similar_name" }), + mkMilestone({ name: "Different Name", milestoneID: "radically_different_name" }), + mkMilestone({ + game: "chunithm", + playtype: "Single", + milestoneID: "matching name but different gpt", + }), + mkMilestone({ playtype: "DP", milestoneID: "matching name but different playtype" }), + ]); + + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestones?search=Testing"); + + t.equal(res.statusCode, 200); + + t.hasStrict( + (res.body.body as MilestoneDocument[]).sort((a, b) => a.name.localeCompare(b.name)), + [{ milestoneID: "similar_name" }, { milestoneID: "name" }] + ); + + t.end(); + }); + + t.end(); +}); + +// this is my lazy sample data for these tests. +const LoadLazySampleData = async () => { + await db.users.insert(FakeOtherUser); + await db.goals.insert(IIDXSPMilestoneGoals); + await db.milestones.insert([ + TestingIIDXSPMilestone, + dm(TestingIIDXSPMilestone, { milestoneID: "other_milestone" }) as MilestoneDocument, + ]); + await db["milestone-subs"].insert([ + TestingIIDXSPMilestoneSub, + dm(TestingIIDXSPMilestoneSub, { milestoneID: "other_milestone" }), + dm(TestingIIDXSPMilestoneSub, { + userID: 2, + }), + ] as MilestoneSubscriptionDocument[]); +}; + +t.test("GET /api/v1/games/:game/:playtype/targets/milestones/popular", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadLazySampleData); + + t.test("Should return the most subscribed milestones for this GPT.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestones/popular"); + + t.equal(res.statusCode, 200); + + t.hasStrict( + (res.body.body as MilestoneDocument[]).sort((a, b) => a.name.localeCompare(b.name)), + [ + { milestoneID: TestingIIDXSPMilestone.milestoneID, __subscriptions: 2 }, + { milestoneID: "other_milestone", __subscriptions: 1 }, + ] + ); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await Promise.all([ + db["goal-subs"].insert(IIDXSPMilestoneGoalSubs), + db["milestone-sets"].insert({ + setID: "set_id", + milestones: [TestingIIDXSPMilestone.milestoneID], + } as MilestoneSetDocument), + ]); + }); + + t.beforeEach(LoadLazySampleData); + + t.test("Should return the milestone and its goals.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + ); + + t.hasStrict(res.body.body, { + milestone: { milestoneID: TestingIIDXSPMilestone.milestoneID }, + milestoneSubs: [{ userID: 1, milestoneID: TestingIIDXSPMilestone.milestoneID }], + users: [{ id: 1 }, { id: 2 }], + goals: [ + { goalID: "eg_goal_1" }, + { goalID: "eg_goal_2" }, + { goalID: "eg_goal_3" }, + { goalID: "eg_goal_4" }, + ], + parentMilestoneSets: [{ setID: "set_id" }], + }); + + t.end(); + }); + + t.test("Should return 404 if the requested milestone doesn't exist.", async (t) => { + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/milestones/fake_milestone"); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 404 if the milestone exists but for a different GPT.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/DP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID/evaluate-for", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadLazySampleData); + + t.test("Should evaluate a milestone for a user.", async (t) => { + // remove all milestone subs -- the user isn't properly + // subscribed to this milestone due to test mocking. + // this will force a live calc. + await db["milestone-subs"].remove({}); + + await db["personal-bests"].insert(TestingIIDXSPScorePB); + + const res = await mockApi.get( + `/api/v1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}/evaluate-for?userID=1` + ); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + goals: [ + { + goalID: "eg_goal_1", + }, + { + goalID: "eg_goal_2", + }, + { + goalID: "eg_goal_3", + }, + { + goalID: "eg_goal_4", + }, + ], + goalResults: [ + { + achieved: true, + progress: 6, + outOf: 5, + progressHuman: "EX HARD CLEAR (BP: 2)", + outOfHuman: "HARD CLEAR", + goalID: "eg_goal_1", + }, + { + achieved: true, + progress: 6, + outOf: 2, + progressHuman: "EX HARD CLEAR (BP: 2)", + outOfHuman: "ASSIST CLEAR", + goalID: "eg_goal_2", + }, + { + achieved: true, + progress: 1479, + outOf: 300, + outOfHuman: "300", + progressHuman: "1479", + goalID: "eg_goal_3", + }, + { + achieved: true, + progress: 1479, + outOf: 1100, + outOfHuman: "1100", + progressHuman: "1479", + goalID: "eg_goal_4", + }, + ], + achieved: true, + progress: 4, + outOf: 4, + }); + + t.end(); + }); + + t.test("Should return 404 if the requested milestone doesn't exist.", async (t) => { + const res = await mockApi.get( + "/api/v1/games/iidx/SP/targets/milestones/fake_milestone/evaluate-for?userID=1" + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 404 if the milestone exists but for a different GPT.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/DP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}/evaluate-for?userID=1` + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 404 if the requested user doesn't exist.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}/evaluate-for?userID=3` + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 400 if the requested user hasn't played this gpt.", async (t) => { + const res = await mockApi.get( + `/api/v1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}/evaluate-for?userID=2` + ); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.ts new file mode 100644 index 000000000..3f4fce551 --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/milestones/router.ts @@ -0,0 +1,171 @@ +import { RequestHandler, Router } from "express"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import { SearchCollection } from "lib/search/search"; +import { EvaluateMilestoneProgress, GetGoalsInMilestone } from "lib/targets/milestones"; +import prValidate from "server/middleware/prudence-validate"; +import { FormatGame } from "tachi-common"; +import { GetMostSubscribedMilestones } from "utils/db"; +import { IsString } from "utils/misc"; +import { AssignToReqTachiData, GetGPT } from "utils/req-tachi-data"; +import { GetUsersWithIDs, ResolveUser } from "utils/user"; + +const router: Router = Router({ mergeParams: true }); + +const ResolveMilestoneID: RequestHandler = async (req, res, next) => { + const { game, playtype } = GetGPT(req); + const milestoneID = req.params.milestoneID; + + const milestone = await db.milestones.findOne({ + milestoneID, + game, + playtype, + }); + + if (!milestone) { + return res.status(404).json({ + success: false, + description: `A milestone with ID ${milestoneID} doesn't exist.`, + }); + } + + AssignToReqTachiData(req, { milestoneDoc: milestone }); + + return next(); +}; + +/** + * Search milestones for this GPT. + * + * @param search - The query to search for. + * + * @name GET /api/v1/games/:game/:playtype/targets/milestones + */ +router.get("/", async (req, res) => { + const { game, playtype } = GetGPT(req); + + if (!IsString(req.query.search)) { + return res.status(400).json({ + success: false, + description: `Invalid value for search.`, + }); + } + + const milestones = await SearchCollection( + db.milestones, + req.query.search, + { game, playtype }, + 50 + ); + + return res.status(200).json({ + success: true, + description: `Returned ${milestones.length} milestones.`, + body: milestones, + }); +}); + +/** + * Find the most subscribed-to milestones for this GPT. + * + * @name GET /api/v1/games/:game/:playtype/targets/milestones/popular + */ +router.get("/popular", async (req, res) => { + const { game, playtype } = GetGPT(req); + + const milestones = await GetMostSubscribedMilestones({ game, playtype }); + + return res.status(200).json({ + success: true, + description: `Returned ${milestones.length} popular milestones.`, + body: milestones, + }); +}); + +/** + * Retrieve information about this milestone and who is subscribed to it. + * + * @name GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID + */ +router.get("/:milestoneID", ResolveMilestoneID, async (req, res) => { + const milestone = req[SYMBOL_TachiData]!.milestoneDoc!; + + const milestoneSubs = await db["milestone-subs"].find({ + milestoneID: milestone.milestoneID, + }); + + const users = await GetUsersWithIDs(milestoneSubs.map((e) => e.userID)); + + const goals = await GetGoalsInMilestone(milestone); + + const parentMilestoneSets = await db["milestone-sets"].find({ + milestones: milestone.milestoneID, + }); + + return res.status(200).json({ + success: true, + description: `Retrieved information about ${milestone.name}.`, + body: { + milestone, + milestoneSubs, + users, + goals, + parentMilestoneSets, + }, + }); +}); + +/** + * Evaluates a milestone upon a user, even if they aren't subscribed to it. + * + * @param userID - The userID to evaluate this goal against. Must be a player of this GPT. + * + * @name GET /api/v1/games/:game/:playtype/targets/milestones/:milestoneID/evaluate-for + */ +router.get( + "/:milestoneID/evaluate-for", + ResolveMilestoneID, + prValidate({ userID: "string" }), + async (req, res) => { + const { game, playtype } = GetGPT(req); + + const userID = req.query.userID as string; + + const user = await ResolveUser(userID); + + if (!user) { + return res.status(404).json({ + success: false, + description: `The user ${userID} does not exist.`, + }); + } + + const hasPlayed = await db["game-stats"].findOne({ + game, + playtype, + userID: user.id, + }); + + if (!hasPlayed) { + return res.status(400).json({ + success: false, + description: `The user ${user.username} hasn't played ${FormatGame( + game, + playtype + )}.`, + }); + } + + const milestone = req[SYMBOL_TachiData]!.milestoneDoc!; + + const milestoneProgress = await EvaluateMilestoneProgress(user.id, milestone); + + return res.status(200).json({ + success: true, + description: `Evaluated ${milestone.name} for ${user.username}.`, + body: milestoneProgress, + }); + } +); + +export default router; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/router.test.ts new file mode 100644 index 000000000..9585382a8 --- /dev/null +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/router.test.ts @@ -0,0 +1,92 @@ +import dm from "deepmerge"; +import db from "external/mongo/db"; +import { GoalSubscriptionDocument } from "tachi-common"; +import t from "tap"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { HC511UserGoal } from "test-utils/test-data"; + +t.test("GET /api/v1/games/:game/:playtype/targets/recently-achieved", (t) => { + t.beforeEach(ResetDBState); + + // mutate + function m(partial: Partial): GoalSubscriptionDocument { + return dm(HC511UserGoal, partial); + } + + t.test("Should return some recently achieved goals.", async (t) => { + await db["goal-subs"].insert([ + // not achieved + HC511UserGoal, + m({ goalID: "achieved", achieved: true, timeAchieved: 1000 }), + m({ goalID: "achieved_more_recently", achieved: true, timeAchieved: 2000 }), + m({ + goalID: "achieved_instantly", + achieved: true, + timeAchieved: 1000, + wasInstantlyAchieved: true, + }), + ]); + + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/recently-achieved"); + + t.equal(res.statusCode, 200); + + // Shouldn't have the unachieved goal, shouldn't have the instantly achieved goal. + // should also have them in the right order. + t.strictSame( + res.body.body.goalSubs.map((e: GoalSubscriptionDocument) => e.goalID), + ["achieved_more_recently", "achieved"] + ); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/games/:game/:playtype/targets/recently-raised", (t) => { + t.beforeEach(ResetDBState); + + // mutate + function m(partial: Partial): GoalSubscriptionDocument { + return dm(HC511UserGoal, partial); + } + + t.test("Should return some recently interacted goals.", async (t) => { + await db["goal-subs"].insert([ + // not achieved + HC511UserGoal, + m({ goalID: "interacted", achieved: false, lastInteraction: 1000 }), + // happened more recently + m({ goalID: "interacted_more_recently", achieved: false, lastInteraction: 2000 }), + // shouldnt be included -- just recently-raised. + m({ + goalID: "achieved", + achieved: true, + lastInteraction: 1000, + }), + m({ + goalID: "achieved_instantly", + achieved: true, + lastInteraction: 1000, + wasInstantlyAchieved: true, + }), + ]); + + const res = await mockApi.get("/api/v1/games/iidx/SP/targets/recently-raised"); + + t.equal(res.statusCode, 200); + + // Shouldn't have the uninteracted goal, shouldn't have the instantly achieved goal. + // should also have them in the right order. + t.strictSame( + res.body.body.goalSubs.map((e: GoalSubscriptionDocument) => e.goalID), + ["interacted_more_recently", "interacted"] + ); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts index 63929423c..0763f162d 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/router.ts @@ -1,8 +1,73 @@ import { Router } from "express"; +import { FormatGame } from "tachi-common"; +import { + GetRecentlyAchievedGoals, + GetRecentlyAchievedMilestones, + GetRecentlyInteractedGoals, + GetRecentlyInteractedMilestones, +} from "utils/db"; +import { GetGPT } from "utils/req-tachi-data"; import goalsRouter from "./goals/router"; +import milestonesRouter from "./milestones/router"; +import milestoneSetsRouter from "./milestone-sets/router"; const router: Router = Router({ mergeParams: true }); +/** + * Retrieve all of this game's recently achieved goals and milestones. + * + * @name GET /api/v1/games/:game/:playtype/targets/recently-achieved + */ +router.get("/recently-achieved", async (req, res) => { + const { game, playtype } = GetGPT(req); + + const [{ goals, goalSubs }, { milestones, milestoneSubs }] = await Promise.all([ + GetRecentlyAchievedGoals({ game, playtype }), + GetRecentlyAchievedMilestones({ game, playtype }), + ]); + + return res.status(200).json({ + success: true, + description: `Retrieved some recently achieved targets for ${FormatGame(game, playtype)}`, + body: { + goals, + goalSubs, + milestones, + milestoneSubs, + }, + }); +}); + +/** + * Retrieve all of this game's recently interacted-with goals and milestones. + * + * @name GET /api/v1/games/:game/:playtype/targets/recently-raised + */ +router.get("/recently-raised", async (req, res) => { + const { game, playtype } = GetGPT(req); + + const [{ goals, goalSubs }, { milestones, milestoneSubs }] = await Promise.all([ + GetRecentlyInteractedGoals({ game, playtype }), + GetRecentlyInteractedMilestones({ game, playtype }), + ]); + + return res.status(200).json({ + success: true, + description: `Retrieved some recently interacted-with targets for ${FormatGame( + game, + playtype + )}`, + body: { + goals, + goalSubs, + milestones, + milestoneSubs, + }, + }); +}); + router.use("/goals", goalsRouter); +router.use("/milestones", milestonesRouter); +router.use("/milestone-sets", milestoneSetsRouter); export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts index e7557defa..be7cca9e2 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts @@ -20,7 +20,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID", (t) folderID: "testing_folder", }) as FolderDocument; await db.folders.insert(folder); - await CreateFolderChartLookup(folder); + await CreateFolderChartLookup(folder, true); await db["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})); }); @@ -56,7 +56,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeli t.beforeEach(ResetDBState); t.beforeEach(async () => { await db.folders.insert(folder); - await CreateFolderChartLookup(folder); + await CreateFolderChartLookup(folder, true); await db["personal-bests"].insert(deepmerge(TestingIIDXSPScorePB, {})); }); @@ -68,7 +68,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeli await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json")); await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json")); - await CreateFolderChartLookup(folder); + await CreateFolderChartLookup(folder, true); await db.scores.insert([ deepmerge(TestingIIDXSPScore, { diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts index a22964c41..7631870e5 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts @@ -4,6 +4,7 @@ import { SYMBOL_TachiData } from "lib/constants/tachi"; import { FilterQuery } from "mongodb"; import { GetGamePTConfig, ScoreDocument } from "tachi-common"; import { GetFolderCharts, GetGradeLampDistributionForFolder, GetPBsOnFolder } from "utils/folder"; +import { GetUGPT } from "utils/req-tachi-data"; import { ParseStrPositiveInt } from "utils/string-checks"; import { GetFolderFromParam } from "../../../../../../../games/_game/_playtype/folders/middleware"; import { RequireSelfRequestFromUser } from "../../../../../middleware"; @@ -18,7 +19,8 @@ router.use(GetFolderFromParam); * @name GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID */ router.get("/", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; + const { user } = GetUGPT(req); + const folder = req[SYMBOL_TachiData]!.folderDoc!; const { songs, charts, pbs } = await GetPBsOnFolder(user.id, folder); @@ -41,8 +43,9 @@ router.get("/", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/stats */ router.get("/stats", async (req, res) => { + const { user } = GetUGPT(req); + const folder = req[SYMBOL_TachiData]!.folderDoc!; - const user = req[SYMBOL_TachiData]!.requestedUser!; const stats = await GetGradeLampDistributionForFolder(user.id, folder); @@ -64,7 +67,8 @@ router.get("/stats", async (req, res) => { * @name POST /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/viewed */ router.post("/viewed", RequireSelfRequestFromUser, async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; + const { user } = GetUGPT(req); + const folder = req[SYMBOL_TachiData]!.folderDoc!; await db["recent-folder-views"].update( @@ -101,10 +105,9 @@ router.post("/viewed", RequireSelfRequestFromUser, async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeline */ router.get("/timeline", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; + const { user, game, playtype } = GetUGPT(req); + const folder = req[SYMBOL_TachiData]!.folderDoc!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; const gptConfig = GetGamePTConfig(game, playtype); const intIndex = ParseStrPositiveInt(req.query.criteriaValue); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts index 343a5cf25..934037454 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts @@ -1,9 +1,9 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; import { SearchCollection } from "lib/search/search"; import { GetGradeLampDistributionForFolders, GetRecentlyViewedFolders } from "utils/folder"; import { IsString } from "utils/misc"; +import { GetUGPT } from "utils/req-tachi-data"; import folderIDRouter from "./_folderID/router"; const router: Router = Router({ mergeParams: true }); @@ -21,9 +21,7 @@ const router: Router = Router({ mergeParams: true }); * @name GET /api/v1/users/:userID/games/:game/:playtype/folders */ router.get("/", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; - const user = req[SYMBOL_TachiData]!.requestedUser!; + const { game, playtype, user } = GetUGPT(req); if (!IsString(req.query.search)) { return res.status(400).json({ @@ -60,9 +58,7 @@ router.get("/", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/folders/recent */ router.get("/recent", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; - const user = req[SYMBOL_TachiData]!.requestedUser!; + const { game, playtype, user } = GetUGPT(req); const { views, folders } = await GetRecentlyViewedFolders(user.id, game, playtype); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts index 3e3889062..bde226a4b 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts @@ -1,11 +1,11 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; import { SearchGameSongsAndCharts } from "lib/search/search"; import { GetGamePTConfig } from "tachi-common"; import { GetRelevantSongsAndCharts } from "utils/db"; import { IsValidScoreAlg } from "utils/misc"; import { GetAdjacentAbove, GetAdjacentBelow } from "utils/queries/pbs"; +import { GetUGPT } from "utils/req-tachi-data"; import { FilterChartsAndSongs, GetScoreIDsFromComposed } from "utils/scores"; import { GetUsersWithIDs } from "utils/user"; @@ -19,9 +19,7 @@ const router: Router = Router({ mergeParams: true }); * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs */ router.get("/", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); if (typeof req.query.search !== "string") { return res.status(400).json({ @@ -71,9 +69,7 @@ router.get("/", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/all */ router.get("/all", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const pbs = await db["personal-bests"].find({ userID: user.id, @@ -99,9 +95,8 @@ router.get("/all", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/best */ router.get("/best", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); + const gptConfig = GetGamePTConfig(game, playtype); if (req.query.alg && !IsValidScoreAlg(gptConfig, req.query.alg)) { @@ -152,9 +147,7 @@ router.get("/best", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID */ router.get("/:chartID", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const chart = await db.charts[game].findOne({ chartID: req.params.chartID, @@ -215,9 +208,7 @@ router.get("/:chartID", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID/leaderboard-adjacent */ router.get("/:chartID/leaderboard-adjacent", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const chart = await db.charts[game].findOne({ chartID: req.params.chartID, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts index 3cb3f5f5d..e9ec94104 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts @@ -1,12 +1,6 @@ import deepmerge from "deepmerge"; import db from "external/mongo/db"; -import { - GoalDocument, - MilestoneDocument, - ScoreDocument, - UserGoalDocument, - UserMilestoneDocument, -} from "tachi-common"; +import { ScoreDocument } from "tachi-common"; import t from "tap"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; @@ -180,149 +174,6 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/history", (t) => { t.end(); }); -t.test("GET /api/v1/users/:userID/games/:game/:playtype/goals", (t) => { - t.beforeEach(ResetDBState); - - t.test( - "Should return all of a users goals, and only unachieved goals if the argument is set.", - async (t) => { - await db["user-goals"].insert([ - { - goalID: "foo", - userID: 1, - game: "iidx", - playtype: "SP", - achieved: false, - }, - { - goalID: "bar", - userID: 1, - game: "iidx", - playtype: "SP", - achieved: true, - }, - ] as UserGoalDocument[]); - - await db.goals.insert([{ goalID: "foo" }, { goalID: "bar" }] as GoalDocument[]); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/goals"); - - t.hasStrict(res.body, { - success: true, - description: "Successfully returned 2 goal(s).", - body: { - userGoals: [ - { - goalID: "foo", - achieved: false, - }, - { - goalID: "bar", - achieved: true, - }, - ], - goals: [{ goalID: "bar" }, { goalID: "foo" }], - }, - }); - - const resUnachieved = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/goals?unachieved=true" - ); - - t.hasStrict(resUnachieved.body, { - success: true, - description: "Successfully returned 1 goal(s).", - body: { - userGoals: [ - { - goalID: "foo", - achieved: false, - }, - ], - goals: [{ goalID: "foo" }], - }, - }); - - t.end(); - } - ); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/milestones", (t) => { - t.beforeEach(ResetDBState); - - t.test( - "Should return all of a users milestones, and only unachieved milestones if the argument is set.", - async (t) => { - await db["user-milestones"].insert([ - { - milestoneID: "foo", - userID: 1, - game: "iidx", - playtype: "SP", - achieved: false, - }, - { - milestoneID: "bar", - userID: 1, - game: "iidx", - playtype: "SP", - achieved: true, - }, - ] as UserMilestoneDocument[]); - - await db.milestones.insert([ - { milestoneID: "foo" }, - { milestoneID: "bar" }, - ] as MilestoneDocument[]); - - const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/milestones"); - - t.hasStrict(res.body, { - success: true, - description: "Successfully returned 2 milestone(s).", - body: { - userMilestones: [ - { - milestoneID: "foo", - achieved: false, - }, - { - milestoneID: "bar", - achieved: true, - }, - ], - milestones: [{ milestoneID: "bar" }, { milestoneID: "foo" }], - }, - }); - - const resUnachieved = await mockApi.get( - "/api/v1/users/1/games/iidx/SP/milestones?unachieved=true" - ); - - t.hasStrict(resUnachieved.body, { - success: true, - description: "Successfully returned 1 milestone(s).", - body: { - userMilestones: [ - { - milestoneID: "foo", - achieved: false, - }, - ], - milestones: [{ milestoneID: "foo" }], - }, - }); - - t.end(); - } - ); - - t.end(); -}); - t.test("GET /api/v1/users/:userID/games/:game/:playtype/most-played", (t) => { t.beforeEach(ResetDBState); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts index 596e96a10..ee0a19d12 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts @@ -1,16 +1,9 @@ import { Router } from "express"; import db from "external/mongo/db"; import { SYMBOL_TachiData } from "lib/constants/tachi"; -import { FilterQuery } from "mongodb"; -import { - GetGamePTConfig, - integer, - PBScoreDocument, - UserGameStatsSnapshot, - UserGoalDocument, - UserMilestoneDocument, -} from "tachi-common"; +import { GetGamePTConfig, integer, PBScoreDocument, UserGameStatsSnapshot } from "tachi-common"; import { IsString } from "utils/misc"; +import { GetUGPT } from "utils/req-tachi-data"; import { CheckStrProfileAlg } from "utils/string-checks"; import { GetAllRankings, @@ -26,7 +19,7 @@ import sessionsRouter from "./sessions/router"; import settingsRouter from "./settings/router"; import showcaseRouter from "./showcase/router"; import tablesRouter from "./tables/router"; -import goalsRouter from "./targets/goals/router"; +import targetsRouter from "./targets/router"; const router: Router = Router({ mergeParams: true }); @@ -37,10 +30,9 @@ router.use(CheckUserPlayedGamePlaytype); * @name GET /api/v1/users/:userID/games/:game/:playtype */ router.get("/", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; + const { game, playtype, user } = GetUGPT(req); + const stats = req[SYMBOL_TachiData]!.requestedUserGameStats!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; const [totalScores, firstScore, mostRecentScore, rankingData] = await Promise.all([ db.scores.count({ @@ -95,10 +87,9 @@ router.get("/", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/history */ router.get("/history", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; + const { game, playtype, user } = GetUGPT(req); + const stats = req[SYMBOL_TachiData]!.requestedUserGameStats!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; const snapshots = (await db["game-stats-snapshots"].find( { @@ -135,89 +126,13 @@ router.get("/history", async (req, res) => { }); }); -/** - * Returns a user's set goals for this game. - * @param unachieved - If set, achieved goals will be hidden. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/goals - */ -router.get("/goals", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; - - const query: FilterQuery = { - userID: user.id, - game, - playtype, - }; - - if (req.query.unachieved) { - query.achieved = false; - } - - const userGoals = await db["user-goals"].find(query); - - const goals = await db.goals.find({ - goalID: { $in: userGoals.map((e) => e.goalID) }, - }); - - return res.status(200).json({ - success: true, - description: `Successfully returned ${userGoals.length} goal(s).`, - body: { - userGoals, - goals, - }, - }); -}); - -/** - * Returns a user's set milestones for this game. - * @param unachieved - If set, achieved milestones will be hidden. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/milestones - */ -router.get("/milestones", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; - - const query: FilterQuery = { - userID: user.id, - game, - playtype, - }; - - if (req.query.unachieved) { - query.achieved = false; - } - - const userMilestones = await db["user-milestones"].find(query); - - const milestones = await db.milestones.find({ - milestoneID: { $in: userMilestones.map((e) => e.milestoneID) }, - }); - - return res.status(200).json({ - success: true, - description: `Successfully returned ${userMilestones.length} milestone(s).`, - body: { - userMilestones, - milestones, - }, - }); -}); - /** * Returns the users most played charts by playcount. * * @name GET /api/v1/users/:userID/games/:game/:playtype/most-played */ router.get("/most-played", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype, user } = GetUGPT(req); const mostPlayed: { playcount: integer; songID: integer; _id: string }[] = await db.scores.aggregate([ @@ -290,10 +205,9 @@ router.get("/most-played", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/leaderboard-adjacent */ router.get("/leaderboard-adjacent", async (req, res) => { - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { game, playtype, user } = GetUGPT(req); + const gptConfig = GetGamePTConfig(game, playtype); - const user = req[SYMBOL_TachiData]!.requestedUser!; let alg = gptConfig.defaultProfileRatingAlg; if (IsString(req.query.alg)) { @@ -378,6 +292,6 @@ router.use("/tables", tablesRouter); router.use("/showcase", showcaseRouter); router.use("/settings", settingsRouter); router.use("/folders", foldersRouter); -router.use("/goals", goalsRouter); +router.use("/targets", targetsRouter); export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts index 9c1eaa46c..fca5befb8 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts @@ -1,8 +1,8 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; import { SearchGameSongsAndCharts } from "lib/search/search"; import { GetRelevantSongsAndCharts } from "utils/db"; +import { GetUGPT } from "utils/req-tachi-data"; import { FilterChartsAndSongs } from "utils/scores"; const router: Router = Router({ mergeParams: true }); @@ -13,9 +13,7 @@ const router: Router = Router({ mergeParams: true }); * @name GET /api/v1/users/:userID/games/:game/:playtype/scores */ router.get("/", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); if (typeof req.query.search !== "string") { return res.status(400).json({ @@ -62,9 +60,7 @@ router.get("/", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/scores/recent */ router.get("/recent", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const recentScores = await db.scores.find( { @@ -99,9 +95,7 @@ router.get("/recent", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/scores/:chartID */ router.get("/:chartID", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const chart = await db.charts[game].findOne({ chartID: req.params.chartID, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts index e2f540cea..509d81af1 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts @@ -1,8 +1,8 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; import { SearchSessions } from "lib/search/search"; import { GetGamePTConfig } from "tachi-common"; +import { GetUGPT } from "utils/req-tachi-data"; import { CheckStrSessionAlg } from "utils/string-checks"; const router: Router = Router({ mergeParams: true }); @@ -13,9 +13,7 @@ const router: Router = Router({ mergeParams: true }); * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions */ router.get("/", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); if (typeof req.query.search !== "string") { return res.status(400).json({ @@ -41,9 +39,8 @@ router.get("/", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/best */ router.get("/best", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); + const gptConfig = GetGamePTConfig(game, playtype); let alg = gptConfig.defaultSessionRatingAlg; @@ -89,9 +86,7 @@ router.get("/best", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted */ router.get("/highlighted", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const sessions = await db.sessions.find( { userID: user.id, game, playtype, highlight: true }, @@ -111,9 +106,7 @@ router.get("/highlighted", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent */ router.get("/recent", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const sessions = await db.sessions.find( { userID: user.id, game, playtype }, @@ -133,9 +126,7 @@ router.get("/recent", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/last */ router.get("/last", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const session = await db.sessions.findOne( { userID: user.id, game, playtype }, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts index f6c1fde0d..aec8c30f7 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts @@ -1,11 +1,11 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import p from "prudence"; import { RequirePermissions } from "server/middleware/auth"; import { GetGamePTConfig } from "tachi-common"; import { FormatPrError, optNull } from "utils/prudence"; +import { GetUGPT } from "utils/req-tachi-data"; import { FormatUserDoc } from "utils/user"; import { RequireAuthedAsUser } from "../../../../middleware"; @@ -25,9 +25,7 @@ router.patch( RequireAuthedAsUser, RequirePermissions("customise_profile"), async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const gptConfig = GetGamePTConfig(game, playtype); @@ -170,9 +168,7 @@ router.patch( * @name GET /api/v1/users/:userID/games/:game/:playtype/settings */ router.get("/", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const settings = await db["game-settings"].findOne({ userID: user.id, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts index f3ec80f82..ba385673c 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.test.ts @@ -9,7 +9,7 @@ import { CreateFolderChartLookup } from "utils/folder"; const SetFolders = async () => { await db.folders.insert(TestingIIDXFolderSP10); - await CreateFolderChartLookup(TestingIIDXFolderSP10); + await CreateFolderChartLookup(TestingIIDXFolderSP10, true); await db["game-settings"].remove({}); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts index f2189279e..466fb6dcb 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts @@ -1,6 +1,5 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; import { EvaluateShowcaseStat } from "lib/showcase/evaluator"; import { GetRelatedStatDocuments } from "lib/showcase/get-related"; import { EvaluateUsersStatsShowcase } from "lib/showcase/get-stats"; @@ -9,6 +8,7 @@ import { RequirePermissions } from "server/middleware/auth"; import { RequireAuthedAsUser } from "server/router/api/v1/users/_userID/middleware"; import { GetGamePTConfig, ShowcaseStatDetails } from "tachi-common"; import { FormatPrError } from "utils/prudence"; +import { GetUGPT } from "utils/req-tachi-data"; import { ResolveUser } from "utils/user"; const router: Router = Router({ mergeParams: true }); @@ -20,9 +20,7 @@ const router: Router = Router({ mergeParams: true }); * @name GET /api/v1/users/:userID/games/:game/:playtype/showcase */ router.get("/", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); let projectUser; @@ -60,9 +58,7 @@ router.get("/", async (req, res) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom */ router.get("/custom", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); let stat: ShowcaseStatDetails; @@ -164,9 +160,7 @@ router.get("/custom", async (req, res) => { * @name PUT /api/v1/users/:userID/games/:game/:playtype/showcase */ router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); const gptConfig = GetGamePTConfig(game, playtype); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts index 5263e6d23..77b01499a 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts @@ -16,7 +16,7 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/tables/:tableID", (t) => }) as FolderDocument; await db.folders.insert(folder); - await CreateFolderChartLookup(folder); + await CreateFolderChartLookup(folder, true); await db["personal-bests"].insert(TestingIIDXSPScorePB); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts index b811916ab..c4f10feb3 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { SYMBOL_TachiData } from "lib/constants/tachi"; import { GetFoldersFromTable, GetGradeLampDistributionForFolders } from "utils/folder"; +import { GetUGPT } from "utils/req-tachi-data"; // @todo maybe refactor where middleware is stored to avoid paths this ugly. import { GetTableFromParam } from "../../../../../../games/_game/_playtype/tables/middleware"; @@ -12,8 +13,9 @@ const router: Router = Router({ mergeParams: true }); * @name GET /api/v1/users/:userID/games/:game/:playtype/tables/:tableID */ router.get("/:tableID", GetTableFromParam, async (req, res) => { + const { user } = GetUGPT(req); + const table = req[SYMBOL_TachiData]!.tableDoc!; - const user = req[SYMBOL_TachiData]!.requestedUser!; const folders = await GetFoldersFromTable(table); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts new file mode 100644 index 000000000..60df6888d --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts @@ -0,0 +1,782 @@ +/* eslint-disable no-await-in-loop */ +import dm from "deepmerge"; +import db from "external/mongo/db"; +import { + ChartDocument, + GoalSubscriptionDocument, + IIDX_GRADES, + IIDX_LAMPS, + PBScoreDocument, +} from "tachi-common"; +import t from "tap"; +import { CreateFakeAuthCookie } from "test-utils/fake-auth"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { TestSnapshot } from "test-utils/single-process-snapshot"; +import { + HC511Goal, + HC511UserGoal, + IIDXSPMilestoneGoals, + IIDXSPMilestoneGoalSubs, + Testing511SPA, + TestingIIDXFolderSP10, + TestingIIDXSPMilestone, + TestingIIDXSPMilestoneSub, + TestingIIDXSPScore, + TestingIIDXSPScorePB, +} from "test-utils/test-data"; +import { CreateFolderChartLookup } from "utils/folder"; + +// @ts-expect-error Not sure why the types break here, but they do. +const dupedGoal = dm({}, HC511Goal); +// @ts-expect-error Not sure why the types break here, but they do. +const dupedGoalSub = dm({}, HC511UserGoal); + +// having two charts available is useful here for testing multi-chart goals. +const anotherFakeChart = dm(Testing511SPA, { + chartID: "another_chart", + difficulty: "HYPER", + data: { + arcChartID: null, + }, +}) as ChartDocument; + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should retrieve this user's goal subscriptions.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/goals"); + + t.equal(res.statusCode, 200); + + // due to poor design decisions, these props may be set anyway. + delete HC511Goal._id; + delete HC511UserGoal._id; + + t.strictSame(res.body.body, { + goals: [HC511Goal], + goalSubs: [HC511UserGoal], + }); + + t.end(); + }); + + t.end(); +}); + +t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", async (t) => { + t.beforeEach(ResetDBState); + + const customScorePB = dm(TestingIIDXSPScorePB, { + scoreData: TestingIIDXSPScore.scoreData, + }) as PBScoreDocument; + + t.beforeEach(async () => { + await db["personal-bests"].remove({}); + await db["personal-bests"].insert(customScorePB); + }); + + const cookie = await CreateFakeAuthCookie(mockApi); + + const baseInput = { + criteria: { + key: "scoreData.percent", + value: 80, + mode: "single", + }, + charts: { + type: "single", + data: Testing511SPA.chartID, + }, + }; + + function mkInput(caseName: string, merge: any) { + const input = { + caseName, + content: dm(baseInput, merge) as any, + }; + + // if we've set this prop to undefined, we want to delete it, actually. + if (input.content.charts.data === undefined) { + delete input.content.charts.data; + } + + return input; + } + + t.beforeEach(() => db.charts.iidx.insert(anotherFakeChart)); + // @ts-expect-error weird type issues + t.beforeEach(() => db.folders.insert(dm(TestingIIDXFolderSP10, {}))); + + t.beforeEach(() => CreateFolderChartLookup(TestingIIDXFolderSP10, true)); + + const multiCharts = { + type: "multi", + data: [Testing511SPA.chartID, "another_chart"], + }; + + const anyCharts = { + type: "any", + data: undefined, + }; + + const folderCharts = { + type: "folder", + data: TestingIIDXFolderSP10.folderID, + }; + + const absModeCriteria = { + mode: "absolute", + countNum: 2, + }; + + const proportionModeCriteria = { + mode: "proportion", + countNum: 1, + }; + + t.test("Should set valid goals.", async (t) => { + const validInput = [ + mkInput("Percent:Single Case", {}), + mkInput("Score:Single Case", { + criteria: { + key: "scoreData.score", + value: 1000, + }, + }), + mkInput("LampIndex:Single Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.EX_HARD_CLEAR, + }, + }), + mkInput("GradeIndex:Single Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + }, + }), + mkInput("Percent:Multi Case", { + charts: multiCharts, + }), + mkInput("Percent:Multi:Abs Case", { + criteria: absModeCriteria, + charts: multiCharts, + }), + mkInput("Percent:Multi:Abs Case", { + criteria: proportionModeCriteria, + charts: multiCharts, + }), + // NOT LEGAL FOR IIDX, due to being nonsense! NEEDS SPECIFIC TESTING. + // mkInput("Score:Multi Case", { + // criteria: { + // key: "scoreData.score", + // value: 1, + // }, + // charts: multiCharts, + // }), + mkInput("LampIndex:Multi Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + }, + charts: multiCharts, + }), + mkInput("LampIndex:Multi:Abs Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + ...absModeCriteria, + }, + charts: multiCharts, + }), + mkInput("LampIndex:Multi:Proportion Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + ...proportionModeCriteria, + }, + charts: multiCharts, + }), + mkInput("GradeIndex:Multi Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + }, + charts: multiCharts, + }), + mkInput("GradeIndex:Multi:Abs Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + ...absModeCriteria, + }, + charts: multiCharts, + }), + mkInput("GradeIndex:Multi:Proportion Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + ...proportionModeCriteria, + }, + charts: multiCharts, + }), + mkInput("Percent:Any Case", { + charts: anyCharts, + }), + mkInput("Percent:Any:Abs Case", { + criteria: absModeCriteria, + charts: anyCharts, + }), + mkInput("Percent:Any:Proportion Case", { + criteria: proportionModeCriteria, + charts: anyCharts, + }), + // also illegal in iidx, bms and pms. + // mkInput("Score:Any Case", { + // criteria: { + // key: "scoreData.score", + // value: 1000, + // }, + // charts: anyCharts, + // }), + mkInput("LampIndex:Any Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + }, + charts: anyCharts, + }), + mkInput("LampIndex:Any:Abs Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + ...absModeCriteria, + }, + charts: anyCharts, + }), + mkInput("LampIndex:Any:Proportion Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + ...proportionModeCriteria, + }, + charts: anyCharts, + }), + mkInput("GradeIndex:Any Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + }, + charts: anyCharts, + }), + mkInput("GradeIndex:Any:Abs Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + ...absModeCriteria, + }, + charts: anyCharts, + }), + mkInput("GradeIndex:Any:Proportion Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + ...proportionModeCriteria, + }, + charts: anyCharts, + }), + mkInput("Percent:Folder Case", { + charts: folderCharts, + }), + mkInput("Percent:Folder:Abs Case", { + criteria: absModeCriteria, + charts: folderCharts, + }), + mkInput("Percent:Folder:Proportion Case", { + criteria: proportionModeCriteria, + charts: folderCharts, + }), + // also illegal in iidx, bms and pms. + // mkInput("Score:Folder Case", { + // criteria: { + // key: "scoreData.score", + // value: 1000, + // }, + // charts: folderCharts, + // }), + mkInput("LampIndex:Folder Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + }, + charts: folderCharts, + }), + mkInput("LampIndex:Folder:Abs Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + ...absModeCriteria, + }, + charts: folderCharts, + }), + mkInput("LampIndex:Folder:Proportion Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + ...proportionModeCriteria, + }, + charts: folderCharts, + }), + mkInput("GradeIndex:Folder Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + }, + charts: folderCharts, + }), + mkInput("GradeIndex:Folder:Abs Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + ...absModeCriteria, + }, + charts: folderCharts, + }), + mkInput("GradeIndex:Folder:Proportion Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + ...proportionModeCriteria, + }, + charts: folderCharts, + }), + ]; + + for (const input of validInput) { + await t.test(`Valid Goal: ${input.caseName}`, async (t) => { + const res = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send(input.content); + + t.equal( + // this looks stupid, but results in better errmsgs. + res.statusCode === 200 ? 200 : res.body.description, + 200, + "Should return 200" + ); + + // don't bother doing db checks if this is good. + if (res.statusCode !== 200) { + return; + } + + const existsInDb = await db.goals.findOne({ + charts: input.content.charts, + criteria: input.content.criteria, + }); + + t.not(existsInDb, null, "A new goal should be added to the database."); + + const subscribed = await db["goal-subs"].findOne({ + userID: 1, + goalID: existsInDb?.goalID ?? "edge_case", + }); + + t.not( + subscribed, + null, + `Requesting user 1 should be subscribed to their new goal.` + ); + + t.end(); + }); + } + + t.end(); + }); + + t.test("Should reject invalid goals.", async (t) => { + const invalidInput = [ + mkInput("negative percent", { + criteria: { + value: -1, + }, + }), + mkInput("percent of 0 is a non-goal", { + criteria: { + value: 0, + }, + }), + mkInput("percent greater than 100", { + criteria: { + value: 100.1, + }, + }), + mkInput("too big grade", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.MAX + 1, + }, + }), + mkInput("invalid grade", { + criteria: { + key: "scoreData.gradeIndex", + value: 0.5, + }, + }), + mkInput("too big lamp", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.FULL_COMBO + 1, + }, + }), + mkInput("invalid lamp", { + criteria: { + key: "scoreData.lampIndex", + value: 0.5, + }, + }), + mkInput("too big score", { + criteria: { + key: "scoreData.score", + value: 9_000, + }, + }), + mkInput("abs without countNum", { + criteria: { + mode: "absolute", + }, + charts: multiCharts, + }), + mkInput("proportion without countNum", { + criteria: { + mode: "proportion", + }, + charts: multiCharts, + }), + mkInput("single with countNum", { + criteria: { + mode: "single", + countNum: 123, + }, + }), + mkInput("proportion with negative countNum", { + criteria: { + mode: "proportion", + countNum: -0.5, + }, + charts: multiCharts, + }), + mkInput("proportion with greater than 1 countNum", { + criteria: { + mode: "proportion", + countNum: 1.1, + }, + charts: multiCharts, + }), + mkInput("proportion which results in countNum of 0", { + criteria: { + mode: "proportion", + countNum: 0.05, + }, + charts: multiCharts, + }), + mkInput("nonsense mode", { + criteria: { + mode: "nonsense", + }, + }), + mkInput("abs with countNum but charts.type == single", { + criteria: { + mode: "absolute", + countNum: 1, + }, + charts: { + type: "single", + }, + }), + mkInput("nonsense charts.type", { + charts: { + type: "nonsense", + }, + }), + mkInput("charts.data when type == any", { + charts: { + type: "any", + data: "foo", + }, + }), + mkInput("charts.data array when type == single", { + charts: { + type: "single", + data: multiCharts.data, + }, + }), + mkInput("charts.data array of identical chartIDs", { + charts: { + type: "multi", + data: [Testing511SPA.chartID, Testing511SPA.chartID], + }, + }), + mkInput("charts.data array of single chartID", { + charts: { + type: "multi", + data: [Testing511SPA.chartID], + }, + }), + mkInput("charts.data array of chartIDs where some don't exist", { + charts: { + type: "multi", + data: [Testing511SPA.chartID, "not_exist"], + }, + }), + mkInput("charts.data folder refers to folder that doesn't exist", { + charts: { + type: "folder", + data: "fake-folder", + }, + }), + mkInput("nonsense charts.data", { + charts: { + data: "nonsense", + }, + }), + mkInput("multi-score for iidx is illegal", { + criteria: { + key: "scoreData.score", + value: 1000, + }, + charts: multiCharts, + }), + mkInput("folder-score for iidx is illegal", { + criteria: { + key: "scoreData.score", + value: 1000, + }, + charts: folderCharts, + }), + mkInput("any-score for iidx is illegal", { + criteria: { + key: "scoreData.score", + value: 1000, + }, + charts: anyCharts, + }), + mkInput("charts single but criteria not.", { + criteria: { + mode: "absolute", + countNum: 2, + }, + }), + ]; + + for (const input of invalidInput) { + await t.test(`Invalid Goal: ${input.caseName}`, async (t) => { + const res = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send(input.content); + + t.equal(res.statusCode, 400); + + TestSnapshot(t, res.body.description, `Invalid Goal: ${input.caseName}`); + + t.end(); + }); + } + + t.end(); + }); + + t.test("Should reject if user tries to subscribe to already subscribed goal.", async (t) => { + const res = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send(baseInput); + + t.equal(res.statusCode, 200, "Should allow a subscription the first time."); + + const res2 = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send(baseInput); + + t.equal(res2.statusCode, 409, "Should fail the second time."); + + t.end(); + }); + + t.test("Should reject if user tries to subscribe to immediately achieved goal.", async (t) => { + const res = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send( + dm(baseInput, { + criteria: { + key: "scoreData.score", + value: 1, + }, + }) + ); + + t.equal( + res.statusCode, + 400, + "Should disallow goal subscriptions that would be instantly achieved." + ); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should retrieve this user's goal subscription.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/targets/goals/${HC511Goal.goalID}` + ); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + goal: HC511Goal, + goalSub: HC511UserGoal, + milestones: [], + user: { + id: 1, + }, + }); + + t.end(); + }); + + t.test("Should return parent milestones if goal has any.", async (t) => { + await db.goals.insert(IIDXSPMilestoneGoals); + await db["goal-subs"].insert(IIDXSPMilestoneGoalSubs); + await db.milestones.insert(TestingIIDXSPMilestone); + + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/targets/goals/${IIDXSPMilestoneGoalSubs[0].goalID}` + ); + + t.equal(res.statusCode, 200); + + delete IIDXSPMilestoneGoals[0]._id; + delete IIDXSPMilestoneGoalSubs[0]._id; + delete TestingIIDXSPMilestone._id; + + t.hasStrict(res.body.body, { + goal: IIDXSPMilestoneGoals[0], + goalSub: IIDXSPMilestoneGoalSubs[0], + milestones: [TestingIIDXSPMilestone], + user: { + id: 1, + }, + }); + + t.end(); + }); + + t.test("Should return 404 if the user is not subscribed to this goal ID.", async (t) => { + const res = await mockApi.get(`/api/v1/users/1/games/iidx/SP/targets/goals/INVALID_GOAL`); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.end(); +}); + +t.test("DELETE /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should delete a goal subscription if subscribed.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi + .delete(`/api/v1/users/1/games/iidx/SP/targets/goals/${dupedGoalSub.goalID}`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + const dbRes = await db["goal-subs"].findOne({ + userID: 1, + goalID: dupedGoal.goalID, + }); + + t.equal(dbRes, null, "Should delete the goal sub from the database."); + + t.end(); + }); + + t.test("Should reject a goal deletion if goal has parent milestones.", async (t) => { + await db.milestones.insert(TestingIIDXSPMilestone); + await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + await db.goals.insert(IIDXSPMilestoneGoals); + await db["goal-subs"].insert( + dm(dupedGoalSub, { goalID: "eg_goal_1" }) as GoalSubscriptionDocument + ); + + const res = await mockApi + .delete(`/api/v1/users/1/games/iidx/SP/targets/goals/eg_goal_1`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 400); + t.equal( + res.body.description, + `This goal is part of a milestone you are subscribed to. It can only be removed by unsubscribing from the relevant milestones: '${TestingIIDXSPMilestone.name}'.` + ); + + const dbRes = await db["goal-subs"].findOne({ + userID: 1, + goalID: "eg_goal_1", + }); + + t.not(dbRes, null, "Should NOT delete the goal sub from the database."); + + t.end(); + }); + + t.test("Should reject a goal deletion if not subscribed.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi + .delete(`/api/v1/users/1/games/iidx/SP/targets/goals/INVALID_GOAL`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should reject a goal deletion if not authed.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi.delete( + `/api/v1/users/1/games/iidx/SP/targets/goals/${dupedGoalSub.goalID}` + ); + + t.equal(res.statusCode, 401); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index 68672847a..7410c943e 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -1,14 +1,21 @@ import { RequestHandler, Router } from "express"; import db from "external/mongo/db"; +import { SubscribeFailReasons } from "lib/constants/err-codes"; import { SYMBOL_TachiData } from "lib/constants/tachi"; -import prValidate from "server/middleware/prudence-validate"; -import p from "prudence"; -import { GoalDocument } from "tachi-common"; -import { ConstructGoal, SubscribeFailReasons, SubscribeToGoal } from "lib/achievables/goals"; import CreateLogCtx from "lib/logger/logger"; +import { ServerConfig } from "lib/setup/config"; +import { + ConstructGoal, + GetBlockingParentMilestoneSubs, + GetMilestonesThatContainGoal, + SubscribeToGoal, +} from "lib/targets/goals"; +import p from "prudence"; import { RequirePermissions } from "server/middleware/auth"; -import { AssignToReqTachiData } from "utils/req-tachi-data"; -import { GetGoalForIDGuaranteed, GetMilestoneForIDGuaranteed } from "utils/db"; +import prValidate from "server/middleware/prudence-validate"; +import { GoalDocument, MilestoneDocument } from "tachi-common"; +import { GetGoalForIDGuaranteed } from "utils/db"; +import { AssignToReqTachiData, GetUGPT } from "utils/req-tachi-data"; import { RequireAuthedAsUser } from "../../../../../middleware"; const router: Router = Router({ mergeParams: true }); @@ -16,31 +23,29 @@ const router: Router = Router({ mergeParams: true }); const logger = CreateLogCtx(__filename); /** - * Retrieves this users' set goals. + * Retrieves this user's set goals for this GPT. * * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/goals */ router.get("/", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); - const userGoals = await db["user-goals"].find({ + const goalSubs = await db["goal-subs"].find({ userID: user.id, game, playtype, }); const goals = await db.goals.find({ - goalID: { $in: userGoals.map((e) => e.goalID) }, + goalID: { $in: goalSubs.map((e) => e.goalID) }, }); return res.status(200).json({ success: true, - description: `Retrieved ${userGoals.length} goal(s).`, + description: `Retrieved ${goalSubs.length} goal(s).`, body: { goals, - userGoals, + goalSubs, }, }); }); @@ -53,7 +58,7 @@ type GoalCreationBody = Pick; * * @param criteria.key - The key for the goal to be on. This is stuff like scoreData.percent. * @param criteria.value - The value the key must be greater than for it to count as achieved. - * @param criteria.mode - "single", "abs" or "proportion". If abs or proportion, countNum + * @param criteria.mode - "single", "absolute" or "proportion". If abs or proportion, countNum * must be supplied. * @param criteria.countNum - For abs/proportion mode. Atleast N scores must achieve the * key:value condition. @@ -68,7 +73,7 @@ type GoalCreationBody = Pick; router.post( "/add-goal", RequireAuthedAsUser, - RequirePermissions("set_goals"), + RequirePermissions("manage_targets"), prValidate({ criteria: { key: p.isIn( @@ -79,7 +84,7 @@ router.post( ), // we do proper validation on this later. value: p.gte(0), - mode: p.isIn("single", "abs", "proportion"), + mode: p.isIn("single", "absolute", "proportion"), countNum: (self, parent) => { if (parent.mode === "single") { return ( @@ -113,6 +118,7 @@ router.post( self.length > 1) || "Expected an array of 2 to 5 strings in charts.data due to charts.type being 'multi'." ); + /* istanbul ignore next */ } else if (parent.type === "folder") { return ( typeof self === "string" || @@ -120,25 +126,25 @@ router.post( ); } + // impossible to reach, so doesn't count for coverage. + /* istanbul ignore next */ return "Unknown charts.type."; }, }, }), async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); - const existingGoalsCount = await db["user-goals"].count({ + const existingGoalsCount = await db["goal-subs"].count({ userID: user.id, game, playtype, }); - if (existingGoalsCount > 1_000) { + if (existingGoalsCount > ServerConfig.MAX_GOAL_SUBSCRIPTIONS) { return res.status(400).json({ success: false, - description: `You already have 1000 goals. You cannot have anymore.`, + description: `You already have ${ServerConfig.MAX_GOAL_SUBSCRIPTIONS} goals. You cannot have anymore.`, }); } @@ -158,16 +164,16 @@ router.post( }); } - const userGoal = await SubscribeToGoal(user.id, goal, { origin: "manual" }); + const goalSub = await SubscribeToGoal(user.id, goal); - if (userGoal === SubscribeFailReasons.ALREADY_SUBSCRIBED) { + if (goalSub === SubscribeFailReasons.ALREADY_SUBSCRIBED) { return res.status(409).json({ success: false, description: `You are already subscribed to this goal.`, }); } - if (userGoal === SubscribeFailReasons.ALREADY_ACHIEVED) { + if (goalSub === SubscribeFailReasons.ALREADY_ACHIEVED) { return res.status(400).json({ success: false, description: `You can't directly assign goals that you would immediately achieve.`, @@ -176,34 +182,33 @@ router.post( return res.status(200).json({ success: true, - description: `Subscribed to ${goal.title}.`, + description: `Subscribed to ${goal.name}.`, body: { goal, - userGoal, + goalSub, }, }); } ); const GetGoalSubscription: RequestHandler = async (req, res, next) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); - const userGoal = await db["user-goals"].findOne({ + const goalSub = await db["goal-subs"].findOne({ userID: user.id, game, playtype, + goalID: req.params.goalID, }); - if (!userGoal) { + if (!goalSub) { return res.status(404).json({ success: false, description: `${user.username} is not subscribed to this goal.`, }); } - AssignToReqTachiData(req, { userGoalDoc: userGoal }); + AssignToReqTachiData(req, { goalSubDoc: goalSub }); return next(); }; @@ -214,31 +219,28 @@ const GetGoalSubscription: RequestHandler = async (req, res, next) => { * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID */ router.get("/:goalID", GetGoalSubscription, async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const userGoal = req[SYMBOL_TachiData]!.userGoalDoc!; + const { user } = GetUGPT(req); - let milestone = null; + const goalSub = req[SYMBOL_TachiData]!.goalSubDoc!; - if (userGoal.from.origin === "milestone") { - milestone = await GetMilestoneForIDGuaranteed(userGoal.from.milestoneID); - } + const milestones: MilestoneDocument[] = await GetMilestonesThatContainGoal(goalSub.goalID); - const goal = await GetGoalForIDGuaranteed(userGoal.goalID); + const goal = await GetGoalForIDGuaranteed(goalSub.goalID); return res.status(200).json({ success: true, - description: `Returned information about goal ${goal.title}.`, + description: `Returned information about goal '${goal.name}'.`, body: { goal, - userGoal, - milestone, + goalSub, + milestones, user, }, }); }); /** - * Removes a goal from your profile. + * Unsubscribe from a goal. * * @name DELETE /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID */ @@ -246,36 +248,25 @@ router.delete( "/:goalID", RequireAuthedAsUser, GetGoalSubscription, - RequirePermissions("unset_goals"), - prValidate({ goalID: "string" }), + RequirePermissions("manage_targets"), async (req, res) => { const goalID = req.params.goalID; - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; + const { user, game, playtype } = GetUGPT(req); - const userGoal = await db["user-goals"].findOne({ - goalID, - userID: user.id, - game, - playtype, - }); + const goalSub = req[SYMBOL_TachiData]!.goalSubDoc!; - if (!userGoal) { + const parentMilestones = await GetBlockingParentMilestoneSubs(goalSub); + + if (parentMilestones.length) { return res.status(400).json({ success: false, - description: `You aren't subscribed to this goal.`, + description: `This goal is part of a milestone you are subscribed to. It can only be removed by unsubscribing from the relevant milestones: ${parentMilestones + .map((e) => `'${e.milestone.name}'`) + .join(", ")}.`, }); } - if (userGoal.from.origin === "milestone") { - return res.status(400).json({ - success: false, - description: `This goal is from a milestone. You can't remove it directly, only by removing the parent milestone.`, - }); - } - - await db["user-goals"].remove({ + await db["goal-subs"].remove({ userID: user.id, goalID, game, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.test.ts new file mode 100644 index 000000000..db32884fb --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.test.ts @@ -0,0 +1,268 @@ +import db from "external/mongo/db"; +import { PublicUserDocument, UserGameStats } from "tachi-common"; +import t from "tap"; +import { CreateFakeAuthCookie } from "test-utils/fake-auth"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { + IIDXSPMilestoneGoals, + IIDXSPMilestoneGoalSubs, + TestingIIDXSPMilestone, + TestingIIDXSPMilestoneSub, +} from "test-utils/test-data"; + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should return all milestone subscriptions.", async (t) => { + await db.milestones.insert(TestingIIDXSPMilestone); + await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/milestones"); + + delete TestingIIDXSPMilestone._id; + delete TestingIIDXSPMilestoneSub._id; + + t.strictSame(res.body.body, { + milestones: [TestingIIDXSPMilestone], + milestoneSubs: [TestingIIDXSPMilestoneSub], + }); + + t.end(); + }); + + t.test("Should panic if milestone subs are parentless.", async (t) => { + // await db.milestones.insert(TestingIIDXSPMilestone); + await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/milestones"); + + t.equal(res.statusCode, 500); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should return the user's specific milestone sub.", async (t) => { + await db.milestones.insert(TestingIIDXSPMilestone); + await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + + await db.goals.insert(IIDXSPMilestoneGoals); + await db["goal-subs"].insert(IIDXSPMilestoneGoalSubs); + + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + ); + + // i hate the fact that monk just adds this to objects! terrible. + delete TestingIIDXSPMilestone._id; + delete TestingIIDXSPMilestoneSub._id; + + for (const goal of IIDXSPMilestoneGoals) { + delete goal._id; + } + + t.strictSame(res.body.body, { + milestone: TestingIIDXSPMilestone, + milestoneSub: TestingIIDXSPMilestoneSub, + results: IIDXSPMilestoneGoalSubs.map((e) => ({ + achieved: e.achieved, + progress: e.progress, + progressHuman: e.progressHuman, + outOf: e.outOf, + outOfHuman: e.outOfHuman, + goalID: e.goalID, + })), + goals: IIDXSPMilestoneGoals, + }); + + t.end(); + }); + + t.test("Should return 404 if the user is not subscribed to this milestone.", async (t) => { + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone` + ); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.end(); +}); + +t.test( + "PUT /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID", + async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should return 404 if this milestone doesn't exist.", async (t) => { + const res = await mockApi + .put(`/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 409 if user is already subscribed to this milestone.", async (t) => { + await db.milestones.insert(TestingIIDXSPMilestone); + await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + + const res = await mockApi + .put( + `/api/v1/users/1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + ) + .set("Cookie", cookie); + + t.equal(res.statusCode, 409); + + t.end(); + }); + + t.test("Should subscribe to a milestone.", async (t) => { + await db.milestones.insert(TestingIIDXSPMilestone); + await db.goals.insert(IIDXSPMilestoneGoals); + + const res = await mockApi + .put( + `/api/v1/users/1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + ) + .set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + const dbRes = await db["milestone-subs"].findOne({ + userID: 1, + milestoneID: TestingIIDXSPMilestone.milestoneID, + }); + + t.not(dbRes, null, "Should have subscribed the user to the milestone."); + + t.end(); + }); + + t.test("Should return 401 if the user is not authed.", async (t) => { + const res = await mockApi.put( + `/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone` + ); + + t.equal(res.statusCode, 401); + + t.end(); + }); + + t.test("Should return 403 if the user is authed as someone else.", async (t) => { + // We have to make sure that the user exists, otherwise we'll just get + // a normal 404. + await db.users.insert({ + id: 2, + username: "fake_person", + usernameLowercase: "fake_person", + } as PublicUserDocument); + + await db["game-stats"].insert({ + game: "iidx", + playtype: "SP", + userID: 2, + } as UserGameStats); + + const res = await mockApi + .put(`/api/v1/users/2/games/iidx/SP/targets/milestones/fake_milestone`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.end(); + } +); + +t.test( + "DELETE /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID", + async (t) => { + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should return 404 if the user is not subscribed to this milestone.", async (t) => { + const res = await mockApi + .delete(`/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should return 401 if the user is not authed.", async (t) => { + const res = await mockApi.delete( + `/api/v1/users/1/games/iidx/SP/targets/milestones/fake_milestone` + ); + + t.equal(res.statusCode, 401); + + t.end(); + }); + + t.test("Should return 403 if the user is authed as someone else.", async (t) => { + // We have to make sure that the user exists, otherwise we'll just get + // a normal 404. + await db.users.insert({ + id: 2, + username: "fake_person", + usernameLowercase: "fake_person", + } as PublicUserDocument); + + await db["game-stats"].insert({ + game: "iidx", + playtype: "SP", + userID: 2, + } as UserGameStats); + + const res = await mockApi + .delete(`/api/v1/users/2/games/iidx/SP/targets/milestones/fake_milestone`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Should unsubscribe from a milestone.", async (t) => { + await db.milestones.insert(TestingIIDXSPMilestone); + await db["milestone-subs"].insert(TestingIIDXSPMilestoneSub); + + const res = await mockApi + .delete( + `/api/v1/users/1/games/iidx/SP/targets/milestones/${TestingIIDXSPMilestone.milestoneID}` + ) + .set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + delete TestingIIDXSPMilestone._id; + t.strictSame(res.body.body.milestone, TestingIIDXSPMilestone); + + const dbRes = await db["milestone-subs"].findOne({ + userID: 1, + milestoneID: TestingIIDXSPMilestone.milestoneID, + }); + + t.equal(dbRes, null, "Should have removed the milestone from the database."); + + t.end(); + }); + + t.end(); + } +); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.ts new file mode 100644 index 000000000..aae9ff40f --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/milestones/router.ts @@ -0,0 +1,231 @@ +import { RequestHandler, Router } from "express"; +import db from "external/mongo/db"; +import { SubscribeFailReasons } from "lib/constants/err-codes"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import CreateLogCtx from "lib/logger/logger"; +import { ServerConfig } from "lib/setup/config"; +import { + EvaluateMilestoneProgress, + SubscribeToMilestone, + UnsubscribeFromMilestone, +} from "lib/targets/milestones"; +import { RequirePermissions } from "server/middleware/auth"; +import { AssignToReqTachiData, GetGPT, GetUGPT } from "utils/req-tachi-data"; +import { FormatUserDoc } from "utils/user"; +import { RequireAuthedAsUser } from "../../../../../middleware"; + +const logger = CreateLogCtx(__filename); + +const router: Router = Router({ mergeParams: true }); + +/** + * Retrieves this user's subscribed milestones. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones + */ +router.get("/", async (req, res) => { + const { user, game, playtype } = GetUGPT(req); + + const milestoneSubs = await db["milestone-subs"].find({ + userID: user.id, + game, + playtype, + }); + + const milestones = await db.milestones.find({ + milestoneID: { $in: milestoneSubs.map((e) => e.milestoneID) }, + }); + + if (milestones.length !== milestoneSubs.length) { + logger.error( + `Found ${milestoneSubs.length} subscriptions, but got ${milestones.length} parents. This is a state desync.` + ); + throw new Error("Failed to fetch milestones"); + } + + return res.status(200).json({ + success: true, + description: `Retrieved ${milestoneSubs.length} milestone(s).`, + body: { + milestones, + milestoneSubs, + }, + }); +}); + +const GetMilestoneSubscription: RequestHandler = async (req, res, next) => { + const { user, game, playtype } = GetUGPT(req); + + const milestoneSub = await db["milestone-subs"].findOne({ + userID: user.id, + game, + playtype, + milestoneID: req.params.milestoneID, + }); + + if (!milestoneSub) { + return res.status(404).json({ + success: false, + description: `${user.username} is not subscribed to this milestone.`, + }); + } + + AssignToReqTachiData(req, { milestoneSubDoc: milestoneSub }); + + return next(); +}; + +const GetMilestone: RequestHandler = async (req, res, next) => { + const { game, playtype } = GetGPT(req); + + const milestone = await db.milestones.findOne({ + game, + playtype, + milestoneID: req.params.milestoneID, + }); + + if (!milestone) { + return res.status(404).json({ + success: false, + description: `Can't find a milestone with id '${req.params.milestoneID}'.`, + }); + } + + AssignToReqTachiData(req, { milestoneDoc: milestone }); + + return next(); +}; + +/** + * Returns this user's progress on this milestone. + * This also evaluates individual progress on all of the milestones goals. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID + */ +router.get("/:milestoneID", GetMilestone, GetMilestoneSubscription, async (req, res) => { + const { user } = GetUGPT(req); + + const milestoneSub = req[SYMBOL_TachiData]!.milestoneSubDoc!; + const milestone = req[SYMBOL_TachiData]!.milestoneDoc!; + + // Evaluate each goal for the user. This operation is much faster if the user is + // subscribed to the milestone (they are), as we can just read their goalSub + // for each goal. + const { goalResults: results, goals } = await EvaluateMilestoneProgress(user.id, milestone); + + return res.status(200).json({ + success: true, + description: `Returned information about ${FormatUserDoc(user)}'s progress on ${ + milestone.name + }.`, + body: { + milestoneSub, + milestone, + results, + goals, + }, + }); +}); + +/** + * Subscribe to a milestone. + * + * @name PUT /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID + */ +router.put( + "/:milestoneID", + RequireAuthedAsUser, + GetMilestone, + RequirePermissions("manage_targets"), + async (req, res) => { + const { user, game, playtype } = GetUGPT(req); + + const existingMilestonesCount = await db["milestone-subs"].count({ + userID: user.id, + game, + playtype, + }); + + if (existingMilestonesCount > ServerConfig.MAX_MILESTONE_SUBSCRIPTIONS) { + return res.status(400).json({ + success: false, + description: `You already have ${ServerConfig.MAX_MILESTONE_SUBSCRIPTIONS} milestones. You cannot have anymore for this game.`, + }); + } + + const milestone = req[SYMBOL_TachiData]!.milestoneDoc!; + + const alreadySubscibed = await db["milestone-subs"].findOne({ + userID: user.id, + milestoneID: milestone.milestoneID, + }); + + if (alreadySubscibed) { + return res.status(409).json({ + success: false, + description: `You are already subscribed to this goal.`, + }); + } + + const subResult = await SubscribeToMilestone(user.id, milestone, false); + + // Users should be able to subscribe to milestones EVEN IF they would instantly + // achieve them. + + // if (subResult === SubscribeFailReasons.ALREADY_ACHIEVED) { + // return res.status(400).json({ + // success: false, + // description: `You cannot assign a milestone that would be immediately achieved.`, + // }); + // } + + if (subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED) { + return res.status(409).json({ + success: false, + description: `You're already subscribed to this milestone.`, + }); + } + + return res.status(200).json({ + success: true, + description: `Subscribed to milestone '${milestone.name}'.`, + body: { ...subResult, milestone }, + }); + } +); + +/** + * Unsubscribe from a milestone. + * + * @name DELETE /api/v1/users/:userID/games/:game/:playtype/targets/milestones/:milestoneID + */ +router.delete( + "/:milestoneID", + RequireAuthedAsUser, + GetMilestone, + RequirePermissions("manage_targets"), + async (req, res) => { + const { user } = GetUGPT(req); + const milestone = req[SYMBOL_TachiData]!.milestoneDoc!; + + logger.info( + `User ${FormatUserDoc(user)} is unsubscribing from milestone '${milestone.name}'.`, + { + milestone, + user, + } + ); + + await UnsubscribeFromMilestone(user.id, milestone); + + return res.status(200).json({ + success: true, + description: `Unsubscribed from milestone.`, + body: { + milestone, + }, + }); + } +); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts new file mode 100644 index 000000000..f08bee5ab --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.test.ts @@ -0,0 +1,162 @@ +import db from "external/mongo/db"; +import t from "tap"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import dm from "deepmerge"; +import { + HC511Goal, + HC511UserGoal, + TestingIIDXSPMilestone, + TestingIIDXSPMilestoneSub, +} from "test-utils/test-data"; +import { + GoalDocument, + GoalSubscriptionDocument, + MilestoneDocument, + MilestoneSubscriptionDocument, +} from "tachi-common"; + +function mkGoalSub(merge: Partial) { + return dm(HC511UserGoal, merge); +} + +function mkGoal(merge: Partial) { + return dm(HC511Goal, merge); +} + +function mkMilestone(merge: Partial) { + return dm(TestingIIDXSPMilestone, merge); +} + +function mkMilestoneSub(merge: Partial) { + return dm(TestingIIDXSPMilestoneSub, merge); +} + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieved", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should return subscriptions that were recently achieved by this user.", async (t) => { + await db.goals.insert([ + mkGoal({ + goalID: "achieved_goal", + }), + mkGoal({ + goalID: "not_achieved_goal", + }), + ]); + + await db["goal-subs"].insert([ + mkGoalSub({ + achieved: true, + timeAchieved: 1000, + goalID: "achieved_goal", + }), + mkGoalSub({ + achieved: false, + goalID: "not_achieved_goal", + }), + ]); + + await db.milestones.insert([ + mkMilestone({}), + mkMilestone({ + milestoneID: "not_achieved_milestone", + }), + ]); + + await db["milestone-subs"].insert([ + mkMilestoneSub({ + achieved: true, + timeAchieved: 2000, + }), + mkMilestoneSub({ + achieved: false, + milestoneID: "not_achieved_milestone", + }), + ]); + + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/recently-achieved"); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + milestones: [{ milestoneID: "example_milestone_id" }], + goals: [{ goalID: "achieved_goal" }], + goalSubs: [{ goalID: "achieved_goal" }], + milestoneSubs: [{ milestoneID: "example_milestone_id" }], + user: { id: 1 }, + }); + + t.equal(res.body.body.goals.length, 1); + t.equal(res.body.body.milestones.length, 1); + t.equal(res.body.body.goalSubs.length, 1); + t.equal(res.body.body.milestoneSubs.length, 1); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should return subscriptions that were recently achieved by this user.", async (t) => { + await db.goals.insert([ + mkGoal({ + goalID: "achieved_goal", + }), + mkGoal({ + goalID: "not_achieved_goal", + }), + ]); + + await db["goal-subs"].insert([ + mkGoalSub({ + achieved: true, + timeAchieved: 1000, + goalID: "achieved_goal", + }), + mkGoalSub({ + achieved: false, + lastInteraction: 1000, + goalID: "not_achieved_goal", + }), + ]); + + await db.milestones.insert([ + mkMilestone({}), + mkMilestone({ + milestoneID: "not_achieved_milestone", + }), + ]); + + await db["milestone-subs"].insert([ + mkMilestoneSub({ + achieved: true, + timeAchieved: 2000, + }), + mkMilestoneSub({ + achieved: false, + lastInteraction: 1000, + milestoneID: "not_achieved_milestone", + }), + ]); + + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/recently-raised"); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + milestones: [{ milestoneID: "not_achieved_milestone" }], + goals: [{ goalID: "not_achieved_goal" }], + goalSubs: [{ goalID: "not_achieved_goal" }], + milestoneSubs: [{ milestoneID: "not_achieved_milestone" }], + user: { id: 1 }, + }); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts index 63929423c..2189b31ba 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts @@ -1,8 +1,74 @@ import { Router } from "express"; +import { + GetRecentlyAchievedGoals, + GetRecentlyAchievedMilestones, + GetRecentlyInteractedGoals, + GetRecentlyInteractedMilestones, +} from "utils/db"; +import { GetUGPT } from "utils/req-tachi-data"; import goalsRouter from "./goals/router"; +import milestonesRouter from "./milestones/router"; const router: Router = Router({ mergeParams: true }); +/** + * Return a user's recently achieved goals and milestones. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieved + */ +router.get("/recently-achieved", async (req, res) => { + const { game, playtype, user } = GetUGPT(req); + + const userID = user.id; + + const [{ goals, goalSubs }, { milestones, milestoneSubs }] = await Promise.all([ + GetRecentlyAchievedGoals({ userID, game, playtype }), + GetRecentlyAchievedMilestones({ userID, game, playtype }), + ]); + + return res.status(200).json({ + success: true, + description: `Returned ${user.username}'s recently achieved targets.`, + body: { + goals, + milestones, + goalSubs, + milestoneSubs, + user, + }, + }); +}); + +/** + * Returns a user's recently interacted with (raised, etc.) goals and milestones. + * Note that this does not include recently achieved. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised + */ +router.get("/recently-raised", async (req, res) => { + const { game, playtype, user } = GetUGPT(req); + + const userID = user.id; + + const [{ goals, goalSubs }, { milestones, milestoneSubs }] = await Promise.all([ + GetRecentlyInteractedGoals({ userID, game, playtype }), + GetRecentlyInteractedMilestones({ userID, game, playtype }), + ]); + + return res.status(200).json({ + success: true, + description: `Returned ${user.username}'s recently achieved targets.`, + body: { + goals, + milestones, + goalSubs, + milestoneSubs, + user, + }, + }); +}); + router.use("/goals", goalsRouter); +router.use("/milestones", milestonesRouter); export default router; diff --git a/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts b/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts index 6180c0b3d..6bb0ed61c 100644 --- a/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/pfp/router.test.ts @@ -1,8 +1,7 @@ -import t from "tap"; import db from "external/mongo/db"; import { CDNStoreOrOverwrite } from "lib/cdn/cdn"; import { GetProfilePictureURL } from "lib/cdn/url-format"; - +import t from "tap"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; import { GetKTDataBuffer } from "test-utils/test-data"; diff --git a/server/src/server/router/api/v1/users/_userID/pfp/router.ts b/server/src/server/router/api/v1/users/_userID/pfp/router.ts index 1d78d6283..22b7ae445 100644 --- a/server/src/server/router/api/v1/users/_userID/pfp/router.ts +++ b/server/src/server/router/api/v1/users/_userID/pfp/router.ts @@ -1,15 +1,15 @@ import { Router } from "express"; import db from "external/mongo/db"; -import { CDNStoreOrOverwrite, CDNRedirect, CDNDelete } from "lib/cdn/cdn"; +import { CDNDelete, CDNRedirect, CDNStoreOrOverwrite } from "lib/cdn/cdn"; import { GetProfilePictureURL } from "lib/cdn/url-format"; import { ONE_MEGABYTE } from "lib/constants/filesize"; import { SYMBOL_TachiData } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; -import { FormatUserDoc } from "utils/user"; import { RequirePermissions } from "server/middleware/auth"; import { CreateMulterSingleUploadMiddleware } from "server/middleware/multer-upload"; -import { RequireAuthedAsUser } from "../middleware"; import { HashSHA256 } from "utils/crypto"; +import { FormatUserDoc } from "utils/user"; +import { RequireAuthedAsUser } from "../middleware"; const logger = CreateLogCtx(__filename); diff --git a/server/src/server/router/api/v1/users/_userID/router.test.ts b/server/src/server/router/api/v1/users/_userID/router.test.ts index 0714156a1..124f5d041 100644 --- a/server/src/server/router/api/v1/users/_userID/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/router.test.ts @@ -1,6 +1,6 @@ import db from "external/mongo/db"; import { ONE_DAY, ONE_YEAR } from "lib/constants/time"; -import { UserGameStats, ImportTypes, ImportDocument, Game } from "tachi-common"; +import { Game, ImportDocument, ImportTypes, UserGameStats } from "tachi-common"; import t from "tap"; import { CreateFakeAuthCookie } from "test-utils/fake-auth"; import mockApi from "test-utils/mock-api"; diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index b68e3be01..26a96f393 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -9,7 +9,7 @@ import { ImportTypes, integer, UserGameStats } from "tachi-common"; import { DeleteUndefinedProps, StripUrl } from "utils/misc"; import { optNullFluffStrField } from "utils/prudence"; import { - GetRecentlyAchievedGoals, + GetGoalSummary, GetRecentlyViewedFoldersAnyGPT, GetRecentPlaycount, GetRecentSessions, @@ -230,7 +230,7 @@ router.get("/recent-summary", async (req, res) => { GetRecentPlaycount(user.id), GetRecentSessions(user.id), GetRecentlyViewedFoldersAnyGPT(user.id), - GetRecentlyAchievedGoals(user.id), + GetGoalSummary(user.id), ]); return res.status(200).json({ diff --git a/server/src/server/router/ir/router.ts b/server/src/server/router/ir/router.ts index 5dd7ce82c..3a58cd1e1 100644 --- a/server/src/server/router/ir/router.ts +++ b/server/src/server/router/ir/router.ts @@ -11,8 +11,8 @@ import beatorajaIR from "./beatoraja/router"; import directManualIR from "./direct-manual/router"; import fervidexIR from "./fervidex/router"; import ksHookIR from "./kshook/router"; -import uscIR from "./usc/router"; import lr2hookIR from "./lr2hook/router"; +import uscIR from "./usc/router"; const router: Router = Router({ mergeParams: true }); diff --git a/server/src/test-utils/close-connections.ts b/server/src/test-utils/cleanup.ts similarity index 73% rename from server/src/test-utils/close-connections.ts rename to server/src/test-utils/cleanup.ts index b6d8c1e52..d3fba002f 100644 --- a/server/src/test-utils/close-connections.ts +++ b/server/src/test-utils/cleanup.ts @@ -3,8 +3,13 @@ import { CloseRedisPubSub } from "external/redis/redis-IPC"; import { CloseRedisConnection } from "external/redis/redis"; import { CloseServerConnection } from "./mock-api"; import { CloseScoreImportQueue } from "lib/score-import/worker/queue"; +import { WriteSnapshotData } from "./single-process-snapshot"; + +export async function CleanUpAfterTests() { + if (process.env.TAP_SNAPSHOT) { + WriteSnapshotData(); + } -export async function CloseAllConnections() { await CloseMongoConnection(); await CloseServerConnection(); await CloseRedisConnection(); diff --git a/server/src/test-utils/mock-api.ts b/server/src/test-utils/mock-api.ts index a30f23fab..b5a9593f3 100644 --- a/server/src/test-utils/mock-api.ts +++ b/server/src/test-utils/mock-api.ts @@ -1,10 +1,9 @@ -import supertest from "supertest"; import CreateLogCtx from "lib/logger/logger"; +import supertest from "supertest"; +import server from "../server/server"; const logger = CreateLogCtx(__filename); -import server from "../server/server"; - logger.verbose("Creating Mock Server Connection..."); const connection = server.listen(); @@ -12,7 +11,7 @@ logger.verbose("Connecting to Supertest..."); const mockApi = supertest(connection); export function CloseServerConnection() { - connection.close(); + return connection.close(); } export default mockApi; diff --git a/server/src/test-utils/mock-db/folder-chart-lookup.json b/server/src/test-utils/mock-db/folder-chart-lookup.json index 0637a088a..85f1a6ea3 100644 --- a/server/src/test-utils/mock-db/folder-chart-lookup.json +++ b/server/src/test-utils/mock-db/folder-chart-lookup.json @@ -1 +1,6 @@ -[] \ No newline at end of file +[ + { + "chartID": "c2311194e3897ddb5745b1760d2c0141f933e683", + "folderID": "ed9d8c734447ce67d7135c0067441a98cc81aeaf" + } +] \ No newline at end of file diff --git a/server/src/test-utils/mock-db/user-goals.json b/server/src/test-utils/mock-db/goal-subs.json similarity index 100% rename from server/src/test-utils/mock-db/user-goals.json rename to server/src/test-utils/mock-db/goal-subs.json diff --git a/server/src/test-utils/mock-db/user-milestones.json b/server/src/test-utils/mock-db/milestone-sets.json similarity index 100% rename from server/src/test-utils/mock-db/user-milestones.json rename to server/src/test-utils/mock-db/milestone-sets.json diff --git a/server/src/test-utils/mock-db/milestone-subs.json b/server/src/test-utils/mock-db/milestone-subs.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/server/src/test-utils/mock-db/milestone-subs.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/server/src/test-utils/server.ts b/server/src/test-utils/server.ts index f8806cf6f..2ec386f46 100644 --- a/server/src/test-utils/server.ts +++ b/server/src/test-utils/server.ts @@ -1,5 +1,5 @@ -import server from "../server/server"; import supertest from "supertest"; +import server from "../server/server"; const mockServer = supertest(server); diff --git a/server/src/test-utils/single-process-snapshot.ts b/server/src/test-utils/single-process-snapshot.ts new file mode 100644 index 000000000..d257a5f35 --- /dev/null +++ b/server/src/test-utils/single-process-snapshot.ts @@ -0,0 +1,35 @@ +import fs from "fs"; +import path from "path"; + +const SNAP_PATH = path.join(__dirname, "./snapshots/snapshot-data.json"); + +type Snapshots = Record; + +function ReadSnapshotData() { + let snapshots: Snapshots = {}; + + if (fs.existsSync(SNAP_PATH)) { + snapshots = JSON.parse(fs.readFileSync(SNAP_PATH, "utf-8")); + } + + return snapshots; +} + +export function WriteSnapshotData() { + fs.writeFileSync(SNAP_PATH, JSON.stringify(snapshotData, null, "\t")); +} + +const snapshotData = ReadSnapshotData(); + +export function TestSnapshot(t: Tap.Test, value: string, testName: string) { + if (process.env.TAP_SNAPSHOT) { + snapshotData[testName] = value; + WriteSnapshotData(); + } else { + if (!snapshotData[testName]) { + return t.fail(`No snapshot exists for ${testName}. Have you ran pnpm snap?`); + } + + t.equal(value, snapshotData[testName], `Snapshot: ${testName}`); + } +} diff --git a/server/src/test-utils/single-process-tap.ts b/server/src/test-utils/single-process-tap.ts index e8f04fcfb..349e134bb 100644 --- a/server/src/test-utils/single-process-tap.ts +++ b/server/src/test-utils/single-process-tap.ts @@ -2,7 +2,7 @@ import "external/mongo/db"; import glob from "glob"; import path from "path"; import t from "tap"; -import { CloseAllConnections } from "./close-connections"; +import { CleanUpAfterTests } from "./cleanup"; const files = glob.sync(path.join(__dirname, "../../", "**/*.test.ts")); @@ -12,4 +12,4 @@ for (const file of files) { require(file); } -t.teardown(CloseAllConnections); +t.teardown(CleanUpAfterTests); diff --git a/server/src/test-utils/snapshots/snapshot-data.json b/server/src/test-utils/snapshots/snapshot-data.json new file mode 100644 index 000000000..f9be219bf --- /dev/null +++ b/server/src/test-utils/snapshots/snapshot-data.json @@ -0,0 +1,31 @@ +{ + "Invalid Goal: negative percent": "[criteria.value] Expected number to be greater than or equal to 0. (Received -1)", + "Invalid Goal: percent of 0 is a non-goal": "Invalid value of 0 for percent goal. Percents must be between 0 and 100.", + "Invalid Goal: percent greater than 100": "Invalid value of 100.1 for percent goal. Percents must be between 0 and 100.", + "Invalid Goal: abs without countNum": "[criteria.countNum] Expected number to be greater than or equal to 0. (Received nothing)", + "Invalid Goal: proportion without countNum": "[criteria.countNum] Expected number to be greater than or equal to 0. (Received nothing)", + "Invalid Goal: single with countNum": "[criteria.countNum] Invalid countNum for mode 'single'. Must not have one! (Received 123)", + "Invalid Goal: nonsense mode": "[criteria.mode] Expected any of single, absolute, proportion. (Received nonsense)", + "Invalid Goal: abs with countNum but charts.type == single": "Invalid countNum for goal with criteria.mode of 'absolute'. Expected a whole number less than the total amount of charts available and greater than 1. (Got 1, while total charts was 1.)", + "Invalid Goal: nonsense charts.type": "[charts.type] Expected any of single, multi, folder, any. (Received nonsense)", + "Invalid Goal: charts.data when type == any": "[charts.data] Invalid charts.data for type 'any'. Must not have any data! (Received foo)", + "Invalid Goal: charts.data array when type == single": "[charts.data] Expected a string in charts.data due to charts.type being 'single'. (Received c2311194e3897ddb5745b1760d2c0141f933e683,another_chart)", + "Invalid Goal: charts.data array of identical chartIDs": "Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid?", + "Invalid Goal: charts.data array of single chartID": "[charts.data] Expected an array of 2 to 5 strings in charts.data due to charts.type being 'multi'. (Received c2311194e3897ddb5745b1760d2c0141f933e683)", + "Invalid Goal: charts.data array of chartIDs that don't exist": "Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid?", + "Invalid Goal: charts.data folder refers to folder that doesn't exist": "A folder with id fake-folder does not exist for iidx:SP.", + "Invalid Goal: nonsense charts.data": "A chart with id nonsense does not exist for iidx:SP.", + "Invalid Goal: multi-score for iidx is illegal": "Invalid key for iidx with multiple charts. Creating score goals on multiple charts where score is relative to notecount is a terrible idea, and has been disabled.", + "Invalid Goal: too big grade": "Invalid value of 10 for grade goal. No such grade exists at that index.", + "Invalid Goal: invalid grade": "Invalid value of 0.5 for grade goal. No such grade exists at that index.", + "Invalid Goal: too big lamp": "Invalid value of 8 for lamp goal. No such lamp exists at that index.", + "Invalid Goal: invalid lamp": "Invalid value of 0.5 for lamp goal. No such lamp exists at that index.", + "Invalid Goal: proportion with negative countNum": "[criteria.countNum] Expected number to be greater than or equal to 0. (Received -0.5)", + "Invalid Goal: proportion with greater than 1 countNum": "Invalid countNum for goal with criteria.mode of 'proportion'. Expected a decimal in (0, 1]", + "Invalid Goal: proportion which results in countNum of 0": "countNum (0.05) is too small for a goal with 2 charts. Would result in requiring 0 charts to achieve the goal.", + "Invalid Goal: charts.data array of chartIDs where some don't exist": "Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid?", + "Invalid Goal: folder-score for iidx is illegal": "Invalid key for iidx with multiple charts. Creating score goals on multiple charts where score is relative to notecount is a terrible idea, and has been disabled.", + "Invalid Goal: any-score for iidx is illegal": "Invalid key for iidx with multiple charts. Creating score goals on multiple charts where score is relative to notecount is a terrible idea, and has been disabled.", + "Invalid Goal: charts single but criteria not.": "Invalid countNum for goal with criteria.mode of 'absolute'. Expected a whole number less than the total amount of charts available and greater than 1. (Got 2, while total charts was 1.)", + "Invalid Goal: too big score": "Invalid value of 9000 for goal. Maximum score possible on this chart is 1572." +} \ No newline at end of file diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 6e3cb40b6..84d55f76d 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -1,3 +1,4 @@ +import dm from "deepmerge"; import db from "external/mongo/db"; import fs from "fs"; import { @@ -21,7 +22,9 @@ import { PBScoreDocument, ScoreDocument, SongDocument, - UserGoalDocument, + GoalSubscriptionDocument, + MilestoneSubscriptionDocument, + PublicUserDocument, } from "tachi-common"; import { ApplyNTimes, RFA } from "utils/misc"; @@ -414,7 +417,7 @@ export const HC511Goal: GoalDocument = { goalID: "mock_goalID", playtype: "SP", timeAdded: 0, - title: "HC 5.1.1. SPA", + name: "HC 5.1.1. SPA", criteria: { mode: "single", value: 5, @@ -422,7 +425,7 @@ export const HC511Goal: GoalDocument = { }, }; -export const HC511UserGoal: UserGoalDocument = { +export const HC511UserGoal: GoalSubscriptionDocument = { achieved: false, wasInstantlyAchieved: false, timeAchieved: null, @@ -436,9 +439,6 @@ export const HC511UserGoal: UserGoalDocument = { progressHuman: "NO DATA", timeSet: 0, userID: 1, - from: { - origin: "manual", - }, }; export const TestingIIDXFolderSP10: FolderDocument = { @@ -491,6 +491,33 @@ export const TestingIIDXSPMilestone: MilestoneDocument = { ], }; +export const IIDXSPMilestoneGoals: GoalDocument[] = [ + dm(HC511Goal, { goalID: "eg_goal_1" }) as GoalDocument, + dm(HC511Goal, { goalID: "eg_goal_2", criteria: { value: 2 } }), + dm(HC511Goal, { goalID: "eg_goal_3", criteria: { key: "scoreData.score", value: 300 } }), + dm(HC511Goal, { goalID: "eg_goal_4", criteria: { key: "scoreData.score", value: 1100 } }), +]; + +export const IIDXSPMilestoneGoalSubs: GoalSubscriptionDocument[] = [ + dm(HC511UserGoal, { goalID: "eg_goal_1" }) as GoalSubscriptionDocument, + dm(HC511UserGoal, { goalID: "eg_goal_2" }) as GoalSubscriptionDocument, + dm(HC511UserGoal, { goalID: "eg_goal_3" }) as GoalSubscriptionDocument, + dm(HC511UserGoal, { goalID: "eg_goal_4" }) as GoalSubscriptionDocument, +]; + +export const TestingIIDXSPMilestoneSub: MilestoneSubscriptionDocument = { + userID: 1, + achieved: false, + game: "iidx", + playtype: "SP", + lastInteraction: null, + milestoneID: "example_milestone_id", + progress: 4, + timeAchieved: null, + timeSet: 1900, + wasInstantlyAchieved: false, +}; + let KTDATA_CACHE: { songs: unknown[]; charts: unknown[] } | undefined; export async function LoadTachiIIDXData() { @@ -542,6 +569,7 @@ export const uscChart: ChartDocument<"usc:Controller" | "usc:Keyboard"> = { hashSHA1: "USC_CHART_HASH", isOfficial: false, effector: "foo", + tableFolders: [], }, tierlistInfo: {}, isPrimary: true, @@ -573,3 +601,19 @@ export const uscScore: USCClientScore = { slam: USC_DEFAULT_SLAM, }, }; + +export const FakeOtherUser: PublicUserDocument = { + id: 2, + username: "other_user", + about: "", + authLevel: 0, + badges: [], + clan: null, + customBannerLocation: null, + customPfpLocation: null, + joinDate: 0, + lastSeen: 0, + socialMedia: {}, + status: null, + usernameLowercase: "other_user", +}; diff --git a/server/src/utils/db.ts b/server/src/utils/db.ts index 38aefab15..fbc3a87df 100644 --- a/server/src/utils/db.ts +++ b/server/src/utils/db.ts @@ -1,6 +1,18 @@ import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; -import { FormatChart, Game, integer, PBScoreDocument, ScoreDocument } from "tachi-common"; +import { FilterQuery } from "mongodb"; +import { + FormatChart, + Game, + GoalDocument, + GoalSubscriptionDocument, + integer, + MilestoneDocument, + MilestoneSetDocument, + MilestoneSubscriptionDocument, + PBScoreDocument, + ScoreDocument, +} from "tachi-common"; const logger = CreateLogCtx(__filename); export async function GetNextCounterValue(counterName: string): Promise { @@ -153,3 +165,277 @@ export async function HumaniseChartID(game: Game, chartID: string) { return FormatChart(game, song, chart); } + +/** + * Get recently achieved goals for this query. + * + * @param baseQuery - A base query, used to limit results on GPTs or UGPTs. + * @param limit - How many recently achieved goals to search for. + * @returns - The goals and their subs. + */ +export async function GetRecentlyAchievedGoals( + baseQuery: FilterQuery, + limit = 100 +) { + const query = Object.assign( + { + wasInstantlyAchieved: false, + achieved: true, + }, + baseQuery + ); + + const goalSubs = await db["goal-subs"].find(query, { + sort: { + timeAchieved: -1, + }, + limit, + }); + + const goals = await db.goals.find({ + goalID: { $in: goalSubs.map((e) => e.goalID) }, + }); + + if (goals.length !== goals.length) { + logger.error( + `Found ${goals.length} goals when looking for parents of ${goalSubs.length} subscriptions. This mismatch implies a state desync.` + ); + + throw new Error("Failed to fetch goals."); + } + + return { goals, goalSubs }; +} + +/** + * Get recently interacted-with goals for this query. + * + * @param baseQuery - A base query, used to limit results on GPTs or UGPTs. + * @param limit - How many recently achieved goals to search for. + * @returns - The goals and their subs. + */ +export async function GetRecentlyInteractedGoals( + baseQuery: FilterQuery, + limit = 100 +) { + const query = Object.assign( + { + wasInstantlyAchieved: false, + achieved: false, + lastInteraction: { $ne: null }, + }, + baseQuery + ); + + const goalSubs = await db["goal-subs"].find(query, { + sort: { + lastInteraction: -1, + }, + limit, + }); + + const goals = await db.goals.find({ + goalID: { $in: goalSubs.map((e) => e.goalID) }, + }); + + if (goals.length !== goals.length) { + logger.error( + `Found ${goals.length} goals when looking for parents of ${goalSubs.length} subscriptions. This mismatch implies a state desync.` + ); + + throw new Error("Failed to fetch goals."); + } + + return { goals, goalSubs }; +} + +/** + * Get recently achieved goals for this query. + * + * @param baseQuery - A base query, used to limit results on GPTs or UGPTs. + * @param limit - How many recently achieved goals to search for. + * @returns - The goals and their subs. + */ +export async function GetRecentlyAchievedMilestones( + baseQuery: FilterQuery, + limit = 100 +) { + const query = Object.assign( + { + wasInstantlyAchieved: false, + achieved: true, + }, + baseQuery + ); + + const milestoneSubs = await db["milestone-subs"].find(query, { + sort: { + timeAchieved: -1, + }, + limit, + }); + + const milestones = await db.milestones.find({ + milestoneID: { $in: milestoneSubs.map((e) => e.milestoneID) }, + }); + + if (milestones.length !== milestoneSubs.length) { + logger.error( + `Found ${milestones.length} milestones when looking for parents of ${milestoneSubs.length} subscriptions. This mismatch implies a state desync.` + ); + + throw new Error("Failed to fetch milestones."); + } + + return { milestones, milestoneSubs }; +} + +/** + * Get recently interacted-with milestones for this query. + * + * @param baseQuery - A base query, used to limit results on GPTs or UGPTs. + * @param limit - How many recently achieved milestones to search for. + * @returns - The milestones and their subs. + */ +export async function GetRecentlyInteractedMilestones( + baseQuery: FilterQuery, + limit = 100 +) { + const query = Object.assign( + { + lastInteraction: { $ne: null }, + achieved: false, + wasInstantlyAchieved: false, + }, + baseQuery + ); + + const milestoneSubs = await db["milestone-subs"].find(query, { + sort: { + lastInteraction: -1, + }, + limit, + }); + + const milestones = await db.milestones.find({ + milestoneID: { $in: milestoneSubs.map((e) => e.milestoneID) }, + }); + + if (milestones.length !== milestoneSubs.length) { + logger.error( + `Found ${milestones.length} milestones when looking for parents of ${milestoneSubs.length} subscriptions. This mismatch implies a state desync.` + ); + + throw new Error("Failed to fetch milestones."); + } + + return { milestones, milestoneSubs }; +} + +export async function GetMostSubscribedGoals( + query: FilterQuery, + limit = 100 +): Promise<(GoalDocument & { __subscriptions: integer })[]> { + const mostSubscribedGoals = (await db["goal-subs"].aggregate([ + { + $match: query, + }, + { + $group: { + _id: "$goalID", + subscriptions: { $sum: 1 }, + }, + }, + { + $sort: { + subscriptions: -1, + }, + }, + { + $limit: limit, + }, + { + $lookup: { + from: "goals", + localField: "_id", + foreignField: "goalID", + as: "goal", + }, + }, + { + $set: { + goal: { $arrayElemAt: ["$goal", 0] }, + }, + }, + { + $unset: "goal._id", + }, + ])) as { goal: GoalDocument; subscriptions: integer }[]; + + return mostSubscribedGoals.map((e) => ({ + __subscriptions: e.subscriptions, + ...e.goal, + })); +} + +export async function GetMostSubscribedMilestones( + query: FilterQuery, + limit = 100 +): Promise<(MilestoneDocument & { __subscriptions: integer })[]> { + const mostSubscribedMilesones = (await db["milestone-subs"].aggregate([ + { + $match: query, + }, + { + $group: { + _id: "$milestoneID", + subscriptions: { $sum: 1 }, + }, + }, + { + $sort: { + subscriptions: -1, + }, + }, + { + $limit: limit, + }, + { + $lookup: { + from: "milestones", + localField: "_id", + foreignField: "milestoneID", + as: "milestone", + }, + }, + { + $set: { + milestone: { $arrayElemAt: ["$milestone", 0] }, + }, + }, + { + $unset: "milestone._id", + }, + ])) as { milestone: MilestoneDocument; subscriptions: integer }[]; + + return mostSubscribedMilesones.map((e) => ({ + __subscriptions: e.subscriptions, + ...e.milestone, + })); +} + +export async function GetChildMilestones(milestoneSet: MilestoneSetDocument) { + const milestones = await db.milestones.find({ + milestoneID: { $in: milestoneSet.milestones }, + }); + + if (milestones.length !== milestoneSet.milestones.length) { + logger.error( + `Expected to find ${milestoneSet.milestones.length} milestones in the database, but only found ${milestones.length}.`, + { milestoneSet } + ); + throw new Error(`Failed to retrieve milestone sets' children.`); + } + + return milestones; +} diff --git a/server/src/utils/folder.ts b/server/src/utils/folder.ts index 276fdecb1..a81495cb9 100644 --- a/server/src/utils/folder.ts +++ b/server/src/utils/folder.ts @@ -152,9 +152,15 @@ export async function GetFolderChartIDs(folderID: string) { return chartIDs.map((e) => e.chartID); } -export async function CreateFolderChartLookup(folder: FolderDocument) { +export async function CreateFolderChartLookup(folder: FolderDocument, flush = false) { const { charts } = await ResolveFolderToCharts(folder, {}, false); + if (flush) { + await db["folder-chart-lookup"].remove({ + folderID: folder.folderID, + }); + } + await db["folder-chart-lookup"].insert( charts.map((c) => ({ folderID: folder.folderID, @@ -180,7 +186,7 @@ export async function InitaliseFolderChartLookup() { }); logger.info(`Reloading ${folders.length} folders.`); - await Promise.all(folders.map(CreateFolderChartLookup)); + await Promise.all(folders.map((folder) => CreateFolderChartLookup(folder))); logger.info(`Completed InitialiseFolderChartLookup.`); } diff --git a/server/src/utils/milestone.ts b/server/src/utils/milestone.ts deleted file mode 100644 index f97d4374b..000000000 --- a/server/src/utils/milestone.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { integer, MilestoneDocument } from "tachi-common"; - -/** - * Retrieves the goalID documents in a single array from the - * nested structure of milestones. - */ -export function GetGoalIDsFromMilestone(milestone: MilestoneDocument) { - // this sucks - maybe a nicer way to do this, because nested - // maps are just ugly - return milestone.milestoneData.map((e) => e.goals.map((e) => e.goalID)).flat(1); -} - -/** - * Work out how many goals need to be achieved for this - * milestone to be considered completed. - */ -export function CalculateMilestoneOutOf(milestone: MilestoneDocument, goalIDs: string[]): integer { - if (milestone.criteria.type === "all") { - return goalIDs.length; - } else if (milestone.criteria.type === "abs") { - if (milestone.criteria.value === null) { - throw new Error( - `Invalid milestone ${milestone.milestoneID} - abs and null are not compatible.` - ); - } - - return milestone.criteria.value!; - } else if (milestone.criteria.type === "proportion") { - if (milestone.criteria.value === null) { - throw new Error( - `Invalid milestone ${milestone.milestoneID} - proportion and null are not compatible.` - ); - } - - return Math.floor(milestone.criteria.value * goalIDs.length); - } - - throw new Error( - `Invalid milestone.criteria.type of ${milestone.criteria.type} -- milestoneID ${milestone.milestoneID}` - ); -} diff --git a/server/src/utils/queries/summary.ts b/server/src/utils/queries/summary.ts index 8963424b3..4b9dcb62b 100644 --- a/server/src/utils/queries/summary.ts +++ b/server/src/utils/queries/summary.ts @@ -1,9 +1,6 @@ import db from "external/mongo/db"; import { integer } from "tachi-common"; -import { - GetGradeLampDistributionForFolder, - GetGradeLampDistributionForFolders, -} from "utils/folder"; +import { GetGradeLampDistributionForFolders } from "utils/folder"; import { GetTimeXHoursAgo } from "utils/misc"; // Various utils related to the player summary endpoint. @@ -52,15 +49,15 @@ export async function GetRecentlyViewedFoldersAnyGPT(userID: integer) { return { folders, stats }; } -export async function GetRecentlyAchievedGoals(userID: integer) { +export async function GetGoalSummary(userID: integer) { const time = GetTimeXHoursAgo(REASONABLE_HOURS_AGO); - const achievedGoals = await db["user-goals"].find({ + const achievedGoals = await db["goal-subs"].find({ timeAchieved: { $gte: time }, userID, }); - const improvedGoals = await db["user-goals"].find({ + const improvedGoals = await db["goal-subs"].find({ lastInteraction: { $gte: time }, achieved: false, userID, diff --git a/server/src/utils/req-tachi-data.ts b/server/src/utils/req-tachi-data.ts index 8cf867399..c287486a6 100644 --- a/server/src/utils/req-tachi-data.ts +++ b/server/src/utils/req-tachi-data.ts @@ -2,6 +2,7 @@ import { SYMBOL_TachiData } from "lib/constants/tachi"; import { Request } from "express"; import { TachiRequestData } from "./types"; import deepmerge from "deepmerge"; +import CreateLogCtx from "lib/logger/logger"; export function AssignToReqTachiData(req: Request, data: Partial) { if (!req[SYMBOL_TachiData]) { @@ -10,3 +11,40 @@ export function AssignToReqTachiData(req: Request, data: Partial; } + +// This is only used on tachi-server, and isn't exposed -- so shouldn't be a part +// of common. +export interface PrivateUserInfoDocument { + userID: integer; + password: string; + email: string; +}