Merge pull request #675 from TNG-dev/zkldi/issue-673

This commit is contained in:
zkldi
2022-04-17 02:52:27 +00:00
committed by GitHub
98 changed files with 3759 additions and 777 deletions
+2 -1
View File
@@ -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",
+13 -13
View File
@@ -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
+11 -8
View File
@@ -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<FolderDocument>("folders"),
"folder-chart-lookup": monkDB.get<FolderChartLookup>("folder-chart-lookup"),
goals: monkDB.get<GoalDocument>("goals"),
"user-goals": monkDB.get<UserGoalDocument>("user-goals"),
"goal-subs": monkDB.get<GoalSubscriptionDocument>("goal-subs"),
milestones: monkDB.get<MilestoneDocument>("milestones"),
"user-milestones": monkDB.get<UserMilestoneDocument>("user-milestones"),
"milestone-subs": monkDB.get<MilestoneSubscriptionDocument>("milestone-subs"),
users: monkDB.get<PublicUserDocument>("users"),
imports: monkDB.get<ImportDocument>("imports"),
"import-timings": monkDB.get<ImportTimingsDocument>("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<RecentlyViewedFolderDocument>("recent-folder-views"),
"milestone-sets": monkDB.get<MilestoneSetDocument>("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}`;
+17 -6
View File
@@ -41,13 +41,23 @@ const staticIndexes: Partial<Record<Databases, Index[]>> = {
"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<Record<Databases, Index[]>> = {
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);
}
})
+10 -4
View File
@@ -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"],
}),
};
+9 -1
View File
@@ -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);
}
})
);
}
+4
View File
@@ -0,0 +1,4 @@
export enum SubscribeFailReasons {
ALREADY_SUBSCRIBED,
ALREADY_ACHIEVED,
}
@@ -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.");
@@ -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<string>,
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<string, UserGoalDocument>,
goalSubsMap: Map<string, GoalSubscriptionDocument>,
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<GoalSubscriptionDocument>;
// 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<GoalDocument>,
$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<string>,
logger: KtLogger
): Promise<{ goals: GoalDocument[]; userGoalsMap: Map<string, UserGoalDocument> }> {
const userGoals = await db["user-goals"].find({ game, userID }, { projectID: true });
): Promise<{ goals: GoalDocument[]; goalSubsMap: Map<string, GoalSubscriptionDocument> }> {
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<string, UserGoalDocument> = new Map();
const goalSubsMap: Map<string, GoalSubscriptionDocument> = 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,
};
}
@@ -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,
},
@@ -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<string, GoalImportInfo["new"]> = new Map();
const goalSubInfoMap: Map<string, GoalImportInfo["new"]> = 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<UserMilestoneDocument>[] = [];
const bwrite: BulkWriteUpdateOneOperation<MilestoneSubscriptionDocument>[] = [];
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<MilestoneSubscriptionDocument> = {
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 };
}
@@ -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(
@@ -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.
@@ -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";
@@ -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.
*/
@@ -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";
@@ -3,10 +3,10 @@ import { KtLogger } from "lib/logger/logger";
import {
BatchManualScore,
ChartDocument,
ImportTypes,
SongDocument,
Grades,
IDStrings,
ImportTypes,
SongDocument,
} from "tachi-common";
import {
FindBMSChartOnHash,
@@ -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";
@@ -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";
+1
View File
@@ -49,6 +49,7 @@ export function SearchCollection<T>(
// hide nonsense
{ $match: { __textScore: { $gt: 0.25 } } },
{ $limit: limit },
{ $unset: "_id" },
])
.catch((err) => {
logger.error(
+6
View File
@@ -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(
+1 -1
View File
@@ -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, {}))
);
+1 -1
View File
@@ -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({});
@@ -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}.)`
);
}
@@ -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) => {
@@ -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;
}
+263
View File
@@ -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<string, GoalSubscriptionDocument>();
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<MilestoneSubscriptionReturns | SubscribeFailReasons.ALREADY_SUBSCRIBED>;
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,
});
}
View File
+5 -2
View File
@@ -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);
});
}
}
@@ -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 } });
}
+3
View File
@@ -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<any>,
logger: KtLogger,
collectionName: string
+3 -4
View File
@@ -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<APIPermissions, true> = {
customise_session: true,
customise_score: true,
delete_score: true,
set_goals: true,
unset_goals: true,
manage_targets: true,
};
export const SetRequestPermissions: RequestHandler = CreateSetRequestPermissions("description");
@@ -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);
@@ -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" });
@@ -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);
@@ -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);
@@ -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) => {
+4 -9
View File
@@ -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);
@@ -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,
@@ -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);
@@ -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);
@@ -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;
@@ -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 });
@@ -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");
@@ -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({
@@ -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;
@@ -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(
{
@@ -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,
@@ -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<TableDocument> = { game, playtype };
@@ -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();
});
@@ -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,
});
}
@@ -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();
});
@@ -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;
@@ -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();
});
@@ -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;
@@ -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>): 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>): 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();
});
@@ -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;
@@ -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, {
@@ -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);
@@ -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);
@@ -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,
@@ -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);
@@ -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<UserGoalDocument> = {
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<UserMilestoneDocument> = {
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;
@@ -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,
@@ -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 },
@@ -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,
@@ -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({});
@@ -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);
@@ -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);
@@ -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);
@@ -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();
});
@@ -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<GoalDocument, "charts" | "criteria">;
*
* @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<GoalDocument, "charts" | "criteria">;
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,
@@ -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();
}
);
@@ -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;
@@ -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<GoalSubscriptionDocument>) {
return dm(HC511UserGoal, merge);
}
function mkGoal(merge: Partial<GoalDocument>) {
return dm(HC511Goal, merge);
}
function mkMilestone(merge: Partial<MilestoneDocument>) {
return dm(TestingIIDXSPMilestone, merge);
}
function mkMilestoneSub(merge: Partial<MilestoneSubscriptionDocument>) {
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();
});
@@ -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;
@@ -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";
@@ -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);
@@ -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";
@@ -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({
+1 -1
View File
@@ -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 });
@@ -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();
+3 -4
View File
@@ -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;
@@ -1 +1,6 @@
[]
[
{
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
"folderID": "ed9d8c734447ce67d7135c0067441a98cc81aeaf"
}
]
@@ -0,0 +1 @@
[]
+1 -1
View File
@@ -1,5 +1,5 @@
import server from "../server/server";
import supertest from "supertest";
import server from "../server/server";
const mockServer = supertest(server);
@@ -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<string, string>;
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}`);
}
}
+2 -2
View File
@@ -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);
@@ -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."
}
+50 -6
View File
@@ -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",
};
+287 -1
View File
@@ -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<integer> {
@@ -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<GoalSubscriptionDocument>,
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<GoalSubscriptionDocument>,
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<MilestoneSubscriptionDocument>,
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<MilestoneSubscriptionDocument>,
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<GoalSubscriptionDocument>,
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<MilestoneSubscriptionDocument>,
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;
}
+8 -2
View File
@@ -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.`);
}
-41
View File
@@ -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}`
);
}
+4 -7
View File
@@ -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,
+38
View File
@@ -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<TachiRequestData>) {
if (!req[SYMBOL_TachiData]) {
@@ -10,3 +11,40 @@ export function AssignToReqTachiData(req: Request, data: Partial<TachiRequestDat
req[SYMBOL_TachiData] = deepmerge(req[SYMBOL_TachiData]!, data);
}
}
const logger = CreateLogCtx(__filename);
export function GetUGPT(req: Request) {
if (!req[SYMBOL_TachiData]) {
logger.error(`Tried to get UGPT from a req that doesn't have SYMBOL_TachiData.`);
throw new Error("Failed to get UGPT.");
}
const user = req[SYMBOL_TachiData]!.requestedUser;
const game = req[SYMBOL_TachiData]!.game;
const playtype = req[SYMBOL_TachiData]!.playtype;
if (!user || !game || !playtype) {
logger.error(`Tried to get UGPT from a req that doesnt have U, G and PT.`);
throw new Error(`Failed to get UGPT.`);
}
return { user, game, playtype };
}
export function GetGPT(req: Request) {
if (!req[SYMBOL_TachiData]) {
logger.error(`Tried to get GPT from a req that doesn't have SYMBOL_TachiData.`);
throw new Error("Failed to get GPT.");
}
const game = req[SYMBOL_TachiData]!.game;
const playtype = req[SYMBOL_TachiData]!.playtype;
if (!game || !playtype) {
logger.error(`Tried to get GPT from a req that doesnt have a Game and Playtype.`);
throw new Error(`Failed to get GPT.`);
}
return { game, playtype };
}
+17 -2
View File
@@ -11,8 +11,12 @@ import {
SongDocument,
UserSettings,
TachiAPIClientDocument,
UserGoalDocument,
GoalDocument,
MilestoneSubscriptionDocument,
GoalSubscriptionDocument,
MilestoneDocument,
MilestoneSetDocument,
integer,
} from "tachi-common";
declare module "express-session" {
@@ -65,7 +69,18 @@ export interface TachiRequestData {
tableDoc?: TableDocument;
folderDoc?: FolderDocument;
goalDoc?: GoalDocument;
userGoalDoc?: UserGoalDocument;
milestoneDoc?: MilestoneDocument;
goalSubDoc?: GoalSubscriptionDocument;
milestoneSubDoc?: MilestoneSubscriptionDocument;
milestoneSetDoc?: MilestoneSetDocument;
apiClientDoc: Omit<TachiAPIClientDocument, "clientSecret">;
}
// 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;
}