mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-12 09:18:25 +03:00
kill mongodb (#51)
* fool me * feat: rivals * refactor: chart document movements * fix: tests * feat: probably working goals mig * feat: kill mongodb
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Fully qualify SQL/Kysely column names (table.alias prefix)
|
||||
globs: typescript/server/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Fully qualified fields in queries
|
||||
|
||||
In Kysely `select`, `where`, `orderBy`, `join` conditions, and raw `sql` fragments, **always** qualify column names with the table or join alias. Do not use bare column names when the query references a table (including single-table queries, for consistency and safer refactors).
|
||||
|
||||
```typescript
|
||||
// ❌ BAD — ambiguous once joins or CTEs are added; harder to grep
|
||||
.select(["data", "chart_id", "user_id"])
|
||||
.where("id", "=", id)
|
||||
|
||||
// ✅ GOOD
|
||||
.select(["chart.data", "score.chart_id", "score.user_id"])
|
||||
.where("chart.id", "=", chartId)
|
||||
```
|
||||
|
||||
- Prefer the same alias the query uses in `from` / `innerJoin` (e.g. `chart`, `score`, `song`).
|
||||
- For `sql` tagged templates, qualify identifiers the same way: `` sql`chart.data` ``, not bare `` sql`data` `` when `data` is a column name.
|
||||
- When selecting with `as`, keep the qualified source on the left: `"chart.data as chart_data"` (see existing patterns in actions and queries).
|
||||
|
||||
This avoids duplicate-column errors, makes intent obvious in code review, and matches how API-facing selects are written alongside `db-formats` column lists.
|
||||
+1
-1
@@ -68,7 +68,7 @@ test-typescript FILTER="*":
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
|
||||
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --coverage --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
|
||||
|
||||
|
||||
# Run all parity suites, or a single one if a name is given.
|
||||
|
||||
@@ -23243,7 +23243,7 @@
|
||||
"title": "Timepiece phase Ⅱ"
|
||||
},
|
||||
{
|
||||
"altTitles": [],
|
||||
"altTitles": ["¡¡Fanta∽cramble!!"],
|
||||
"artist": "Potwi",
|
||||
"data": {
|
||||
"displayVersion": "exceed"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// This file is automatically generated by Kanel. Do not modify manually.
|
||||
|
||||
import type { account_id } from './Account';
|
||||
import type { default as GameGroup } from './GameGroup';
|
||||
import type { import_id } from './Import';
|
||||
import type { default as ImportType } from './ImportType';
|
||||
import type { default as GameGroup } from './GameGroup';
|
||||
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
|
||||
|
||||
/** Identifier type for public.orphan_score */
|
||||
@@ -12,25 +12,25 @@ export type orphan_score_row_id = string;
|
||||
|
||||
/** Represents the table public.orphan_score */
|
||||
export default interface OrphanScoreTable {
|
||||
row_id: ColumnType<orphan_score_row_id, orphan_score_row_id | undefined, orphan_score_row_id>;
|
||||
row_id: ColumnType<orphan_score_row_id, orphan_score_row_id | undefined, orphan_score_row_id>;
|
||||
|
||||
orphan_id: ColumnType<string, string, string>;
|
||||
user_id: ColumnType<account_id, account_id, account_id>;
|
||||
|
||||
user_id: ColumnType<account_id, account_id, account_id>;
|
||||
import_id: ColumnType<import_id | null, import_id | null, import_id | null>;
|
||||
|
||||
import_id: ColumnType<import_id | null, import_id | null | undefined, import_id | null>;
|
||||
orphan_id: ColumnType<string, string, string>;
|
||||
|
||||
import_type: ColumnType<ImportType, ImportType, ImportType>;
|
||||
import_type: ColumnType<ImportType, ImportType, ImportType>;
|
||||
|
||||
game_group: ColumnType<GameGroup, GameGroup, GameGroup>;
|
||||
game_group: ColumnType<GameGroup, GameGroup, GameGroup>;
|
||||
|
||||
data: ColumnType<unknown, unknown, unknown>;
|
||||
data: ColumnType<unknown, unknown, unknown>;
|
||||
|
||||
context: ColumnType<unknown, unknown, unknown>;
|
||||
context: ColumnType<unknown, unknown, unknown>;
|
||||
|
||||
time_inserted: ColumnType<string, string, string>;
|
||||
time_inserted: ColumnType<string, string, string>;
|
||||
|
||||
error_message: ColumnType<string, string, string>;
|
||||
error_message: ColumnType<string, string, string>;
|
||||
}
|
||||
|
||||
export type OrphanScore = Selectable<OrphanScoreTable>;
|
||||
|
||||
@@ -20,8 +20,6 @@ export default interface SessionTable {
|
||||
|
||||
description: ColumnType<string | null, string | null, string | null>;
|
||||
|
||||
textsearch: ColumnType<string, never, never>;
|
||||
|
||||
time_inserted: ColumnType<string, string, string>;
|
||||
|
||||
time_started: ColumnType<string, string, string>;
|
||||
@@ -31,6 +29,8 @@ export default interface SessionTable {
|
||||
calculated_data: ColumnType<unknown, unknown, unknown>;
|
||||
|
||||
highlight: ColumnType<boolean, boolean, boolean>;
|
||||
|
||||
textsearch: ColumnType<string, never, never>;
|
||||
}
|
||||
|
||||
export type Session = Selectable<SessionTable>;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db.js";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_AddGoal } from "./add-goal";
|
||||
|
||||
describe("ACTION_AddGoal", () => {
|
||||
let userID: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userID, username } = await seedUser({ username: `goal_add_${Date.now()}` }));
|
||||
});
|
||||
|
||||
it("throws 403 when targeting another user as non-admin", async () => {
|
||||
const other = await seedUser({ username: `goal_add_other_${Date.now()}` });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_AddGoal(taker, {
|
||||
userID: other.id,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
charts: { type: "single", data: "chart" },
|
||||
criteria: { key: "lamp", value: 0, mode: "single" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 403 });
|
||||
});
|
||||
|
||||
it("inserts goal and goal_sub rows when criteria are valid and the user has no PB", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
|
||||
|
||||
const { goalID } = await ACTION_AddGoal(taker, {
|
||||
userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { key: "lamp", value: 7, mode: "single" },
|
||||
});
|
||||
|
||||
const goal = await GetGoalForIDGuaranteed(goalID);
|
||||
const goalSub = await GetGoalSubscriptionForIDGuaranteed(goalID, userID);
|
||||
expect(goalSub).toMatchObject({
|
||||
goalID,
|
||||
userID,
|
||||
});
|
||||
|
||||
const goalRow = await DB.selectFrom("goal")
|
||||
.selectAll()
|
||||
.where("id", "=", goalID)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(goalRow).toMatchObject({
|
||||
id: goal.goalID,
|
||||
game: "iidx-sp",
|
||||
name: goal.name,
|
||||
});
|
||||
|
||||
const subRow = await DB.selectFrom("goal_sub")
|
||||
.selectAll()
|
||||
.where("goal_id", "=", goal.goalID)
|
||||
.where("user_id", "=", userID)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(subRow).toMatchObject({
|
||||
goal_id: goal.goalID,
|
||||
user_id: userID,
|
||||
achieved: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a GOOD ADD_GOAL action row on success", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const taker = { ip: "10.1.2.3", acct: { id: userID, username } };
|
||||
|
||||
await ACTION_AddGoal(taker, {
|
||||
userID: userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { key: "lamp", value: 7, mode: "single" },
|
||||
});
|
||||
|
||||
const actionRow = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "ADD_GOAL")
|
||||
.orderBy("ts_start", "desc")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(actionRow).toMatchObject({
|
||||
result: "GOOD",
|
||||
ip: "10.1.2.3",
|
||||
user_id: userID,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws 400 for an invalid criteria key", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_AddGoal(taker, {
|
||||
userID: userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { key: "not_a_real_metric", value: 0, mode: "single" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("throws 400 when the chart does not exist", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_AddGoal(taker, {
|
||||
userID: userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
charts: { type: "single", data: "00000000-0000-0000-0000-00000000dead" },
|
||||
criteria: { key: "lamp", value: 7, mode: "single" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("throws 409 when the user is already subscribed to that goal", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
|
||||
|
||||
const input = {
|
||||
userID: userID,
|
||||
game: "iidx" as const,
|
||||
playtype: "SP" as const,
|
||||
charts: { type: "single" as const, data: chartId },
|
||||
criteria: { key: "lamp" as const, value: 7, mode: "single" as const },
|
||||
};
|
||||
|
||||
await ACTION_AddGoal(taker, input);
|
||||
|
||||
await expect(ACTION_AddGoal(taker, input)).rejects.toMatchObject({ code: 409 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { SubscribeFailReasons } from "#lib/constants/err-codes.js";
|
||||
import { ServerConfig } from "#lib/setup/config.js";
|
||||
import { ConstructGoal, SubscribeToGoal } from "#lib/targets/goals.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { IsUserAdmin } from "#utils/user.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { sql } from "kysely";
|
||||
import {
|
||||
type GameGroup,
|
||||
GamePTToV3,
|
||||
GetGamePTConfig,
|
||||
type MONGO_GoalDocument,
|
||||
type Playtype,
|
||||
} from "tachi-common";
|
||||
|
||||
export const ACTION_AddGoal = MakeAction("ADD_GOAL", async (taker, input) => {
|
||||
const { userID, game: gameStr, playtype: playtypeStr, charts, criteria } = input;
|
||||
const game = gameStr as GameGroup;
|
||||
const playtype = playtypeStr as Playtype;
|
||||
|
||||
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
|
||||
throw new ExpectedErr(403, "You are not authorised to modify this user's goals.");
|
||||
}
|
||||
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const row = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.select(sql<number>`count(*)::int`.as("c"))
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.where("goal.game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
const existingGoalsCount = Number(row?.c ?? 0);
|
||||
|
||||
if (existingGoalsCount > ServerConfig.MAX_GOAL_SUBSCRIPTIONS) {
|
||||
throw new ExpectedErr(
|
||||
400,
|
||||
`You already have ${ServerConfig.MAX_GOAL_SUBSCRIPTIONS} goals. You cannot have anymore.`,
|
||||
);
|
||||
}
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const validCriteria = [
|
||||
...Object.keys(gptConfig.providedMetrics),
|
||||
...Object.keys(gptConfig.derivedMetrics),
|
||||
];
|
||||
|
||||
const criteriaKey = (criteria as { key?: string }).key;
|
||||
|
||||
if (typeof criteriaKey !== "string" || !validCriteria.includes(criteriaKey)) {
|
||||
throw new ExpectedErr(
|
||||
400,
|
||||
`Invalid criteria '${String(criteriaKey)}', expected any of ${validCriteria.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let goal: MONGO_GoalDocument;
|
||||
|
||||
try {
|
||||
goal = await ConstructGoal(
|
||||
charts as MONGO_GoalDocument["charts"],
|
||||
criteria as MONGO_GoalDocument["criteria"],
|
||||
game,
|
||||
playtype,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new ExpectedErr(400, (e as Error).message);
|
||||
}
|
||||
|
||||
const goalSub = await SubscribeToGoal(userID, goal, true);
|
||||
|
||||
if (goalSub === SubscribeFailReasons.ALREADY_SUBSCRIBED) {
|
||||
throw new ExpectedErr(409, "You are already subscribed to this goal.");
|
||||
}
|
||||
|
||||
if (goalSub === SubscribeFailReasons.ALREADY_ACHIEVED) {
|
||||
throw new ExpectedErr(
|
||||
400,
|
||||
"You can't directly assign goals that you would immediately achieve.",
|
||||
);
|
||||
}
|
||||
|
||||
return { goalID: goal.goalID };
|
||||
});
|
||||
@@ -51,7 +51,6 @@ async function loadAllChartsForGame(game: GameGroup): Promise<Array<MONGO_ChartD
|
||||
let q = DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("song.game_group", "=", game)
|
||||
.orderBy("chart.id", "asc")
|
||||
.limit(FETCH_CHUNK);
|
||||
@@ -67,7 +66,7 @@ async function loadAllChartsForGame(game: GameGroup): Promise<Array<MONGO_ChartD
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
out.push(ToChartDocument(row, row.song_legacy_id));
|
||||
out.push(ToChartDocument(row));
|
||||
}
|
||||
|
||||
lastChartId = rows[rows.length - 1]!.chart_id;
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock("tachi-common", async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe("ACTION_BMSTableSync", () => {
|
||||
const songPgId = "song-bms-action-test";
|
||||
const songNewID = "song-bms-action-test";
|
||||
const chartId = CreateChartID();
|
||||
const legacySongId = 884_001;
|
||||
let adminId: number;
|
||||
@@ -55,7 +55,7 @@ describe("ACTION_BMSTableSync", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await DB.deleteFrom("chart").where("id", "=", chartId).execute();
|
||||
await DB.deleteFrom("song").where("id", "=", songPgId).execute();
|
||||
await DB.deleteFrom("song").where("id", "=", songNewID).execute();
|
||||
await DB.deleteFrom("action").where("user_id", "=", adminId).execute();
|
||||
await DB.deleteFrom("account").where("id", "=", adminId).execute();
|
||||
mockLoadBMSTable.mockReset();
|
||||
@@ -64,7 +64,7 @@ describe("ACTION_BMSTableSync", () => {
|
||||
it("applies mocked table levels to an existing BMS chart and song (DefaultAdminUser taker)", async () => {
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: legacySongId,
|
||||
game_group: "bms",
|
||||
title: "Unit Song",
|
||||
@@ -86,7 +86,7 @@ describe("ACTION_BMSTableSync", () => {
|
||||
id: chartId,
|
||||
legacy_id: chartId,
|
||||
game: "bms-7k",
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
level: "?",
|
||||
level_num: 0,
|
||||
is_primary: true,
|
||||
@@ -121,7 +121,7 @@ describe("ACTION_BMSTableSync", () => {
|
||||
|
||||
const songRow = await DB.selectFrom("song")
|
||||
.select("data")
|
||||
.where("id", "=", songPgId)
|
||||
.where("id", "=", songNewID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const ts = (songRow.data as { tableString: string | null }).tableString;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LoadScoreDocumentById } from "#lib/db-formats/score.js";
|
||||
import { mongoScoreDataToPg, pgScoreDataToMongo } from "#lib/v3/migration-tools";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { type MONGO_ScoreData, type MONGO_ScoreDocument } from "tachi-common";
|
||||
import { type MONGO_ScoreData } from "tachi-common";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_CustomiseScore } from "./customise-score";
|
||||
@@ -109,19 +110,14 @@ describe("ACTION_CustomiseScore", () => {
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_CustomiseScore(taker, {
|
||||
await ACTION_CustomiseScore(taker, {
|
||||
scoreID: scoreId,
|
||||
comment: "nice",
|
||||
});
|
||||
|
||||
expect((result.score as MONGO_ScoreDocument).comment).toBe("nice");
|
||||
const score = await LoadScoreDocumentById(scoreId);
|
||||
|
||||
const row = await DB.selectFrom("score")
|
||||
.select("comment")
|
||||
.where("id", "=", scoreId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row?.comment).toBe("nice");
|
||||
expect(score?.comment).toBe("nice");
|
||||
});
|
||||
|
||||
it("updates highlight and propagates to pb when present", async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { LoadScoreDocumentById } from "#lib/db-formats/score.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
@@ -41,11 +40,5 @@ export const ACTION_CustomiseScore = MakeAction("CUSTOMISE_SCORE", async (_taker
|
||||
}
|
||||
});
|
||||
|
||||
const doc = await LoadScoreDocumentById(input.scoreID);
|
||||
|
||||
if (!doc) {
|
||||
throw new ExpectedErr(500, "Score disappeared after update.");
|
||||
}
|
||||
|
||||
return { score: doc };
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -62,7 +62,10 @@ describe("ACTION_DeleteImport", () => {
|
||||
|
||||
await ACTION_DeleteImport(taker, { id: importId });
|
||||
|
||||
const row = await DB.selectFrom("import").select("id").where("id", "=", importId).executeTakeFirst();
|
||||
const row = await DB.selectFrom("import")
|
||||
.select("id")
|
||||
.where("id", "=", importId)
|
||||
.executeTakeFirst();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -73,7 +76,10 @@ describe("ACTION_DeleteImport", () => {
|
||||
|
||||
await ACTION_DeleteImport(taker, { id: importId });
|
||||
|
||||
const row = await DB.selectFrom("import").select("id").where("id", "=", importId).executeTakeFirst();
|
||||
const row = await DB.selectFrom("import")
|
||||
.select("id")
|
||||
.where("id", "=", importId)
|
||||
.executeTakeFirst();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -11,10 +11,7 @@ export const ACTION_DeleteImport = MakeAction("DELETE_IMPORT", async (taker, { i
|
||||
throw new ExpectedErr(404, "This import does not exist.");
|
||||
}
|
||||
|
||||
if (
|
||||
importDoc.userID !== taker.acct.id &&
|
||||
!(await IsUserAdmin(taker.acct.id))
|
||||
) {
|
||||
if (importDoc.userID !== taker.acct.id && !(await IsUserAdmin(taker.acct.id))) {
|
||||
throw new ExpectedErr(403, "You are not authorised to perform this action.");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type MONGO_ScoreData } from "tachi-common";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_DeleteScore } from "./delete-score";
|
||||
|
||||
@@ -108,7 +108,10 @@ describe("ACTION_DeleteScore", () => {
|
||||
|
||||
await ACTION_DeleteScore(taker, { id: scoreId });
|
||||
|
||||
const row = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
|
||||
const row = await DB.selectFrom("score")
|
||||
.select("id")
|
||||
.where("id", "=", scoreId)
|
||||
.executeTakeFirst();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -119,7 +122,10 @@ describe("ACTION_DeleteScore", () => {
|
||||
|
||||
await ACTION_DeleteScore(taker, { id: scoreId });
|
||||
|
||||
const row = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
|
||||
const row = await DB.selectFrom("score")
|
||||
.select("id")
|
||||
.where("id", "=", scoreId)
|
||||
.executeTakeFirst();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -11,10 +11,7 @@ export const ACTION_DeleteScore = MakeAction("DELETE_SCORE", async (taker, { id,
|
||||
throw new ExpectedErr(404, "This score does not exist.");
|
||||
}
|
||||
|
||||
if (
|
||||
score.userID !== taker.acct.id &&
|
||||
!(await IsUserAdmin(taker.acct.id))
|
||||
) {
|
||||
if (score.userID !== taker.acct.id && !(await IsUserAdmin(taker.acct.id))) {
|
||||
throw new ExpectedErr(403, "You are not authorised to perform this action.");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type MONGO_ScoreData } from "tachi-common";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_DeleteSession } from "./delete-session";
|
||||
|
||||
@@ -124,8 +124,14 @@ describe("ACTION_DeleteSession", () => {
|
||||
|
||||
await ACTION_DeleteSession(taker, { id: sessionId });
|
||||
|
||||
const sess = await DB.selectFrom("session").select("id").where("id", "=", sessionId).executeTakeFirst();
|
||||
const score = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
|
||||
const sess = await DB.selectFrom("session")
|
||||
.select("id")
|
||||
.where("id", "=", sessionId)
|
||||
.executeTakeFirst();
|
||||
const score = await DB.selectFrom("score")
|
||||
.select("id")
|
||||
.where("id", "=", scoreId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(sess).toBeUndefined();
|
||||
expect(score).toBeUndefined();
|
||||
@@ -138,8 +144,14 @@ describe("ACTION_DeleteSession", () => {
|
||||
|
||||
await ACTION_DeleteSession(taker, { id: sessionId });
|
||||
|
||||
const sess = await DB.selectFrom("session").select("id").where("id", "=", sessionId).executeTakeFirst();
|
||||
const score = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
|
||||
const sess = await DB.selectFrom("session")
|
||||
.select("id")
|
||||
.where("id", "=", sessionId)
|
||||
.executeTakeFirst();
|
||||
const score = await DB.selectFrom("score")
|
||||
.select("id")
|
||||
.where("id", "=", scoreId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(sess).toBeUndefined();
|
||||
expect(score).toBeUndefined();
|
||||
|
||||
@@ -12,10 +12,7 @@ export const ACTION_DeleteSession = MakeAction("DELETE_SESSION", async (taker, {
|
||||
throw new ExpectedErr(404, "This session does not exist.");
|
||||
}
|
||||
|
||||
if (
|
||||
session.userID !== taker.acct.id &&
|
||||
!(await IsUserAdmin(taker.acct.id))
|
||||
) {
|
||||
if (session.userID !== taker.acct.id && !(await IsUserAdmin(taker.acct.id))) {
|
||||
throw new ExpectedErr(403, "You are not authorised to perform this action.");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_AddGoal } from "./add-goal";
|
||||
import { ACTION_RemoveGoalSubscription } from "./remove-goal-subscription";
|
||||
|
||||
describe("ACTION_RemoveGoalSubscription", () => {
|
||||
let userID: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userID, username } = await seedUser({ username: `goal_rm_${Date.now()}` }));
|
||||
});
|
||||
|
||||
it("throws 403 when targeting another user as non-admin", async () => {
|
||||
const other = await seedUser({ username: `goal_rm_other_${Date.now()}` });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_RemoveGoalSubscription(taker, {
|
||||
userID: other.id,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
goalID: "Gfake",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 403 });
|
||||
});
|
||||
|
||||
it("throws 404 when the user is not subscribed to the goal", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_RemoveGoalSubscription(taker, {
|
||||
userID: userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
goalID: "Gnonexistent_goal_id_xyz",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 404 });
|
||||
});
|
||||
|
||||
it("deletes goal_sub when the user was subscribed", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
|
||||
|
||||
const { goalID } = await ACTION_AddGoal(taker, {
|
||||
userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { key: "lamp", value: 7, mode: "single" },
|
||||
});
|
||||
|
||||
await ACTION_RemoveGoalSubscription(taker, {
|
||||
userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
goalID,
|
||||
});
|
||||
|
||||
const subRow = await DB.selectFrom("goal_sub")
|
||||
.selectAll()
|
||||
.where("goal_id", "=", goalID)
|
||||
.where("user_id", "=", userID)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(subRow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes a GOOD REMOVE_GOAL_SUBSCRIPTION action row on success", async () => {
|
||||
const chartId = await seedMinimalIidxSpChart();
|
||||
const taker = { ip: "10.4.5.6", acct: { id: userID, username } };
|
||||
|
||||
const { goalID } = await ACTION_AddGoal(taker, {
|
||||
userID: userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
charts: { type: "single", data: chartId },
|
||||
criteria: { key: "lamp", value: 7, mode: "single" },
|
||||
});
|
||||
|
||||
await ACTION_RemoveGoalSubscription(taker, {
|
||||
userID: userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
goalID,
|
||||
});
|
||||
|
||||
const actionRow = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "REMOVE_GOAL_SUBSCRIPTION")
|
||||
.orderBy("ts_start", "desc")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(actionRow).toMatchObject({
|
||||
result: "GOOD",
|
||||
ip: "10.4.5.6",
|
||||
user_id: userID,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a BAD action row when removal fails with 404", async () => {
|
||||
const taker = { ip: "10.7.8.9", acct: { id: userID, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_RemoveGoalSubscription(taker, {
|
||||
userID: userID,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
goalID: "Gmissing_subscription",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 404 });
|
||||
|
||||
const actionRow = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "REMOVE_GOAL_SUBSCRIPTION")
|
||||
.orderBy("ts_start", "desc")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(actionRow).toMatchObject({
|
||||
result: "BAD",
|
||||
ip: "10.7.8.9",
|
||||
user_id: userID,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents.js";
|
||||
import { UnsubscribeFromGoal } from "#lib/targets/goals.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { staticAssertUnreachable } from "#utils/misc.js";
|
||||
import { IsUserAdmin } from "#utils/user.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import type { Game } from "tachi-db";
|
||||
import { GamePTToV3, type GameGroup, type Playtype } from "tachi-common";
|
||||
|
||||
export const ACTION_RemoveGoalSubscription = MakeAction(
|
||||
"REMOVE_GOAL_SUBSCRIPTION",
|
||||
async (taker, input) => {
|
||||
const { userID, game: gameStr, playtype: playtypeStr, goalID } = input;
|
||||
const game = gameStr as GameGroup;
|
||||
const playtype = playtypeStr as Playtype;
|
||||
|
||||
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
|
||||
throw new ExpectedErr(403, "You are not authorised to modify this user's goals.");
|
||||
}
|
||||
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const row = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.where("goal_sub.goal_id", "=", goalID)
|
||||
.where("goal.game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
throw new ExpectedErr(404, "You are not subscribed to this goal.");
|
||||
}
|
||||
|
||||
const goalSub = ToGoalSubscriptionDocument({
|
||||
...row,
|
||||
goal_game: row.goal_game as Game,
|
||||
});
|
||||
|
||||
const fail = await UnsubscribeFromGoal(goalSub, false);
|
||||
|
||||
if (!fail) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (fail.reason) {
|
||||
case "HAS_QUEST_DEPENDENCIES":
|
||||
throw new ExpectedErr(
|
||||
400,
|
||||
`This goal is part of a quest you are subscribed to. It can only be removed by unsubscribing from the relevant quests: ${fail.parentQuests
|
||||
.map((e) => `'${e.quest.name}'`)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
|
||||
case "WAS_STANDALONE":
|
||||
throw new ExpectedErr(
|
||||
400,
|
||||
"This goal was assigned by you and can't be removed as a consequence of another action.",
|
||||
);
|
||||
|
||||
default:
|
||||
staticAssertUnreachable(fail);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_SetRivals } from "./set-rivals";
|
||||
|
||||
describe("ACTION_SetRivals", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
let rivalId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: `rival_set_${Date.now()}` }));
|
||||
rivalId = (await seedUser({ username: `rival_target_${Date.now()}` })).id;
|
||||
|
||||
const ugptRow = {
|
||||
game: "iidx-sp" as const,
|
||||
pf_preferred_score_alg: null,
|
||||
pf_preferred_session_alg: null,
|
||||
pf_preferred_profile_alg: null,
|
||||
pf_preferred_default_enum: null,
|
||||
pf_default_table: null,
|
||||
pf_preferred_ranking: null,
|
||||
data: JSON.stringify({ display2DXTra: false, bpiTarget: 0 }),
|
||||
};
|
||||
|
||||
await DB.insertInto("game_settings")
|
||||
.values([
|
||||
{ user_id: userId, ...ugptRow },
|
||||
{ user_id: rivalId, ...ugptRow },
|
||||
])
|
||||
.execute();
|
||||
});
|
||||
|
||||
it("replaces game_rival rows for the UGPT", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_SetRivals(taker, {
|
||||
userID: userId,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
rivalIDs: [rivalId],
|
||||
});
|
||||
|
||||
const rows = await DB.selectFrom("game_rival")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.where("game", "=", "iidx-sp")
|
||||
.execute();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.rival).toBe(rivalId);
|
||||
});
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.3", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_SetRivals(taker, {
|
||||
userID: userId,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
rivalIDs: [rivalId],
|
||||
});
|
||||
|
||||
const actionRow = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "SET_RIVALS")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(actionRow).toMatchObject({ result: "GOOD", ip: "10.0.0.3", user_id: userId });
|
||||
});
|
||||
|
||||
it("throws 403 when targeting another user as non-admin", async () => {
|
||||
const other = await seedUser({ username: `other_rival_${Date.now()}` });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_SetRivals(taker, {
|
||||
userID: other.id,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
rivalIDs: [rivalId],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 403 });
|
||||
});
|
||||
|
||||
it("throws 400 when rivaling yourself", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_SetRivals(taker, {
|
||||
userID: userId,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
rivalIDs: [userId],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("throws 400 when more than MAX_RIVALS rivals are set", async () => {
|
||||
const main = await seedUser({ username: `too_many_${Date.now()}` });
|
||||
const rivalUsers = await Promise.all(
|
||||
Array.from({ length: ServerConfig.MAX_RIVALS + 1 }, (_, i) =>
|
||||
seedUser({ username: `too_many_r_${i}_${Date.now()}` }),
|
||||
),
|
||||
);
|
||||
|
||||
const ugptRow = {
|
||||
game: "iidx-sp" as const,
|
||||
pf_preferred_score_alg: null,
|
||||
pf_preferred_session_alg: null,
|
||||
pf_preferred_profile_alg: null,
|
||||
pf_preferred_default_enum: null,
|
||||
pf_default_table: null,
|
||||
pf_preferred_ranking: null,
|
||||
data: JSON.stringify({ display2DXTra: false, bpiTarget: 0 }),
|
||||
};
|
||||
|
||||
await DB.insertInto("game_settings")
|
||||
.values([
|
||||
{ user_id: main.id, ...ugptRow },
|
||||
...rivalUsers.map((u) => ({ user_id: u.id, ...ugptRow })),
|
||||
])
|
||||
.execute();
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: main.id, username: main.username } };
|
||||
|
||||
await expect(
|
||||
ACTION_SetRivals(taker, {
|
||||
userID: main.id,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
rivalIDs: rivalUsers.map((u) => u.id),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { SetRivalsFailReasons } from "#lib/constants/err-codes.js";
|
||||
import { setRivalsWithResult } from "#lib/rivals/rivals.js";
|
||||
import { staticAssertUnreachable } from "#utils/misc.js";
|
||||
import { IsUserAdmin } from "#utils/user.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { FormatGameGroup, type GameGroup, type Playtype } from "tachi-common";
|
||||
|
||||
function throwIfSetRivalsFailed(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
reason: SetRivalsFailReasons,
|
||||
): never {
|
||||
switch (reason) {
|
||||
case SetRivalsFailReasons.RIVALED_SELF:
|
||||
throw new ExpectedErr(400, `You cannot rival yourself.`);
|
||||
case SetRivalsFailReasons.RIVALS_HAVENT_PLAYED_GPT:
|
||||
throw new ExpectedErr(
|
||||
400,
|
||||
`Not all of the rivals you specified have played ${FormatGameGroup(game, playtype)}.`,
|
||||
);
|
||||
case SetRivalsFailReasons.TOO_MANY:
|
||||
throw new ExpectedErr(400, `You can't set more than 5 rivals.`);
|
||||
default:
|
||||
staticAssertUnreachable(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export const ACTION_SetRivals = MakeAction("SET_RIVALS", async (taker, input) => {
|
||||
const { userID, game: gameStr, playtype: playtypeStr, rivalIDs } = input;
|
||||
const game = gameStr as GameGroup;
|
||||
const playtype = playtypeStr as Playtype;
|
||||
|
||||
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
|
||||
throw new ExpectedErr(403, "You are not authorised to modify this user's rivals.");
|
||||
}
|
||||
|
||||
const err = await setRivalsWithResult(userID, game, playtype, rivalIDs);
|
||||
if (err !== null) {
|
||||
throwIfSetRivalsFailed(game, playtype, err);
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
@@ -129,10 +129,8 @@ export async function updateBpiDataCore() {
|
||||
|
||||
if (updatedChartIDs.length !== 0) {
|
||||
log.info(`Finished applying BPI changes in Postgres. Recalcing scores.`);
|
||||
await RecalcAllScores({
|
||||
game: "iidx",
|
||||
chartID: { $in: updatedChartIDs },
|
||||
});
|
||||
// TODO(zk): We don't want to recalc _everything_ on changes like this?
|
||||
await RecalcAllScores();
|
||||
log.info(`Finished recalcing scores.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -101,11 +101,8 @@ export async function updateDpTiersCore() {
|
||||
if (updatedSongIDs.size !== 0) {
|
||||
log.info(`${updatedSongIDs.size} songs were changed. Recalcing the relevant scores now.`);
|
||||
|
||||
await RecalcAllScores({
|
||||
game: "iidx",
|
||||
playtype: "DP",
|
||||
songID: { $in: [...updatedSongIDs.values()] },
|
||||
});
|
||||
// TODO(zk): We don't want to recalc _everything_ on changes like this?
|
||||
await RecalcAllScores();
|
||||
|
||||
log.info(`Recalced those scores.`);
|
||||
}
|
||||
|
||||
@@ -190,10 +190,8 @@ export async function updateSp12DataCore() {
|
||||
|
||||
if (updatedChartIDs.length !== 0) {
|
||||
log.info(`Finished applying SP12 changes. Recalcing.`);
|
||||
await RecalcAllScores({
|
||||
game: "iidx",
|
||||
chartID: { $in: updatedChartIDs },
|
||||
});
|
||||
// TODO(zk): We don't want to recalc _everything_ on changes like this?
|
||||
await RecalcAllScores();
|
||||
log.info(`Finished recalcing scores.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -264,9 +264,7 @@ export const ActionSignatures = {
|
||||
comment: z.union([z.string().min(1).max(120), z.null()]).optional(),
|
||||
highlight: z.boolean().optional(),
|
||||
}),
|
||||
output: z.object({
|
||||
score: z.unknown(),
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
UPSERT_KAI_AUTH_TOKEN: {
|
||||
input: z.object({
|
||||
@@ -315,6 +313,36 @@ export const ActionSignatures = {
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
SET_RIVALS: {
|
||||
input: z.object({
|
||||
userID: z.number().int(),
|
||||
game: z.string(),
|
||||
playtype: z.string(),
|
||||
rivalIDs: z.array(z.number().int().positive()),
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
ADD_GOAL: {
|
||||
input: z.object({
|
||||
userID: z.number().int(),
|
||||
game: z.string(),
|
||||
playtype: z.string(),
|
||||
charts: z.any(),
|
||||
criteria: z.any(),
|
||||
}),
|
||||
output: z.object({
|
||||
goalID: z.string(),
|
||||
}),
|
||||
},
|
||||
REMOVE_GOAL_SUBSCRIPTION: {
|
||||
input: z.object({
|
||||
userID: z.number().int(),
|
||||
game: z.string(),
|
||||
playtype: z.string(),
|
||||
goalID: z.string(),
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
UPDATE_UGPT_SHOWCASE: {
|
||||
input: z.object({
|
||||
userID: z.number().int(),
|
||||
|
||||
@@ -267,9 +267,7 @@ describe("GET /api/v1/games/:game/:playtype/activity (smoke)", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.recentSessions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ sessionID: `act-api-s-${base}` }),
|
||||
]),
|
||||
expect.arrayContaining([expect.objectContaining({ sessionID: `act-api-s-${base}` })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,12 +60,7 @@ function whereActivityUserId(
|
||||
return (eb: ActivityWhereEb) => eb(column, "=", userID);
|
||||
}
|
||||
|
||||
if (
|
||||
userID &&
|
||||
typeof userID === "object" &&
|
||||
"$in" in userID &&
|
||||
Array.isArray(userID.$in)
|
||||
) {
|
||||
if (userID && typeof userID === "object" && "$in" in userID && Array.isArray(userID.$in)) {
|
||||
const ids = userID.$in;
|
||||
|
||||
if (ids.length === 0) {
|
||||
@@ -204,17 +199,13 @@ export async function GetRecentActivity(
|
||||
highlightQ = highlightQ.where(scoreUserWhere);
|
||||
}
|
||||
|
||||
const [
|
||||
classRows,
|
||||
highlightRows,
|
||||
{ goals, goalSubs },
|
||||
{ quests, questSubs },
|
||||
] = await Promise.all([
|
||||
classQ.orderBy("class_achievement.timestamp", "desc").execute(),
|
||||
highlightQ.orderBy(sql`score.time_achieved desc nulls last`).execute(),
|
||||
GetRecentlyAchievedGoals({ ...query, timeAchieved: timeConstraint }, 0),
|
||||
GetRecentlyAchievedQuests({ ...query, timeAchieved: timeConstraint }, 0),
|
||||
]);
|
||||
const [classRows, highlightRows, { goals, goalSubs }, { quests, questSubs }] =
|
||||
await Promise.all([
|
||||
classQ.orderBy("class_achievement.timestamp", "desc").execute(),
|
||||
highlightQ.orderBy(sql`score.time_achieved desc nulls last`).execute(),
|
||||
GetRecentlyAchievedGoals({ ...query, timeAchieved: timeConstraint }, 0),
|
||||
GetRecentlyAchievedQuests({ ...query, timeAchieved: timeConstraint }, 0),
|
||||
]);
|
||||
|
||||
const achievedClasses = classRows.map(ToClassAchievementDocument);
|
||||
const recentlyHighlightedScores = highlightRows.map((row) =>
|
||||
|
||||
@@ -23,16 +23,17 @@ export const SELECT_CHART = [
|
||||
"chart.versions as chart_versions",
|
||||
"chart.data as chart_data",
|
||||
"chart.song_id as chart_song_id",
|
||||
"song.legacy_id as song_legacy_id",
|
||||
] as const;
|
||||
|
||||
type ChartRow = Selection<Database, "chart", (typeof SELECT_CHART)[number]>;
|
||||
export type ChartRow = Selection<Database, "chart" | "song", (typeof SELECT_CHART)[number]>;
|
||||
|
||||
export function ToChartDocument(row: ChartRow, songLegacyId: number): MONGO_ChartDocument {
|
||||
export function ToChartDocument(row: ChartRow): MONGO_ChartDocument {
|
||||
const { playtype } = V3ToGamePT(row.chart_game);
|
||||
|
||||
return {
|
||||
chartID: row.chart_id,
|
||||
songID: songLegacyId,
|
||||
songID: row.song_legacy_id,
|
||||
level: row.chart_level,
|
||||
levelNum: row.chart_level_num,
|
||||
isPrimary: row.chart_is_primary,
|
||||
@@ -44,30 +45,30 @@ export function ToChartDocument(row: ChartRow, songLegacyId: number): MONGO_Char
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all charts for a given PG game string (e.g. "iidx-sp") and song PG UUID,
|
||||
* Fetches all charts for a given game (e.g. "iidx-sp") and song id,
|
||||
* including `chart.versions`. Returns fully-formed ChartDocuments using the provided
|
||||
* legacy song ID for the `songID` field.
|
||||
*/
|
||||
export async function GetChartsBySongPgId(
|
||||
export async function GetChartsBySongId(
|
||||
v3Game: Game,
|
||||
songPgId: string,
|
||||
songLegacyId: number,
|
||||
songID: string,
|
||||
opts?: { omit2dxtraCharts?: boolean },
|
||||
): Promise<MONGO_ChartDocument[]> {
|
||||
let q = DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.where("song_id", "=", songPgId)
|
||||
.where("song_id", "=", songID)
|
||||
.where("game", "=", v3Game);
|
||||
|
||||
const gameGroup = V3ToGameGroup(v3Game);
|
||||
|
||||
if (opts?.omit2dxtraCharts && gameGroup === "iidx") {
|
||||
q = q.where(sql<SqlBool>`(data->>'2dxtraSet') IS NULL`);
|
||||
q = q.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`);
|
||||
}
|
||||
|
||||
const rows = await q.execute();
|
||||
|
||||
return rows.map((row) => ToChartDocument(row, songLegacyId));
|
||||
return rows.map((row) => ToChartDocument(row));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +81,6 @@ export async function GetChartById(
|
||||
const chartRow = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where("chart.id", "=", chartID)
|
||||
.executeTakeFirst();
|
||||
@@ -89,28 +89,27 @@ export async function GetChartById(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return ToChartDocument(chartRow, chartRow.song_legacy_id);
|
||||
return ToChartDocument(chartRow);
|
||||
}
|
||||
|
||||
// Loads charts from a list of IDs.
|
||||
// This function should rarely be used - it's an antipattern in sql to do queries like this.
|
||||
export async function GetChartsByIds(
|
||||
game: GameGroup,
|
||||
chartKeys: Array<string>,
|
||||
chartIDs: Array<string>,
|
||||
): Promise<Array<MONGO_ChartDocument>> {
|
||||
if (chartKeys.length === 0) {
|
||||
if (chartIDs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const unique = [...new Set(chartKeys)];
|
||||
const unique = [...new Set(chartIDs)];
|
||||
|
||||
const rows = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("song.game_group", "=", game)
|
||||
.where("chart.id", "in", unique)
|
||||
.execute();
|
||||
|
||||
return rows.map((r) => ToChartDocument(r, r.song_legacy_id));
|
||||
return rows.map(ToChartDocument);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ type ClassAchievementRow = Selection<
|
||||
(typeof SELECT_CLASS_ACHIEVEMENT_DOCUMENT)[number]
|
||||
>;
|
||||
|
||||
export function ToClassAchievementDocument(row: ClassAchievementRow): MONGO_ClassAchievementDocument {
|
||||
export function ToClassAchievementDocument(
|
||||
row: ClassAchievementRow,
|
||||
): MONGO_ClassAchievementDocument {
|
||||
const { game, playtype } = V3ToGamePT(row.game as Game);
|
||||
|
||||
return {
|
||||
|
||||
@@ -267,6 +267,61 @@ export async function LoadPbDocumentsForUserPrimaryChartsSortedByAlg(
|
||||
return Promise.all(rows.map((r) => ToPbScoreDocument(r as PbDocumentJoinRow)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Top PBs in this GPT (`chart.game`), sorted by a numeric field in `calculated_data`
|
||||
* (descending). Matches legacy Mongo `personal-bests` queries for the GPT PB leaderboard.
|
||||
*/
|
||||
export async function LoadPbDocumentsForGameSortedByCalculatedAlg(
|
||||
v3Game: Game,
|
||||
alg: string,
|
||||
limit: number,
|
||||
): Promise<MONGO_PBScoreDocument[]> {
|
||||
const rows = await DB.selectFrom("pb")
|
||||
.innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id")
|
||||
.innerJoin("chart", "chart.id", "pb.chart_id")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where("pb.lens", "is", null)
|
||||
.orderBy(sql`(pb.calculated_data::jsonb->>${sql.lit(alg)})::double precision`, "desc")
|
||||
.orderBy("pb.time_achieved", "desc")
|
||||
.limit(limit)
|
||||
.execute();
|
||||
|
||||
return Promise.all(rows.map((r) => ToPbScoreDocument(r as PbDocumentJoinRow)));
|
||||
}
|
||||
|
||||
/**
|
||||
* PBs for any of the given users in this GPT (`chart.game`), sorted by a numeric field in
|
||||
* `calculated_data` (descending). Matches legacy Mongo `personal-bests` queries that did not
|
||||
* filter to primary charts only.
|
||||
*/
|
||||
export async function LoadPbDocumentsForUserSetSortedByCalculatedAlg(
|
||||
userIds: number[],
|
||||
v3Game: Game,
|
||||
alg: string,
|
||||
limit: number,
|
||||
): Promise<MONGO_PBScoreDocument[]> {
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await DB.selectFrom("pb")
|
||||
.innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id")
|
||||
.innerJoin("chart", "chart.id", "pb.chart_id")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD)
|
||||
.where("pb.user_id", "in", userIds)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where("pb.lens", "is", null)
|
||||
.orderBy(sql`(pb.calculated_data::jsonb->>${sql.lit(alg)})::double precision`, "desc")
|
||||
.orderBy("pb.time_achieved", "desc")
|
||||
.limit(limit)
|
||||
.execute();
|
||||
|
||||
return Promise.all(rows.map((r) => ToPbScoreDocument(r as PbDocumentJoinRow)));
|
||||
}
|
||||
|
||||
/** PBs for a set of users on one chart (Postgres `chart.id`). */
|
||||
export async function LoadPbsByUserIdsAndChartPgId(
|
||||
userIds: number[],
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { MONGO_QuestlineDocument } from "tachi-common";
|
||||
import { V3ToGamePT } from "tachi-common";
|
||||
import { type Selection } from "kysely";
|
||||
import { type Database } from "tachi-db";
|
||||
|
||||
export const SELECT_QUESTLINE = [
|
||||
"questline.id as questline_id",
|
||||
"questline.game as questline_game",
|
||||
"questline.name as questline_name",
|
||||
"questline.description as questline_description",
|
||||
] as const;
|
||||
|
||||
export type QuestlineRow = Selection<Database, "questline", (typeof SELECT_QUESTLINE)[number]>;
|
||||
|
||||
export function ToQuestlineDocument(
|
||||
row: QuestlineRow,
|
||||
questIdsOrdered: Array<string>,
|
||||
): MONGO_QuestlineDocument {
|
||||
const { game, playtype } = V3ToGamePT(row.questline_game);
|
||||
|
||||
return {
|
||||
questlineID: row.questline_id,
|
||||
name: row.questline_name,
|
||||
desc: row.questline_description,
|
||||
game,
|
||||
playtype,
|
||||
quests: questIdsOrdered,
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const SELECT_SONG_DOCUMENT = [
|
||||
export async function GetSongByLegacyID(
|
||||
game: GameGroup,
|
||||
legacyId: number,
|
||||
): Promise<{ doc: MONGO_SongDocument; pgId: string } | undefined> {
|
||||
): Promise<{ doc: MONGO_SongDocument; newSongID: string } | undefined> {
|
||||
const row = await DB.selectFrom("song")
|
||||
.select(SELECT_SONG_DOCUMENT)
|
||||
.where("legacy_id", "=", legacyId)
|
||||
@@ -43,7 +43,7 @@ export async function GetSongByLegacyID(
|
||||
data: row.song_data as SongDocumentData[typeof game],
|
||||
};
|
||||
|
||||
return { doc, pgId: row.song_id };
|
||||
return { doc, newSongID: row.song_id };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,11 +47,11 @@ export async function GetFolderCharts(
|
||||
|
||||
const rows = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select([...SELECT_CHART, "song.legacy_id as song_legacy_id"])
|
||||
.select(SELECT_CHART)
|
||||
.where("chart.id", "in", chartIds)
|
||||
.execute();
|
||||
|
||||
const charts = rows.map((row) => ToChartDocument(row, row.song_legacy_id));
|
||||
const charts = rows.map(ToChartDocument);
|
||||
|
||||
return { charts };
|
||||
}
|
||||
|
||||
@@ -118,13 +118,12 @@ export const CUSTOM_TACHI_IIDX_PLAYLISTS: Array<TachiIIDXPlaylist> = [
|
||||
const rows = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<SqlBool>`(chart.data->>'kaidenAverage') IS NOT NULL`)
|
||||
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
|
||||
.execute();
|
||||
|
||||
const charts = rows.map((r) => ToChartDocument(r, r.song_legacy_id)) as Array<
|
||||
const charts = rows.map(ToChartDocument) as Array<
|
||||
MONGO_ChartDocument<"iidx:DP" | "iidx:SP">
|
||||
>;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type https from "https";
|
||||
|
||||
import { log } from "#lib/log/log";
|
||||
import { CloseScoreImportQueue } from "#lib/score-import/worker/queue";
|
||||
import { monkDB } from "#services/mongo/db";
|
||||
import { ClosePgConnection } from "#services/pg/db";
|
||||
import { CloseRedisConnection } from "#services/redis/redis";
|
||||
|
||||
@@ -18,9 +17,6 @@ export function HandleSIGTERMGracefully(instance?: http.Server | https.Server) {
|
||||
}
|
||||
|
||||
async function CloseEverythingElse() {
|
||||
log.info({ shutdownInfo: true }, "Closing Mongo Database.");
|
||||
await monkDB.close();
|
||||
|
||||
log.info({ shutdownInfo: true }, "Closing database...");
|
||||
await ClosePgConnection();
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ import { DeorphanScores } from "#lib/score-import/framework/orphans/orphans";
|
||||
import { WrapScriptPromise } from "#utils/misc";
|
||||
|
||||
export async function DeorphanScoresMain() {
|
||||
const { success, failed, removed } = await DeorphanScores(
|
||||
{},
|
||||
log,
|
||||
);
|
||||
const { success, failed, removed } = await DeorphanScores({}, log);
|
||||
|
||||
log.info(`Finished attempting deorphaning.`);
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function DeorphanBmsIfInOrphanChartPg(
|
||||
log.info(`Song ${songDoc.title} was unorphaned forcefully (Postgres).`);
|
||||
|
||||
const songLegacyId = await GetNextBmsPmsSongLegacyId("bms");
|
||||
const songPgId = CreateSongID();
|
||||
const songNewID = CreateSongID();
|
||||
|
||||
songDoc.id = songLegacyId;
|
||||
chartDoc.songID = songLegacyId;
|
||||
@@ -66,7 +66,7 @@ export async function DeorphanBmsIfInOrphanChartPg(
|
||||
await trx
|
||||
.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: songLegacyId,
|
||||
game_group: "bms",
|
||||
title: songDoc.title,
|
||||
@@ -84,7 +84,7 @@ export async function DeorphanBmsIfInOrphanChartPg(
|
||||
id: chartDoc.chartID,
|
||||
legacy_id: chartDoc.chartID,
|
||||
game: v3Game,
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
level: chartDoc.level,
|
||||
level_num: chartDoc.levelNum,
|
||||
is_primary: chartDoc.isPrimary,
|
||||
|
||||
@@ -78,7 +78,7 @@ async function writeBmsPmsSongAndChart(
|
||||
v3Game: Game,
|
||||
songDoc: MONGO_SongDocument<"bms" | "pms">,
|
||||
chartDoc: MONGO_ChartDocument,
|
||||
songPgId: string,
|
||||
songNewID: string,
|
||||
songLegacyId: integer,
|
||||
) {
|
||||
const ftsDocument = [...songDoc.searchTerms, ...songDoc.altTitles].filter(Boolean).join(" ");
|
||||
@@ -86,7 +86,7 @@ async function writeBmsPmsSongAndChart(
|
||||
await trx
|
||||
.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: songLegacyId,
|
||||
game_group: gameGroup,
|
||||
title: songDoc.title,
|
||||
@@ -104,7 +104,7 @@ async function writeBmsPmsSongAndChart(
|
||||
id: chartDoc.chartID,
|
||||
legacy_id: chartDoc.chartID,
|
||||
game: v3Game,
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
level: chartDoc.level,
|
||||
level_num: chartDoc.levelNum,
|
||||
is_primary: chartDoc.isPrimary,
|
||||
@@ -198,7 +198,7 @@ export async function HandleOrphanQueue<GPT extends GPTString>(
|
||||
songDocU.id = songLegacyId;
|
||||
chartDocU.songID = songLegacyId;
|
||||
|
||||
const songPgId = CreateSongID();
|
||||
const songNewID = CreateSongID();
|
||||
|
||||
await DB.transaction().execute(async (trx) => {
|
||||
await writeBmsPmsSongAndChart(
|
||||
@@ -207,7 +207,7 @@ export async function HandleOrphanQueue<GPT extends GPTString>(
|
||||
v3Game,
|
||||
songDocU as MONGO_SongDocument<"bms" | "pms">,
|
||||
chartDocU,
|
||||
songPgId,
|
||||
songNewID,
|
||||
songLegacyId,
|
||||
);
|
||||
|
||||
@@ -272,7 +272,7 @@ export async function DeorphanIfInQueue<GPT extends GPTString>(
|
||||
songDocU.id = songLegacyId;
|
||||
chartDocU.songID = songLegacyId;
|
||||
|
||||
const songPgId = CreateSongID();
|
||||
const songNewID = CreateSongID();
|
||||
|
||||
await DB.transaction().execute(async (trx) => {
|
||||
await writeBmsPmsSongAndChart(
|
||||
@@ -281,7 +281,7 @@ export async function DeorphanIfInQueue<GPT extends GPTString>(
|
||||
v3Game,
|
||||
songDocU as MONGO_SongDocument<"bms" | "pms">,
|
||||
chartDocU,
|
||||
songPgId,
|
||||
songNewID,
|
||||
songLegacyId,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BulkWriteUpdateOneOperation } from "mongodb";
|
||||
import type { Game } from "tachi-db";
|
||||
|
||||
import { SetRivalsFailReasons } from "#lib/constants/err-codes";
|
||||
import { log } from "#lib/log/log";
|
||||
import { SendSetRivalNotification } from "#lib/notifications/notification-wrappers";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { pgScoreDataToMongo } from "#lib/v3/migration-tools";
|
||||
import DB from "#services/pg/db";
|
||||
import { ArrayDiff } from "#utils/misc";
|
||||
import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "#utils/user";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
GamePTToV3,
|
||||
GetGamePTConfig,
|
||||
type integer,
|
||||
type MONGO_PBScoreDocument,
|
||||
type PgScoreData,
|
||||
type Playtype,
|
||||
} from "tachi-common";
|
||||
|
||||
@@ -66,39 +66,52 @@ export async function GetEveryonesRivalIDs(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
): Promise<Record<number, Array<number>>> {
|
||||
const allGameSettings = await MONGODB_KILL["game-settings"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
userID: 1,
|
||||
rivals: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
const v3Game = GamePTToV3(game, playtype) as Game;
|
||||
|
||||
const rows = await DB.selectFrom("game_rival")
|
||||
.select(["user_id", "rival"])
|
||||
.where("game", "=", v3Game)
|
||||
.execute();
|
||||
|
||||
const lookupTable: Record<integer, Array<integer>> = {};
|
||||
|
||||
for (const d of allGameSettings) {
|
||||
lookupTable[d.userID] = d.rivals;
|
||||
for (const r of rows) {
|
||||
const list = lookupTable[r.user_id] ?? [];
|
||||
list.push(r.rival);
|
||||
lookupTable[r.user_id] = list;
|
||||
}
|
||||
|
||||
return lookupTable;
|
||||
}
|
||||
|
||||
function metricValueFromPbRow(
|
||||
v3Game: Game,
|
||||
data: unknown,
|
||||
derivedData: unknown,
|
||||
metricKey: string,
|
||||
): number | null {
|
||||
const scoreData = pgScoreDataToMongo(v3Game, {
|
||||
data,
|
||||
derived: derivedData,
|
||||
judgements: {},
|
||||
} as PgScoreData<Game>);
|
||||
const v = (scoreData as Record<string, unknown>)[metricKey];
|
||||
return typeof v === "number" && !Number.isNaN(v) ? v : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an array of userIDs to be this user's rivals. Performs validation on all of the
|
||||
* rivals being players of the game, and not being duplicates. The maximum amount of rivals
|
||||
* a player can have is ServerConfig.MAX_RIVALS (defaults to 5).
|
||||
*
|
||||
* @returns `null` on success, or a {@link SetRivalsFailReasons} code on validation failure.
|
||||
*/
|
||||
export async function SetRivals(
|
||||
export async function setRivalsWithResult(
|
||||
userID: integer,
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
newRivals: Array<integer>,
|
||||
) {
|
||||
): Promise<SetRivalsFailReasons | null> {
|
||||
if (newRivals.length > ServerConfig.MAX_RIVALS) {
|
||||
return SetRivalsFailReasons.TOO_MANY;
|
||||
}
|
||||
@@ -107,22 +120,25 @@ export async function SetRivals(
|
||||
return SetRivalsFailReasons.RIVALED_SELF;
|
||||
}
|
||||
|
||||
const playedGPTCount = await MONGODB_KILL["game-settings"].count({
|
||||
userID: { $in: newRivals },
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype) as Game;
|
||||
|
||||
const { count } = await DB.selectFrom("game_settings")
|
||||
.select(DB.fn.countAll().as("count"))
|
||||
.where("game", "=", v3Game)
|
||||
.where("user_id", "in", newRivals)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const playedGPTCount = Number(count);
|
||||
|
||||
// note: this check also checks that nothing provided is a duplicate.
|
||||
if (playedGPTCount !== newRivals.length) {
|
||||
return SetRivalsFailReasons.RIVALS_HAVENT_PLAYED_GPT;
|
||||
}
|
||||
|
||||
const currentGameSettings = await MONGODB_KILL["game-settings"].findOne({
|
||||
userID,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const currentGameSettings = await DB.selectFrom("game_settings")
|
||||
.select("user_id")
|
||||
.where("user_id", "=", userID)
|
||||
.where("game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!currentGameSettings) {
|
||||
log.error(
|
||||
@@ -140,7 +156,8 @@ export async function SetRivals(
|
||||
);
|
||||
}
|
||||
|
||||
const newSubs = ArrayDiff(currentGameSettings.rivals, newRivals);
|
||||
const currentRivalIDs = await GetRivalIDs(userID, game, playtype);
|
||||
const newSubs = ArrayDiff(currentRivalIDs, newRivals);
|
||||
|
||||
const user = await GetUserWithIDGuaranteed(userID);
|
||||
|
||||
@@ -148,20 +165,39 @@ export async function SetRivals(
|
||||
newSubs.map((toUserID) => SendSetRivalNotification(toUserID, user, game, playtype)),
|
||||
);
|
||||
|
||||
await MONGODB_KILL["game-settings"].update(
|
||||
{
|
||||
userID,
|
||||
game,
|
||||
playtype,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
rivals: newRivals,
|
||||
},
|
||||
},
|
||||
);
|
||||
await DB.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.deleteFrom("game_rival")
|
||||
.where("user_id", "=", userID)
|
||||
.where("game", "=", v3Game)
|
||||
.execute();
|
||||
|
||||
if (newRivals.length > 0) {
|
||||
await trx
|
||||
.insertInto("game_rival")
|
||||
.values(
|
||||
newRivals.map((rival) => ({
|
||||
user_id: userID,
|
||||
game: v3Game,
|
||||
rival,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
await UpdatePlayersRivalRankings(userID, game, playtype);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SetRivals(
|
||||
userID: integer,
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
newRivals: Array<integer>,
|
||||
): Promise<SetRivalsFailReasons | null> {
|
||||
return setRivalsWithResult(userID, game, playtype, newRivals);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,9 +213,7 @@ export async function AddRival(
|
||||
|
||||
rivalIDs.push(newRival);
|
||||
|
||||
// We use set rivals because its race condition safe. We don't have to check > 5
|
||||
// or anything.
|
||||
return SetRivals(userID, game, playtype, rivalIDs);
|
||||
return setRivalsWithResult(userID, game, playtype, rivalIDs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,27 +236,22 @@ export async function RemoveRival(
|
||||
return null;
|
||||
}
|
||||
|
||||
return SetRivals(userID, game, playtype, rivalIDs);
|
||||
return setRivalsWithResult(userID, game, playtype, filteredRivals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the userIDs of people who rival the userID for this GPT.
|
||||
*/
|
||||
export async function GetChallengerIDs(userID: integer, game: GameGroup, playtype: Playtype) {
|
||||
const result = await MONGODB_KILL["game-settings"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
rivals: userID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
userID: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
const v3Game = GamePTToV3(game, playtype) as Game;
|
||||
|
||||
return result.map((e) => e.userID);
|
||||
const result = await DB.selectFrom("game_rival")
|
||||
.select("user_id")
|
||||
.where("game", "=", v3Game)
|
||||
.where("rival", "=", userID)
|
||||
.execute();
|
||||
|
||||
return result.map((e) => e.user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,42 +280,77 @@ export async function UpdatePlayersRivalRankings(
|
||||
playtype: Playtype,
|
||||
) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const metricKey = String(gptConfig.defaultMetric);
|
||||
const rivalIDs = await GetRivalIDs(userID, game, playtype);
|
||||
const v3Game = GamePTToV3(game, playtype) as Game;
|
||||
|
||||
// get all of this user's chartIDs so we know what to update
|
||||
const userPBs = (await MONGODB_KILL["personal-bests"].find(
|
||||
{ userID, game, playtype },
|
||||
{ projection: { chartID: 1, [`scoreData.${gptConfig.defaultMetric}`]: 1 } },
|
||||
)) as Array<{ chartID: string; scoreData: { percent: number } }>;
|
||||
const userPBs = await DB.selectFrom("pb")
|
||||
.innerJoin("chart", "chart.id", "pb.chart_id")
|
||||
.select(["pb.row_id", "pb.chart_id", "pb.data", "pb.derived_data", "pb.calculated_data"])
|
||||
.where("pb.user_id", "=", userID)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where("pb.lens", "is", null)
|
||||
.execute();
|
||||
|
||||
const bwrite: Array<BulkWriteUpdateOneOperation<MONGO_PBScoreDocument>> = [];
|
||||
|
||||
await Promise.all(
|
||||
userPBs.map(async (pb) => {
|
||||
const rivalRank =
|
||||
(await MONGODB_KILL["personal-bests"].count({
|
||||
chartID: pb.chartID,
|
||||
userID: { $in: rivalIDs },
|
||||
[`scoreData.${gptConfig.defaultMetric}`]: { $gt: pb.scoreData.percent },
|
||||
})) + 1;
|
||||
|
||||
bwrite.push({
|
||||
updateOne: {
|
||||
filter: { chartID: pb.chartID, userID },
|
||||
update: {
|
||||
$set: {
|
||||
"rankingData.rivalRank": rivalRank,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (bwrite.length === 0) {
|
||||
if (userPBs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await MONGODB_KILL["personal-bests"].bulkWrite(bwrite, { ordered: false });
|
||||
const chartIds = [...new Set(userPBs.map((p) => p.chart_id))];
|
||||
|
||||
let rivalPBs: Array<{
|
||||
chart_id: string;
|
||||
data: unknown;
|
||||
derived_data: unknown;
|
||||
user_id: number;
|
||||
}> = [];
|
||||
|
||||
if (rivalIDs.length > 0) {
|
||||
rivalPBs = await DB.selectFrom("pb")
|
||||
.select(["user_id", "chart_id", "data", "derived_data"])
|
||||
.where("user_id", "in", rivalIDs)
|
||||
.where("chart_id", "in", chartIds)
|
||||
.where("lens", "is", null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
userPBs.map(async (pb) => {
|
||||
const userVal = metricValueFromPbRow(v3Game, pb.data, pb.derived_data, metricKey);
|
||||
if (userVal === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let betterCount = 0;
|
||||
for (const r of rivalPBs) {
|
||||
if (r.chart_id !== pb.chart_id) {
|
||||
continue;
|
||||
}
|
||||
const rivalVal = metricValueFromPbRow(v3Game, r.data, r.derived_data, metricKey);
|
||||
if (rivalVal !== null && rivalVal > userVal) {
|
||||
betterCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const rivalRank = betterCount + 1;
|
||||
|
||||
const raw = pb.calculated_data;
|
||||
const cd =
|
||||
typeof raw === "string"
|
||||
? (JSON.parse(raw) as Record<string, unknown>)
|
||||
: ((raw ?? {}) as Record<string, unknown>);
|
||||
delete cd.rank;
|
||||
delete cd.outOf;
|
||||
|
||||
await DB.updateTable("pb")
|
||||
.set({
|
||||
calculated_data: JSON.stringify({
|
||||
...cd,
|
||||
rivalRank,
|
||||
}),
|
||||
})
|
||||
.where("row_id", "=", pb.row_id)
|
||||
.execute();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ import type {
|
||||
MONGO_GoalSubscriptionDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents";
|
||||
import { EvaluateGoalForUser, GetRelevantGoals } from "#lib/targets/goals";
|
||||
import { EmitWebhookEvent } from "#lib/webhooks/webhooks";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import { sql } from "kysely";
|
||||
import type { Game } from "tachi-db";
|
||||
|
||||
/**
|
||||
* Update a user's progress on all of their set goals.
|
||||
@@ -22,7 +26,6 @@ export async function GetAndUpdateUsersGoals(
|
||||
const { goals, goalSubsMap } = await GetRelevantGoals(game, userID, chartIDs, log);
|
||||
|
||||
if (!goals.length) {
|
||||
// if we hit the below code with an empty array mongodb will flip out on the bulkwrite op
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -66,7 +69,6 @@ export async function UpdateGoalsForUser(
|
||||
);
|
||||
|
||||
const importInfo = [];
|
||||
const bulkWrite = [];
|
||||
const webhookEventContent = [];
|
||||
|
||||
for (const ret of returns) {
|
||||
@@ -75,16 +77,21 @@ export async function UpdateGoalsForUser(
|
||||
}
|
||||
|
||||
importInfo.push(ret.import);
|
||||
bulkWrite.push(ret.bwrite);
|
||||
|
||||
if (ret.pgUpdate) {
|
||||
await DB.updateTable("goal_sub")
|
||||
.set(ret.pgUpdate.set)
|
||||
.where("goal_sub.goal_id", "=", ret.pgUpdate.goalId)
|
||||
.where("goal_sub.user_id", "=", ret.pgUpdate.userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
if (ret.webhookEvent) {
|
||||
webhookEventContent.push(ret.webhookEvent);
|
||||
}
|
||||
}
|
||||
|
||||
if (bulkWrite.length === 0) {
|
||||
// bulkwrite cannot be an empty array -- this means there's nothing to update or return, then.
|
||||
// i.e. goals was non empty but returns was entirely [undefined, undefined...].
|
||||
if (importInfo.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -95,14 +102,12 @@ export async function UpdateGoalsForUser(
|
||||
});
|
||||
}
|
||||
|
||||
await MONGODB_KILL["goal-subs"].bulkWrite(bulkWrite, { ordered: false });
|
||||
|
||||
return importInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls EvaluateGoalForUser, then processes the returns into a bulkWrite
|
||||
* operation and an import statistic.
|
||||
* Calls EvaluateGoalForUser, then processes the returns into a Postgres update
|
||||
* and an import statistic.
|
||||
* @returns undefined on error (i.e. EvaluateGoalForUser) OR if there's nothing
|
||||
* to say (i.e. user didnt raise the goal).
|
||||
*/
|
||||
@@ -115,11 +120,9 @@ export async function ProcessGoal(
|
||||
const res = await EvaluateGoalForUser(goal, userID, log);
|
||||
|
||||
if (!res) {
|
||||
// some sort of error occured - its logged by the previous function.
|
||||
return;
|
||||
}
|
||||
|
||||
// nothing has changed
|
||||
if (goalSub.progress === res.progress && goalSub.outOf === res.outOf) {
|
||||
return;
|
||||
}
|
||||
@@ -142,8 +145,6 @@ export async function ProcessGoal(
|
||||
|
||||
let webhookEvent = null;
|
||||
|
||||
// if this is a newly-achieved goal
|
||||
|
||||
if (res.achieved && !goalSub.achieved) {
|
||||
webhookEvent = {
|
||||
goalID: goal.goalID,
|
||||
@@ -153,32 +154,20 @@ export async function ProcessGoal(
|
||||
};
|
||||
}
|
||||
|
||||
let newTimeAchieved = null;
|
||||
let newTimeAchievedMs: number | null = null;
|
||||
|
||||
if (newData.achieved) {
|
||||
// if this goal was just achieved
|
||||
if (goalSub.timeAchieved === null) {
|
||||
newTimeAchieved = Date.now();
|
||||
newTimeAchievedMs = Date.now();
|
||||
} else {
|
||||
// keep the old timestamp
|
||||
newTimeAchieved = goalSub.timeAchieved;
|
||||
newTimeAchievedMs = goalSub.timeAchieved;
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise if this goal wasn't achieved then the timeAchieved is always null
|
||||
const lastInteractionIso = UnixMillisecondsToISO8601(Date.now());
|
||||
|
||||
const setData = {
|
||||
...newData,
|
||||
timeAchieved: newTimeAchieved,
|
||||
let wasInstantlyAchieved = goalSub.wasInstantlyAchieved;
|
||||
|
||||
// we're guaranteed that this works, because things
|
||||
// that haven't changed return nothing instead of
|
||||
// getting to this point.
|
||||
lastInteraction: Date.now(),
|
||||
} as unknown as Partial<MONGO_GoalSubscriptionDocument>;
|
||||
|
||||
// If this goal was achieved, and is now *not* achieved, we need to unset
|
||||
// some things.
|
||||
if (goalSub.achieved && !res.achieved) {
|
||||
log.info(
|
||||
{
|
||||
@@ -189,21 +178,30 @@ export async function ProcessGoal(
|
||||
`User ${userID} lost their achieved status on ${goal.name}.`,
|
||||
);
|
||||
|
||||
// This goal can't be marked as instantly achieved, since it was lost.
|
||||
setData.wasInstantlyAchieved = false;
|
||||
wasInstantlyAchieved = false;
|
||||
}
|
||||
|
||||
const bulkWrite = {
|
||||
updateOne: {
|
||||
filter: { goalID: goalSub.goalID, userID: goalSub.userID },
|
||||
update: {
|
||||
$set: setData,
|
||||
},
|
||||
},
|
||||
const setPayload = {
|
||||
progress: newData.progress,
|
||||
progress_human: newData.progressHuman,
|
||||
out_of: newData.outOf,
|
||||
out_of_human: newData.outOfHuman,
|
||||
achieved: newData.achieved,
|
||||
time_achieved: newData.achieved
|
||||
? newTimeAchievedMs !== null
|
||||
? UnixMillisecondsToISO8601(newTimeAchievedMs)
|
||||
: null
|
||||
: null,
|
||||
last_interaction: lastInteractionIso,
|
||||
was_instantly_achieved: wasInstantlyAchieved,
|
||||
};
|
||||
|
||||
return {
|
||||
bwrite: bulkWrite,
|
||||
pgUpdate: {
|
||||
goalId: goalSub.goalID,
|
||||
userId: goalSub.userID,
|
||||
set: setPayload,
|
||||
},
|
||||
import: {
|
||||
goalID: goal.goalID,
|
||||
old: oldData,
|
||||
@@ -214,42 +212,54 @@ export async function ProcessGoal(
|
||||
}
|
||||
|
||||
export async function UpdateGoalsInFolder(folderID: string, log: KtLogger) {
|
||||
const goals = await MONGODB_KILL.goals.find({
|
||||
"charts.type": "folder",
|
||||
"charts.data": folderID,
|
||||
});
|
||||
const goalRows = await DB.selectFrom("goal")
|
||||
.selectAll()
|
||||
.where(sql`goal.charts->>'type'`, "=", "folder")
|
||||
.where(sql`goal.charts->>'data'`, "=", folderID)
|
||||
.execute();
|
||||
|
||||
const goals = goalRows.map(ToGoalDocument);
|
||||
|
||||
log.info(`Updating ${goals.length} goals for ${folderID}`);
|
||||
|
||||
const goalSubs = await MONGODB_KILL["goal-subs"].find({
|
||||
goalID: { $in: goals.map((e) => e.goalID) },
|
||||
});
|
||||
const goalIds = goals.map((g) => g.goalID);
|
||||
|
||||
log.info(`Updating ${goalSubs.length} goal subs for ${folderID}`);
|
||||
if (goalIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.goal_id", "in", goalIds)
|
||||
.execute();
|
||||
|
||||
log.info(`Updating ${subRows.length} goal subs for ${folderID}`);
|
||||
|
||||
const goalSubs = subRows.map((r) =>
|
||||
ToGoalSubscriptionDocument({
|
||||
...r,
|
||||
goal_game: r.goal_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
// (User -> (goalID -> GoalSub))
|
||||
const ugsMap = new Map<integer, Map<string, MONGO_GoalSubscriptionDocument>>();
|
||||
|
||||
for (const gSub of goalSubs) {
|
||||
const userID = gSub.userID;
|
||||
const uid = gSub.userID;
|
||||
|
||||
if (ugsMap.has(userID)) {
|
||||
ugsMap.get(userID)!.set(gSub.goalID, gSub);
|
||||
if (ugsMap.has(uid)) {
|
||||
ugsMap.get(uid)!.set(gSub.goalID, gSub);
|
||||
} else {
|
||||
ugsMap.set(userID, new Map());
|
||||
ugsMap.get(userID)!.set(gSub.goalID, gSub);
|
||||
ugsMap.set(uid, new Map());
|
||||
ugsMap.get(uid)!.set(gSub.goalID, gSub);
|
||||
}
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (const [userID, goalSubsMap] of ugsMap.entries()) {
|
||||
promises.push(async () => {
|
||||
// hack: pass *all* goals here to avoid mass allocations
|
||||
// use `skipMismatch` to ignore the cases where they don't match
|
||||
await UpdateGoalsForUser(goals, goalSubsMap, userID, log, true);
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
await Promise.all(
|
||||
[...ugsMap.entries()].map(([uid, goalSubsMap]) =>
|
||||
UpdateGoalsForUser(goals, goalSubsMap, uid, log, true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,7 +324,9 @@ describe("DeorphanScores beatoraja filter (Postgres)", () => {
|
||||
.where("user_id", "=", userId)
|
||||
.where("import_type", "=", "ir/beatoraja")
|
||||
.where(sql<boolean>`(orphan_score.context::jsonb->'chart'->>'sha256') = ${sha}`)
|
||||
.where(sql<boolean>`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`)
|
||||
.where(
|
||||
sql<boolean>`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`,
|
||||
)
|
||||
.execute();
|
||||
|
||||
expect(keyboardRows.map((r) => r.orphan_id)).toEqual(["obe-kb"]);
|
||||
|
||||
@@ -235,13 +235,19 @@ export async function DeorphanScores(filter: DeorphanScoresFilter, log: KtLogger
|
||||
if (filter.chartSha256 !== undefined) {
|
||||
q = q
|
||||
.where("import_type", "=", "ir/beatoraja")
|
||||
.where(sql<boolean>`(orphan_score.context::jsonb->'chart'->>'sha256') = ${filter.chartSha256}`);
|
||||
.where(
|
||||
sql<boolean>`(orphan_score.context::jsonb->'chart'->>'sha256') = ${filter.chartSha256}`,
|
||||
);
|
||||
|
||||
if (filter.pmsPlaytype !== undefined) {
|
||||
if (filter.pmsPlaytype === "Controller") {
|
||||
q = q.where(sql<boolean>`(orphan_score.data::jsonb->>'deviceType') = ${"BM_CONTROLLER"}`);
|
||||
q = q.where(
|
||||
sql<boolean>`(orphan_score.data::jsonb->>'deviceType') = ${"BM_CONTROLLER"}`,
|
||||
);
|
||||
} else {
|
||||
q = q.where(sql<boolean>`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`);
|
||||
q = q.where(
|
||||
sql<boolean>`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { BulkWriteUpdateOneOperation } from "mongodb";
|
||||
import type {
|
||||
GameGroup,
|
||||
GoalImportInfo,
|
||||
@@ -9,10 +8,14 @@ import type {
|
||||
Playtype,
|
||||
QuestImportInfo,
|
||||
} from "tachi-common";
|
||||
import { GamePTToV3 } from "tachi-common";
|
||||
|
||||
import { EvaluateQuestProgress } from "#lib/targets/quests";
|
||||
import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents";
|
||||
import { EvaluateQuestProgress, GetGoalIDsFromQuest } from "#lib/targets/quests";
|
||||
import { EmitWebhookEvent } from "#lib/webhooks/webhooks";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import type { Game } from "tachi-db";
|
||||
|
||||
export async function UpdateUsersQuests(
|
||||
importGoalInfo: Array<GoalImportInfo>,
|
||||
@@ -36,16 +39,12 @@ export async function UpdateQuestsForUser(
|
||||
userID: integer,
|
||||
log: KtLogger,
|
||||
) {
|
||||
// create a map here to avoid linear searching when
|
||||
// co-iterating
|
||||
const questSubMap = new Map<string, MONGO_QuestSubscriptionDocument>();
|
||||
|
||||
for (const um of questSubs) {
|
||||
questSubMap.set(um.questID, um);
|
||||
}
|
||||
|
||||
const bwrite: Array<BulkWriteUpdateOneOperation<MONGO_QuestSubscriptionDocument>> = [];
|
||||
|
||||
const importQuestInfo: Array<QuestImportInfo> = [];
|
||||
|
||||
await Promise.all(
|
||||
@@ -62,18 +61,6 @@ export async function UpdateQuestsForUser(
|
||||
return;
|
||||
}
|
||||
|
||||
const bwriteOp: BulkWriteUpdateOneOperation<MONGO_QuestSubscriptionDocument> = {
|
||||
updateOne: {
|
||||
filter: { questID: quest.questID, userID },
|
||||
update: {
|
||||
$set: {
|
||||
achieved,
|
||||
progress,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const questInfo = {
|
||||
questID: questSub.questID,
|
||||
old: {
|
||||
@@ -86,11 +73,19 @@ export async function UpdateQuestsForUser(
|
||||
},
|
||||
};
|
||||
|
||||
const setPayload: {
|
||||
progress: number;
|
||||
achieved: boolean;
|
||||
last_interaction?: string;
|
||||
time_achieved?: string | null;
|
||||
} = {
|
||||
progress,
|
||||
achieved,
|
||||
};
|
||||
|
||||
if (progress !== questSub.progress) {
|
||||
importQuestInfo.push(questInfo);
|
||||
|
||||
// @ts-expect-error This property isn't read only, because I said so.
|
||||
bwriteOp.updateOne.update.$set!.lastInteraction = Date.now();
|
||||
setPayload.last_interaction = UnixMillisecondsToISO8601(Date.now());
|
||||
}
|
||||
|
||||
if (achieved && !questSub.achieved) {
|
||||
@@ -104,19 +99,17 @@ export async function UpdateQuestsForUser(
|
||||
},
|
||||
});
|
||||
|
||||
// make sure we mark the time achieved if this was just achieved.
|
||||
// @ts-expect-error This property isn't read only, because I said so.
|
||||
bwriteOp.updateOne.update.$set!.timeAchieved = Date.now();
|
||||
setPayload.time_achieved = UnixMillisecondsToISO8601(Date.now());
|
||||
}
|
||||
|
||||
bwrite.push(bwriteOp);
|
||||
await DB.updateTable("quest_sub")
|
||||
.set(setPayload)
|
||||
.where("quest_sub.quest_id", "=", quest.questID)
|
||||
.where("quest_sub.user_id", "=", userID)
|
||||
.execute();
|
||||
}),
|
||||
);
|
||||
|
||||
if (bwrite.length !== 0) {
|
||||
await MONGODB_KILL["quest-subs"].bulkWrite(bwrite, { ordered: false });
|
||||
}
|
||||
|
||||
return importQuestInfo;
|
||||
}
|
||||
|
||||
@@ -127,20 +120,42 @@ async function GetRelevantQuests(
|
||||
userID: integer,
|
||||
log: KtLogger,
|
||||
) {
|
||||
const questSubs = await MONGODB_KILL["quest-subs"].find({
|
||||
game,
|
||||
playtype: { $in: playtypes },
|
||||
userID,
|
||||
});
|
||||
if (goalIDs.length === 0) {
|
||||
return { quests: [] as Array<MONGO_QuestDocument>, questSubs: [] };
|
||||
}
|
||||
|
||||
log.debug(`Found ${questSubs.length} quest-subs.`);
|
||||
const v3Games = playtypes.map((pt) => GamePTToV3(game, pt));
|
||||
const goalIdSet = new Set(goalIDs);
|
||||
|
||||
const quests = await MONGODB_KILL.quests.find({
|
||||
questID: { $in: questSubs.map((e) => e.questID) },
|
||||
"questData.goals.goalID": { $in: goalIDs },
|
||||
});
|
||||
const questSubRows = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.user_id", "=", userID)
|
||||
.where("quest.game", "in", v3Games)
|
||||
.execute();
|
||||
|
||||
log.debug(`Found ${questSubRows.length} quest-subs.`);
|
||||
|
||||
const questSubs = questSubRows.map((r) =>
|
||||
ToQuestSubscriptionDocument({
|
||||
...r,
|
||||
quest_game: r.quest_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const questIds = [...new Set(questSubRows.map((r) => r.quest_id))];
|
||||
|
||||
const questRows =
|
||||
questIds.length === 0
|
||||
? []
|
||||
: await DB.selectFrom("quest").selectAll().where("id", "in", questIds).execute();
|
||||
|
||||
const quests = questRows
|
||||
.map(ToQuestDocument)
|
||||
.filter((q) => GetGoalIDsFromQuest(q).some((gid) => goalIdSet.has(gid)));
|
||||
|
||||
log.debug(`Found ${quests.length} relevant quests.`);
|
||||
|
||||
return { questSubs, quests };
|
||||
return { quests, questSubs };
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ async function HandleOrphanChartProcess(
|
||||
|
||||
let chart;
|
||||
let deorphanFilter:
|
||||
| { chartSha256: string }
|
||||
| { chartSha256: string; pmsPlaytype: Playtypes["pms"] };
|
||||
| { chartSha256: string; pmsPlaytype: Playtypes["pms"] }
|
||||
| { chartSha256: string };
|
||||
|
||||
if (game === "bms") {
|
||||
deorphanFilter = { chartSha256: context.chart.sha256 };
|
||||
|
||||
@@ -70,9 +70,9 @@ async function finalizeFolderSearchRows(
|
||||
async function searchFoldersFtsAndTrgmCore(
|
||||
search: string,
|
||||
opts: {
|
||||
allowedV3Games: Array<Game> | null;
|
||||
limit: integer;
|
||||
onlyActiveFolders: boolean;
|
||||
allowedV3Games: Array<Game> | null;
|
||||
},
|
||||
): Promise<Array<{ __textScore: number } & MONGO_FolderDocument>> {
|
||||
const q = search.trim();
|
||||
@@ -237,8 +237,8 @@ async function searchFoldersFtsAndTrgmCore(
|
||||
export async function SearchFoldersFtsAndTrgmGlobal(
|
||||
search: string,
|
||||
opts: {
|
||||
limit?: integer;
|
||||
games?: GameGroup[];
|
||||
limit?: integer;
|
||||
playtypes?: Playtype[];
|
||||
} = {},
|
||||
): Promise<Array<{ __textScore: number } & MONGO_FolderDocument>> {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FilterQuery } from "mongodb";
|
||||
import type { ICollection } from "monk";
|
||||
|
||||
import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user";
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetSongForIDGuaranteed } from "#utils/db";
|
||||
import { EscapeForILIKE } from "#utils/misc";
|
||||
@@ -29,136 +27,10 @@ import {
|
||||
import { type Game } from "tachi-db";
|
||||
|
||||
import { SearchFoldersFtsAndTrgmGlobal } from "./folders.js";
|
||||
import { AsyncFzf } from "./fzf/main";
|
||||
import { type SearchSessionHit, SearchSessionsForUserGptFtsAndTrgm } from "./session-search.js";
|
||||
|
||||
export type { SearchSessionHit } from "./session-search.js";
|
||||
import { SearchSpecificGameSongs, type SongSearchReturn } from "./songs.js";
|
||||
|
||||
interface SearchControls {
|
||||
keys: Array<string>;
|
||||
primary: string;
|
||||
}
|
||||
|
||||
const SEARCH_CONTROLS = {
|
||||
songs: {
|
||||
keys: ["title", "artist", "searchTerms", "altTitles"],
|
||||
primary: "id",
|
||||
},
|
||||
sessions: { keys: ["name"], primary: "sessionID" },
|
||||
goals: { keys: ["name"], primary: "goalID" },
|
||||
quests: { keys: ["name"], primary: "questID" },
|
||||
users: { keys: ["username"], primary: "id" },
|
||||
} satisfies Partial<Record<keyof typeof MONGODB_KILL, SearchControls>>;
|
||||
|
||||
interface SearchData {
|
||||
primaryKey: number | string;
|
||||
searchKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a $text index search on a collection.
|
||||
*
|
||||
* This throws an error if the collection does not have a text index.
|
||||
*
|
||||
* @param existingMatch - An existing $match query to further filter results
|
||||
* by.
|
||||
*/
|
||||
export async function SearchCollection<T extends object>(
|
||||
collection: ICollection<T>,
|
||||
search: string,
|
||||
searchMethod: keyof typeof SEARCH_CONTROLS,
|
||||
existingMatch: FilterQuery<T> = {},
|
||||
limit = 500,
|
||||
): Promise<Array<{ __textScore: number } & T>> {
|
||||
const controls = SEARCH_CONTROLS[searchMethod];
|
||||
|
||||
const projection = Object.fromEntries([...controls.keys, controls.primary].map((e) => [e, 1]));
|
||||
|
||||
// we do the searching in-memory, because mongodb's search offerings are truly
|
||||
// abysmal. I'm sorry. The performance is fine. I think.
|
||||
const data = (await collection.find(existingMatch, { projection })) as Array<any>;
|
||||
|
||||
// instead of creating different AsyncFzf instances for each search control, we instead
|
||||
// pool everything into the same instance. this ensures that all search controls are weighted
|
||||
// equally, avoiding the case where bad results are included because all values for that search
|
||||
// control are quite bad.
|
||||
const searchData: Array<SearchData> = [];
|
||||
|
||||
for (const key of controls.keys) {
|
||||
searchData.push(
|
||||
...data
|
||||
.filter((d) => d[key])
|
||||
.flatMap<SearchData>((d) => {
|
||||
// handles stuff like searchTerms or altTitles being an array
|
||||
if (Array.isArray(d[key])) {
|
||||
return d[key].map((k: any) => ({
|
||||
primaryKey: d[controls.primary],
|
||||
searchKey: k.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
primaryKey: d[controls.primary],
|
||||
searchKey: d[key].toString(),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// we don't use fzf's limit since our best results may include duplicates,
|
||||
// e.g. "gravekeeper" might match both the search terms "Gravekeeper" and
|
||||
// "Gravekeeper of the Dead Tree" from the same song. The library doesn't do
|
||||
// anything special with the option anyways; it still runs through all results
|
||||
// but simply remove excess items before returning it to us.
|
||||
const fzf = new AsyncFzf(searchData, {
|
||||
selector: (item) => item.searchKey,
|
||||
sort: true,
|
||||
casing: "case-insensitive",
|
||||
});
|
||||
let results = await fzf.find(search);
|
||||
|
||||
// filter out anything far from the best match
|
||||
// 30 was chosen arbitrarily :)
|
||||
const max = results[0]?.score ?? 0;
|
||||
|
||||
results = results.filter((r) => r.score >= max - 30);
|
||||
|
||||
const pkeys: Set<number | string> = new Set();
|
||||
const scores: Record<string, number> = {};
|
||||
|
||||
for (const res of results) {
|
||||
const pkey = res.item.primaryKey;
|
||||
const existingScore = scores[pkey];
|
||||
|
||||
pkeys.add(pkey);
|
||||
|
||||
if (!existingScore || res.score > existingScore) {
|
||||
scores[pkey] = res.score;
|
||||
}
|
||||
|
||||
if (pkeys.size > limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const documents: Array<any> = await (collection as ICollection).find({
|
||||
[controls.primary]: { $in: Array.from(pkeys) },
|
||||
});
|
||||
|
||||
// however, the results we get back from MongoDB are unordered, so we have to sort again.
|
||||
documents.sort(
|
||||
(a, b) => (scores[b[controls.primary]] ?? 0) - (scores[a[controls.primary]] ?? 0),
|
||||
);
|
||||
|
||||
for (const doc of documents) {
|
||||
doc.__textScore = scores[doc[controls.primary]] ?? 0;
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
export type { SearchSessionHit } from "./session-search.js";
|
||||
|
||||
export function SearchSessions(
|
||||
search: string,
|
||||
@@ -227,14 +99,10 @@ async function SearchAllGamesSingleGame(game: GameGroup, search: string) {
|
||||
/**
|
||||
* Searches all games' songs.
|
||||
*/
|
||||
export async function SearchAllGamesSongs(search: string) {
|
||||
return SearchGamesSongs(search, TachiConfig.GAMES);
|
||||
}
|
||||
|
||||
export async function SearchGamesSongs(search: string, games: Array<GameGroup>) {
|
||||
export async function SearchGamesSongs(search: string, games: Array<GameGroup> | null) {
|
||||
const promises = [];
|
||||
|
||||
for (const game of games) {
|
||||
for (const game of games ?? TachiConfig.GAMES) {
|
||||
promises.push(SearchAllGamesSingleGame(game, search));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
|
||||
import { GetChartsBySongId } from "#lib/db-formats/chart";
|
||||
import DB from "#services/pg/db";
|
||||
import { sql } from "kysely";
|
||||
import {
|
||||
@@ -31,13 +31,13 @@ export async function SearchSpecificGameSongsAndCharts(
|
||||
|
||||
const chartLists = await Promise.all(
|
||||
songs.map((song) => {
|
||||
const pgId = pgIdByLegacyId.get(song.id);
|
||||
const songID = pgIdByLegacyId.get(song.id);
|
||||
|
||||
if (!pgId) {
|
||||
if (!songID) {
|
||||
return Promise.resolve([] as Array<MONGO_ChartDocument>);
|
||||
}
|
||||
|
||||
return GetChartsBySongPgId(v3Game, pgId, song.id, {
|
||||
return GetChartsBySongId(v3Game, songID, {
|
||||
omit2dxtraCharts: game === "iidx",
|
||||
});
|
||||
}),
|
||||
@@ -60,7 +60,11 @@ export async function SearchGlobalGameSongsAndCharts(
|
||||
playtype?: Playtype,
|
||||
limit = MAX_SONG_SEARCH_RESULTS_PER_GAME,
|
||||
): Promise<Array<{ chart: MONGO_ChartDocument; playcount: integer; song: MONGO_SongDocument }>> {
|
||||
const { songs, pgIdByLegacyId } = await searchSpecificGameSongsWithPgIds(game, search, limit);
|
||||
const { songs, pgIdByLegacyId: songIdByLegacyId } = await searchSpecificGameSongsWithPgIds(
|
||||
game,
|
||||
search,
|
||||
limit,
|
||||
);
|
||||
|
||||
if (!playtype) {
|
||||
throw new Error("SearchGlobalGameSongsAndCharts requires playtype");
|
||||
@@ -79,14 +83,14 @@ export async function SearchGlobalGameSongsAndCharts(
|
||||
break;
|
||||
}
|
||||
|
||||
const pgId = pgIdByLegacyId.get(song.id);
|
||||
const songID = songIdByLegacyId.get(song.id);
|
||||
|
||||
if (!pgId) {
|
||||
if (!songID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop -- stop after enough charts; avoids loading every song's charts when the cap is already reached.
|
||||
const songCharts = await GetChartsBySongPgId(v3Game, pgId, song.id, {
|
||||
const songCharts = await GetChartsBySongId(v3Game, songID, {
|
||||
omit2dxtraCharts: game === "iidx",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
|
||||
import { GetChartsBySongId } from "#lib/db-formats/chart";
|
||||
import { SearchGlobalGameSongsAndCharts } from "#lib/search/song-charts.js";
|
||||
import DB from "#services/pg/db";
|
||||
import { importSeedsSubset } from "#services/pg/seeds";
|
||||
@@ -523,7 +523,7 @@ describe("IIDX 2dxtraSet exclusion from search", () => {
|
||||
expect(res.some((r) => r.legacy_id === 9_000_861)).toBe(true);
|
||||
});
|
||||
|
||||
it("GetChartsBySongPgId omits 2dxtra charts when omit2dxtraCharts is true", async () => {
|
||||
it("GetChartsBysongNewID omits 2dxtra charts when omit2dxtraCharts is true", async () => {
|
||||
const sid = makeSongId(870);
|
||||
|
||||
await DB.insertInto("song")
|
||||
@@ -569,8 +569,8 @@ describe("IIDX 2dxtraSet exclusion from search", () => {
|
||||
])
|
||||
.execute();
|
||||
|
||||
const all = await GetChartsBySongPgId(GamePTToV3("iidx", "SP"), sid, 9_000_870);
|
||||
const filtered = await GetChartsBySongPgId(GamePTToV3("iidx", "SP"), sid, 9_000_870, {
|
||||
const all = await GetChartsBySongId(GamePTToV3("iidx", "SP"), sid);
|
||||
const filtered = await GetChartsBySongId(GamePTToV3("iidx", "SP"), sid, {
|
||||
omit2dxtraCharts: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type PbDocumentJoinRow,
|
||||
SELECT_PB_DOCUMENT_WITH_LEADERBOARD,
|
||||
ToPbScoreDocument,
|
||||
} from "#lib/db-formats/pb.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import {
|
||||
type GetScoreMetricConf,
|
||||
type MONGO_GoalDocument,
|
||||
type MONGO_PBScoreDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
type GoalMetricConf = NonNullable<ReturnType<typeof GetScoreMetricConf>>;
|
||||
|
||||
/**
|
||||
* Loads the user's leaderboard PB row for each chart in `chartPgIds` (Postgres `chart.id`).
|
||||
*/
|
||||
export async function LoadPbsForUserOnChartsForGoal(
|
||||
userId: number,
|
||||
chartIDs: string[],
|
||||
): Promise<MONGO_PBScoreDocument[]> {
|
||||
if (chartIDs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await DB.selectFrom("pb")
|
||||
.innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id")
|
||||
.innerJoin("chart", "chart.id", "pb.chart_id")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD)
|
||||
.where("pb.user_id", "=", userId)
|
||||
.where("chart.id", "in", chartIDs)
|
||||
.execute();
|
||||
|
||||
return Promise.all(rows.map((r) => ToPbScoreDocument(r as PbDocumentJoinRow)));
|
||||
}
|
||||
|
||||
export function getGoalMetricValueFromPb(
|
||||
pb: MONGO_PBScoreDocument,
|
||||
criteriaKey: MONGO_GoalDocument["criteria"]["key"],
|
||||
scoreConf: GoalMetricConf,
|
||||
): number | null {
|
||||
if (scoreConf.type === "ENUM") {
|
||||
const v = pb.scoreData.enumIndexes[criteriaKey as keyof typeof pb.scoreData.enumIndexes];
|
||||
|
||||
return typeof v === "number" ? v : null;
|
||||
}
|
||||
|
||||
const v = (pb.scoreData as Record<string, unknown>)[criteriaKey as string];
|
||||
|
||||
return typeof v === "number" ? v : null;
|
||||
}
|
||||
|
||||
export function pbMeetsGoalThreshold(
|
||||
pb: MONGO_PBScoreDocument,
|
||||
criteriaKey: MONGO_GoalDocument["criteria"]["key"],
|
||||
threshold: number,
|
||||
scoreConf: GoalMetricConf,
|
||||
): boolean {
|
||||
const v = getGoalMetricValueFromPb(pb, criteriaKey, scoreConf);
|
||||
|
||||
if (v === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return v >= threshold;
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { GoalCriteriaFormatter } from "#game-implementations/types";
|
||||
|
||||
import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations";
|
||||
import { GetChartById } from "#lib/db-formats/chart";
|
||||
import { LoadFolderDocumentById } from "#lib/db-formats/folders";
|
||||
import { GetFolderChartIDs } from "#lib/folders/folders.js";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetFolderForIDGuaranteed, HumaniseChartID } from "#utils/db";
|
||||
import { HumaniseChartID } from "#utils/db";
|
||||
import { HumanisedJoinArray, OnlyFloatToDP } from "#utils/misc";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type GameGroup,
|
||||
GamePTToV3,
|
||||
GetGPTConfig,
|
||||
GetGPTString,
|
||||
GetScoreMetricConf,
|
||||
@@ -118,7 +120,11 @@ async function FormatCharts(
|
||||
}
|
||||
|
||||
case "folder": {
|
||||
const folder = await GetFolderForIDGuaranteed(charts.data);
|
||||
const folder = await LoadFolderDocumentById(charts.data);
|
||||
|
||||
if (!folder) {
|
||||
throw new Error(`Folder ${charts.data} not found.`);
|
||||
}
|
||||
|
||||
return `the ${folder.title} folder`;
|
||||
}
|
||||
@@ -182,14 +188,13 @@ export async function ValidateGoalChartsAndCriteria(
|
||||
) {
|
||||
let chartCount = 0;
|
||||
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
// Validating the charts supplied
|
||||
|
||||
switch (charts.type) {
|
||||
case "single": {
|
||||
const chart = await MONGODB_KILL.anyCharts[game].findOne({
|
||||
playtype,
|
||||
chartID: charts.data,
|
||||
});
|
||||
const chart = await GetChartById(v3Game, charts.data);
|
||||
|
||||
if (!chart) {
|
||||
throw new Error(
|
||||
@@ -202,13 +207,9 @@ export async function ValidateGoalChartsAndCriteria(
|
||||
}
|
||||
|
||||
case "folder": {
|
||||
const folder = await MONGODB_KILL.folders.findOne({
|
||||
game,
|
||||
playtype,
|
||||
folderID: charts.data,
|
||||
});
|
||||
const folder = await LoadFolderDocumentById(charts.data);
|
||||
|
||||
if (!folder) {
|
||||
if (!folder || folder.game !== game || folder.playtype !== playtype) {
|
||||
throw new Error(
|
||||
`A folder with id ${charts.data} does not exist for ${game}:${playtype}.`,
|
||||
);
|
||||
@@ -225,14 +226,11 @@ export async function ValidateGoalChartsAndCriteria(
|
||||
);
|
||||
}
|
||||
|
||||
const multiCharts = await MONGODB_KILL.anyCharts[game].find({
|
||||
playtype,
|
||||
chartID: { $in: charts.data },
|
||||
});
|
||||
const multiCharts = await Promise.all(charts.data.map((chartID) => GetChartById(v3Game, chartID)));
|
||||
|
||||
if (multiCharts.length !== charts.data.length) {
|
||||
if (multiCharts.some((c) => c === undefined)) {
|
||||
throw new Error(
|
||||
`Expected charts.data to match ${charts.data.length} charts. Instead, it only matched ${multiCharts.length}. Are all of these chartIDs valid?`,
|
||||
`Expected charts.data to match ${charts.data.length} charts. Instead, it only matched ${multiCharts.filter(Boolean).length}. Are all of these chartIDs valid?`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,11 +295,7 @@ export async function ValidateGoalChartsAndCriteria(
|
||||
let err;
|
||||
|
||||
if (config.chartDependentMax) {
|
||||
const chart = await MONGODB_KILL.anyCharts[game].findOne({
|
||||
playtype,
|
||||
// guaranteed by previous if statement
|
||||
chartID: charts.data as string,
|
||||
});
|
||||
const chart = await GetChartById(v3Game, charts.data as string);
|
||||
|
||||
if (!chart) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import type { GoalCriteriaFormatter } from "#game-implementations/types";
|
||||
import type { FilterQuery } from "mongodb";
|
||||
|
||||
import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations";
|
||||
import { SubscribeFailReasons } from "#lib/constants/err-codes";
|
||||
import {
|
||||
ToGoalDocument,
|
||||
ToGoalSubscriptionDocument,
|
||||
ToQuestDocument,
|
||||
ToQuestSubscriptionDocument,
|
||||
} from "#lib/db-formats/target-documents";
|
||||
import { GetFolderChartIDs } from "#lib/folders/folders.js";
|
||||
import {
|
||||
getGoalMetricValueFromPb,
|
||||
LoadPbsForUserOnChartsForGoal,
|
||||
pbMeetsGoalThreshold,
|
||||
} from "#lib/targets/goal-pb-queries.js";
|
||||
import { type KtLogger, log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import fjsh from "fast-json-stable-hash";
|
||||
import { sql } from "kysely";
|
||||
import type { Game } from "tachi-db";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type GameGroup,
|
||||
GamePTToV3,
|
||||
GetGPTConfig,
|
||||
GetGPTString,
|
||||
GetScoreMetricConf,
|
||||
@@ -21,6 +35,8 @@ import {
|
||||
type MONGO_QuestDocument,
|
||||
type MONGO_QuestSubscriptionDocument,
|
||||
type Playtype,
|
||||
V3ToGameGroup,
|
||||
v3AllGames,
|
||||
} from "tachi-common";
|
||||
|
||||
import { CreateGoalTitle as CreateGoalName, ValidateGoalChartsAndCriteria } from "./goal-utils";
|
||||
@@ -58,8 +74,6 @@ export async function EvaluateGoalForUser(
|
||||
userID: integer,
|
||||
log: KtLogger,
|
||||
): Promise<EvaluatedGoalReturn | null> {
|
||||
// First, we need to resolve the set of charts this
|
||||
// goal involves.
|
||||
const chartIDs = await ResolveGoalCharts(goal);
|
||||
const gptString = GetGPTString(goal.game, goal.playtype);
|
||||
const gptConfig = GetGPTConfig(gptString);
|
||||
@@ -71,41 +85,27 @@ export async function EvaluateGoalForUser(
|
||||
);
|
||||
}
|
||||
|
||||
let scoreDataKey;
|
||||
|
||||
if (scoreConf.type === "ENUM") {
|
||||
scoreDataKey = `scoreData.enumIndexes.${goal.criteria.key}`;
|
||||
} else {
|
||||
scoreDataKey = `scoreData.${goal.criteria.key}`;
|
||||
}
|
||||
|
||||
// lets configure a "base" query for our requests.
|
||||
const scoreQuery: FilterQuery<MONGO_PBScoreDocument> = {
|
||||
userID,
|
||||
game: goal.game,
|
||||
playtype: goal.playtype,
|
||||
|
||||
// normally, this would be a VERY WORRYING line of code, but goal.criteria.key is guaranteed to be
|
||||
// within a specific set of fields.
|
||||
[scoreDataKey]: { $gte: goal.criteria.value },
|
||||
chartID: { $in: chartIDs },
|
||||
};
|
||||
const pbs = await LoadPbsForUserOnChartsForGoal(userID, chartIDs);
|
||||
|
||||
switch (goal.criteria.mode) {
|
||||
case "single": {
|
||||
const res = await MONGODB_KILL["personal-bests"].findOne(scoreQuery);
|
||||
|
||||
const outOfHuman = HumaniseGoalOutOf(gptString, goal.criteria.key, goal.criteria.value);
|
||||
|
||||
if (res) {
|
||||
const qualifying = pbs.filter((pb) =>
|
||||
pbMeetsGoalThreshold(pb, goal.criteria.key, goal.criteria.value, scoreConf),
|
||||
);
|
||||
|
||||
if (qualifying.length > 0) {
|
||||
const res = qualifying[0]!;
|
||||
|
||||
return {
|
||||
achieved: true,
|
||||
outOf: goal.criteria.value,
|
||||
progress:
|
||||
scoreConf.type === "ENUM"
|
||||
? // @ts-expect-error this is always correct but the typesystem is rightfully concerned
|
||||
? // @ts-expect-error narrow
|
||||
res.scoreData.enumIndexes[goal.criteria.key]
|
||||
: // @ts-expect-error see above
|
||||
: // @ts-expect-error narrow
|
||||
res.scoreData[goal.criteria.key],
|
||||
outOfHuman,
|
||||
progressHuman: HumaniseGoalProgress(
|
||||
@@ -117,21 +117,15 @@ export async function EvaluateGoalForUser(
|
||||
};
|
||||
}
|
||||
|
||||
// if we didn't find a PB that achieved the goal immediately
|
||||
// fetch the next best thing.
|
||||
const nextBestQuery: FilterQuery<MONGO_PBScoreDocument> = {
|
||||
userID,
|
||||
game: goal.game,
|
||||
playtype: goal.playtype,
|
||||
chartID: { $in: chartIDs },
|
||||
};
|
||||
const scored = pbs
|
||||
.map((pb) => ({
|
||||
pb,
|
||||
v: getGoalMetricValueFromPb(pb, goal.criteria.key, scoreConf),
|
||||
}))
|
||||
.filter((e): e is { pb: MONGO_PBScoreDocument; v: number } => e.v !== null)
|
||||
.sort((a, b) => b.v - a.v);
|
||||
|
||||
const nextBestScore = await MONGODB_KILL["personal-bests"].findOne(nextBestQuery, {
|
||||
sort: { [scoreDataKey]: -1 },
|
||||
});
|
||||
|
||||
// user has no scores on any charts in this set.
|
||||
if (!nextBestScore) {
|
||||
if (scored.length === 0) {
|
||||
return {
|
||||
achieved: false,
|
||||
outOf: goal.criteria.value,
|
||||
@@ -141,15 +135,17 @@ export async function EvaluateGoalForUser(
|
||||
};
|
||||
}
|
||||
|
||||
const nextBestScore = scored[0]!.pb;
|
||||
|
||||
return {
|
||||
achieved: false,
|
||||
outOf: goal.criteria.value,
|
||||
outOfHuman,
|
||||
progress:
|
||||
scoreConf.type === "ENUM"
|
||||
? // @ts-expect-error this is always correct but the typesystem is rightfully concerned
|
||||
? // @ts-expect-error narrow
|
||||
nextBestScore.scoreData.enumIndexes[goal.criteria.key]
|
||||
: // @ts-expect-error see above
|
||||
: // @ts-expect-error narrow
|
||||
nextBestScore.scoreData[goal.criteria.key],
|
||||
progressHuman: HumaniseGoalProgress(
|
||||
gptString,
|
||||
@@ -162,21 +158,19 @@ export async function EvaluateGoalForUser(
|
||||
|
||||
case "absolute":
|
||||
case "proportion": {
|
||||
let count;
|
||||
let count: number;
|
||||
|
||||
// abs -> Absolute mode, such as clear 10 charts.
|
||||
if (goal.criteria.mode === "absolute") {
|
||||
count = goal.criteria.countNum;
|
||||
} else {
|
||||
// proportion -> Proportional mode, the value
|
||||
// is a multiplier for the amount of charts
|
||||
// available -- i.e. 0.1 * charts.
|
||||
const totalChartCount = chartIDs.length;
|
||||
|
||||
count = Math.floor(goal.criteria.countNum * totalChartCount);
|
||||
}
|
||||
|
||||
const userCount = await MONGODB_KILL["personal-bests"].count(scoreQuery);
|
||||
const userCount = pbs.filter((pb) =>
|
||||
pbMeetsGoalThreshold(pb, goal.criteria.key, goal.criteria.value, scoreConf),
|
||||
).length;
|
||||
|
||||
return {
|
||||
achieved: userCount >= count,
|
||||
@@ -188,8 +182,6 @@ export async function EvaluateGoalForUser(
|
||||
}
|
||||
|
||||
default: {
|
||||
// note that this seemingly nonsensical type assertion is because typescript has whittled down
|
||||
// goal.criteria (correctly) to 'never', but we want to log if something somehow ends up here (it shouldn't).
|
||||
log.warn(
|
||||
{ goal },
|
||||
`Invalid goal: ${goal.goalID}, unknown criteria.mode ${
|
||||
@@ -305,11 +297,9 @@ export async function ConstructGoal(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
): Promise<MONGO_GoalDocument> {
|
||||
// Throws if the charts or criteria are invalid somehow.
|
||||
await ValidateGoalChartsAndCriteria(charts, criteria, game, playtype);
|
||||
|
||||
// @ts-expect-error It's complaining because the potential criteria types might mismatch.
|
||||
// they're right, but this is enforced by ValidateGoalChartsAndCriteria.
|
||||
const MONGO_GoalDocument: MONGO_GoalDocument = {
|
||||
game,
|
||||
playtype,
|
||||
@@ -322,6 +312,26 @@ export async function ConstructGoal(
|
||||
return MONGO_GoalDocument;
|
||||
}
|
||||
|
||||
async function ensureGoalRow(doc: MONGO_GoalDocument) {
|
||||
const v3Game = GamePTToV3(doc.game, doc.playtype);
|
||||
|
||||
const existed = await DB.selectFrom("goal").select("goal.id").where("id", "=", doc.goalID).executeTakeFirst();
|
||||
|
||||
if (!existed) {
|
||||
await DB.insertInto("goal")
|
||||
.values({
|
||||
id: doc.goalID,
|
||||
game: v3Game,
|
||||
name: doc.name,
|
||||
charts: doc.charts,
|
||||
criteria: doc.criteria,
|
||||
})
|
||||
.execute();
|
||||
|
||||
log.info(`Inserting new goal '${doc.name}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a user to the provided goal document. Handles deduping goals naturally
|
||||
* and general good stuff.
|
||||
@@ -338,47 +348,48 @@ export async function SubscribeToGoal(
|
||||
MONGO_GoalDocument: MONGO_GoalDocument,
|
||||
isStandaloneAssignment: boolean,
|
||||
) {
|
||||
const goalExists = await MONGODB_KILL.goals.findOne({ goalID: MONGO_GoalDocument.goalID });
|
||||
await ensureGoalRow(MONGO_GoalDocument);
|
||||
|
||||
if (!goalExists) {
|
||||
await MONGODB_KILL.goals.insert(MONGO_GoalDocument);
|
||||
log.info(`Inserting new goal '${MONGO_GoalDocument.name}'.`);
|
||||
}
|
||||
const existingSub = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.goal_id", "=", MONGO_GoalDocument.goalID)
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.executeTakeFirst();
|
||||
|
||||
const userAlreadySubscribed = await MONGODB_KILL["goal-subs"].findOne({
|
||||
userID,
|
||||
goalID: MONGO_GoalDocument.goalID,
|
||||
});
|
||||
if (existingSub) {
|
||||
const userAlreadySubscribed = ToGoalSubscriptionDocument({
|
||||
...existingSub,
|
||||
goal_game: existingSub.goal_game as Game,
|
||||
});
|
||||
|
||||
if (userAlreadySubscribed) {
|
||||
// A quest trying to assign an already subscribed goal should know that.
|
||||
// (not that it cares)
|
||||
if (!isStandaloneAssignment) {
|
||||
return SubscribeFailReasons.ALREADY_SUBSCRIBED;
|
||||
}
|
||||
|
||||
// if the user was already standalone-subscribed, ignore another standalone
|
||||
// assignment.
|
||||
if (userAlreadySubscribed.wasAssignedStandalone) {
|
||||
return SubscribeFailReasons.ALREADY_SUBSCRIBED;
|
||||
}
|
||||
|
||||
// otherwise, this is a standalone assignment to a goal that was already assigned
|
||||
// as a consequence of a quest. Mark it as standalone
|
||||
await MONGODB_KILL["goal-subs"].update(
|
||||
{
|
||||
userID,
|
||||
goalID: MONGO_GoalDocument.goalID,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
wasAssignedStandalone: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await DB.updateTable("goal_sub")
|
||||
.set({ was_assigned_standalone: true })
|
||||
.where("goal_id", "=", MONGO_GoalDocument.goalID)
|
||||
.where("user_id", "=", userID)
|
||||
.execute();
|
||||
|
||||
// return this goal sub document, it's fast!
|
||||
return { ...userAlreadySubscribed, wasAssignedStandalone: true };
|
||||
const updated = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.goal_id", "=", MONGO_GoalDocument.goalID)
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return ToGoalSubscriptionDocument({
|
||||
...updated,
|
||||
goal_game: updated.goal_game as Game,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await EvaluateGoalForUser(MONGO_GoalDocument, userID, log);
|
||||
@@ -387,39 +398,72 @@ export async function SubscribeToGoal(
|
||||
throw new Error(`Couldn't evaluate goal? See previous logs.`);
|
||||
}
|
||||
|
||||
// standalone assignments shouldn't be allowed to assign instantly-achieved
|
||||
// goals
|
||||
if (result.achieved && isStandaloneAssignment) {
|
||||
return SubscribeFailReasons.ALREADY_ACHIEVED;
|
||||
}
|
||||
|
||||
// @ts-expect-error TS can't resolve this.
|
||||
// because it can't explode out the types.
|
||||
const goalSub: MONGO_GoalSubscriptionDocument = {
|
||||
const nowMs = Date.now();
|
||||
const nowIso = UnixMillisecondsToISO8601(nowMs);
|
||||
|
||||
const baseSub = {
|
||||
outOf: result.outOf,
|
||||
outOfHuman: result.outOfHuman,
|
||||
progress: result.progress,
|
||||
progressHuman: result.progressHuman,
|
||||
userID,
|
||||
lastInteraction: null,
|
||||
timeAchieved: result.achieved ? Date.now() : null,
|
||||
game: MONGO_GoalDocument.game,
|
||||
playtype: MONGO_GoalDocument.playtype,
|
||||
goalID: MONGO_GoalDocument.goalID,
|
||||
achieved: result.achieved,
|
||||
wasInstantlyAchieved: result.achieved,
|
||||
wasAssignedStandalone: isStandaloneAssignment,
|
||||
};
|
||||
|
||||
await MONGODB_KILL["goal-subs"].insert(goalSub);
|
||||
const goalSub: MONGO_GoalSubscriptionDocument = result.achieved
|
||||
? {
|
||||
...baseSub,
|
||||
achieved: true,
|
||||
timeAchieved: nowMs,
|
||||
}
|
||||
: {
|
||||
...baseSub,
|
||||
achieved: false,
|
||||
timeAchieved: null,
|
||||
};
|
||||
|
||||
await DB.insertInto("goal_sub")
|
||||
.values({
|
||||
goal_id: MONGO_GoalDocument.goalID,
|
||||
user_id: userID,
|
||||
last_interaction: null,
|
||||
progress: goalSub.progress,
|
||||
progress_human: goalSub.progressHuman,
|
||||
out_of: goalSub.outOf,
|
||||
out_of_human: goalSub.outOfHuman,
|
||||
achieved: goalSub.achieved,
|
||||
time_achieved: result.achieved ? nowIso : null,
|
||||
was_instantly_achieved: goalSub.wasInstantlyAchieved,
|
||||
was_assigned_standalone: goalSub.wasAssignedStandalone,
|
||||
})
|
||||
.execute();
|
||||
|
||||
return goalSub;
|
||||
}
|
||||
|
||||
export function GetQuestsThatContainGoal(goalID: string) {
|
||||
return MONGODB_KILL.quests.find({
|
||||
"questData.goals.goalID": goalID,
|
||||
});
|
||||
export async function GetQuestsThatContainGoal(goalID: string): Promise<Array<MONGO_QuestDocument>> {
|
||||
const rows = await DB.selectFrom("quest")
|
||||
.selectAll()
|
||||
.where(
|
||||
sql<boolean>`exists (
|
||||
select 1
|
||||
from jsonb_array_elements(quest.quest_data) as section,
|
||||
lateral jsonb_array_elements(coalesce(section->'goals', '[]'::jsonb)) as g
|
||||
where g->>'goalID' = ${goalID}
|
||||
)`,
|
||||
)
|
||||
.execute();
|
||||
|
||||
return rows.map(ToQuestDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,42 +476,54 @@ export function GetQuestsThatContainGoal(goalID: string) {
|
||||
export async function GetQuestSubsWhichDependOnThisGoalSub(
|
||||
goalSub: MONGO_GoalSubscriptionDocument,
|
||||
): Promise<Array<{ quest: MONGO_QuestDocument } & MONGO_QuestSubscriptionDocument>> {
|
||||
const dependencies: Array<{ quest: MONGO_QuestDocument } & MONGO_QuestSubscriptionDocument> =
|
||||
await MONGODB_KILL["quest-subs"].aggregate([
|
||||
{
|
||||
// find all quests that this user is subscribed to
|
||||
$match: {
|
||||
userID: goalSub.userID,
|
||||
game: goalSub.game,
|
||||
playtype: goalSub.playtype,
|
||||
},
|
||||
},
|
||||
{
|
||||
// look up the parent quests
|
||||
$lookup: {
|
||||
from: "quests",
|
||||
localField: "questID",
|
||||
foreignField: "questID",
|
||||
as: "parentQuestSubs",
|
||||
},
|
||||
},
|
||||
{
|
||||
// then project it onto the $quest field. This will be null
|
||||
// if the quest has no parent, which we hopefully won't have
|
||||
// to consider (illegal)
|
||||
$set: {
|
||||
quest: { $arrayElemAt: ["$parentQuestSubs", 0] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// then finally, filter to only quests that pertain to this goal.
|
||||
$match: {
|
||||
"quest.questData.goals.goalID": goalSub.goalID,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const v3Game = GamePTToV3(goalSub.game, goalSub.playtype);
|
||||
|
||||
return dependencies;
|
||||
const subRows = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.user_id", "=", goalSub.userID)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.where(
|
||||
sql<boolean>`exists (
|
||||
select 1
|
||||
from jsonb_array_elements(quest.quest_data) as section,
|
||||
lateral jsonb_array_elements(coalesce(section->'goals', '[]'::jsonb)) as g
|
||||
where g->>'goalID' = ${goalSub.goalID}
|
||||
)`,
|
||||
)
|
||||
.execute();
|
||||
|
||||
const questIds = [...new Set(subRows.map((r) => r.quest_id))];
|
||||
|
||||
const questRows =
|
||||
questIds.length === 0
|
||||
? []
|
||||
: await DB.selectFrom("quest").selectAll().where("id", "in", questIds).execute();
|
||||
|
||||
const questById = new Map(questRows.map((q) => [q.id, ToQuestDocument(q)]));
|
||||
|
||||
return subRows.map((r) => {
|
||||
const quest = questById.get(r.quest_id);
|
||||
|
||||
if (!quest) {
|
||||
throw new Error(`quest ${r.quest_id} missing after join`);
|
||||
}
|
||||
|
||||
return {
|
||||
quest,
|
||||
...ToQuestSubscriptionDocument({
|
||||
quest_id: r.quest_id,
|
||||
user_id: r.user_id,
|
||||
progress: r.progress,
|
||||
last_interaction: r.last_interaction,
|
||||
achieved: r.achieved,
|
||||
time_achieved: r.time_achieved,
|
||||
was_instantly_achieved: r.was_instantly_achieved,
|
||||
quest_game: r.quest_game as Game,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,11 +545,9 @@ export async function UnsubscribeFromGoal(
|
||||
|
||||
switch (dependencies.reason) {
|
||||
case "HAS_QUEST_DEPENDENCIES":
|
||||
// never remove a goalSub if it has quests depending on it
|
||||
return dependencies;
|
||||
|
||||
case "WAS_STANDALONE": {
|
||||
// only prevent standalone removal if we're told to
|
||||
if (preventStandaloneRemoval) {
|
||||
return dependencies;
|
||||
}
|
||||
@@ -501,15 +555,13 @@ export async function UnsubscribeFromGoal(
|
||||
break;
|
||||
}
|
||||
|
||||
// no handling necessary, orphaned goals should never happen.
|
||||
case "WAS_ORPHAN":
|
||||
}
|
||||
|
||||
// if we have no reason to prevent the removal, remove it.
|
||||
await MONGODB_KILL["goal-subs"].remove({
|
||||
userID: goalSub.userID,
|
||||
goalID: goalSub.goalID,
|
||||
});
|
||||
await DB.deleteFrom("goal_sub")
|
||||
.where("goal_id", "=", goalSub.goalID)
|
||||
.where("user_id", "=", goalSub.userID)
|
||||
.execute();
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -552,7 +604,22 @@ export async function UnsubscribeFromOrphanedGoalSubs(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
) {
|
||||
const goalSubs = await MONGODB_KILL["goal-subs"].find({ game, playtype, userID });
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const subRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.where("goal.game", "=", v3Game)
|
||||
.execute();
|
||||
|
||||
const goalSubs = subRows.map((r) =>
|
||||
ToGoalSubscriptionDocument({
|
||||
...r,
|
||||
goal_game: r.goal_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const maybeToRemove = await Promise.all(
|
||||
goalSubs.map(async (goalSub) => {
|
||||
@@ -566,8 +633,7 @@ export async function UnsubscribeFromOrphanedGoalSubs(
|
||||
}),
|
||||
);
|
||||
|
||||
// impressive that ts can't resolve this without a cast
|
||||
const toRemove = maybeToRemove.filter((e) => e !== null) as Array<string>;
|
||||
const toRemove = maybeToRemove.filter((e): e is string => e !== null);
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
log.info(
|
||||
@@ -577,10 +643,10 @@ export async function UnsubscribeFromOrphanedGoalSubs(
|
||||
)} as they were orphanned.`,
|
||||
);
|
||||
|
||||
await MONGODB_KILL["goal-subs"].remove({
|
||||
userID,
|
||||
goalID: { $in: toRemove },
|
||||
});
|
||||
await DB.deleteFrom("goal_sub")
|
||||
.where("user_id", "=", userID)
|
||||
.where("goal_id", "in", toRemove)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,23 +671,34 @@ export async function GetRelevantGoals(
|
||||
goals: Array<MONGO_GoalDocument>;
|
||||
goalSubsMap: Map<string, MONGO_GoalSubscriptionDocument>;
|
||||
}> {
|
||||
const gsQuery: FilterQuery<MONGO_GoalSubscriptionDocument> = {
|
||||
game,
|
||||
userID,
|
||||
};
|
||||
const v3GamesForGroup = v3AllGames.filter((v) => V3ToGameGroup(v) === game);
|
||||
|
||||
let q = DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.where("goal.game", "in", v3GamesForGroup);
|
||||
|
||||
if (onlyUnachieved) {
|
||||
gsQuery.achieved = false;
|
||||
q = q.where("goal_sub.achieved", "=", false);
|
||||
}
|
||||
|
||||
const goalSubs = await MONGODB_KILL["goal-subs"].find(gsQuery);
|
||||
const subRows = await q.execute();
|
||||
|
||||
log.debug(`Found user has ${goalSubs.length} goals.`);
|
||||
log.debug(`Found user has ${subRows.length} goals.`);
|
||||
|
||||
if (!goalSubs.length) {
|
||||
if (!subRows.length) {
|
||||
return { goals: [], goalSubsMap: new Map() };
|
||||
}
|
||||
|
||||
const goalSubs = subRows.map((r) =>
|
||||
ToGoalSubscriptionDocument({
|
||||
...r,
|
||||
goal_game: r.goal_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const goalIDs = goalSubs.map((e) => e.goalID);
|
||||
|
||||
const chartIDsArr: Array<string> = [];
|
||||
@@ -630,18 +707,25 @@ export async function GetRelevantGoals(
|
||||
chartIDsArr.push(c);
|
||||
}
|
||||
|
||||
const promises = [
|
||||
// this gets the relevantGoals for direct and multi
|
||||
MONGODB_KILL.goals.find({
|
||||
"charts.type": { $in: ["single", "multi"] },
|
||||
"charts.data": { $in: chartIDsArr },
|
||||
goalID: { $in: goalIDs },
|
||||
}),
|
||||
GetRelevantFolderGoals(goalIDs, chartIDsArr),
|
||||
];
|
||||
const goalRows = await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute();
|
||||
|
||||
const goals = await Promise.all(promises).then((r) => r.flat(1));
|
||||
const directMulti: Array<MONGO_GoalDocument> = [];
|
||||
|
||||
for (const g of goalRows) {
|
||||
const doc = ToGoalDocument(g);
|
||||
|
||||
if (doc.charts.type === "single" && chartIDsArr.includes(doc.charts.data)) {
|
||||
directMulti.push(doc);
|
||||
} else if (doc.charts.type === "multi") {
|
||||
if (doc.charts.data.some((c) => chartIDsArr.includes(c))) {
|
||||
directMulti.push(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const folderGoals = await GetRelevantFolderGoals(goalIDs, chartIDsArr);
|
||||
|
||||
const goals = [...directMulti, ...folderGoals];
|
||||
const goalSet = new Set(goals.map((e) => e.goalID));
|
||||
|
||||
const goalSubsMap: Map<string, MONGO_GoalSubscriptionDocument> = new Map();
|
||||
@@ -651,8 +735,6 @@ export async function GetRelevantGoals(
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -667,39 +749,21 @@ export async function GetRelevantGoals(
|
||||
* of chartIDsArr.
|
||||
*/
|
||||
export async function GetRelevantFolderGoals(goalIDs: Array<string>, chartIDsArr: Array<string>) {
|
||||
// Slightly black magic - this is kind of like doing an SQL join.
|
||||
// it's weird to do this in mongodb, but this seems like the right
|
||||
// way to actually handle this.
|
||||
if (goalIDs.length === 0 || chartIDsArr.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: Array<MONGO_GoalDocument> = await MONGODB_KILL.goals.aggregate([
|
||||
{
|
||||
$match: {
|
||||
"charts.type": "folder",
|
||||
goalID: { $in: goalIDs },
|
||||
},
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "folder-chart-lookup",
|
||||
localField: "charts.data",
|
||||
foreignField: "folderID",
|
||||
as: "folderCharts",
|
||||
},
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
"folderCharts.chartID": { $in: chartIDsArr },
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
folderCharts: 0,
|
||||
_id: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const rows = await DB.selectFrom("goal")
|
||||
.innerJoin("folder_chart_lookup", (join) =>
|
||||
join.on(sql`folder_chart_lookup.folder_id`, "=", sql`goal.charts->>'data'`),
|
||||
)
|
||||
.selectAll("goal")
|
||||
.where(sql`goal.charts->>'type'`, "=", "folder")
|
||||
.where("goal.id", "in", goalIDs)
|
||||
.where("folder_chart_lookup.chart_id", "in", chartIDsArr)
|
||||
.execute();
|
||||
|
||||
return result;
|
||||
return rows.map(ToGoalDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -717,17 +781,8 @@ export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalD
|
||||
|
||||
newGoal.goalID = newGoalID;
|
||||
|
||||
await MONGODB_KILL["goal-subs"].update(
|
||||
{
|
||||
goalID: oldGoal.goalID,
|
||||
},
|
||||
{
|
||||
$set: { goalID: newGoalID },
|
||||
},
|
||||
{ multi: true },
|
||||
);
|
||||
await DB.updateTable("goal_sub").set({ goal_id: newGoalID }).where("goal_id", "=", oldGoal.goalID).execute();
|
||||
|
||||
// update any dangling quest references
|
||||
const quests = await GetQuestsThatContainGoal(oldGoal.goalID);
|
||||
|
||||
for (const quest of quests) {
|
||||
@@ -750,15 +805,13 @@ export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalD
|
||||
});
|
||||
}
|
||||
|
||||
await MONGODB_KILL.quests.update(
|
||||
{ questID: quest.questID },
|
||||
{
|
||||
$set: { questData: newQuestData },
|
||||
},
|
||||
);
|
||||
await DB.updateTable("quest")
|
||||
.set({ quest_data: newQuestData })
|
||||
.where("quest.id", "=", quest.questID)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await MONGODB_KILL.goals.remove({ goalID: oldGoal.goalID });
|
||||
await DB.deleteFrom("goal").where("goal.id", "=", oldGoal.goalID).execute();
|
||||
|
||||
newGoal.name = await CreateGoalName(
|
||||
newGoal.charts,
|
||||
@@ -766,9 +819,20 @@ export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalD
|
||||
newGoal.game,
|
||||
newGoal.playtype,
|
||||
);
|
||||
|
||||
const v3Game = GamePTToV3(newGoal.game, newGoal.playtype);
|
||||
|
||||
try {
|
||||
await MONGODB_KILL.goals.insert(newGoal);
|
||||
} catch (err) {
|
||||
await DB.insertInto("goal")
|
||||
.values({
|
||||
id: newGoal.goalID,
|
||||
game: v3Game,
|
||||
name: newGoal.name,
|
||||
charts: newGoal.charts,
|
||||
criteria: newGoal.criteria,
|
||||
})
|
||||
.execute();
|
||||
} catch {
|
||||
log.info(`Goal ${newGoal.name} already existed.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,19 @@ import type {
|
||||
} from "tachi-common";
|
||||
|
||||
import { SubscribeFailReasons } from "#lib/constants/err-codes";
|
||||
import {
|
||||
ToGoalDocument,
|
||||
ToGoalSubscriptionDocument,
|
||||
ToQuestDocument,
|
||||
ToQuestSubscriptionDocument,
|
||||
} from "#lib/db-formats/target-documents";
|
||||
import { log } from "#lib/log/log";
|
||||
import { BulkSendNotification } from "#lib/notifications/notifications";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import { sql } from "kysely";
|
||||
import type { Game } from "tachi-db";
|
||||
import { GamePTToV3, V3ToGamePT } from "tachi-common";
|
||||
|
||||
import {
|
||||
type EvaluatedGoalReturn,
|
||||
@@ -26,8 +36,6 @@ import {
|
||||
* nested structure of quests.
|
||||
*/
|
||||
export function GetGoalIDsFromQuest(quest: MONGO_QuestDocument) {
|
||||
// this sucks - maybe a nicer way to do this, because nested
|
||||
// maps are just ugly
|
||||
return quest.questData.map((e) => e.goals.map((e) => e.goalID)).flat(1);
|
||||
}
|
||||
|
||||
@@ -37,9 +45,7 @@ export function GetGoalIDsFromQuest(quest: MONGO_QuestDocument) {
|
||||
export async function GetGoalsInQuest(quest: MONGO_QuestDocument) {
|
||||
const goalIDs = GetGoalIDsFromQuest(quest);
|
||||
|
||||
const goals = await MONGODB_KILL.goals.find({
|
||||
goalID: { $in: goalIDs },
|
||||
});
|
||||
const goals = await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute();
|
||||
|
||||
if (goals.length !== goalIDs.length) {
|
||||
log.error(
|
||||
@@ -49,7 +55,6 @@ export async function GetGoalsInQuest(quest: MONGO_QuestDocument) {
|
||||
throw new Error(`Quest is corrupt. Not the right amount of goals in db?`);
|
||||
}
|
||||
|
||||
// this shouldn't happen, but if it does it's recoverable by just ignoring it.
|
||||
if (goalIDs.length < 2) {
|
||||
log.warn(
|
||||
{
|
||||
@@ -59,7 +64,7 @@ export async function GetGoalsInQuest(quest: MONGO_QuestDocument) {
|
||||
);
|
||||
}
|
||||
|
||||
return goals;
|
||||
return goals.map(ToGoalDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,11 +73,13 @@ export async function GetGoalsInQuest(quest: MONGO_QuestDocument) {
|
||||
export async function GetGoalsInQuests(quests: Array<MONGO_QuestDocument>) {
|
||||
const goalIDs = quests.flatMap((quest) => GetGoalIDsFromQuest(quest));
|
||||
|
||||
const goals = await MONGODB_KILL.goals.find({
|
||||
goalID: { $in: goalIDs },
|
||||
});
|
||||
if (goalIDs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return goals;
|
||||
const goals = await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute();
|
||||
|
||||
return goals.map(ToGoalDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,23 +105,35 @@ type EvaluatedGoalResult = { goalID: string } & EvaluatedGoalReturn;
|
||||
export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestDocument) {
|
||||
const goals = await GetGoalsInQuest(quest);
|
||||
|
||||
const isSubscribedToQuest = await MONGODB_KILL["quest-subs"].findOne({
|
||||
questID: quest.questID,
|
||||
userID,
|
||||
});
|
||||
const isSubscribedToQuest = await DB.selectFrom("quest_sub")
|
||||
.selectAll()
|
||||
.where("quest_sub.quest_id", "=", quest.questID)
|
||||
.where("quest_sub.user_id", "=", userID)
|
||||
.executeTakeFirst();
|
||||
|
||||
// If the user is subscribed the quest, we don't need to calculate
|
||||
// their progress on each goal.
|
||||
const goalSubMap = new Map<string, MONGO_GoalSubscriptionDocument>();
|
||||
|
||||
if (isSubscribedToQuest) {
|
||||
const goalSubs = await MONGODB_KILL["goal-subs"].find({
|
||||
goalID: { $in: goals.map((e) => e.goalID) },
|
||||
userID,
|
||||
});
|
||||
const goalSubRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.user_id", "=", userID)
|
||||
.where(
|
||||
"goal_sub.goal_id",
|
||||
"in",
|
||||
goals.map((e) => e.goalID),
|
||||
)
|
||||
.execute();
|
||||
|
||||
for (const sub of goalSubs) {
|
||||
goalSubMap.set(sub.goalID, sub);
|
||||
for (const row of goalSubRows) {
|
||||
goalSubMap.set(
|
||||
row.goal_id,
|
||||
ToGoalSubscriptionDocument({
|
||||
...row,
|
||||
goal_game: row.goal_game as Game,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +143,6 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD
|
||||
let goalSub = goalSubMap.get(goal.goalID);
|
||||
|
||||
if (!goalSub) {
|
||||
// shouldn't happen. Let's just correct the user silently.
|
||||
|
||||
log.warn(
|
||||
`User ${userID} has a corrupt subscription to quest '${quest.name}', They do not have all the goals in this quest assigned. Automatically subscribing them to the new goal.`,
|
||||
);
|
||||
@@ -142,7 +159,6 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD
|
||||
}
|
||||
|
||||
if (newGoalSub === SubscribeFailReasons.ALREADY_ACHIEVED) {
|
||||
// lol, wut
|
||||
log.error(
|
||||
`Impossible via typesystem: attempted resubscription for user ${userID} on goal ${goal.goalID}, was rejected for being already achieved. Not possible, as we allow already achieved goals here.`,
|
||||
);
|
||||
@@ -155,12 +171,14 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD
|
||||
goalSub = newGoalSub;
|
||||
}
|
||||
|
||||
const gSub = goalSub;
|
||||
|
||||
return {
|
||||
achieved: goalSub.achieved,
|
||||
progress: goalSub.progress,
|
||||
outOf: goalSub.outOf,
|
||||
progressHuman: goalSub.progressHuman,
|
||||
outOfHuman: goalSub.outOfHuman,
|
||||
achieved: gSub.achieved,
|
||||
progress: gSub.progress,
|
||||
outOf: gSub.outOf,
|
||||
progressHuman: gSub.progressHuman,
|
||||
outOfHuman: gSub.outOfHuman,
|
||||
goalID: goal.goalID,
|
||||
};
|
||||
}
|
||||
@@ -228,10 +246,11 @@ export async function SubscribeToQuest(
|
||||
| SubscribeFailReasons.ALREADY_ACHIEVED
|
||||
| SubscribeFailReasons.ALREADY_SUBSCRIBED
|
||||
> {
|
||||
const isSubscribedToQuest = await MONGODB_KILL["quest-subs"].findOne({
|
||||
userID,
|
||||
questID: quest.questID,
|
||||
});
|
||||
const isSubscribedToQuest = await DB.selectFrom("quest_sub")
|
||||
.selectAll()
|
||||
.where("quest_sub.quest_id", "=", quest.questID)
|
||||
.where("quest_sub.user_id", "=", userID)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (isSubscribedToQuest) {
|
||||
return SubscribeFailReasons.ALREADY_SUBSCRIBED;
|
||||
@@ -243,27 +262,44 @@ export async function SubscribeToQuest(
|
||||
return SubscribeFailReasons.ALREADY_ACHIEVED;
|
||||
}
|
||||
|
||||
// @ts-expect-error TS can't resolve this.
|
||||
// because it can't explode out the types.
|
||||
const questSub: MONGO_QuestSubscriptionDocument = {
|
||||
const nowMs = Date.now();
|
||||
const nowIso = UnixMillisecondsToISO8601(nowMs);
|
||||
|
||||
const questSubBase = {
|
||||
progress: result.progress,
|
||||
userID,
|
||||
questID: quest.questID,
|
||||
wasInstantlyAchieved: result.achieved,
|
||||
game: quest.game,
|
||||
playtype: quest.playtype,
|
||||
achieved: result.achieved,
|
||||
timeAchieved: result.achieved ? Date.now() : null,
|
||||
lastInteraction: null,
|
||||
};
|
||||
|
||||
// @optimisable, EvaluateQuestProgress 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.
|
||||
const questSub: MONGO_QuestSubscriptionDocument = result.achieved
|
||||
? {
|
||||
...questSubBase,
|
||||
achieved: true,
|
||||
timeAchieved: nowMs,
|
||||
}
|
||||
: {
|
||||
...questSubBase,
|
||||
achieved: false,
|
||||
timeAchieved: null,
|
||||
};
|
||||
|
||||
await Promise.all(result.goals.map((goal) => SubscribeToGoal(userID, goal, false)));
|
||||
|
||||
await MONGODB_KILL["quest-subs"].insert(questSub);
|
||||
await DB.insertInto("quest_sub")
|
||||
.values({
|
||||
quest_id: quest.questID,
|
||||
user_id: userID,
|
||||
progress: questSub.progress,
|
||||
last_interaction: null,
|
||||
achieved: questSub.achieved,
|
||||
time_achieved: result.achieved ? nowIso : null,
|
||||
was_instantly_achieved: questSub.wasInstantlyAchieved,
|
||||
})
|
||||
.execute();
|
||||
|
||||
log.info(`User ${userID} subscribed to '${quest.name}'.`);
|
||||
|
||||
@@ -282,25 +318,24 @@ export async function SubscribeToQuest(
|
||||
export async function UpdateQuestSubscriptions(questID: string) {
|
||||
log.info(`Received update-subscribe call to quest ${questID}.`);
|
||||
|
||||
const subscriptions = await MONGODB_KILL["quest-subs"].find({ questID });
|
||||
const subscriptions = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.quest_id", "=", questID)
|
||||
.execute();
|
||||
|
||||
const maybeQuest = await MONGODB_KILL.quests.findOne({ questID });
|
||||
const maybeQuest = await DB.selectFrom("quest").selectAll().where("quest.id", "=", questID).executeTakeFirst();
|
||||
|
||||
// if the quest was deleted, we have to take a more manual approach.
|
||||
if (!maybeQuest) {
|
||||
// first, remove all subs to this quest
|
||||
await MONGODB_KILL["quest-subs"].remove({
|
||||
questID,
|
||||
});
|
||||
await DB.deleteFrom("quest_sub").where("quest_sub.quest_id", "=", questID).execute();
|
||||
|
||||
// then, this presents us with an interesting problem.
|
||||
// We can't actually know what goals this user was subscribed to as a result
|
||||
// of this quest, because said quest no longer exists.
|
||||
|
||||
// To mitigate this, we just prune all goalsubs that no longer have any
|
||||
// dependencies
|
||||
await Promise.all(
|
||||
subscriptions.map((e) => UnsubscribeFromOrphanedGoalSubs(e.userID, e.game, e.playtype)),
|
||||
subscriptions.map((e) => {
|
||||
const { game, playtype } = V3ToGamePT(e.quest_game as Game);
|
||||
|
||||
return UnsubscribeFromOrphanedGoalSubs(e.user_id, game, playtype);
|
||||
}),
|
||||
);
|
||||
|
||||
log.info(`Quest ${questID} has been deleted. Unsubscribed ${subscriptions.length} users.`);
|
||||
@@ -308,21 +343,34 @@ export async function UpdateQuestSubscriptions(questID: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the easiest way to do this? unsubscribe all users from the quest, then subscribe
|
||||
// them all again.
|
||||
await Promise.all(subscriptions.map((e) => UnsubscribeFromQuest(e, maybeQuest)));
|
||||
const quest = ToQuestDocument(maybeQuest);
|
||||
|
||||
await Promise.all(subscriptions.map((e) => SubscribeToQuest(e.userID, maybeQuest, false)));
|
||||
const mappedSubs = subscriptions.map((e) =>
|
||||
ToQuestSubscriptionDocument({
|
||||
quest_id: e.quest_id,
|
||||
user_id: e.user_id,
|
||||
progress: e.progress,
|
||||
last_interaction: e.last_interaction,
|
||||
achieved: e.achieved,
|
||||
time_achieved: e.time_achieved,
|
||||
was_instantly_achieved: e.was_instantly_achieved,
|
||||
quest_game: e.quest_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(mappedSubs.map((e) => UnsubscribeFromQuest(e, quest)));
|
||||
|
||||
await Promise.all(mappedSubs.map((e) => SubscribeToQuest(e.userID, quest, false)));
|
||||
|
||||
await BulkSendNotification(
|
||||
`The quest '${maybeQuest.name}' has received an update.`,
|
||||
subscriptions.map((e) => e.userID),
|
||||
`The quest '${quest.name}' has received an update.`,
|
||||
subscriptions.map((e) => e.user_id),
|
||||
{
|
||||
type: "QUEST_CHANGED",
|
||||
content: {
|
||||
questID,
|
||||
game: maybeQuest.game,
|
||||
playtype: maybeQuest.playtype,
|
||||
game: quest.game,
|
||||
playtype: quest.playtype,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -340,20 +388,26 @@ export async function UnsubscribeFromQuest(
|
||||
) {
|
||||
const goalIDs = GetGoalIDsFromQuest(quest);
|
||||
|
||||
// remove the quest sub
|
||||
// (preventing HAS_QUEST_DEPENDENCIES when this is the quest we're removing anyway)
|
||||
await MONGODB_KILL["quest-subs"].remove({
|
||||
questID: questSub.questID,
|
||||
userID: questSub.userID,
|
||||
});
|
||||
await DB.deleteFrom("quest_sub")
|
||||
.where("quest_sub.quest_id", "=", questSub.questID)
|
||||
.where("quest_sub.user_id", "=", questSub.userID)
|
||||
.execute();
|
||||
|
||||
const goalSubs = await MONGODB_KILL["goal-subs"].find({
|
||||
userID: questSub.userID,
|
||||
goalID: { $in: goalIDs },
|
||||
});
|
||||
const goalSubRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.user_id", "=", questSub.userID)
|
||||
.where("goal_sub.goal_id", "in", goalIDs)
|
||||
.execute();
|
||||
|
||||
const goalSubs = goalSubRows.map((r) =>
|
||||
ToGoalSubscriptionDocument({
|
||||
...r,
|
||||
goal_game: r.goal_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
// unsub the user from all goals we can. If we can't unsub from a goal, that's
|
||||
// not a problem, we weren't meant to unsubscribe from it.
|
||||
await Promise.all(goalSubs.map((e) => UnsubscribeFromGoal(e, true)));
|
||||
}
|
||||
|
||||
@@ -367,55 +421,57 @@ export async function GetParentQuests(
|
||||
playtype: Playtype,
|
||||
goalSubs: Array<MONGO_GoalSubscriptionDocument>,
|
||||
) {
|
||||
const questSubs: Array<{ questID: string }> = await MONGODB_KILL["quest-subs"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
userID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
questID: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
const goalIds = goalSubs.map((e) => e.goalID);
|
||||
|
||||
const questSubIDs = questSubs.map((e) => e.questID);
|
||||
if (goalIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const quests = await MONGODB_KILL.quests.find({
|
||||
questID: { $in: questSubIDs },
|
||||
"questData.goals.goalID": { $in: goalSubs.map((e) => e.goalID) },
|
||||
});
|
||||
const questSubRows = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.select("quest_sub.quest_id")
|
||||
.where("quest_sub.user_id", "=", userID)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.execute();
|
||||
|
||||
return quests;
|
||||
const questSubIDs = questSubRows.map((e) => e.quest_id);
|
||||
|
||||
if (questSubIDs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const questRows = await DB.selectFrom("quest")
|
||||
.selectAll()
|
||||
.where("quest.id", "in", questSubIDs)
|
||||
.execute();
|
||||
|
||||
return questRows
|
||||
.filter((q) => {
|
||||
const doc = ToQuestDocument(q);
|
||||
|
||||
return doc.questData.some((section) =>
|
||||
section.goals.some((g) => goalIds.includes(g.goalID)),
|
||||
);
|
||||
})
|
||||
.map(ToQuestDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all quests not in any questlines.
|
||||
*/
|
||||
export async function FindStandaloneQuests(game: GameGroup, playtype: Playtype) {
|
||||
const res: Array<MONGO_QuestDocument> = await MONGODB_KILL.quests.aggregate([
|
||||
{
|
||||
$match: {
|
||||
game,
|
||||
playtype,
|
||||
},
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "questlines",
|
||||
localField: "questID",
|
||||
foreignField: "quests",
|
||||
as: "parentQuestlines",
|
||||
},
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
// is an empty array
|
||||
"parentQuestlines.0": { $exists: false },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
return res;
|
||||
const rows = await DB.selectFrom("quest")
|
||||
.selectAll()
|
||||
.where("quest.game", "=", v3Game)
|
||||
.where(
|
||||
sql<boolean>`not exists (
|
||||
select 1 from questline_quest qq where qq.quest_id = quest.id
|
||||
)`,
|
||||
)
|
||||
.execute();
|
||||
|
||||
return rows.map(ToQuestDocument);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import { HandleSIGTERMGracefully } from "#lib/handlers/sigterm";
|
||||
import { log } from "#lib/log/log";
|
||||
import { Env, ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import server from "#server/server";
|
||||
import { monkDB } from "#services/mongo/db";
|
||||
import { UpdateIndexes } from "#services/mongo/indexes";
|
||||
import DB from "#services/pg/db";
|
||||
import fetch from "#utils/fetch";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
@@ -29,8 +27,6 @@ log.info({ bootInfo: true }, `Log level is set to ${Env.LOG_LEVEL}.`);
|
||||
log.info({ bootInfo: true }, `Loading sequence documents...`);
|
||||
|
||||
async function RunOnInit() {
|
||||
await UpdateIndexes(monkDB, false);
|
||||
|
||||
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
|
||||
|
||||
if (Env.NODE_ENV === "dev") {
|
||||
|
||||
@@ -1001,9 +1001,15 @@ async function main(): Promise<void> {
|
||||
|
||||
const uniqueGoalIds = [...new Set(goalSubs.map((gs) => gs.goalID))];
|
||||
const existingGoalIds = new Set(
|
||||
(
|
||||
await pg.selectFrom("goal").select("id").where("id", "in", uniqueGoalIds).execute()
|
||||
).map((r) => r.id),
|
||||
uniqueGoalIds.length === 0
|
||||
? []
|
||||
: (
|
||||
await pg
|
||||
.selectFrom("goal")
|
||||
.select("id")
|
||||
.where("id", "in", uniqueGoalIds)
|
||||
.execute()
|
||||
).map((r) => r.id),
|
||||
);
|
||||
|
||||
const goalSubRows: Array<NewGoalSub> = [];
|
||||
|
||||
+3
-3
@@ -12,12 +12,12 @@ async function seedBmsSongAndChart(opts: {
|
||||
sglHC: number | null;
|
||||
}) {
|
||||
const n = ++seedCounter;
|
||||
const songPgId = `s_gpt_bms_${n}`;
|
||||
const songNewID = `s_gpt_bms_${n}`;
|
||||
const chartPgId = `c_gpt_bms_${n}`;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: 970_000 + n,
|
||||
game_group: "bms",
|
||||
title: `Sieglinde GPT Test ${n}`,
|
||||
@@ -42,7 +42,7 @@ async function seedBmsSongAndChart(opts: {
|
||||
id: chartPgId,
|
||||
legacy_id: chartPgId,
|
||||
game: opts.game,
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
level: "?",
|
||||
level_num: 0,
|
||||
is_primary: true,
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { GetSongsByLegacyIDs } from "#lib/db-formats/song";
|
||||
import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings";
|
||||
import { log } from "#lib/log/log";
|
||||
import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter";
|
||||
import { SearchSpecificGameSongs } from "#lib/search/songs.js";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { FindChartsOnPopularity } from "#utils/queries/charts";
|
||||
import { GetGPT } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
import {
|
||||
GamePTToV3,
|
||||
type integer,
|
||||
type MatchTypeResolver,
|
||||
type MONGO_ChartDocument,
|
||||
type MONGO_UGPTSettingsDocument,
|
||||
} from "tachi-common";
|
||||
import { PR_RESOLVER } from "tachi-common/lib/schemas";
|
||||
import { type Game } from "tachi-db";
|
||||
|
||||
import chartIDRouter from "./_chartID/router";
|
||||
|
||||
@@ -55,12 +58,16 @@ router.get("/", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const playedSongs = (
|
||||
await MONGODB_KILL["personal-bests"].find(
|
||||
{ userID, game, playtype },
|
||||
{ projection: { songID: 1 } },
|
||||
)
|
||||
).map((e) => e.songID);
|
||||
const v3Game = GamePTToV3(game, playtype) as Game;
|
||||
const playedSongRows = await DB.selectFrom("pb")
|
||||
.innerJoin("chart", "chart.id", "pb.chart_id")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.distinct()
|
||||
.where("pb.user_id", "=", userID)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.execute();
|
||||
const playedSongs = playedSongRows.map((e) => e.song_legacy_id);
|
||||
|
||||
if (songIDs) {
|
||||
songIDs = songIDs.filter((e) => playedSongs.includes(e));
|
||||
@@ -104,13 +111,16 @@ router.get("/", async (req, res) => {
|
||||
(e) => (e as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">).data["2dxtraSet"] === null,
|
||||
);
|
||||
} else {
|
||||
const iidxSettings = (await MONGODB_KILL["game-settings"].findOne({
|
||||
userID: req[SYMBOL_TACHI_API_AUTH].userID,
|
||||
const iidxSettings = await GetUGPTSettingsDocument(
|
||||
req[SYMBOL_TACHI_API_AUTH].userID,
|
||||
game,
|
||||
playtype,
|
||||
})) as MONGO_UGPTSettingsDocument<"iidx:DP" | "iidx:SP"> | null;
|
||||
);
|
||||
|
||||
if (!iidxSettings?.preferences.gameSpecific.display2DXTra) {
|
||||
const iidxGameSpecific = iidxSettings?.preferences
|
||||
.gameSpecific as MONGO_UGPTSettingsDocument<"iidx:DP" | "iidx:SP">["preferences"]["gameSpecific"];
|
||||
|
||||
if (!iidxGameSpecific?.display2DXTra) {
|
||||
charts = charts.filter(
|
||||
(e) =>
|
||||
(e as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">).data["2dxtraSet"] ===
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { FindOptions } from "monk";
|
||||
|
||||
import { CreateActivityRouteHandler } from "#lib/activity/activity";
|
||||
import { ONE_HOUR } from "#lib/constants/time";
|
||||
import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js";
|
||||
import { LoadPbDocumentsForGameSortedByCalculatedAlg } from "#lib/db-formats/pb";
|
||||
import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user.js";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { EscapeForILIKE, IsString } from "#utils/misc";
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from "#utils/string-checks";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { sql } from "kysely";
|
||||
import NodeCache from "node-cache";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
GamePTToV3,
|
||||
GetGamePTConfig,
|
||||
type integer,
|
||||
type MONGO_UserGameStats,
|
||||
type Playtype,
|
||||
} from "tachi-common";
|
||||
|
||||
@@ -47,16 +46,24 @@ async function GetGameStats(
|
||||
const cacheRes = gptStatCache.get(`${game}:${playtype}`);
|
||||
|
||||
if (cacheRes === undefined) {
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const [scoreCount, playerCount, chartCount] = await Promise.all([
|
||||
MONGODB_KILL.scores.count({
|
||||
game,
|
||||
playtype,
|
||||
}),
|
||||
MONGODB_KILL["game-stats"].count({
|
||||
game,
|
||||
playtype,
|
||||
}),
|
||||
MONGODB_KILL.anyCharts[game].count({ playtype }),
|
||||
DB.selectFrom("score")
|
||||
.select((eb) => eb.fn.countAll().as("c"))
|
||||
.where("score.game", "=", v3Game)
|
||||
.executeTakeFirst()
|
||||
.then((r) => Number(r?.c ?? 0)),
|
||||
DB.selectFrom("game_profile")
|
||||
.select((eb) => eb.fn.countAll().as("c"))
|
||||
.where("game_profile.game", "=", v3Game)
|
||||
.executeTakeFirst()
|
||||
.then((r) => Number(r?.c ?? 0)),
|
||||
DB.selectFrom("chart")
|
||||
.select((eb) => eb.fn.countAll().as("c"))
|
||||
.where("chart.game", "=", v3Game)
|
||||
.executeTakeFirst()
|
||||
.then((r) => Number(r?.c ?? 0)),
|
||||
]);
|
||||
|
||||
gptStatCache.set(`${game}:${playtype}`, { scoreCount, playerCount, chartCount }, ONE_HOUR);
|
||||
@@ -128,20 +135,16 @@ router.get("/leaderboard", async (req, res) => {
|
||||
alg = temp;
|
||||
}
|
||||
|
||||
const options: FindOptions<MONGO_UserGameStats> = {
|
||||
sort: {
|
||||
[`ratings.${alg}`]: -1,
|
||||
},
|
||||
limit,
|
||||
};
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
const ratingCol = sql<number>`coalesce((game_profile.ratings::jsonb->>${sql.lit(alg)})::numeric, 0)`;
|
||||
|
||||
const gameStats = await MONGODB_KILL["game-stats"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
},
|
||||
options,
|
||||
);
|
||||
const gameStats = await DB.selectFrom("game_profile")
|
||||
.select(SELECT_GAME_PROFILE)
|
||||
.where("game_profile.game", "=", v3Game)
|
||||
.orderBy(ratingCol, "desc")
|
||||
.limit(limit)
|
||||
.execute()
|
||||
.then((rows) => rows.map(ToGameStatsDocument));
|
||||
|
||||
const users = await GetUsersWithIDs(gameStats.map((e) => e.userID));
|
||||
|
||||
@@ -193,18 +196,8 @@ router.get("/pb-leaderboard", async (req, res) => {
|
||||
alg = temp;
|
||||
}
|
||||
|
||||
const pbs = await MONGODB_KILL["personal-bests"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
},
|
||||
{
|
||||
sort: {
|
||||
[`calculatedData.${alg}`]: -1,
|
||||
},
|
||||
limit,
|
||||
},
|
||||
);
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
const pbs = await LoadPbDocumentsForGameSortedByCalculatedAlg(v3Game, alg, limit);
|
||||
|
||||
const users = await GetUsersWithIDs(pbs.map((e) => e.userID));
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ export const ValidateAndGetSong: RequestHandler = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { songDoc: result.doc, songPgId: result.pgId });
|
||||
AssignToReqTachiData(req, { songDoc: result.doc, songNewID: result.newSongID });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@ async function seedSong({
|
||||
async function seedChart({
|
||||
pgId = CHART_PG_ID,
|
||||
legacyId = CHART_LEGACY_ID,
|
||||
songPgId = SONG_PG_ID,
|
||||
songNewID = SONG_PG_ID,
|
||||
difficulty = "ANOTHER",
|
||||
level = "10",
|
||||
levelNum = 10,
|
||||
@@ -51,7 +51,7 @@ async function seedChart({
|
||||
id: pgId,
|
||||
legacy_id: legacyId,
|
||||
game: "iidx-sp",
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
difficulty,
|
||||
level,
|
||||
level_num: levelNum,
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
|
||||
import { GetChartsBySongId } from "#lib/db-formats/chart";
|
||||
import { GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
import { GamePTToV3 } from "tachi-common";
|
||||
@@ -16,10 +16,10 @@ router.use(ValidateAndGetSong);
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const song = GetTachiData(req, "songDoc");
|
||||
const songPgId = GetTachiData(req, "songPgId");
|
||||
const songNewID = GetTachiData(req, "songNewID");
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const charts = await GetChartsBySongPgId(GamePTToV3(game, playtype), songPgId, song.id);
|
||||
const charts = await GetChartsBySongId(GamePTToV3(game, playtype), songNewID);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { GetTableDocumentsForGamePlaytype } from "#lib/db-formats/table";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetFoldersFromTable } from "#utils/folder";
|
||||
import { GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
|
||||
+22
-12
@@ -1,15 +1,17 @@
|
||||
import type { MONGO_GoalDocument } from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log";
|
||||
import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents";
|
||||
import { CreateGoalTitle, ValidateGoalChartsAndCriteria } from "#lib/targets/goal-utils";
|
||||
import { GetQuestsThatContainGoal } from "#lib/targets/goals";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetMostSubscribedGoals } from "#utils/db";
|
||||
import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
import { p } from "prudence";
|
||||
import { GamePTToV3 } from "tachi-common";
|
||||
import type { Game } from "tachi-db";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
@@ -131,20 +133,18 @@ const ResolveGoalID: RequestHandler = async (req, res, next) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
const goalID = req.params.goalID;
|
||||
|
||||
const goal = await MONGODB_KILL.goals.findOne({
|
||||
goalID,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
if (!goal) {
|
||||
const row = await DB.selectFrom("goal").selectAll().where("goal.id", "=", goalID).where("goal.game", "=", v3Game).executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `A goal with ID ${goalID} doesn't exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { goalDoc: goal });
|
||||
AssignToReqTachiData(req, { goalDoc: ToGoalDocument(row) });
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -157,9 +157,19 @@ const ResolveGoalID: RequestHandler = async (req, res, next) => {
|
||||
router.get("/:goalID", ResolveGoalID, async (req, res) => {
|
||||
const goal = GetTachiData(req, "goalDoc");
|
||||
|
||||
const goalSubs = await MONGODB_KILL["goal-subs"].find({
|
||||
goalID: goal.goalID,
|
||||
});
|
||||
const subRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.goal_id", "=", goal.goalID)
|
||||
.execute();
|
||||
|
||||
const goalSubs = subRows.map((r) =>
|
||||
ToGoalSubscriptionDocument({
|
||||
...r,
|
||||
goal_game: r.goal_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const users = await GetUsersWithIDs(goalSubs.map((e) => e.userID));
|
||||
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
describe("GET /api/v1/games/:game/:playtype/targets/questlines", () => {
|
||||
it("returns questlines and standalone quests for the GPT", async () => {
|
||||
const suffix = `${Date.now()}`;
|
||||
const qlId = `ql-rt-${suffix}`;
|
||||
const qIn = `q-in-${suffix}`;
|
||||
const qStandalone = `q-standalone-${suffix}`;
|
||||
|
||||
await DB.insertInto("quest")
|
||||
.values([
|
||||
{
|
||||
id: qIn,
|
||||
game: "iidx-sp",
|
||||
name: "In line",
|
||||
description: "d",
|
||||
quest_data: JSON.stringify([]),
|
||||
},
|
||||
{
|
||||
id: qStandalone,
|
||||
game: "iidx-sp",
|
||||
name: "Standalone",
|
||||
description: "d",
|
||||
quest_data: JSON.stringify([]),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("questline")
|
||||
.values({
|
||||
id: qlId,
|
||||
game: "iidx-sp",
|
||||
name: "Test line",
|
||||
description: "desc",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("questline_quest")
|
||||
.values({
|
||||
questline_id: qlId,
|
||||
quest_id: qIn,
|
||||
sort_order: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const ids = (res.body.body.questlines as Array<{ questlineID: string }>).map(
|
||||
(q) => q.questlineID,
|
||||
);
|
||||
|
||||
expect(ids).toContain(qlId);
|
||||
|
||||
const ql = (
|
||||
res.body.body.questlines as Array<{ questlineID: string; quests: Array<string> }>
|
||||
).find((x) => x.questlineID === qlId);
|
||||
|
||||
expect(ql?.quests).toEqual([qIn]);
|
||||
|
||||
const standaloneIds = (res.body.body.standalone as Array<{ questID: string }>).map(
|
||||
(q) => q.questID,
|
||||
);
|
||||
|
||||
expect(standaloneIds).toContain(qStandalone);
|
||||
});
|
||||
|
||||
it("returns 404 when the questline is missing", async () => {
|
||||
const res = await mockApi.get(
|
||||
"/api/v1/games/iidx/SP/targets/questlines/no_such_questline_zzz",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a questline with ordered child quests", async () => {
|
||||
const suffix = `${Date.now()}-b`;
|
||||
const qlId = `ql-ord-${suffix}`;
|
||||
const qa = `q-a-${suffix}`;
|
||||
const qb = `q-b-${suffix}`;
|
||||
|
||||
await DB.insertInto("quest")
|
||||
.values([
|
||||
{
|
||||
id: qa,
|
||||
game: "iidx-sp",
|
||||
name: "A",
|
||||
description: "d",
|
||||
quest_data: JSON.stringify([]),
|
||||
},
|
||||
{
|
||||
id: qb,
|
||||
game: "iidx-sp",
|
||||
name: "B",
|
||||
description: "d",
|
||||
quest_data: JSON.stringify([]),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("questline")
|
||||
.values({
|
||||
id: qlId,
|
||||
game: "iidx-sp",
|
||||
name: "Ordered",
|
||||
description: "d",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("questline_quest")
|
||||
.values([
|
||||
{ questline_id: qlId, quest_id: qb, sort_order: 0 },
|
||||
{ questline_id: qlId, quest_id: qa, sort_order: 1 },
|
||||
])
|
||||
.execute();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/games/iidx/SP/targets/questlines/${qlId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.questline.questlineID).toBe(qlId);
|
||||
|
||||
const order = (res.body.body.quests as Array<{ questID: string }>).map((q) => q.questID);
|
||||
|
||||
expect(order).toEqual([qb, qa]);
|
||||
});
|
||||
});
|
||||
+3
-8
@@ -1,7 +1,6 @@
|
||||
import { FindStandaloneQuests, GetGoalsInQuests } from "#lib/targets/quests";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetChildQuests } from "#utils/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { GetQuestlineById, GetQuestlinesForGamePlaytype } from "#utils/queries/questlines";
|
||||
import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
|
||||
@@ -11,11 +10,7 @@ const ResolveQuestlineID: RequestHandler = async (req, res, next) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
const questlineID = req.params.questlineID;
|
||||
|
||||
const questline = await MONGODB_KILL.questlines.findOne({
|
||||
questlineID,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const questline = await GetQuestlineById(game, playtype, questlineID);
|
||||
|
||||
if (!questline) {
|
||||
return res.status(404).json({
|
||||
@@ -37,7 +32,7 @@ const ResolveQuestlineID: RequestHandler = async (req, res, next) => {
|
||||
router.get("/", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const questlines = await MONGODB_KILL.questlines.find({ game, playtype });
|
||||
const questlines = await GetQuestlinesForGamePlaytype(game, playtype);
|
||||
|
||||
const standalone = await FindStandaloneQuests(game, playtype);
|
||||
const standaloneGoals = await GetGoalsInQuests(standalone);
|
||||
|
||||
+64
-24
@@ -1,31 +1,34 @@
|
||||
import { SearchCollection } from "#lib/search/search";
|
||||
import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents";
|
||||
import { GetGoalsInQuest, GetGoalsInQuests } from "#lib/targets/quests";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import DB from "#services/pg/db";
|
||||
import { EscapeForILIKE } from "#utils/misc";
|
||||
import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
|
||||
import { GamePTToV3, V3ToGamePT } from "tachi-common";
|
||||
import type { Game } from "tachi-db";
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const ResolveQuestID: RequestHandler = async (req, res, next) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
const questID = req.params.questID;
|
||||
|
||||
const quest = await MONGODB_KILL.quests.findOne({
|
||||
questID,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
if (!quest) {
|
||||
const row = await DB.selectFrom("quest")
|
||||
.selectAll()
|
||||
.where("quest.id", "=", questID)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `A quest with ID ${questID} doesn't exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { questDoc: quest });
|
||||
AssignToReqTachiData(req, { questDoc: ToQuestDocument(row) });
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -40,20 +43,27 @@ const ResolveQuestID: RequestHandler = async (req, res, next) => {
|
||||
router.get("/", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
if (!IsString(req.query.search)) {
|
||||
if (typeof req.query.search !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid value for search.`,
|
||||
});
|
||||
}
|
||||
|
||||
const quests = await SearchCollection(
|
||||
MONGODB_KILL.quests,
|
||||
req.query.search,
|
||||
"quests",
|
||||
{ game, playtype },
|
||||
50,
|
||||
);
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
const likeEsc = EscapeForILIKE(req.query.search.trim());
|
||||
const pattern = `%${likeEsc}%`;
|
||||
|
||||
const rows = await DB.selectFrom("quest")
|
||||
.selectAll()
|
||||
.where("quest.game", "=", v3Game)
|
||||
.where((eb) =>
|
||||
eb.or([eb("quest.name", "ilike", pattern), eb("quest.description", "ilike", pattern)]),
|
||||
)
|
||||
.limit(50)
|
||||
.execute();
|
||||
|
||||
const quests = rows.map(ToQuestDocument);
|
||||
const goals = await GetGoalsInQuests(quests);
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -71,16 +81,46 @@ router.get("/", async (req, res) => {
|
||||
router.get("/:questID", ResolveQuestID, async (req, res) => {
|
||||
const quest = GetTachiData(req, "questDoc");
|
||||
|
||||
const questSubs = await MONGODB_KILL["quest-subs"].find({
|
||||
questID: quest.questID,
|
||||
});
|
||||
const questSubRows = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.quest_id", "=", quest.questID)
|
||||
.execute();
|
||||
|
||||
const questSubs = questSubRows.map((r) =>
|
||||
ToQuestSubscriptionDocument({
|
||||
quest_id: r.quest_id,
|
||||
user_id: r.user_id,
|
||||
progress: r.progress,
|
||||
last_interaction: r.last_interaction,
|
||||
achieved: r.achieved,
|
||||
time_achieved: r.time_achieved,
|
||||
was_instantly_achieved: r.was_instantly_achieved,
|
||||
quest_game: r.quest_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const users = await GetUsersWithIDs(questSubs.map((e) => e.userID));
|
||||
|
||||
const goals = await GetGoalsInQuest(quest);
|
||||
|
||||
const parentQuestlines = await MONGODB_KILL.questlines.find({
|
||||
quests: quest.questID,
|
||||
const qlRows = await DB.selectFrom("questline")
|
||||
.innerJoin("questline_quest", "questline_quest.questline_id", "questline.id")
|
||||
.selectAll("questline")
|
||||
.where("questline_quest.quest_id", "=", quest.questID)
|
||||
.execute();
|
||||
|
||||
const parentQuestlines = qlRows.map((ql) => {
|
||||
const { game: gg, playtype: pt } = V3ToGamePT(ql.game);
|
||||
|
||||
return {
|
||||
questlineID: ql.id,
|
||||
name: ql.name,
|
||||
desc: ql.description,
|
||||
game: gg,
|
||||
playtype: pt,
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
|
||||
@@ -162,7 +162,10 @@ router.post("/orphans", RequirePermissions("submit_score"), async (req, res) =>
|
||||
|
||||
log.info(`User ${FormatUserDoc(userDoc)} forced an orphan sync.`);
|
||||
|
||||
const { processed, removed, failed, success } = await DeorphanScores({ userID: userDoc.id }, log);
|
||||
const { processed, removed, failed, success } = await DeorphanScores(
|
||||
{ userID: userDoc.id },
|
||||
log,
|
||||
);
|
||||
|
||||
log.info(`Finished attempting deorphaning.`);
|
||||
|
||||
|
||||
@@ -167,39 +167,35 @@ router.get("/:importID", GetImportFromParam, async (req, res) => {
|
||||
*
|
||||
* @name POST /api/v1/imports/:importID/revert
|
||||
*/
|
||||
router.post(
|
||||
"/:importID/revert",
|
||||
RequirePermissions("delete_score"),
|
||||
async (req, res) => {
|
||||
const auth = req[SYMBOL_TACHI_API_AUTH];
|
||||
router.post("/:importID/revert", RequirePermissions("delete_score"), async (req, res) => {
|
||||
const auth = req[SYMBOL_TACHI_API_AUTH];
|
||||
|
||||
if (auth.userID === null) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await GetUserWithID(auth.userID);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`,
|
||||
});
|
||||
}
|
||||
|
||||
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
|
||||
|
||||
await ACTION_DeleteImport(taker, { id: req.params.importID });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Reverted import.`,
|
||||
body: {},
|
||||
if (auth.userID === null) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const user = await GetUserWithID(auth.userID);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`,
|
||||
});
|
||||
}
|
||||
|
||||
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
|
||||
|
||||
await ACTION_DeleteImport(taker, { id: req.params.importID });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Reverted import.`,
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
// Finding jobs is slightly harder than just doing a key lookup, because of retrying.
|
||||
async function FindImportJob(importID: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ACTION_CustomiseScore } from "#actions/customise-score";
|
||||
import { ACTION_DeleteScore } from "#actions/delete-score.js";
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
|
||||
import { GetChartsBySongId } from "#lib/db-formats/chart";
|
||||
import { GetSongByLegacyID } from "#lib/db-formats/song";
|
||||
import { log } from "#lib/log/log";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
@@ -86,11 +86,7 @@ router.get("/", async (req, res) => {
|
||||
const charts =
|
||||
songRes === undefined
|
||||
? []
|
||||
: await GetChartsBySongPgId(
|
||||
toPgGame(score.game, score.playtype),
|
||||
songRes.pgId,
|
||||
score.songID,
|
||||
);
|
||||
: await GetChartsBySongId(toPgGame(score.game, score.playtype), songRes.newSongID);
|
||||
|
||||
const chart = charts.find((c) => c.chartID === score.chartID);
|
||||
const song = songRes?.doc;
|
||||
|
||||
@@ -36,13 +36,13 @@ describe("GET /api/v1/search/chart-hash", () => {
|
||||
const n = ++seedCounter;
|
||||
const md5 = `a${n.toString(16).padStart(31, "0")}`.slice(0, 32);
|
||||
const sha256 = "b".repeat(64);
|
||||
const songPgId = `s_ch_hash_bms_${n}`;
|
||||
const songNewID = `s_ch_hash_bms_${n}`;
|
||||
const chartPgId = `c_ch_hash_bms_${n}`;
|
||||
const legacySongId = 970_000 + n;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: legacySongId,
|
||||
game_group: "bms",
|
||||
title: "Chart Hash Search BMS",
|
||||
@@ -64,7 +64,7 @@ describe("GET /api/v1/search/chart-hash", () => {
|
||||
id: chartPgId,
|
||||
legacy_id: chartPgId,
|
||||
game: "bms-7k",
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
level: "?",
|
||||
level_num: 0,
|
||||
is_primary: true,
|
||||
@@ -95,13 +95,13 @@ describe("GET /api/v1/search/chart-hash", () => {
|
||||
const n = ++seedCounter;
|
||||
const md5 = "c".repeat(32);
|
||||
const sha256 = `d${n.toString(16).padStart(63, "0")}`.slice(0, 64);
|
||||
const songPgId = `s_ch_hash_pms_${n}`;
|
||||
const songNewID = `s_ch_hash_pms_${n}`;
|
||||
const chartPgId = `c_ch_hash_pms_${n}`;
|
||||
const legacySongId = 971_000 + n;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: legacySongId,
|
||||
game_group: "pms",
|
||||
title: "Chart Hash Search PMS",
|
||||
@@ -123,7 +123,7 @@ describe("GET /api/v1/search/chart-hash", () => {
|
||||
id: chartPgId,
|
||||
legacy_id: chartPgId,
|
||||
game: "pms-keyboard",
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
level: "?",
|
||||
level_num: 0,
|
||||
is_primary: true,
|
||||
@@ -151,13 +151,13 @@ describe("GET /api/v1/search/chart-hash", () => {
|
||||
it("returns ITG charts matched by hashGSv3", async () => {
|
||||
const n = ++seedCounter;
|
||||
const gsv3 = `itg-gsv3-${n}`;
|
||||
const songPgId = `s_ch_hash_itg_${n}`;
|
||||
const songNewID = `s_ch_hash_itg_${n}`;
|
||||
const chartPgId = `c_ch_hash_itg_${n}`;
|
||||
const legacySongId = 972_000 + n;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: legacySongId,
|
||||
game_group: "itg",
|
||||
title: "Chart Hash Search ITG",
|
||||
@@ -174,7 +174,7 @@ describe("GET /api/v1/search/chart-hash", () => {
|
||||
id: chartPgId,
|
||||
legacy_id: chartPgId,
|
||||
game: "itg-stamina",
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
level: "10",
|
||||
level_num: 10,
|
||||
is_primary: true,
|
||||
@@ -211,13 +211,13 @@ describe("GET /api/v1/search/chart-hash", () => {
|
||||
it("returns USC charts matched by hashSHA1", async () => {
|
||||
const n = ++seedCounter;
|
||||
const sha1 = `e${n.toString(16).padStart(39, "0")}`.slice(0, 40);
|
||||
const songPgId = `s_ch_hash_usc_${n}`;
|
||||
const songNewID = `s_ch_hash_usc_${n}`;
|
||||
const chartPgId = `c_ch_hash_usc_${n}`;
|
||||
const legacySongId = 973_000 + n;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: legacySongId,
|
||||
game_group: "usc",
|
||||
title: "Chart Hash Search USC",
|
||||
@@ -234,7 +234,7 @@ describe("GET /api/v1/search/chart-hash", () => {
|
||||
id: chartPgId,
|
||||
legacy_id: chartPgId,
|
||||
game: "usc-controller",
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
level: "10",
|
||||
level_num: 10,
|
||||
is_primary: true,
|
||||
|
||||
+5
-5
@@ -11,17 +11,17 @@ afterAll(() => CloseServerConnection());
|
||||
let seedCounter = 0;
|
||||
|
||||
async function seedBmsChartWithHashes(opts: {
|
||||
sha256: string;
|
||||
game: "bms-7k" | "bms-14k";
|
||||
md5: string;
|
||||
sha256: string;
|
||||
}) {
|
||||
const n = ++seedCounter;
|
||||
const songPgId = `s_bms_router_${n}`;
|
||||
const songNewID = `s_bms_router_${n}`;
|
||||
const chartPgId = `c_bms_router_${n}`;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: 960_000 + n,
|
||||
game_group: "bms",
|
||||
title: "BMS Router Test",
|
||||
@@ -43,7 +43,7 @@ async function seedBmsChartWithHashes(opts: {
|
||||
id: chartPgId,
|
||||
legacy_id: chartPgId,
|
||||
game: opts.game,
|
||||
song_id: songPgId,
|
||||
song_id: songNewID,
|
||||
level: "?",
|
||||
level_num: 0,
|
||||
is_primary: true,
|
||||
@@ -61,7 +61,7 @@ async function seedBmsChartWithHashes(opts: {
|
||||
})
|
||||
.execute();
|
||||
|
||||
return { chartPgId, songPgId };
|
||||
return { chartPgId, songNewID };
|
||||
}
|
||||
|
||||
async function insertBmsPb(userId: number, chartPgId: string) {
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup.js";
|
||||
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { TestingIIDXSPScore } from "#test-utils/test-data";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import deepmerge from "deepmerge";
|
||||
import { type MONGO_ScoreData } from "tachi-common";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
let seedCounter = 0;
|
||||
|
||||
async function seedIidxSpProfile(userId: number) {
|
||||
await DB.insertInto("game_profile")
|
||||
.values({
|
||||
user_id: userId,
|
||||
game: "iidx-sp",
|
||||
ratings: JSON.stringify({}),
|
||||
classes: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function seedFolderWithThreeCharts(opts: { folderId: string; folderLegacy: string }) {
|
||||
const n = ++seedCounter;
|
||||
const songId = `S_FL_TL_${n}`;
|
||||
const chartA = `C_FL_TL_A_${n}`;
|
||||
const chartB = `C_FL_TL_B_${n}`;
|
||||
const chartC = `C_FL_TL_C_${n}`;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songId,
|
||||
legacy_id: 91_000 + n,
|
||||
game_group: "iidx",
|
||||
title: "Folder TL",
|
||||
artist: "T",
|
||||
search_terms: [],
|
||||
alt_titles: [],
|
||||
data: JSON.stringify({}),
|
||||
fts_document: "",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values([
|
||||
{
|
||||
id: chartA,
|
||||
legacy_id: chartA,
|
||||
game: "iidx-sp",
|
||||
song_id: songId,
|
||||
level: "10",
|
||||
level_num: 10,
|
||||
is_primary: true,
|
||||
difficulty: "ANOTHER",
|
||||
versions: [],
|
||||
data: JSON.stringify({}),
|
||||
},
|
||||
{
|
||||
id: chartB,
|
||||
legacy_id: chartB,
|
||||
game: "iidx-sp",
|
||||
song_id: songId,
|
||||
level: "10",
|
||||
level_num: 10,
|
||||
is_primary: true,
|
||||
difficulty: "HYPER",
|
||||
versions: [],
|
||||
data: JSON.stringify({}),
|
||||
},
|
||||
{
|
||||
id: chartC,
|
||||
legacy_id: chartC,
|
||||
game: "iidx-sp",
|
||||
song_id: songId,
|
||||
level: "10",
|
||||
level_num: 10,
|
||||
is_primary: true,
|
||||
difficulty: "NORMAL",
|
||||
versions: [],
|
||||
data: JSON.stringify({}),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const where = `chart.id IN ('${chartA}', '${chartB}', '${chartC}')`;
|
||||
|
||||
await DB.insertInto("folder")
|
||||
.values({
|
||||
id: opts.folderId,
|
||||
legacy_id: opts.folderLegacy,
|
||||
game: "iidx-sp",
|
||||
inactive: false,
|
||||
title: "TL folder",
|
||||
slug: null,
|
||||
where,
|
||||
version_filter: null,
|
||||
search_terms: [],
|
||||
})
|
||||
.execute();
|
||||
|
||||
await rebuildFolderChartLookup(DB, { folderId: opts.folderId });
|
||||
|
||||
return { chartA, chartB, chartC };
|
||||
}
|
||||
|
||||
async function insertIidxSpScore(opts: {
|
||||
chartId: string;
|
||||
id: string;
|
||||
scoreData: MONGO_ScoreData<"iidx:SP">;
|
||||
timeAchievedMs: number | null;
|
||||
userId: number;
|
||||
}) {
|
||||
const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", opts.scoreData);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await DB.insertInto("score")
|
||||
.values({
|
||||
id: opts.id,
|
||||
user_id: opts.userId,
|
||||
chart_id: opts.chartId,
|
||||
game: "iidx-sp",
|
||||
session_id: null,
|
||||
import_id: null,
|
||||
data: JSON.stringify(data),
|
||||
derived_data: JSON.stringify(derived),
|
||||
judgements: JSON.stringify(judgements),
|
||||
calculated_data: JSON.stringify(TestingIIDXSPScore.calculatedData),
|
||||
meta: JSON.stringify({}),
|
||||
time_achieved:
|
||||
opts.timeAchievedMs !== null
|
||||
? UnixMillisecondsToISO8601(opts.timeAchievedMs)
|
||||
: null,
|
||||
time_added: now,
|
||||
highlight: false,
|
||||
comment: null,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeline", () => {
|
||||
it("returns one earliest qualifying score per chart, then sorted by time (null as 0)", async () => {
|
||||
const { id: userId } = await seedUser({ username: `folder_tl_${Date.now()}` });
|
||||
await seedIidxSpProfile(userId);
|
||||
|
||||
const folderId = `folder_tl_${Date.now()}`;
|
||||
const { chartA, chartB, chartC } = await seedFolderWithThreeCharts({
|
||||
folderId,
|
||||
folderLegacy: `${folderId}_leg`,
|
||||
});
|
||||
|
||||
const baseSd = TestingIIDXSPScore.scoreData;
|
||||
|
||||
// Chart A: CLEAR lamp, null time — sorts first in final array (null → 0).
|
||||
await insertIidxSpScore({
|
||||
id: "tl_s_a",
|
||||
userId,
|
||||
chartId: chartA,
|
||||
scoreData: baseSd,
|
||||
timeAchievedMs: null,
|
||||
});
|
||||
|
||||
// Chart B: multiple rows meeting lamp >= CLEAR; earliest play time wins (100ms).
|
||||
await insertIidxSpScore({
|
||||
id: "tl_s_b_500",
|
||||
userId,
|
||||
chartId: chartB,
|
||||
scoreData: baseSd,
|
||||
timeAchievedMs: 500,
|
||||
});
|
||||
await insertIidxSpScore({
|
||||
id: "tl_s_b_100",
|
||||
userId,
|
||||
chartId: chartB,
|
||||
scoreData: baseSd,
|
||||
timeAchievedMs: 100,
|
||||
});
|
||||
// EASY CLEAR (index 3) — does not satisfy CLEAR floor (index 4).
|
||||
await insertIidxSpScore({
|
||||
id: "tl_s_b_easy",
|
||||
userId,
|
||||
chartId: chartB,
|
||||
scoreData: deepmerge(baseSd, {
|
||||
lamp: "EASY CLEAR",
|
||||
enumIndexes: { lamp: 3, grade: baseSd.enumIndexes?.grade ?? 3 },
|
||||
}) as MONGO_ScoreData<"iidx:SP">,
|
||||
timeAchievedMs: 50,
|
||||
});
|
||||
await insertIidxSpScore({
|
||||
id: "tl_s_b_null",
|
||||
userId,
|
||||
chartId: chartB,
|
||||
scoreData: baseSd,
|
||||
timeAchievedMs: null,
|
||||
});
|
||||
|
||||
// Chart C
|
||||
await insertIidxSpScore({
|
||||
id: "tl_s_c",
|
||||
userId,
|
||||
chartId: chartC,
|
||||
scoreData: baseSd,
|
||||
timeAchievedMs: 200,
|
||||
});
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx/SP/folders/${folderId}/timeline?criteriaType=lamp&criteriaValue=CLEAR`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
const ids = res.body.body.scores.map((s: { scoreID: string }) => s.scoreID);
|
||||
expect(ids).toEqual(["tl_s_a", "tl_s_b_100", "tl_s_c"]);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid criteriaType", async () => {
|
||||
const { id: userId } = await seedUser({ username: `folder_tl_bad_${Date.now()}` });
|
||||
await seedIidxSpProfile(userId);
|
||||
const folderId = `folder_tl_bad_${Date.now()}`;
|
||||
await seedFolderWithThreeCharts({ folderId, folderLegacy: `${folderId}_leg` });
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx/SP/folders/${folderId}/timeline?criteriaType=not_a_metric&criteriaValue=CLEAR`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid criteriaValue for an ENUM metric", async () => {
|
||||
const { id: userId } = await seedUser({ username: `folder_tl_badval_${Date.now()}` });
|
||||
await seedIidxSpProfile(userId);
|
||||
const folderId = `folder_tl_badval_${Date.now()}`;
|
||||
await seedFolderWithThreeCharts({ folderId, folderLegacy: `${folderId}_leg` });
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/users/${userId}/games/iidx/SP/folders/${folderId}/timeline?criteriaType=lamp&criteriaValue=nope`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
+8
-46
@@ -1,19 +1,12 @@
|
||||
import type { FilterQuery } from "mongodb";
|
||||
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetEnumDistForFolder, GetFolderChartsAndSongs, GetPBsOnFolder } from "#utils/folder";
|
||||
import { GetFolderTimelineScores } from "#utils/queries/scores";
|
||||
import { GetTachiData, GetUGPT } from "#utils/req-tachi-data";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import { Router } from "express";
|
||||
import { sql } from "kysely";
|
||||
import {
|
||||
GetGamePTConfig,
|
||||
GetScoreMetricConf,
|
||||
type MONGO_ScoreDocument,
|
||||
ValidateMetric,
|
||||
} from "tachi-common";
|
||||
import { GetGamePTConfig, GetScoreMetricConf, ValidateMetric } from "tachi-common";
|
||||
|
||||
import { GetFolderFromParam } from "../../../../../../../games/_game/_playtype/folders/middleware";
|
||||
import { RequireSelfRequestFromUser } from "../../../../../middleware";
|
||||
@@ -153,45 +146,14 @@ router.get(
|
||||
});
|
||||
}
|
||||
|
||||
const matchCriteria: FilterQuery<MONGO_ScoreDocument> = {
|
||||
userID: user.id,
|
||||
const scores = await GetFolderTimelineScores(
|
||||
user.id,
|
||||
game,
|
||||
playtype,
|
||||
chartID: { $in: charts.map((e) => e.chartID) },
|
||||
};
|
||||
|
||||
matchCriteria[`scoreData.enumIndexes.${metric}`] = { $gte: criteriaValue };
|
||||
|
||||
// Returns a unique score per-chart that was the first score to achieve
|
||||
// this criteria on that chart.
|
||||
const scoresAgg: Array<{ doc: MONGO_ScoreDocument }> = await MONGODB_KILL.scores.aggregate([
|
||||
{
|
||||
$match: matchCriteria,
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
__sortTime: { $ifNull: ["$timeAchieved", Infinity, "$timeAchieved"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
__sortTime: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$chartID",
|
||||
doc: { $first: "$$ROOT" },
|
||||
},
|
||||
},
|
||||
{
|
||||
$unset: ["doc.__sortTime"],
|
||||
},
|
||||
]);
|
||||
|
||||
const scores = scoresAgg
|
||||
.map((e) => e.doc)
|
||||
.sort((a, b) => (a.timeAchieved ?? 0) - (b.timeAchieved ?? 0));
|
||||
charts.map((e) => e.chartID),
|
||||
metric,
|
||||
criteriaValue,
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
|
||||
+22
-35
@@ -1,17 +1,18 @@
|
||||
import { ACTION_SetRivals } from "#actions/set-rivals";
|
||||
import { CreateActivityRouteHandler } from "#lib/activity/activity";
|
||||
import { SetRivalsFailReasons } from "#lib/constants/err-codes";
|
||||
import { GetChallengerUsers, GetRivalIDs, GetRivalUsers, SetRivals } from "#lib/rivals/rivals";
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { LoadPbDocumentsForUserSetSortedByCalculatedAlg } from "#lib/db-formats/pb";
|
||||
import { GetChallengerUsers, GetRivalIDs, GetRivalUsers } from "#lib/rivals/rivals";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { GetUGPT } from "#utils/req-tachi-data";
|
||||
import { CheckStrScoreAlg } from "#utils/string-checks";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
import { FormatGameGroup, GetGamePTConfig, type integer } from "tachi-common";
|
||||
import { GamePTToV3, GetGamePTConfig, type integer } from "tachi-common";
|
||||
|
||||
import { RequireAuthedAsUser } from "../../../../middleware";
|
||||
|
||||
@@ -56,28 +57,25 @@ router.put(
|
||||
const rivalIDs = body.rivalIDs;
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
|
||||
const result = await SetRivals(user.id, game, playtype, rivalIDs);
|
||||
const authUserID = req[SYMBOL_TACHI_API_AUTH].userID;
|
||||
|
||||
if (result === SetRivalsFailReasons.RIVALED_SELF) {
|
||||
return res.status(400).json({
|
||||
if (authUserID === null) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You cannot rival yourself.`,
|
||||
});
|
||||
} else if (result === SetRivalsFailReasons.RIVALS_HAVENT_PLAYED_GPT) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Not all of the rivals you specified have played ${FormatGameGroup(
|
||||
game,
|
||||
playtype,
|
||||
)}.`,
|
||||
});
|
||||
} else if (result === SetRivalsFailReasons.TOO_MANY) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `You can't set more than 5 rivals.`,
|
||||
description: `Authentication is required for this endpoint.`,
|
||||
});
|
||||
}
|
||||
|
||||
const authedUser = await GetUserWithIDGuaranteed(authUserID);
|
||||
const taker = { ip: req.ip, acct: { id: authedUser.id, username: authedUser.username } };
|
||||
|
||||
await ACTION_SetRivals(taker, {
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
rivalIDs,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Set ${rivalIDs.length} rivals.`,
|
||||
@@ -138,19 +136,8 @@ router.get("/pb-leaderboard", async (req, res) => {
|
||||
const rivalIDs = await GetRivalIDs(user.id, game, playtype);
|
||||
const userSet = [...rivalIDs, user.id];
|
||||
|
||||
const pbs = await MONGODB_KILL["personal-bests"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
userID: { $in: userSet },
|
||||
},
|
||||
{
|
||||
sort: {
|
||||
[`calculatedData.${alg}`]: -1,
|
||||
},
|
||||
limit: 100,
|
||||
},
|
||||
);
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
const pbs = await LoadPbDocumentsForUserSetSortedByCalculatedAlg(userSet, v3Game, alg, 100);
|
||||
|
||||
const users = await GetUsersWithIDs(pbs.map((e) => e.userID));
|
||||
|
||||
|
||||
+102
-100
@@ -1,21 +1,22 @@
|
||||
import { SubscribeFailReasons } from "#lib/constants/err-codes";
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import type { Game } from "tachi-db";
|
||||
|
||||
import { ACTION_AddGoal } from "#actions/add-goal";
|
||||
import { ACTION_RemoveGoalSubscription } from "#actions/remove-goal-subscription";
|
||||
import {
|
||||
ConstructGoal,
|
||||
GetQuestsThatContainGoal,
|
||||
SubscribeToGoal,
|
||||
UnsubscribeFromGoal,
|
||||
} from "#lib/targets/goals";
|
||||
ToGoalDocument,
|
||||
ToGoalSubscriptionDocument,
|
||||
ToQuestSubscriptionDocument,
|
||||
} from "#lib/db-formats/target-documents";
|
||||
import { GetQuestsThatContainGoal } from "#lib/targets/goals";
|
||||
import { GetParentQuests } from "#lib/targets/quests";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetGoalForIDGuaranteed } from "#utils/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db";
|
||||
import { AssignToReqTachiData, GetTachiData, GetUGPT } from "#utils/req-tachi-data";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
import { p } from "prudence";
|
||||
import { GetGamePTConfig, type MONGO_GoalDocument, type MONGO_QuestDocument } from "tachi-common";
|
||||
import { GamePTToV3, type MONGO_GoalDocument, type MONGO_QuestDocument } from "tachi-common";
|
||||
|
||||
import { RequireAuthedAsUser } from "../../../../../middleware";
|
||||
|
||||
@@ -29,23 +30,57 @@ const router: Router = Router({ mergeParams: true });
|
||||
router.get("/", async (req, res) => {
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
|
||||
const goalSubs = await MONGODB_KILL["goal-subs"].find({
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const goals = await MONGODB_KILL.goals.find({
|
||||
goalID: { $in: goalSubs.map((e) => e.goalID) },
|
||||
});
|
||||
const subRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.user_id", "=", user.id)
|
||||
.where("goal.game", "=", v3Game)
|
||||
.execute();
|
||||
|
||||
const goalSubs = subRows.map((r) =>
|
||||
ToGoalSubscriptionDocument({
|
||||
...r,
|
||||
goal_game: r.goal_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const goalIds = goalSubs.map((e) => e.goalID);
|
||||
|
||||
const goalRows =
|
||||
goalIds.length === 0
|
||||
? []
|
||||
: await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIds).execute();
|
||||
|
||||
const goals = goalRows.map(ToGoalDocument);
|
||||
|
||||
const allQuests = await GetParentQuests(user.id, game, playtype, goalSubs);
|
||||
|
||||
const questSubs = await MONGODB_KILL["quest-subs"].find({ userID: user.id, game, playtype });
|
||||
const questSubRows = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.user_id", "=", user.id)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.execute();
|
||||
|
||||
const questSubs = questSubRows.map((r) =>
|
||||
ToQuestSubscriptionDocument({
|
||||
quest_id: r.quest_id,
|
||||
user_id: r.user_id,
|
||||
progress: r.progress,
|
||||
last_interaction: r.last_interaction,
|
||||
achieved: r.achieved,
|
||||
time_achieved: r.time_achieved,
|
||||
was_instantly_achieved: r.was_instantly_achieved,
|
||||
quest_game: r.quest_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const questSubIDs = questSubs.map((e) => e.questID);
|
||||
|
||||
// filter parent quests to only those that this user is subscribed to
|
||||
const quests = allQuests.filter((quest) => questSubIDs.includes(quest.questID));
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -136,67 +171,29 @@ router.post(
|
||||
async (req, res) => {
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
|
||||
const existingGoalsCount = await MONGODB_KILL["goal-subs"].count({
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const sessionUser = req.session.tachi?.user;
|
||||
|
||||
if (existingGoalsCount > ServerConfig.MAX_GOAL_SUBSCRIPTIONS) {
|
||||
return res.status(400).json({
|
||||
if (!sessionUser) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You already have ${ServerConfig.MAX_GOAL_SUBSCRIPTIONS} goals. You cannot have anymore.`,
|
||||
});
|
||||
}
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const validCriteria = [
|
||||
...Object.keys(gptConfig.providedMetrics),
|
||||
...Object.keys(gptConfig.derivedMetrics),
|
||||
];
|
||||
|
||||
if (!validCriteria.includes(req.body.criteria.key)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid criteria '${
|
||||
req.body.criteria.key
|
||||
}', expected any of ${validCriteria.join(", ")}.`,
|
||||
description: "You are not authenticated.",
|
||||
});
|
||||
}
|
||||
|
||||
const data = req.safeBody as GoalCreationBody;
|
||||
|
||||
let goal;
|
||||
const taker = { ip: req.ip, acct: { id: sessionUser.id, username: sessionUser.username } };
|
||||
|
||||
try {
|
||||
goal = await ConstructGoal(data.charts, data.criteria, game, playtype);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const { goalID } = await ACTION_AddGoal(taker, {
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
charts: data.charts,
|
||||
criteria: data.criteria,
|
||||
});
|
||||
|
||||
log.info({ err }, err.message);
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
const goalSub = await SubscribeToGoal(user.id, goal, true);
|
||||
|
||||
if (goalSub === SubscribeFailReasons.ALREADY_SUBSCRIBED) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
description: `You are already subscribed to this goal.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (goalSub === SubscribeFailReasons.ALREADY_ACHIEVED) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `You can't directly assign goals that you would immediately achieve.`,
|
||||
});
|
||||
}
|
||||
const goal = await GetGoalForIDGuaranteed(goalID);
|
||||
const goalSub = await GetGoalSubscriptionForIDGuaranteed(goalID, user.id);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -212,20 +209,29 @@ router.post(
|
||||
const GetGoalSubscription: RequestHandler = async (req, res, next) => {
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
|
||||
const goalSub = await MONGODB_KILL["goal-subs"].findOne({
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
goalID: req.params.goalID,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
if (!goalSub) {
|
||||
const row = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.user_id", "=", user.id)
|
||||
.where("goal_sub.goal_id", "=", req.params.goalID)
|
||||
.where("goal.game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `${user.username} is not subscribed to this goal.`,
|
||||
});
|
||||
}
|
||||
|
||||
const goalSub = ToGoalSubscriptionDocument({
|
||||
...row,
|
||||
goal_game: row.goal_game as Game,
|
||||
});
|
||||
|
||||
AssignToReqTachiData(req, { goalSubDoc: goalSub });
|
||||
|
||||
next();
|
||||
@@ -265,32 +271,28 @@ router.get("/:goalID", GetGoalSubscription, async (req, res) => {
|
||||
router.delete(
|
||||
"/:goalID",
|
||||
RequireAuthedAsUser,
|
||||
GetGoalSubscription,
|
||||
RequirePermissions("manage_targets"),
|
||||
async (req, res) => {
|
||||
const goalSub = GetTachiData(req, "goalSubDoc");
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
|
||||
const fail = await UnsubscribeFromGoal(goalSub, false);
|
||||
const sessionUser = req.session.tachi?.user;
|
||||
|
||||
if (fail) {
|
||||
switch (fail.reason) {
|
||||
case "WAS_STANDALONE":
|
||||
// can't happen. mightaswell handle it though.
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `This goal was assigned by you and can't be removed as a consequence of another action.`,
|
||||
});
|
||||
|
||||
case "HAS_QUEST_DEPENDENCIES":
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `This goal is part of a quest you are subscribed to. It can only be removed by unsubscribing from the relevant quests: ${fail.parentQuests
|
||||
.map((e) => `'${e.quest.name}'`)
|
||||
.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
if (!sessionUser) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: "You are not authenticated.",
|
||||
});
|
||||
}
|
||||
|
||||
const taker = { ip: req.ip, acct: { id: sessionUser.id, username: sessionUser.username } };
|
||||
|
||||
await ACTION_RemoveGoalSubscription(taker, {
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
goalID: req.params.goalID,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Removed this goal from your subscriptions.`,
|
||||
|
||||
+119
-54
@@ -1,4 +1,5 @@
|
||||
import { SubscribeFailReasons } from "#lib/constants/err-codes";
|
||||
import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents";
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import {
|
||||
@@ -8,10 +9,13 @@ import {
|
||||
UnsubscribeFromQuest,
|
||||
} from "#lib/targets/quests";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { AssignToReqTachiData, GetGPT, GetTachiData, GetUGPT } from "#utils/req-tachi-data";
|
||||
import { FormatUserDoc } from "#utils/user";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
import { GamePTToV3 } from "tachi-common";
|
||||
import type { Game } from "tachi-db";
|
||||
import { sql } from "kysely";
|
||||
|
||||
import { RequireAuthedAsUser } from "../../../../../middleware";
|
||||
|
||||
@@ -25,23 +29,55 @@ const router: Router = Router({ mergeParams: true });
|
||||
router.get("/", async (req, res) => {
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
|
||||
const questSubs = await MONGODB_KILL["quest-subs"].find({
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const quests = await MONGODB_KILL.quests.find({
|
||||
questID: { $in: questSubs.map((e) => e.questID) },
|
||||
});
|
||||
const questSubRows = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.user_id", "=", user.id)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.execute();
|
||||
|
||||
if (quests.length !== questSubs.length) {
|
||||
const questSubs = questSubRows.map((r) =>
|
||||
ToQuestSubscriptionDocument({
|
||||
quest_id: r.quest_id,
|
||||
user_id: r.user_id,
|
||||
progress: r.progress,
|
||||
last_interaction: r.last_interaction,
|
||||
achieved: r.achieved,
|
||||
time_achieved: r.time_achieved,
|
||||
was_instantly_achieved: r.was_instantly_achieved,
|
||||
quest_game: r.quest_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const questIds = questSubs.map((e) => e.questID);
|
||||
|
||||
const questRows =
|
||||
questIds.length === 0
|
||||
? []
|
||||
: await DB.selectFrom("quest").selectAll().where("quest.id", "in", questIds).execute();
|
||||
|
||||
if (questRows.length !== questSubs.length) {
|
||||
log.error(
|
||||
`Found ${questSubs.length} subscriptions, but got ${quests.length} parents. This is a state desync.`,
|
||||
`Found ${questSubs.length} subscriptions, but got ${questRows.length} parents. This is a state desync.`,
|
||||
);
|
||||
throw new Error("Failed to fetch quests");
|
||||
}
|
||||
|
||||
const questById = new Map(questRows.map((q) => [q.id, ToQuestDocument(q)]));
|
||||
|
||||
const quests = questIds.map((id) => {
|
||||
const q = questById.get(id);
|
||||
|
||||
if (!q) {
|
||||
throw new Error("Failed to fetch quests");
|
||||
}
|
||||
|
||||
return q;
|
||||
});
|
||||
|
||||
const goals = await GetGoalsInQuests(quests);
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -58,20 +94,35 @@ router.get("/", async (req, res) => {
|
||||
const GetQuestSubscription: RequestHandler = async (req, res, next) => {
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
|
||||
const questSub = await MONGODB_KILL["quest-subs"].findOne({
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
questID: req.params.questID,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
if (!questSub) {
|
||||
const row = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.user_id", "=", user.id)
|
||||
.where("quest_sub.quest_id", "=", req.params.questID)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `${user.username} is not subscribed to this quest.`,
|
||||
});
|
||||
}
|
||||
|
||||
const questSub = ToQuestSubscriptionDocument({
|
||||
quest_id: row.quest_id,
|
||||
user_id: row.user_id,
|
||||
progress: row.progress,
|
||||
last_interaction: row.last_interaction,
|
||||
achieved: row.achieved,
|
||||
time_achieved: row.time_achieved,
|
||||
was_instantly_achieved: row.was_instantly_achieved,
|
||||
quest_game: row.quest_game as Game,
|
||||
});
|
||||
|
||||
AssignToReqTachiData(req, { questSubDoc: questSub });
|
||||
|
||||
next();
|
||||
@@ -80,20 +131,22 @@ const GetQuestSubscription: RequestHandler = async (req, res, next) => {
|
||||
const GetQuest: RequestHandler = async (req, res, next) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const quest = await MONGODB_KILL.quests.findOne({
|
||||
game,
|
||||
playtype,
|
||||
questID: req.params.questID,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
if (!quest) {
|
||||
const row = await DB.selectFrom("quest")
|
||||
.selectAll()
|
||||
.where("quest.id", "=", req.params.questID)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `Can't find a quest with id '${req.params.questID}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { questDoc: quest });
|
||||
AssignToReqTachiData(req, { questDoc: ToQuestDocument(row) });
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -110,9 +163,6 @@ router.get("/:questID", GetQuest, GetQuestSubscription, async (req, res) => {
|
||||
const questSub = GetTachiData(req, "questSubDoc");
|
||||
const quest = GetTachiData(req, "questDoc");
|
||||
|
||||
// Evaluate each goal for the user. This operation is much faster if the user is
|
||||
// subscribed to the quest (they are), as we can just read their goalSub
|
||||
// for each goal.
|
||||
const { goalResults: results, goals } = await EvaluateQuestProgress(user.id, quest);
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -142,11 +192,16 @@ router.put(
|
||||
async (req, res) => {
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
|
||||
const existingQuestsCount = await MONGODB_KILL["quest-subs"].count({
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const countRow = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.select(sql<number>`count(*)::int`.as("c"))
|
||||
.where("quest_sub.user_id", "=", user.id)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
const existingQuestsCount = Number(countRow?.c ?? 0);
|
||||
|
||||
if (existingQuestsCount > ServerConfig.MAX_QUEST_SUBSCRIPTIONS) {
|
||||
return res.status(400).json({
|
||||
@@ -157,12 +212,15 @@ router.put(
|
||||
|
||||
const quest = GetTachiData(req, "questDoc");
|
||||
|
||||
const alreadySubscibed = await MONGODB_KILL["quest-subs"].findOne({
|
||||
userID: user.id,
|
||||
questID: quest.questID,
|
||||
});
|
||||
const alreadySubscribed = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.select("quest_sub.quest_id")
|
||||
.where("quest_sub.user_id", "=", user.id)
|
||||
.where("quest_sub.quest_id", "=", quest.questID)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (alreadySubscibed) {
|
||||
if (alreadySubscribed) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
description: `You are already subscribed to this goal.`,
|
||||
@@ -171,16 +229,6 @@ router.put(
|
||||
|
||||
const subResult = await SubscribeToQuest(user.id, quest, false);
|
||||
|
||||
// Users should be able to subscribe to quests EVEN IF they would instantly
|
||||
// achieve them.
|
||||
|
||||
// if (subResult === SubscribeFailReasons.ALREADY_ACHIEVED) {
|
||||
// return res.status(400).json({
|
||||
// success: false,
|
||||
// description: `You cannot assign a quest that would be immediately achieved.`,
|
||||
// });
|
||||
// }
|
||||
|
||||
if (subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
@@ -207,7 +255,7 @@ router.delete(
|
||||
GetQuest,
|
||||
RequirePermissions("manage_targets"),
|
||||
async (req, res) => {
|
||||
const { user } = GetUGPT(req);
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
const quest = GetTachiData(req, "questDoc");
|
||||
|
||||
log.info(
|
||||
@@ -218,18 +266,35 @@ router.delete(
|
||||
`User ${FormatUserDoc(user)} is unsubscribing from quest '${quest.name}'.`,
|
||||
);
|
||||
|
||||
const questSub = await MONGODB_KILL["quest-subs"].findOne({
|
||||
userID: user.id,
|
||||
questID: quest.questID,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
if (!questSub) {
|
||||
const row = await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.user_id", "=", user.id)
|
||||
.where("quest_sub.quest_id", "=", quest.questID)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
description: `Can't unsubscribe from a quest you were never subscribed to.`,
|
||||
});
|
||||
}
|
||||
|
||||
const questSub = ToQuestSubscriptionDocument({
|
||||
quest_id: row.quest_id,
|
||||
user_id: row.user_id,
|
||||
progress: row.progress,
|
||||
last_interaction: row.last_interaction,
|
||||
achieved: row.achieved,
|
||||
time_achieved: row.time_achieved,
|
||||
was_instantly_achieved: row.was_instantly_achieved,
|
||||
quest_game: row.quest_game as Game,
|
||||
});
|
||||
|
||||
await UnsubscribeFromQuest(questSub, quest);
|
||||
|
||||
return res.status(200).json({
|
||||
|
||||
+139
-38
@@ -1,8 +1,14 @@
|
||||
import { GetChartById } from "#lib/db-formats/chart";
|
||||
import { LoadFolderDocumentById } from "#lib/db-formats/folders";
|
||||
import {
|
||||
ToGoalDocument,
|
||||
ToGoalSubscriptionDocument,
|
||||
ToQuestSubscriptionDocument,
|
||||
} from "#lib/db-formats/target-documents";
|
||||
import { log } from "#lib/log/log";
|
||||
import { GetRelevantGoals } from "#lib/targets/goals";
|
||||
import { GetParentQuests } from "#lib/targets/quests";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import {
|
||||
GetRecentlyAchievedGoals,
|
||||
GetRecentlyAchievedQuests,
|
||||
@@ -13,6 +19,7 @@ import { GetFolderChartIDs } from "#utils/folder";
|
||||
import { GetUGPT } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
import { GamePTToV3, MongoChartLegacyId } from "tachi-common";
|
||||
import type { Game } from "tachi-db";
|
||||
|
||||
import goalsRouter from "./goals/router";
|
||||
import questsRouter from "./quests/router";
|
||||
@@ -107,9 +114,33 @@ router.get("/on-chart/:chartID", async (req, res) => {
|
||||
|
||||
const quests = await GetParentQuests(user.id, game, playtype, goalSubs);
|
||||
|
||||
const questSubs = await MONGODB_KILL["quest-subs"].find({
|
||||
questID: { $in: quests.map((e) => e.questID) },
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
const questIds = quests.map((e) => e.questID);
|
||||
|
||||
const questSubRows =
|
||||
questIds.length === 0
|
||||
? []
|
||||
: await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.user_id", "=", user.id)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.where("quest_sub.quest_id", "in", questIds)
|
||||
.execute();
|
||||
|
||||
const questSubs = questSubRows.map((r) =>
|
||||
ToQuestSubscriptionDocument({
|
||||
quest_id: r.quest_id,
|
||||
user_id: r.user_id,
|
||||
progress: r.progress,
|
||||
last_interaction: r.last_interaction,
|
||||
achieved: r.achieved,
|
||||
time_achieved: r.time_achieved,
|
||||
was_instantly_achieved: r.was_instantly_achieved,
|
||||
quest_game: r.quest_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -133,9 +164,9 @@ router.get("/on-folder/:folderID", async (req, res) => {
|
||||
|
||||
const folderID = req.params.folderID;
|
||||
|
||||
const folder = await MONGODB_KILL.folders.findOne({ folderID, playtype });
|
||||
const folder = await LoadFolderDocumentById(folderID);
|
||||
|
||||
if (!folder) {
|
||||
if (!folder || folder.game !== game || folder.playtype !== playtype) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `Failed to find a folder with folderID '${folderID}'.`,
|
||||
@@ -144,42 +175,78 @@ router.get("/on-folder/:folderID", async (req, res) => {
|
||||
|
||||
const folderChartIDs = await GetFolderChartIDs(folderID);
|
||||
|
||||
const allGoalSubs = await MONGODB_KILL["goal-subs"].find({
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const allSubRows = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.user_id", "=", user.id)
|
||||
.where("goal.game", "=", v3Game)
|
||||
.execute();
|
||||
|
||||
const allGoalSubs = allSubRows.map((r) =>
|
||||
ToGoalSubscriptionDocument({
|
||||
...r,
|
||||
goal_game: r.goal_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const goalIDs = allGoalSubs.map((e) => e.goalID);
|
||||
|
||||
// goals are relevant to a folder if it's part of a folder type goal
|
||||
// or if it's on any of the charts in the folder.
|
||||
// this is convenient for the UI, atleast.
|
||||
const goals = await Promise.all([
|
||||
MONGODB_KILL.goals.find({
|
||||
"charts.type": { $in: ["single", "multi"] },
|
||||
"charts.data": { $in: folderChartIDs },
|
||||
goalID: { $in: goalIDs },
|
||||
}),
|
||||
MONGODB_KILL.goals.find({
|
||||
"charts.type": "folder",
|
||||
"charts.data": folderID,
|
||||
goalID: { $in: goalIDs },
|
||||
}),
|
||||
]).then((r) => r.flat());
|
||||
const goalDocRows =
|
||||
goalIDs.length === 0
|
||||
? []
|
||||
: await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute();
|
||||
|
||||
const goalSubs = await MONGODB_KILL["goal-subs"].find({
|
||||
goalID: { $in: goals.map((e) => e.goalID) },
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
const goals: Array<ReturnType<typeof ToGoalDocument>> = [];
|
||||
|
||||
for (const row of goalDocRows) {
|
||||
const g = ToGoalDocument(row);
|
||||
|
||||
if (g.charts.type === "single" && folderChartIDs.includes(g.charts.data)) {
|
||||
goals.push(g);
|
||||
} else if (
|
||||
g.charts.type === "multi" &&
|
||||
g.charts.data.some((c: string) => folderChartIDs.includes(c))
|
||||
) {
|
||||
goals.push(g);
|
||||
} else if (g.charts.type === "folder" && g.charts.data === folderID) {
|
||||
goals.push(g);
|
||||
}
|
||||
}
|
||||
|
||||
const active = new Set(goals.map((g) => g.goalID));
|
||||
const goalSubs = allGoalSubs.filter((s) => active.has(s.goalID));
|
||||
|
||||
const quests = await GetParentQuests(user.id, game, playtype, goalSubs);
|
||||
|
||||
const questSubs = await MONGODB_KILL["quest-subs"].find({
|
||||
questID: { $in: quests.map((e) => e.questID) },
|
||||
});
|
||||
const questIds = quests.map((e) => e.questID);
|
||||
|
||||
const questSubRows =
|
||||
questIds.length === 0
|
||||
? []
|
||||
: await DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.user_id", "=", user.id)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.where("quest_sub.quest_id", "in", questIds)
|
||||
.execute();
|
||||
|
||||
const questSubs = questSubRows.map((r) =>
|
||||
ToQuestSubscriptionDocument({
|
||||
quest_id: r.quest_id,
|
||||
user_id: r.user_id,
|
||||
progress: r.progress,
|
||||
last_interaction: r.last_interaction,
|
||||
achieved: r.achieved,
|
||||
time_achieved: r.time_achieved,
|
||||
was_instantly_achieved: r.was_instantly_achieved,
|
||||
quest_game: r.quest_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -201,11 +268,45 @@ router.get("/on-folder/:folderID", async (req, res) => {
|
||||
router.get("/all-subs", async (req, res) => {
|
||||
const { user, game, playtype } = GetUGPT(req);
|
||||
|
||||
const [goalSubs, questSubs] = await Promise.all([
|
||||
MONGODB_KILL["goal-subs"].find({ userID: user.id, game, playtype }),
|
||||
MONGODB_KILL["quest-subs"].find({ userID: user.id, game, playtype }),
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const [goalSubRows, questSubRows] = await Promise.all([
|
||||
DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_sub.user_id", "=", user.id)
|
||||
.where("goal.game", "=", v3Game)
|
||||
.execute(),
|
||||
DB.selectFrom("quest_sub")
|
||||
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
|
||||
.selectAll("quest_sub")
|
||||
.select("quest.game as quest_game")
|
||||
.where("quest_sub.user_id", "=", user.id)
|
||||
.where("quest.game", "=", v3Game)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
const goalSubs = goalSubRows.map((r) =>
|
||||
ToGoalSubscriptionDocument({
|
||||
...r,
|
||||
goal_game: r.goal_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
const questSubs = questSubRows.map((r) =>
|
||||
ToQuestSubscriptionDocument({
|
||||
quest_id: r.quest_id,
|
||||
user_id: r.user_id,
|
||||
progress: r.progress,
|
||||
last_interaction: r.last_interaction,
|
||||
achieved: r.achieved,
|
||||
time_achieved: r.time_achieved,
|
||||
was_instantly_achieved: r.was_instantly_achieved,
|
||||
quest_game: r.quest_game as Game,
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned all target subscriptions.`,
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
/**
|
||||
* IR KsHook SV6C integration tests.
|
||||
*
|
||||
* Score import still resolves SDVX charts/songs via Mongo-backed queries until that path is
|
||||
* migrated to Postgres. Tests that require a successful chart lookup are marked with `it.fails`
|
||||
* until the migration is done.
|
||||
*
|
||||
* Vitest truncates Postgres per test. ResetDBState() is currently a no-op.
|
||||
*/
|
||||
|
||||
import { seedApiToken } from "#actions/test-utils/api-tokens";
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { TestingKsHookSV6CScore, TestingKsHookSV6CStaticScore } from "#test-utils/test-data";
|
||||
import deepmerge from "deepmerge";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
@@ -40,7 +29,6 @@ afterAll(() => CloseServerConnection());
|
||||
|
||||
describe("POST /ir/kshook/sv6c/score/save", () => {
|
||||
beforeEach(async () => {
|
||||
await ResetDBState();
|
||||
await seedPgUserAndApiToken();
|
||||
});
|
||||
|
||||
@@ -138,7 +126,6 @@ describe("POST /ir/kshook/sv6c/score/export", () => {
|
||||
.send({ scores: [data] });
|
||||
|
||||
beforeEach(async () => {
|
||||
await ResetDBState();
|
||||
await seedPgUserAndApiToken();
|
||||
await DB.insertInto("svc_kshook_sv6c_settings")
|
||||
.values({ user_id: IR_USER_ID, force_static_import: true })
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { ScoreDocument } from "tachi-common";
|
||||
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import t from "tap";
|
||||
|
||||
import MONGODB_KILL from "./db";
|
||||
|
||||
t.test("ID field autoprojection", async (t) => {
|
||||
await ResetDBState();
|
||||
|
||||
const res = await MONGODB_KILL.scores.findOne();
|
||||
|
||||
t.equal(res!._id, undefined);
|
||||
|
||||
const res2 = await MONGODB_KILL.scores.findOne({}, { projectID: true });
|
||||
|
||||
t.not(res2!._id, undefined);
|
||||
|
||||
const res3 = await MONGODB_KILL.scores.findOne({}, { projection: { scoreID: 1 } });
|
||||
|
||||
t.equal(res3!._id, undefined);
|
||||
|
||||
const res4 = await MONGODB_KILL.scores.findOne(
|
||||
{},
|
||||
{ projection: { scoreID: 1 }, projectID: true },
|
||||
);
|
||||
|
||||
t.not(res4!._id, undefined);
|
||||
|
||||
const res5 = await MONGODB_KILL.scores.findOne({}, { projection: { scoreID: 0 } });
|
||||
|
||||
t.equal(res5!._id, undefined);
|
||||
|
||||
const res6 = await MONGODB_KILL.scores.findOne(
|
||||
{},
|
||||
{ projection: { scoreID: 0 }, projectID: true },
|
||||
);
|
||||
|
||||
t.not(res6!._id, undefined);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
// Literally no fix for this? MongoDB just mutates objects you give it
|
||||
// for fun, i guess.
|
||||
t.test("Don't add stuff onto my objects please", async (t) => {
|
||||
const sc = { scoreID: "foo" } as unknown as ScoreDocument;
|
||||
|
||||
await MONGODB_KILL.scores.insert(sc);
|
||||
|
||||
t.strictSame(sc, { scoreID: "foo" }, "Shouldn't have _id attached onto it.");
|
||||
|
||||
await MONGODB_KILL.scores.remove({});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
import type { OrphanScoreDocument } from "#lib/score-import/import-types/common/types";
|
||||
import type { MigrationDocument, PrivateUserInfoDocument } from "#utils/types";
|
||||
|
||||
import monk, { type ICollection, type TMiddleware } from "monk";
|
||||
import {
|
||||
allSupportedGameGroups,
|
||||
type FolderChartLookup,
|
||||
type GameGroup,
|
||||
type GPTStrings,
|
||||
type integer,
|
||||
type MONGO_APITokenDocument,
|
||||
type MONGO_BMSCourseDocument,
|
||||
type MONGO_CGCardInfo,
|
||||
type MONGO_ChartDocument,
|
||||
type MONGO_ClassAchievementDocument,
|
||||
type MONGO_CounterDocument,
|
||||
type MONGO_FervidexSettingsDocument,
|
||||
type MONGO_FolderDocument,
|
||||
type MONGO_GoalDocument,
|
||||
type MONGO_GoalSubscriptionDocument,
|
||||
type MONGO_ImportDocument,
|
||||
type MONGO_ImportTimingsDocument,
|
||||
type MONGO_ImportTrackerDocument as MONGO_ImportTrackerDocument,
|
||||
type MONGO_InviteCodeDocument,
|
||||
type MONGO_KaiAuthDocument,
|
||||
type MONGO_KsHookSettingsDocument,
|
||||
type MONGO_MytCardInfo,
|
||||
type MONGO_NotificationDocument,
|
||||
type MONGO_OrphanChartDocument,
|
||||
type MONGO_PBScoreDocument,
|
||||
type MONGO_QuestDocument,
|
||||
type MONGO_QuestlineDocument,
|
||||
type MONGO_QuestSubscriptionDocument,
|
||||
type MONGO_RecentlyViewedFolderDocument,
|
||||
type MONGO_ScoreDocument,
|
||||
type MONGO_SessionDocument,
|
||||
type MONGO_SongDocument,
|
||||
type MONGO_TableDocument,
|
||||
type MONGO_TachiAPIClientDocument,
|
||||
type MONGO_UGPTSettingsDocument,
|
||||
type MONGO_UserDocument,
|
||||
type MONGO_UserGameStats,
|
||||
type MONGO_UserGameStatsSnapshotDocument,
|
||||
type MONGO_UserNameChangeDocument,
|
||||
type MONGO_UserSettingsDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
// ^ These rules are disabled for good reason. We have to deal with some very nonsensical types here
|
||||
// so we just disable these rules. I know, it sucks, but we'll live.
|
||||
import { ONE_MINUTE, ONE_SECOND } from "#lib/constants/time";
|
||||
import { log } from "#lib/log/log";
|
||||
import { Env, ServerConfig } from "#lib/setup/config";
|
||||
import { GetMillisecondsSince } from "#utils/misc";
|
||||
|
||||
let dbName = ServerConfig.MONGO_DATABASE_NAME;
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (Env.NODE_ENV === "test") {
|
||||
dbName = `testingdb`;
|
||||
}
|
||||
|
||||
log.info({ bootInfo: true }, `Connecting to database ${Env.MONGO_URL}/${dbName}...`);
|
||||
const dbtime = process.hrtime.bigint();
|
||||
|
||||
export const monkDB = monk(`${Env.MONGO_URL}/${dbName}`, {
|
||||
// Various things cause bizarre issues with mongodb connections. Windows+Docker especially so.
|
||||
// 5 minutes is excessive, but believe it or not, some setups are exceeding 2 minutes!
|
||||
serverSelectionTimeoutMS: ONE_MINUTE * 5,
|
||||
|
||||
// in local dev, don't **ever** add _id onto objects you're inserting
|
||||
// in production, this might have a performance hit.
|
||||
forceServerObjectId: Env.NODE_ENV === "test",
|
||||
});
|
||||
|
||||
/* istanbul ignore next */
|
||||
monkDB
|
||||
.then(() => {
|
||||
log.info(
|
||||
{
|
||||
bootInfo: true,
|
||||
},
|
||||
`Database connection successful: took ${GetMillisecondsSince(dbtime)}ms`,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.fatal(`Failed to connect to database: ${err}`);
|
||||
|
||||
// can't connect. kill self after 1 second.
|
||||
setTimeout(() => {
|
||||
process.exit(1);
|
||||
}, ONE_SECOND);
|
||||
});
|
||||
|
||||
/**
|
||||
* Removes _id from the returns of find/findOne. Note that this function is littered with eslint-disables
|
||||
* due to it working with some pretty wobbly types.
|
||||
*/
|
||||
const RemoveIDFromFindReturnsMiddleware: TMiddleware = () => (next) => (args: any, method) => {
|
||||
if ((method === "find" || method === "findOne") && !args.options.projectID) {
|
||||
if (args.options.projection) {
|
||||
args.options.projection._id = 0;
|
||||
} else {
|
||||
args.options.projection = { _id: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
return next(args, method);
|
||||
};
|
||||
|
||||
// a bug in monks types means that :any has to be used here. Maybe we'll make a PR for this?
|
||||
const StripIDFromDBInsertsMiddleware: TMiddleware = () => (next) => (args: any, method) => {
|
||||
if (method === "insert") {
|
||||
if (Array.isArray(args.data)) {
|
||||
for (const d of args.data) {
|
||||
delete d._id;
|
||||
}
|
||||
} else {
|
||||
delete args.data._id;
|
||||
}
|
||||
}
|
||||
|
||||
return next(args, method);
|
||||
};
|
||||
|
||||
monkDB.addMiddleware(StripIDFromDBInsertsMiddleware);
|
||||
monkDB.addMiddleware(RemoveIDFromFindReturnsMiddleware);
|
||||
|
||||
export async function CloseMongoConnection() {
|
||||
await monkDB.close();
|
||||
}
|
||||
|
||||
// Typescript incorrectly casts this into songs[string] => MONGO_SongDocument,
|
||||
// Force cast it out.
|
||||
const songs = Object.fromEntries(
|
||||
allSupportedGameGroups.map((e) => [e, monkDB.get<MONGO_SongDocument>(`songs-${e}`)]),
|
||||
) as unknown as {
|
||||
[G in GameGroup]: ICollection<MONGO_SongDocument<G>>;
|
||||
};
|
||||
|
||||
const charts = Object.fromEntries(
|
||||
allSupportedGameGroups.map((e) => [e, monkDB.get<MONGO_ChartDocument>(`charts-${e}`)]),
|
||||
) as unknown as {
|
||||
[G in GameGroup]: ICollection<MONGO_ChartDocument<GPTStrings[G]>>;
|
||||
};
|
||||
|
||||
const MONGODB_KILL = {
|
||||
// i have to handwrite this out for TS... :(
|
||||
// dont worry, it was all macro'd.
|
||||
songs,
|
||||
charts,
|
||||
|
||||
// intended for when you want to query arbitrary chart/song documents.
|
||||
anyCharts: charts as Record<GameGroup, ICollection<MONGO_ChartDocument>>,
|
||||
anySongs: songs as Record<GameGroup, ICollection<MONGO_SongDocument>>,
|
||||
|
||||
scores: monkDB.get<MONGO_ScoreDocument>("scores"),
|
||||
"personal-bests": monkDB.get<MONGO_PBScoreDocument>("personal-bests"),
|
||||
folders: monkDB.get<MONGO_FolderDocument>("folders"),
|
||||
"folder-chart-lookup": monkDB.get<FolderChartLookup>("folder-chart-lookup"),
|
||||
goals: monkDB.get<MONGO_GoalDocument>("goals"),
|
||||
"goal-subs": monkDB.get<MONGO_GoalSubscriptionDocument>("goal-subs"),
|
||||
quests: monkDB.get<MONGO_QuestDocument>("quests"),
|
||||
"quest-subs": monkDB.get<MONGO_QuestSubscriptionDocument>("quest-subs"),
|
||||
users: monkDB.get<MONGO_UserDocument>("users"),
|
||||
imports: monkDB.get<MONGO_ImportDocument>("imports"),
|
||||
"import-timings": monkDB.get<MONGO_ImportTimingsDocument>("import-timings"),
|
||||
sessions: monkDB.get<MONGO_SessionDocument>("sessions"),
|
||||
invites: monkDB.get<MONGO_InviteCodeDocument>("invites"),
|
||||
counters: monkDB.get<MONGO_CounterDocument>("counters"),
|
||||
"game-stats": monkDB.get<MONGO_UserGameStats>("game-stats"),
|
||||
"kai-auth-tokens": monkDB.get<MONGO_KaiAuthDocument>("kai-auth-tokens"),
|
||||
"cg-card-info": monkDB.get<MONGO_CGCardInfo>("cg-card-info"),
|
||||
"myt-card-info": monkDB.get<MONGO_MytCardInfo>("myt-card-info"),
|
||||
|
||||
"bms-course-lookup": monkDB.get<MONGO_BMSCourseDocument>("bms-course-lookup"),
|
||||
"api-tokens": monkDB.get<MONGO_APITokenDocument>("api-tokens"),
|
||||
"orphan-scores": monkDB.get<OrphanScoreDocument>("orphan-scores"),
|
||||
"import-locks": monkDB.get<{ locked: boolean; lockedAt: integer | null; userID: integer }>(
|
||||
"import-locks",
|
||||
),
|
||||
tables: monkDB.get<MONGO_TableDocument>("tables"),
|
||||
"invite-locks": monkDB.get<{ locked: boolean; userID: integer }>("invite-locks"),
|
||||
"game-settings": monkDB.get<MONGO_UGPTSettingsDocument>("game-settings"),
|
||||
"game-stats-snapshots": monkDB.get<MONGO_UserGameStatsSnapshotDocument>("game-stats-snapshots"),
|
||||
"user-settings": monkDB.get<MONGO_UserSettingsDocument>("user-settings"),
|
||||
"user-private-information": monkDB.get<PrivateUserInfoDocument>("user-private-information"),
|
||||
"api-clients": monkDB.get<MONGO_TachiAPIClientDocument>("api-clients"),
|
||||
|
||||
// i've inlined this one because i don't see it appearing anywhere else.
|
||||
"oauth2-auth-codes": monkDB.get<{ code: string; createdOn: number; userID: integer }>(
|
||||
"oauth2-auth-codes",
|
||||
),
|
||||
|
||||
"fer-settings": monkDB.get<MONGO_FervidexSettingsDocument>("fer-settings"),
|
||||
"kshook-sv6c-settings": monkDB.get<MONGO_KsHookSettingsDocument>("kshook-sv6c-settings"),
|
||||
"orphan-chart-queue": monkDB.get<MONGO_OrphanChartDocument>("orphan-chart-queue"),
|
||||
"password-reset-codes": monkDB.get<{
|
||||
code: string;
|
||||
createdOn: number;
|
||||
userID: integer;
|
||||
}>("password-reset-codes"),
|
||||
"class-achievements": monkDB.get<MONGO_ClassAchievementDocument>("class-achievements"),
|
||||
"score-blacklist": monkDB.get<{ score: MONGO_ScoreDocument; scoreID: string; userID: integer }>(
|
||||
"score-blacklist",
|
||||
),
|
||||
"verify-email-codes": monkDB.get<{ code: string; email: string; userID: integer }>(
|
||||
"verify-email-codes",
|
||||
),
|
||||
"recent-folder-views": monkDB.get<MONGO_RecentlyViewedFolderDocument>("recent-folder-views"),
|
||||
questlines: monkDB.get<MONGO_QuestlineDocument>("questlines"),
|
||||
migrations: monkDB.get<MigrationDocument>("migrations"),
|
||||
notifications: monkDB.get<MONGO_NotificationDocument>("notifications"),
|
||||
"import-trackers": monkDB.get<MONGO_ImportTrackerDocument>("import-trackers"),
|
||||
"user-name-changes": monkDB.get<MONGO_UserNameChangeDocument>("user-name-changes"),
|
||||
};
|
||||
|
||||
export type StaticDatabases = Exclude<
|
||||
keyof typeof MONGODB_KILL,
|
||||
"anyCharts" | "anySongs" | "charts" | "songs"
|
||||
>;
|
||||
|
||||
export type Databases = `charts-${GameGroup}` | `songs-${GameGroup}` | StaticDatabases;
|
||||
|
||||
export default MONGODB_KILL;
|
||||
@@ -1,220 +0,0 @@
|
||||
import type { IndexOptions } from "mongodb";
|
||||
|
||||
import monk, { type IMonkManager } from "monk";
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { log } from "#lib/log/log";
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import { DedupeArr, Random20Hex } from "#utils/misc";
|
||||
import { GAME_PT_CONFIGS } from "tachi-common";
|
||||
|
||||
import type { Databases } from "./db";
|
||||
|
||||
interface Index {
|
||||
fields: Record<string, unknown>;
|
||||
options?: IndexOptions;
|
||||
}
|
||||
|
||||
function index(fields: Record<string, unknown>, options?: IndexOptions) {
|
||||
return { fields, options };
|
||||
}
|
||||
|
||||
const UNIQUE = { unique: true };
|
||||
|
||||
const allDefaultMetrics = DedupeArr(Object.values(GAME_PT_CONFIGS).map((e) => e.defaultMetric));
|
||||
|
||||
// NOTE: INDEXES REMOVED FROM HERE WILL **NOT** AUTOMATICALLY UPDATE.
|
||||
// YOU NEED TO RUN src/scripts/set-indexes.ts --reset TO REMOVE INDEXES.
|
||||
// (indexes added will automatically apply on tachi-server boot, though).
|
||||
const staticIndexes: Partial<Record<Databases, Array<Index>>> = {
|
||||
scores: [
|
||||
index({ scoreID: 1 }, UNIQUE),
|
||||
index({ chartID: 1, userID: 1 }),
|
||||
index({ userID: 1, game: 1, playtype: 1 }),
|
||||
|
||||
// activity
|
||||
index({ game: 1, playtype: 1, highlight: 1, timeAchieved: 1 }),
|
||||
],
|
||||
"personal-bests": [
|
||||
index({ chartID: 1, userID: 1 }, UNIQUE),
|
||||
|
||||
// one index for every default metric, why not.
|
||||
...allDefaultMetrics.map((e) => index({ chartID: 1, [`scoreData.${e}`]: 1 })),
|
||||
|
||||
index({ userID: 1, game: 1, playtype: 1 }),
|
||||
|
||||
// activity
|
||||
index({ game: 1, playtype: 1, timeAchieved: 1 }),
|
||||
],
|
||||
sessions: [
|
||||
index({ sessionID: 1 }, UNIQUE),
|
||||
index({ game: 1, playtype: 1, timeStarted: 1 }),
|
||||
index({ userID: 1, game: 1, playtype: 1, timeStarted: 1, timeEnded: 1 }),
|
||||
|
||||
// Optimises score modification, since sessions need to be repointed.
|
||||
// also, just generally useful.
|
||||
index({ scoreIDs: 1 }),
|
||||
index({ name: "text" }),
|
||||
],
|
||||
"game-stats": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE)],
|
||||
"game-settings": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE), index({ rivals: 1 })],
|
||||
"folder-chart-lookup": [
|
||||
index({ chartID: 1, folderID: 1 }, UNIQUE),
|
||||
index({ chartID: 1 }),
|
||||
index({ folderID: 1 }),
|
||||
],
|
||||
goals: [index({ goalID: 1 }, UNIQUE), index({ game: 1, playtype: 1 }), index({ name: "text" })],
|
||||
"goal-subs": [index({ goalID: 1, userID: 1 }, UNIQUE), index({ goalID: 1 })],
|
||||
quests: [
|
||||
index({ questID: 1 }, UNIQUE),
|
||||
index({ game: 1, playtype: 1 }),
|
||||
index({ name: "text" }),
|
||||
],
|
||||
"quest-subs": [
|
||||
index({ questID: 1, userID: 1 }, UNIQUE),
|
||||
index({ userID: 1, game: 1, playtype: 1 }),
|
||||
],
|
||||
questlines: [
|
||||
index({ questlineID: 1 }, UNIQUE),
|
||||
index({ game: 1, playtype: 1 }),
|
||||
index({ quests: 1 }),
|
||||
index({ name: "text" }),
|
||||
],
|
||||
imports: [index({ importID: 1 }, UNIQUE)],
|
||||
"import-timings": [
|
||||
index({ importID: 1 }, UNIQUE),
|
||||
index({ timestamp: 1 }),
|
||||
index({ total: 1 }),
|
||||
],
|
||||
users: [
|
||||
index({ id: 1 }, UNIQUE),
|
||||
index({ id: 1, authLevel: 1 }, UNIQUE),
|
||||
index({ username: 1 }, UNIQUE),
|
||||
index({ usernameLowercase: 1 }, UNIQUE),
|
||||
],
|
||||
folders: [
|
||||
index({ folderID: 1 }, UNIQUE),
|
||||
index({ game: 1, playtype: 1, table: 1, tableIndex: 1 }),
|
||||
index({ title: "text", searchTerms: "text" }),
|
||||
],
|
||||
"kai-auth-tokens": [index({ userID: 1, service: 1 }, UNIQUE)],
|
||||
"myt-card-info": [index({ userID: 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)],
|
||||
"game-stats-snapshots": [index({ timestamp: 1, userID: 1, game: 1, playtype: 1 }, UNIQUE)],
|
||||
"user-settings": [index({ userID: 1 }, UNIQUE)],
|
||||
"user-private-information": [index({ userID: 1 }, UNIQUE), index({ email: 1 }, UNIQUE)],
|
||||
"fer-settings": [index({ userID: 1 }, UNIQUE)],
|
||||
"kshook-sv6c-settings": [index({ userID: 1 }, UNIQUE)],
|
||||
counters: [index({ counterName: 1 }, UNIQUE)],
|
||||
"class-achievements": [index({ game: 1, playtype: 1, timeAchieved: 1 })],
|
||||
"api-clients": [index({ clientID: 1 }, UNIQUE)],
|
||||
"charts-iidx": [
|
||||
index({ "data.hashSHA256": 1 }),
|
||||
index({ "data.inGameID": 1, playtype: 1, difficulty: 1 }),
|
||||
],
|
||||
"charts-bms": [index({ "data.hashMD5": 1 }, UNIQUE), index({ "data.hashSHA256": 1 }, UNIQUE)],
|
||||
"charts-popn": [index({ "data.hashSHA256": 1 }, UNIQUE)],
|
||||
"charts-sdvx": [index({ "data.inGameID": 1, difficulty: 1 })],
|
||||
"charts-museca": [index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE)],
|
||||
"charts-chunithm": [index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE)],
|
||||
"charts-gitadora": [index({ "data.inGameID": 1, difficulty: 1, playtype: 1 }, UNIQUE)],
|
||||
"charts-wacca": [index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE)],
|
||||
"charts-usc": [index({ "data.hashSHA1": 1, playtype: 1 }, UNIQUE)],
|
||||
"charts-jubeat": [index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE)],
|
||||
"charts-pms": [
|
||||
index({ "data.hashSHA256": 1, playtype: 1 }, UNIQUE),
|
||||
index({ "data.hashMD5": 1, playtype: 1 }, UNIQUE),
|
||||
],
|
||||
"charts-maimai": [
|
||||
index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE),
|
||||
index({ "data.inGameStrID": 1, difficulty: 1 }, UNIQUE),
|
||||
],
|
||||
"import-locks": [index({ userID: 1 }, UNIQUE)],
|
||||
"score-blacklist": [index({ scoreID: 1 }, UNIQUE)],
|
||||
migrations: [index({ migrationID: 1 }, UNIQUE)],
|
||||
notifications: [index({ notifID: 1 }, UNIQUE), index({ sentTo: 1, sentAt: 1 })],
|
||||
};
|
||||
|
||||
const indexes: Partial<Record<Databases, Array<Index>>> = staticIndexes;
|
||||
|
||||
for (const game of TachiConfig.GAMES) {
|
||||
if (indexes[`charts-${game}` as Databases]) {
|
||||
// @ts-expect-error this is obviously fine
|
||||
indexes[`charts-${game}` as Databases].push(
|
||||
index({ chartID: 1 }, UNIQUE),
|
||||
index(
|
||||
{ songID: 1, difficulty: 1, playtype: 1, isPrimary: 1 },
|
||||
{ unique: true, partialFilterExpression: { isPrimary: { $eq: true } } },
|
||||
),
|
||||
index({ songID: 1 }),
|
||||
index({ playtype: 1 }),
|
||||
);
|
||||
} else {
|
||||
indexes[`charts-${game}` as Databases] = [
|
||||
index({ chartID: 1 }, UNIQUE),
|
||||
index({ songID: 1, difficulty: 1, playtype: 1, isPrimary: 1 }, UNIQUE),
|
||||
index({ songID: 1 }),
|
||||
index({ playtype: 1 }),
|
||||
];
|
||||
}
|
||||
|
||||
if (indexes[`songs-${game}` as Databases]) {
|
||||
// @ts-expect-error this is obviously fine
|
||||
indexes[`songs-${game}` as Databases].push(
|
||||
index({ id: 1 }, UNIQUE),
|
||||
index({ title: "text", artist: "text", altTitles: "text", searchTerms: "text" }),
|
||||
);
|
||||
} else {
|
||||
indexes[`songs-${game}` as Databases] = [
|
||||
index({ id: 1 }, UNIQUE),
|
||||
index({ title: 1 }),
|
||||
index({ title: "text", artist: "text", altTitles: "text", searchTerms: "text" }),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export async function UpdateIndexes(db: IMonkManager, reset: boolean) {
|
||||
const collections = (await db.listCollections()).map((e) => e.name);
|
||||
|
||||
for (const [collection, values] of Object.entries(indexes)) {
|
||||
if (!collections.includes(collection)) {
|
||||
// this creates a collection, i cant find the createCollection
|
||||
// call.
|
||||
const tmp = Random20Hex();
|
||||
|
||||
await db.get(collection).insert({ __tmp: tmp });
|
||||
await db.get(collection).remove({ __tmp: tmp });
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
await db.get(collection).dropIndexes();
|
||||
log.debug(`Reset ${collection}.`);
|
||||
}
|
||||
|
||||
for (const index of values) {
|
||||
try {
|
||||
// @ts-expect-error Type-mismatch here. our index.fields are just boring records. I know
|
||||
// that this sucks...
|
||||
const r = await db.get(collection).createIndex(index.fields, index.options);
|
||||
|
||||
log.debug(r);
|
||||
} catch (err) {
|
||||
log.debug(err);
|
||||
|
||||
// index already exists.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Done.");
|
||||
}
|
||||
|
||||
export async function SetIndexes(mongoUrl: string, reset: boolean) {
|
||||
const monkDb = monk(mongoUrl);
|
||||
|
||||
await UpdateIndexes(monkDb, reset);
|
||||
|
||||
await monkDb.close();
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import { p, type PrudenceSchema } from "prudence";
|
||||
import { allImportTypes } from "tachi-common/constants/import-types";
|
||||
import { SCHEMAS, type SchemaValidatorFunction } from "tachi-common/lib/schemas";
|
||||
|
||||
import type { Databases } from "./db";
|
||||
|
||||
// The idea of this file is to export a function for every database
|
||||
// that will validate that collection.
|
||||
|
||||
// If it returns true, that document is valid. If it throws any sort
|
||||
// of error, it is invalid.
|
||||
|
||||
function prSchemaify(schema: PrudenceSchema) {
|
||||
return (s: unknown): true => {
|
||||
const err = p(s, schema);
|
||||
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export const DatabaseSchemas: Record<Databases, SchemaValidatorFunction> = {
|
||||
"user-name-changes": prSchemaify({
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
username: "string",
|
||||
timestamp: p.isPositiveInteger,
|
||||
previousUsername: "string",
|
||||
}),
|
||||
"import-locks": prSchemaify({
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
locked: "boolean",
|
||||
}),
|
||||
"invite-locks": prSchemaify({
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
locked: "boolean",
|
||||
}),
|
||||
"kai-auth-tokens": prSchemaify({
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
token: "string",
|
||||
refreshToken: "string",
|
||||
service: p.isIn("FLO", "EAG", "MIN"),
|
||||
}),
|
||||
"cg-card-info": prSchemaify({
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
service: p.isIn("dev", "gan", "nag"),
|
||||
cardID: "string",
|
||||
pin: "string",
|
||||
}),
|
||||
"myt-card-info": prSchemaify({
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
cardAccessCode: "string",
|
||||
}),
|
||||
"oauth2-auth-codes": prSchemaify({
|
||||
code: "string",
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
createdOn: p.isPositive,
|
||||
}),
|
||||
"password-reset-codes": prSchemaify({
|
||||
code: "string",
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
createdOn: p.isPositive,
|
||||
}),
|
||||
"verify-email-codes": prSchemaify({
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
code: "string",
|
||||
email: "string",
|
||||
}),
|
||||
migrations: prSchemaify({
|
||||
migrationID: "string",
|
||||
status: p.isIn("applied", "pending"),
|
||||
appliedOn: (self, parent) => {
|
||||
if (parent.status === "pending") {
|
||||
return (
|
||||
self === undefined ||
|
||||
`Expected appliedOn to be undefined since status was pending.`
|
||||
);
|
||||
}
|
||||
|
||||
return p.isPositiveInteger(self);
|
||||
},
|
||||
}),
|
||||
"orphan-scores": prSchemaify({
|
||||
importType: p.isIn(TachiConfig.IMPORT_TYPES),
|
||||
game: p.isIn(TachiConfig.GAMES),
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
orphanID: "string",
|
||||
timeInserted: p.isPositive,
|
||||
errMsg: "?string",
|
||||
|
||||
// @todo Type these properly? This will take a lot of effort, as it will require
|
||||
// formal types for all parser data:context duos.
|
||||
data: p.any,
|
||||
context: p.any,
|
||||
}),
|
||||
"import-trackers": prSchemaify({
|
||||
timeStarted: p.isPositive,
|
||||
importID: "string",
|
||||
userID: p.isPositiveInteger,
|
||||
importType: p.isIn(allImportTypes),
|
||||
userIntent: "boolean",
|
||||
type: p.isIn("ONGOING", "FAILED"),
|
||||
error: p.optional({
|
||||
message: "string",
|
||||
statusCode: "*number",
|
||||
}),
|
||||
}),
|
||||
...SCHEMAS,
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { p, type PrudenceSchema } from "prudence";
|
||||
|
||||
export function isApproximately(
|
||||
t: Tap.Test,
|
||||
number: number,
|
||||
target: number,
|
||||
message?: string,
|
||||
lenience = 0.01,
|
||||
) {
|
||||
const result = Math.abs(number - target) < lenience;
|
||||
|
||||
if (!result) {
|
||||
return t.fail(`${number} was not close enough to ${target}`, {
|
||||
errmsg: `${number} was not close enough to ${target}`,
|
||||
});
|
||||
}
|
||||
|
||||
t.pass(message);
|
||||
|
||||
// return true;
|
||||
}
|
||||
|
||||
export function prAssert(
|
||||
t: Tap.Test,
|
||||
obj: unknown,
|
||||
schema: PrudenceSchema,
|
||||
message = "Unnamed Prudence Assertion",
|
||||
) {
|
||||
const err = p(obj, schema);
|
||||
|
||||
if (err) {
|
||||
return t.fail(message, err);
|
||||
}
|
||||
|
||||
return t.pass(message);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { CloseScoreImportQueue } from "#lib/score-import/worker/queue";
|
||||
import { CloseMongoConnection } from "#services/mongo/db";
|
||||
import { CloseRedisConnection } from "#services/redis/redis";
|
||||
|
||||
import { CloseServerConnection } from "./mock-api";
|
||||
import { WriteSnapshotData } from "./single-process-snapshot";
|
||||
|
||||
export async function CleanUpAfterTests() {
|
||||
if (process.env.TAP_SNAPSHOT !== "" && process.env.TAP_SNAPSHOT !== undefined) {
|
||||
WriteSnapshotData();
|
||||
}
|
||||
|
||||
await CloseMongoConnection();
|
||||
await CloseServerConnection();
|
||||
await CloseRedisConnection();
|
||||
await CloseScoreImportQueue();
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// This runs as JS because of https://github.com/tapjs/node-tap/issues/761
|
||||
|
||||
const assert = require("assert");
|
||||
const { execSync } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
console.log("Starting test setup via hack-setup.js");
|
||||
|
||||
assert(process.env.NODE_ENV === "test", "NODE_ENV WAS NOT TEST, BAILING");
|
||||
execSync(`ts-node ${path.join(__dirname, "./setup.ts")}`, { stdio: "inherit" });
|
||||
@@ -1,6 +1,49 @@
|
||||
import { HashPassword } from "#lib/auth/auth";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
let minimalIidxChartCounter = 0;
|
||||
|
||||
/**
|
||||
* Inserts a minimal `song` + `chart` row for `iidx` / `SP` (`game` = `iidx-sp`) so
|
||||
* goal/chart validation (`GetChartById`) succeeds in tests.
|
||||
*/
|
||||
export async function seedMinimalIidxSpChart() {
|
||||
const n = ++minimalIidxChartCounter;
|
||||
const songId = `S_MIN_IIDX_${n}_${Date.now()}`;
|
||||
const chartId = `C_MIN_IIDX_${n}_${Date.now()}`;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songId,
|
||||
legacy_id: 90_000 + n,
|
||||
game_group: "iidx",
|
||||
title: "Minimal IIDX Test Chart",
|
||||
artist: "Tester",
|
||||
search_terms: [],
|
||||
alt_titles: [],
|
||||
data: { displayVersion: "1", genre: "TEST" },
|
||||
fts_document: "",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: chartId,
|
||||
legacy_id: `c_min_iidx_legacy_${n}`,
|
||||
game: "iidx-sp",
|
||||
song_id: songId,
|
||||
difficulty: "ANOTHER",
|
||||
level: "10",
|
||||
level_num: 10,
|
||||
is_primary: true,
|
||||
versions: ["27"],
|
||||
data: { inGameID: 1000, notecount: 786 },
|
||||
})
|
||||
.execute();
|
||||
|
||||
return chartId;
|
||||
}
|
||||
|
||||
// ─── seedUser ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SeedUserOpts {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { log } from "#lib/log/log";
|
||||
import { Env } from "#lib/setup/config";
|
||||
import { SetIndexes } from "#services/mongo/indexes";
|
||||
|
||||
export default async function ResetDBState() {
|
||||
// not used
|
||||
}
|
||||
|
||||
export async function SetIndexesForDB() {
|
||||
await ResetDBState();
|
||||
const url = `${Env.MONGO_URL}/testingdb`;
|
||||
|
||||
log.info(`Setting indexes for ${url}`);
|
||||
|
||||
await SetIndexes(url, true);
|
||||
|
||||
log.info(`Done.`);
|
||||
return true;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import supertest from "supertest";
|
||||
|
||||
import server from "../server/server";
|
||||
|
||||
const mockServer = supertest(server);
|
||||
|
||||
// mock the server for requests
|
||||
export default mockServer;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { monkDB } from "#services/mongo/db";
|
||||
|
||||
import { SetIndexesForDB } from "./resets";
|
||||
|
||||
SetIndexesForDB()
|
||||
.then(monkDB.close)
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
// we might not *have* a workinglog here
|
||||
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { IsNonEmptyString } from "#utils/misc";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const SNAP_PATH = path.join(__dirname, "./snapshots/snapshot-data.json");
|
||||
|
||||
type Snapshots = Record<string, boolean | number | string>;
|
||||
|
||||
function ReadSnapshotData() {
|
||||
let snapshots: Snapshots = {};
|
||||
|
||||
if (fs.existsSync(SNAP_PATH)) {
|
||||
snapshots = JSON.parse(fs.readFileSync(SNAP_PATH, "utf-8")) as Snapshots;
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
export function WriteSnapshotData() {
|
||||
fs.writeFileSync(SNAP_PATH, JSON.stringify(snapshotData, null, "\t"));
|
||||
}
|
||||
|
||||
const snapshotData = ReadSnapshotData();
|
||||
|
||||
export function TestSnapshot(t: Tap.Test, value: any, testName: string) {
|
||||
if (IsNonEmptyString(process.env.TAP_SNAPSHOT)) {
|
||||
snapshotData[testName] = value;
|
||||
WriteSnapshotData();
|
||||
} else {
|
||||
if (snapshotData[testName] === undefined) {
|
||||
return t.fail(`No snapshot exists for ${testName}. Have you ran pnpm snap?`);
|
||||
}
|
||||
|
||||
t.strictSame(value, snapshotData[testName], `Snapshot: ${testName}`);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import "#services/mongo/db";
|
||||
import glob from "glob";
|
||||
import path from "path";
|
||||
import t from "tap";
|
||||
|
||||
import { CleanUpAfterTests } from "./cleanup";
|
||||
|
||||
const files = glob.sync(path.join(__dirname, "../", "**/*.oldtest.ts"));
|
||||
|
||||
process.env.NODE_PATH = path.join(__dirname, "../");
|
||||
|
||||
for (const file of files) {
|
||||
// Deliberate -- we're doing hackery here.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require(file);
|
||||
}
|
||||
|
||||
t.teardown(CleanUpAfterTests);
|
||||
@@ -1,13 +0,0 @@
|
||||
import t from "tap";
|
||||
|
||||
import { HashSHA256 } from "./crypto";
|
||||
|
||||
t.test("#HashSHA256", (t) => {
|
||||
t.equal(
|
||||
HashSHA256(Buffer.from("something")),
|
||||
"3fc9b689459d738f8c88a3a48aa9e33542016b7a4052e001aaa536fca74813cb",
|
||||
"'something' should hash into its expected value.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { HashSHA256 } from "./crypto";
|
||||
|
||||
describe("HashSHA256", () => {
|
||||
it("hashes 'something' to the expected SHA-256 hex digest", () => {
|
||||
expect(HashSHA256(Buffer.from("something"))).toBe(
|
||||
"3fc9b689459d738f8c88a3a48aa9e33542016b7a4052e001aaa536fca74813cb",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,11 @@ describe("GetNextBmsPmsSongLegacyId", () => {
|
||||
|
||||
const base = maxRow?.m ?? 0;
|
||||
const newLegacyId = base + 1;
|
||||
const songPgId = `test-song-pms-legacy-seq-${newLegacyId}`;
|
||||
const songNewID = `test-song-pms-legacy-seq-${newLegacyId}`;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songPgId,
|
||||
id: songNewID,
|
||||
legacy_id: newLegacyId,
|
||||
game_group: "pms",
|
||||
title: "t",
|
||||
@@ -33,6 +33,6 @@ describe("GetNextBmsPmsSongLegacyId", () => {
|
||||
|
||||
expect(n).toBe(newLegacyId + 1);
|
||||
|
||||
await DB.deleteFrom("song").where("id", "=", songPgId).execute();
|
||||
await DB.deleteFrom("song").where("id", "=", songNewID).execute();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,18 +172,10 @@ export async function GetRelevantSongsAndCharts(
|
||||
return { songs, charts };
|
||||
}
|
||||
|
||||
/** Mongo seed hook used to sync `*-song-id` counters after chart loads. Under Postgres the next id is always `max(legacy_id)+1` at allocation time — nothing to sync. */
|
||||
export async function UpdateGameSongIDCounter(game: "bms" | "pms") {
|
||||
log.debug(
|
||||
`UpdateGameSongIDCounter(${game}) — no-op (next legacy id comes from max(legacy_id) + 1).`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function GetChartForIDGuaranteed(game: GameGroup, chartID: string) {
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("song.game_group", "=", game)
|
||||
.where("chart.id", "=", chartID)
|
||||
.executeTakeFirst();
|
||||
@@ -192,7 +184,7 @@ export async function GetChartForIDGuaranteed(game: GameGroup, chartID: string)
|
||||
throw new Error(`Couldn't find chart with ID ${chartID} (${game}).`);
|
||||
}
|
||||
|
||||
return ToChartDocument(row, row.song_legacy_id);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
export async function GetSongForIDGuaranteed(game: GameGroup, songID: integer) {
|
||||
@@ -231,6 +223,24 @@ export async function GetGoalForIDGuaranteed(goalID: string) {
|
||||
return ToGoalDocument(row);
|
||||
}
|
||||
|
||||
export async function GetGoalSubscriptionForIDGuaranteed(goalID: string, userID: integer) {
|
||||
const row = await DB.selectFrom("goal_sub")
|
||||
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
|
||||
.selectAll("goal_sub")
|
||||
.select("goal.game as goal_game")
|
||||
.where("goal_id", "=", goalID)
|
||||
.where("user_id", "=", userID)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(
|
||||
`Couldn't find goal subscription with goalID ${goalID} and userID ${userID}`,
|
||||
);
|
||||
}
|
||||
|
||||
return ToGoalSubscriptionDocument(row);
|
||||
}
|
||||
|
||||
export async function GetQuestForIDGuaranteed(questID: string) {
|
||||
const row = await DB.selectFrom("quest")
|
||||
.selectAll()
|
||||
@@ -669,9 +679,13 @@ export async function GetMostSubscribedQuests(
|
||||
}
|
||||
|
||||
export async function GetChildQuests(questline: MONGO_QuestlineDocument) {
|
||||
if (questline.quests.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const quests = await DB.selectFrom("quest")
|
||||
.selectAll()
|
||||
.where("id", "in", questline.quests)
|
||||
.where("quest.id", "in", questline.quests)
|
||||
.execute();
|
||||
|
||||
if (quests.length !== questline.quests.length) {
|
||||
@@ -681,5 +695,10 @@ export async function GetChildQuests(questline: MONGO_QuestlineDocument) {
|
||||
);
|
||||
}
|
||||
|
||||
return quests.map(ToQuestDocument);
|
||||
const byId = new Map(quests.map((q) => [q.id, q]));
|
||||
|
||||
return questline.quests
|
||||
.map((id) => byId.get(id))
|
||||
.filter((q): q is NonNullable<typeof q> => q !== undefined)
|
||||
.map(ToQuestDocument);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
EXT_CANNON_BALLERS,
|
||||
EXT_HEROIC_VERSE,
|
||||
MODEL_IIDX,
|
||||
MODEL_INFINITAS_2,
|
||||
MODEL_SDVX3_KONASTE,
|
||||
REV_2DXBMS,
|
||||
REV_NORMAL,
|
||||
} from "#lib/constants/ea3id";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ParseEA3SoftID } from "./ea3id";
|
||||
|
||||
describe("ParseEA3SoftID", () => {
|
||||
it("parses a valid IIDX soft ID built from constants", () => {
|
||||
const ver = `${MODEL_IIDX}:J:A:${REV_2DXBMS}:${EXT_CANNON_BALLERS}`;
|
||||
|
||||
expect(ParseEA3SoftID(ver)).toEqual({
|
||||
model: MODEL_IIDX,
|
||||
dest: "J",
|
||||
spec: "A",
|
||||
rev: REV_2DXBMS,
|
||||
ext: EXT_CANNON_BALLERS,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses Infinitas 2 and SDVX Konaste-style IDs", () => {
|
||||
expect(
|
||||
ParseEA3SoftID(`${MODEL_INFINITAS_2}:J:A:${REV_NORMAL}:${EXT_HEROIC_VERSE}`),
|
||||
).toEqual({
|
||||
model: MODEL_INFINITAS_2,
|
||||
dest: "J",
|
||||
spec: "A",
|
||||
rev: REV_NORMAL,
|
||||
ext: EXT_HEROIC_VERSE,
|
||||
});
|
||||
|
||||
expect(ParseEA3SoftID(`${MODEL_SDVX3_KONASTE}:J:A:${REV_NORMAL}:2024010100`)).toEqual({
|
||||
model: MODEL_SDVX3_KONASTE,
|
||||
dest: "J",
|
||||
spec: "A",
|
||||
rev: REV_NORMAL,
|
||||
ext: "2024010100",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when the string does not split into exactly five components", () => {
|
||||
expect(() => ParseEA3SoftID("LDJ:J:A:Z")).toThrow(
|
||||
"Invalid Version Code. Had 4 components.",
|
||||
);
|
||||
|
||||
expect(() => ParseEA3SoftID("LDJ:J:A:Z:2018091900:extra")).toThrow(
|
||||
"Invalid Version Code. Had 6 components.",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the full string does not match the EA3 format", () => {
|
||||
expect(() =>
|
||||
ParseEA3SoftID(`${MODEL_IIDX}:J:A:${REV_2DXBMS}:${EXT_CANNON_BALLERS}x`),
|
||||
).toThrow("Invalid Version Code.");
|
||||
|
||||
expect(() => ParseEA3SoftID(`ldj:J:A:${REV_2DXBMS}:${EXT_CANNON_BALLERS}`)).toThrow(
|
||||
"Invalid Version Code.",
|
||||
);
|
||||
|
||||
expect(() => ParseEA3SoftID(`XX:J:A:${REV_2DXBMS}:${EXT_CANNON_BALLERS}`)).toThrow(
|
||||
"Invalid Version Code.",
|
||||
);
|
||||
|
||||
expect(() => ParseEA3SoftID(`${MODEL_IIDX}:J:A:${REV_2DXBMS}:201809190`)).toThrow(
|
||||
"Invalid Version Code.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ICollection, IObjectID } from "monk";
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { log } from "#lib/log/log";
|
||||
|
||||
export async function EfficientDBIterate<T extends object, R>(
|
||||
collection: ICollection<T>,
|
||||
callbackFn: (c: T) => Promise<R> | R,
|
||||
saveOp: (c: Array<R>) => Promise<void>,
|
||||
filter: object = {},
|
||||
bucketSize = 10_000,
|
||||
) {
|
||||
let i = 0;
|
||||
|
||||
let lastID: IObjectID | null = null;
|
||||
|
||||
while (true) {
|
||||
log.info(`Running on ${i} - ${i + bucketSize} documents.`);
|
||||
|
||||
const newFilter: any = { ...filter };
|
||||
|
||||
if (lastID) {
|
||||
newFilter._id = { $gt: lastID };
|
||||
}
|
||||
|
||||
const docs = await collection.find(newFilter, {
|
||||
sort: { _id: 1 },
|
||||
limit: bucketSize,
|
||||
projectID: true,
|
||||
});
|
||||
|
||||
// update lastID by taking the last document's ID.
|
||||
lastID = docs.at(-1)?._id ?? null;
|
||||
|
||||
if (docs.length === 0) {
|
||||
log.info(`Ended documents at ${i}.`);
|
||||
break;
|
||||
}
|
||||
|
||||
const rDocs = await Promise.all(docs.map(callbackFn));
|
||||
|
||||
i = i + bucketSize;
|
||||
|
||||
await saveOp(rDocs);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// TODO(zk): remove this - this is stupid
|
||||
export {
|
||||
BuildFolderQuery,
|
||||
CreateFolderID,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import t from "tap";
|
||||
|
||||
import {
|
||||
DeleteUndefinedProps,
|
||||
GetMillisecondsSince,
|
||||
IsValidURL,
|
||||
RoundToNDecimalPlaces,
|
||||
} from "./misc";
|
||||
|
||||
t.test("#GetMillisecondsSince", (t) => {
|
||||
const time = GetMillisecondsSince(10n);
|
||||
|
||||
t.ok(typeof time === "number" && time > 0, "Should return a number greater than 0.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#DeleteUndefinedProps", (t) => {
|
||||
const v = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: undefined,
|
||||
d: {
|
||||
e: 3,
|
||||
f: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
DeleteUndefinedProps(v);
|
||||
|
||||
t.strictSame(v, {
|
||||
a: 1,
|
||||
b: 2,
|
||||
d: {
|
||||
e: 3,
|
||||
},
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#IsValidURL", (t) => {
|
||||
t.ok(IsValidURL("https://example.com"));
|
||||
t.ok(IsValidURL("http://example.com"));
|
||||
t.ok(IsValidURL("http://example.com/suburl"));
|
||||
t.ok(IsValidURL("http://example.com#href"));
|
||||
t.ok(IsValidURL("http://example.com?querystring"));
|
||||
t.ok(IsValidURL("tachi-fdsaf7324hf://example.com?querystring"));
|
||||
|
||||
t.notOk(IsValidURL("ftp://example.com"));
|
||||
|
||||
// t.notOk(IsValidURL("http://example")); lol this is valid???? insane.
|
||||
// t.notOk(IsValidURL("http:/example.com")); this is also valid, the JS URL parser is ridiculously lenient. Whatever.
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#RoundToNDecimalPlaces", (t) => {
|
||||
t.equal(RoundToNDecimalPlaces(10.4999999999, 1), 10.5);
|
||||
t.equal(RoundToNDecimalPlaces(10.4999999999, 2), 10.5);
|
||||
t.equal(RoundToNDecimalPlaces(10.4499999999, 2), 10.45);
|
||||
t.equal(RoundToNDecimalPlaces(10.4449999999, 3), 10.445);
|
||||
t.equal(RoundToNDecimalPlaces(10.4469999999, 3), 10.447);
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DeleteUndefinedProps,
|
||||
GetMillisecondsSince,
|
||||
IsValidURL,
|
||||
RoundToNDecimalPlaces,
|
||||
} from "./misc";
|
||||
|
||||
describe("GetMillisecondsSince", () => {
|
||||
it("returns a number greater than 0", () => {
|
||||
const time = GetMillisecondsSince(10n);
|
||||
|
||||
expect(typeof time).toBe("number");
|
||||
expect(time).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeleteUndefinedProps", () => {
|
||||
it("removes undefined keys recursively", () => {
|
||||
const v = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: undefined,
|
||||
d: {
|
||||
e: 3,
|
||||
f: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
DeleteUndefinedProps(v);
|
||||
|
||||
expect(v).toEqual({
|
||||
a: 1,
|
||||
b: 2,
|
||||
d: {
|
||||
e: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("IsValidURL", () => {
|
||||
it("accepts common http(s) and custom-scheme URLs", () => {
|
||||
expect(IsValidURL("https://example.com")).toBe(true);
|
||||
expect(IsValidURL("http://example.com")).toBe(true);
|
||||
expect(IsValidURL("http://example.com/suburl")).toBe(true);
|
||||
expect(IsValidURL("http://example.com#href")).toBe(true);
|
||||
expect(IsValidURL("http://example.com?querystring")).toBe(true);
|
||||
// Non-http(s) matches return the RegExpExecArray from `.exec()` (truthy).
|
||||
expect(IsValidURL("tachi-fdsaf7324hf://example.com?querystring")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects ftp URLs", () => {
|
||||
// Implementation uses `||` chains that can yield `null` for disallowed schemes.
|
||||
expect(IsValidURL("ftp://example.com")).toBeFalsy();
|
||||
});
|
||||
|
||||
// expect(IsValidURL("http://example")).toBe(false) — lol this is valid???? insane.
|
||||
// expect(IsValidURL("http:/example.com")).toBe(false) — this is also valid, the JS URL parser is ridiculously lenient. Whatever.
|
||||
});
|
||||
|
||||
describe("RoundToNDecimalPlaces", () => {
|
||||
it("rounds to N decimal places", () => {
|
||||
expect(RoundToNDecimalPlaces(10.4999999999, 1)).toBe(10.5);
|
||||
expect(RoundToNDecimalPlaces(10.4999999999, 2)).toBe(10.5);
|
||||
expect(RoundToNDecimalPlaces(10.4499999999, 2)).toBe(10.45);
|
||||
expect(RoundToNDecimalPlaces(10.4449999999, 3)).toBe(10.445);
|
||||
expect(RoundToNDecimalPlaces(10.4469999999, 3)).toBe(10.447);
|
||||
});
|
||||
});
|
||||
@@ -19,24 +19,11 @@ import {
|
||||
type Versions,
|
||||
} from "tachi-common";
|
||||
|
||||
type ChartJoinedRow = {
|
||||
song_legacy_id: number;
|
||||
} & Parameters<typeof ToChartDocument>[0] extends infer R
|
||||
? R
|
||||
: never;
|
||||
|
||||
function chartJoinedToDocument(
|
||||
row: { song_legacy_id: number } & ChartJoinedRow,
|
||||
): MONGO_ChartDocument {
|
||||
const { song_legacy_id, ...chartRow } = row;
|
||||
return ToChartDocument(chartRow, song_legacy_id);
|
||||
}
|
||||
|
||||
export async function FindChartWithChartID(game: GameGroup, chartID: string) {
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", game)
|
||||
.where("chart.id", "=", chartID)
|
||||
.executeTakeFirst();
|
||||
@@ -45,7 +32,7 @@ export async function FindChartWithChartID(game: GameGroup, chartID: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +50,7 @@ export async function FindChartWithPTDF<
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", game)
|
||||
.where("song.legacy_id", "=", songID)
|
||||
.where("chart.game", "=", v3Game)
|
||||
@@ -75,7 +62,7 @@ export async function FindChartWithPTDF<
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +79,7 @@ export async function FindChartWithPTDFVersion<
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", game)
|
||||
.where("song.legacy_id", "=", songID)
|
||||
.where("chart.game", "=", v3Game)
|
||||
@@ -104,14 +91,14 @@ export async function FindChartWithPTDFVersion<
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
export async function FindITGChartOnHash(hash: string) {
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("chart.game", "=", "itg-stamina" as Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashGSv3') = ${hash}`)
|
||||
.executeTakeFirst();
|
||||
@@ -120,7 +107,7 @@ export async function FindITGChartOnHash(hash: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,7 +118,7 @@ export async function FindBMSChartOnHash(hash: string) {
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", "bms")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
@@ -145,9 +132,7 @@ export async function FindBMSChartOnHash(hash: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(
|
||||
row as { song_legacy_id: number } & ChartJoinedRow,
|
||||
) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
|
||||
return ToChartDocument(row) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,11 +161,7 @@ export async function FindBMSSieglindeRatedCharts(playtype: Playtypes["bms"]): P
|
||||
const songs: Array<MONGO_SongDocument<"bms">> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
charts.push(
|
||||
chartJoinedToDocument(
|
||||
row as { song_legacy_id: number } & ChartJoinedRow,
|
||||
) as MONGO_ChartDocument<"bms:7K" | "bms:14K">,
|
||||
);
|
||||
charts.push(ToChartDocument(row) as MONGO_ChartDocument<"bms:7K" | "bms:14K">);
|
||||
songs.push(ToSongDocument(row) as MONGO_SongDocument<"bms">);
|
||||
}
|
||||
|
||||
@@ -192,7 +173,7 @@ export async function FindBMSChartOnHashInGame(hash: string, v3Game: Game) {
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
@@ -206,9 +187,7 @@ export async function FindBMSChartOnHashInGame(hash: string, v3Game: Game) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(
|
||||
row as { song_legacy_id: number } & ChartJoinedRow,
|
||||
) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
|
||||
return ToChartDocument(row) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
|
||||
}
|
||||
|
||||
/** All BMS charts matching MD5 or SHA256 in chart data. Used by global chart-hash search. */
|
||||
@@ -218,7 +197,7 @@ export async function FindBMSChartsByHashMd5OrSha256(
|
||||
const rows = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", "bms")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
@@ -228,9 +207,7 @@ export async function FindBMSChartsByHashMd5OrSha256(
|
||||
)
|
||||
.execute();
|
||||
|
||||
return rows.map((row) =>
|
||||
chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow),
|
||||
);
|
||||
return rows.map(ToChartDocument);
|
||||
}
|
||||
|
||||
/** All PMS charts matching MD5 or SHA256 in chart data. Used by global chart-hash search. */
|
||||
@@ -240,7 +217,7 @@ export async function FindPMSChartsByHashMd5OrSha256(
|
||||
const rows = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", "pms")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
@@ -250,9 +227,7 @@ export async function FindPMSChartsByHashMd5OrSha256(
|
||||
)
|
||||
.execute();
|
||||
|
||||
return rows.map((row) =>
|
||||
chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow),
|
||||
);
|
||||
return rows.map(ToChartDocument);
|
||||
}
|
||||
|
||||
/** All ITG Stamina charts matching hashGSv3. Used by global chart-hash search. */
|
||||
@@ -260,14 +235,12 @@ export async function FindITGChartsByHashGSv3(hash: string): Promise<Array<MONGO
|
||||
const rows = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("chart.game", "=", "itg-stamina" as Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashGSv3') = ${hash}`)
|
||||
.execute();
|
||||
|
||||
return rows.map((row) =>
|
||||
chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow),
|
||||
);
|
||||
return rows.map(ToChartDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,21 +252,17 @@ export async function FindBeatorajaChartOnHashSHA256(
|
||||
const bmsRow = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("song.game_group", "=", "bms")
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (bmsRow) {
|
||||
return chartJoinedToDocument(
|
||||
bmsRow as { song_legacy_id: number } & ChartJoinedRow,
|
||||
) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
|
||||
return ToChartDocument(bmsRow) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
|
||||
}
|
||||
|
||||
const pmsRow = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("song.game_group", "=", "pms")
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
|
||||
.executeTakeFirst();
|
||||
@@ -302,9 +271,7 @@ export async function FindBeatorajaChartOnHashSHA256(
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(
|
||||
pmsRow as { song_legacy_id: number } & ChartJoinedRow,
|
||||
) as MONGO_ChartDocument<"pms:Controller" | "pms:Keyboard">;
|
||||
return ToChartDocument(pmsRow) as MONGO_ChartDocument<"pms:Controller" | "pms:Keyboard">;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,7 +283,6 @@ export async function FindPopnChartOnHashSHA256(hash: string, playtype: Playtype
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("song.game_group", "=", "popn")
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
|
||||
@@ -326,7 +292,7 @@ export async function FindPopnChartOnHashSHA256(hash: string, playtype: Playtype
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,7 +327,6 @@ export async function FindChartOnInGameID(
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("song.game_group", "=", game)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
@@ -372,7 +337,7 @@ export async function FindChartOnInGameID(
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,7 +358,6 @@ export async function FindChartOnInGameIDPrimary(
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("song.game_group", "=", game)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
@@ -405,7 +369,7 @@ export async function FindChartOnInGameIDPrimary(
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,7 +386,6 @@ export async function FindIIDXChartOnInGameID(
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
|
||||
@@ -434,7 +397,7 @@ export async function FindIIDXChartOnInGameID(
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -452,7 +415,6 @@ export async function FindIIDXChartOnInGameIDVersion(
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
|
||||
@@ -464,7 +426,7 @@ export async function FindIIDXChartOnInGameIDVersion(
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -482,7 +444,7 @@ export async function FindChartOnInGameIDVersion<GPT extends GPTString = GPTStri
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", game)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
@@ -494,7 +456,7 @@ export async function FindChartOnInGameIDVersion<GPT extends GPTString = GPTStri
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,7 +477,7 @@ export async function FindChartOnInGameStrIDPrimary(
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", game)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameStrID') = ${inGameStrID}`)
|
||||
@@ -527,7 +489,7 @@ export async function FindChartOnInGameStrIDPrimary(
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -549,7 +511,7 @@ export async function FindChartOnInGameStrIDVersion<GPT extends GPTString = GPTS
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", game)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameStrID') = ${inGameStrID}`)
|
||||
@@ -561,7 +523,7 @@ export async function FindChartOnInGameStrIDVersion<GPT extends GPTString = GPTS
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,7 +533,7 @@ export async function FindIIDXChartWith2DXtraHash(hash: string) {
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("chart.game", "=", "iidx-sp" as Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
|
||||
.executeTakeFirst();
|
||||
@@ -580,7 +542,7 @@ export async function FindIIDXChartWith2DXtraHash(hash: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
const SDVX_INF_DIFFS = ["INF", "GRV", "HVN", "VVD", "XCD"] as const;
|
||||
@@ -598,7 +560,7 @@ export async function FindSDVXChartOnInGameID(
|
||||
let q = DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("chart.game", "=", "sdvx" as Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where("chart.is_primary", "=", true);
|
||||
@@ -614,7 +576,7 @@ export async function FindSDVXChartOnInGameID(
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
export async function FindSDVXChartOnInGameIDVersion(
|
||||
@@ -625,7 +587,7 @@ export async function FindSDVXChartOnInGameIDVersion(
|
||||
let q = DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("chart.game", "=", "sdvx" as Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sql<boolean>`${sql.lit(String(version))} = ANY(chart.versions)`);
|
||||
@@ -641,7 +603,7 @@ export async function FindSDVXChartOnInGameIDVersion(
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
export async function FindSDVXChartOnDFVersion(
|
||||
@@ -652,7 +614,7 @@ export async function FindSDVXChartOnDFVersion(
|
||||
let q = DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", "sdvx")
|
||||
.where("song.legacy_id", "=", songID)
|
||||
.where("chart.game", "=", "sdvx" as Game)
|
||||
@@ -669,7 +631,7 @@ export async function FindSDVXChartOnDFVersion(
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
export async function FindChartOnSHA256(game: GameGroup, hash: string) {
|
||||
@@ -680,7 +642,7 @@ export async function FindChartOnSHA256(game: GameGroup, hash: string) {
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
|
||||
.executeTakeFirst();
|
||||
@@ -689,7 +651,7 @@ export async function FindChartOnSHA256(game: GameGroup, hash: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
export async function FindChartOnSHA256Playtype(game: GameGroup, hash: string, playtype: Playtype) {
|
||||
@@ -702,7 +664,7 @@ export async function FindChartOnSHA256Playtype(game: GameGroup, hash: string, p
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", game)
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
|
||||
@@ -712,7 +674,7 @@ export async function FindChartOnSHA256Playtype(game: GameGroup, hash: string, p
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -725,7 +687,7 @@ export async function FindUSCChartOnSHA1Playtype(hash: string, playtype: Playtyp
|
||||
const row = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", "usc")
|
||||
.where("chart.game", "=", v3Game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA1') = ${hash}`)
|
||||
@@ -735,7 +697,7 @@ export async function FindUSCChartOnSHA1Playtype(hash: string, playtype: Playtyp
|
||||
return null;
|
||||
}
|
||||
|
||||
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
|
||||
return ToChartDocument(row);
|
||||
}
|
||||
|
||||
/** All USC charts matching a SHA1 hash (any playtype). Used by global chart-hash search. */
|
||||
@@ -743,14 +705,12 @@ export async function FindUSCChartsByHashSHA1(hash: string): Promise<Array<MONGO
|
||||
const rows = await DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.select("song.legacy_id as song_legacy_id")
|
||||
|
||||
.where("song.game_group", "=", "usc")
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA1') = ${hash}`)
|
||||
.execute();
|
||||
|
||||
return rows.map((row) =>
|
||||
chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow),
|
||||
);
|
||||
return rows.map((row) => ToChartDocument(row));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user