mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-07 22:38:02 +03:00
Merge pull request #675 from TNG-dev/zkldi/issue-673
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
Generated
+13
-13
@@ -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
|
||||
|
||||
Vendored
+11
-8
@@ -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}`;
|
||||
|
||||
|
||||
Vendored
+17
-6
@@ -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);
|
||||
}
|
||||
})
|
||||
|
||||
Vendored
+10
-4
@@ -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"],
|
||||
}),
|
||||
};
|
||||
|
||||
Vendored
+9
-1
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -49,6 +49,7 @@ export function SearchCollection<T>(
|
||||
// hide nonsense
|
||||
{ $match: { __textScore: { $gt: 0.25 } } },
|
||||
{ $limit: limit },
|
||||
{ $unset: "_id" },
|
||||
])
|
||||
.catch((err) => {
|
||||
logger.error(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {}))
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-4
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+102
@@ -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();
|
||||
});
|
||||
+84
@@ -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;
|
||||
+267
@@ -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;
|
||||
|
||||
+3
-3
@@ -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, {
|
||||
|
||||
+9
-6
@@ -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);
|
||||
|
||||
+3
-7
@@ -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
-150
@@ -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;
|
||||
|
||||
+4
-10
@@ -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,
|
||||
|
||||
+7
-16
@@ -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 },
|
||||
|
||||
+3
-7
@@ -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,
|
||||
|
||||
+1
-1
@@ -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({});
|
||||
|
||||
|
||||
+4
-10
@@ -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);
|
||||
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
|
||||
+782
@@ -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();
|
||||
});
|
||||
+57
-66
@@ -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,
|
||||
|
||||
+268
@@ -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();
|
||||
}
|
||||
);
|
||||
+231
@@ -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;
|
||||
+162
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
@@ -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,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,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."
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user