diff --git a/.cursor/rules/sql-fully-qualified-fields.mdc b/.cursor/rules/sql-fully-qualified-fields.mdc new file mode 100644 index 000000000..d36562149 --- /dev/null +++ b/.cursor/rules/sql-fully-qualified-fields.mdc @@ -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. diff --git a/Justfile-test b/Justfile-test index 84b6359d4..3871f2be0 100644 --- a/Justfile-test +++ b/Justfile-test @@ -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. diff --git a/db/seeds/songs-sdvx.json b/db/seeds/songs-sdvx.json index 5b39c23d1..da6c82861 100644 --- a/db/seeds/songs-sdvx.json +++ b/db/seeds/songs-sdvx.json @@ -23243,7 +23243,7 @@ "title": "Timepiece phase Ⅱ" }, { - "altTitles": [], + "altTitles": ["¡¡Fanta∽cramble!!"], "artist": "Potwi", "data": { "displayVersion": "exceed" diff --git a/typescript/db/src/generated/public/OrphanScore.ts b/typescript/db/src/generated/public/OrphanScore.ts index c2dc6bbd6..4f088639f 100644 --- a/typescript/db/src/generated/public/OrphanScore.ts +++ b/typescript/db/src/generated/public/OrphanScore.ts @@ -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; + row_id: ColumnType; - orphan_id: ColumnType; + user_id: ColumnType; - user_id: ColumnType; + import_id: ColumnType; - import_id: ColumnType; + orphan_id: ColumnType; - import_type: ColumnType; + import_type: ColumnType; - game_group: ColumnType; + game_group: ColumnType; - data: ColumnType; + data: ColumnType; - context: ColumnType; + context: ColumnType; - time_inserted: ColumnType; + time_inserted: ColumnType; - error_message: ColumnType; + error_message: ColumnType; } export type OrphanScore = Selectable; diff --git a/typescript/db/src/generated/public/Session.ts b/typescript/db/src/generated/public/Session.ts index 572c33e96..e3269ec5b 100644 --- a/typescript/db/src/generated/public/Session.ts +++ b/typescript/db/src/generated/public/Session.ts @@ -20,8 +20,6 @@ export default interface SessionTable { description: ColumnType; - textsearch: ColumnType; - time_inserted: ColumnType; time_started: ColumnType; @@ -31,6 +29,8 @@ export default interface SessionTable { calculated_data: ColumnType; highlight: ColumnType; + + textsearch: ColumnType; } export type Session = Selectable; diff --git a/typescript/server/src/actions/add-goal.test.ts b/typescript/server/src/actions/add-goal.test.ts new file mode 100644 index 000000000..3ff812f5e --- /dev/null +++ b/typescript/server/src/actions/add-goal.test.ts @@ -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 }); + }); +}); diff --git a/typescript/server/src/actions/add-goal.ts b/typescript/server/src/actions/add-goal.ts new file mode 100644 index 000000000..c4bc1cb8a --- /dev/null +++ b/typescript/server/src/actions/add-goal.ts @@ -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`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 }; +}); diff --git a/typescript/server/src/actions/backsync-bms-pms-seeds.ts b/typescript/server/src/actions/backsync-bms-pms-seeds.ts index 6cfe70fe8..abbaff31f 100644 --- a/typescript/server/src/actions/backsync-bms-pms-seeds.ts +++ b/typescript/server/src/actions/backsync-bms-pms-seeds.ts @@ -51,7 +51,6 @@ async function loadAllChartsForGame(game: GameGroup): Promise { }); 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; diff --git a/typescript/server/src/actions/customise-score.test.ts b/typescript/server/src/actions/customise-score.test.ts index f2e02397f..6f4381939 100644 --- a/typescript/server/src/actions/customise-score.test.ts +++ b/typescript/server/src/actions/customise-score.test.ts @@ -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 () => { diff --git a/typescript/server/src/actions/customise-score.ts b/typescript/server/src/actions/customise-score.ts index b82d0c796..2df5ca21b 100644 --- a/typescript/server/src/actions/customise-score.ts +++ b/typescript/server/src/actions/customise-score.ts @@ -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 {}; }); diff --git a/typescript/server/src/actions/delete-import.test.ts b/typescript/server/src/actions/delete-import.test.ts index d0c66c483..3dacbac42 100644 --- a/typescript/server/src/actions/delete-import.test.ts +++ b/typescript/server/src/actions/delete-import.test.ts @@ -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(); }); diff --git a/typescript/server/src/actions/delete-import.ts b/typescript/server/src/actions/delete-import.ts index dc40eba2e..345aeec4c 100644 --- a/typescript/server/src/actions/delete-import.ts +++ b/typescript/server/src/actions/delete-import.ts @@ -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."); } diff --git a/typescript/server/src/actions/delete-score.test.ts b/typescript/server/src/actions/delete-score.test.ts index c2d820d95..313ba4517 100644 --- a/typescript/server/src/actions/delete-score.test.ts +++ b/typescript/server/src/actions/delete-score.test.ts @@ -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(); }); diff --git a/typescript/server/src/actions/delete-score.ts b/typescript/server/src/actions/delete-score.ts index e9e0ffcd2..1c5ce3dba 100644 --- a/typescript/server/src/actions/delete-score.ts +++ b/typescript/server/src/actions/delete-score.ts @@ -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."); } diff --git a/typescript/server/src/actions/delete-session.test.ts b/typescript/server/src/actions/delete-session.test.ts index 6e48a1e0c..f53981d3a 100644 --- a/typescript/server/src/actions/delete-session.test.ts +++ b/typescript/server/src/actions/delete-session.test.ts @@ -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(); diff --git a/typescript/server/src/actions/delete-session.ts b/typescript/server/src/actions/delete-session.ts index 90af100c9..a538ce067 100644 --- a/typescript/server/src/actions/delete-session.ts +++ b/typescript/server/src/actions/delete-session.ts @@ -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."); } diff --git a/typescript/server/src/actions/remove-goal-subscription.test.ts b/typescript/server/src/actions/remove-goal-subscription.test.ts new file mode 100644 index 000000000..bee8082aa --- /dev/null +++ b/typescript/server/src/actions/remove-goal-subscription.test.ts @@ -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, + }); + }); +}); diff --git a/typescript/server/src/actions/remove-goal-subscription.ts b/typescript/server/src/actions/remove-goal-subscription.ts new file mode 100644 index 000000000..090eb8ea5 --- /dev/null +++ b/typescript/server/src/actions/remove-goal-subscription.ts @@ -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); + } + }, +); diff --git a/typescript/server/src/actions/set-rivals.test.ts b/typescript/server/src/actions/set-rivals.test.ts new file mode 100644 index 000000000..478502e18 --- /dev/null +++ b/typescript/server/src/actions/set-rivals.test.ts @@ -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 }); + }); +}); diff --git a/typescript/server/src/actions/set-rivals.ts b/typescript/server/src/actions/set-rivals.ts new file mode 100644 index 000000000..eb1043bef --- /dev/null +++ b/typescript/server/src/actions/set-rivals.ts @@ -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 {}; +}); diff --git a/typescript/server/src/actions/update-bpi-data.ts b/typescript/server/src/actions/update-bpi-data.ts index fb244d949..1c28d78f7 100644 --- a/typescript/server/src/actions/update-bpi-data.ts +++ b/typescript/server/src/actions/update-bpi-data.ts @@ -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.`); } diff --git a/typescript/server/src/actions/update-dp-tiers.ts b/typescript/server/src/actions/update-dp-tiers.ts index 990cf2a42..90410d072 100644 --- a/typescript/server/src/actions/update-dp-tiers.ts +++ b/typescript/server/src/actions/update-dp-tiers.ts @@ -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.`); } diff --git a/typescript/server/src/actions/update-sp12-data.ts b/typescript/server/src/actions/update-sp12-data.ts index c6b498936..aaeacf83b 100644 --- a/typescript/server/src/actions/update-sp12-data.ts +++ b/typescript/server/src/actions/update-sp12-data.ts @@ -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.`); } diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts index 3f6beb341..1ec208099 100644 --- a/typescript/server/src/lib/actions/actions.ts +++ b/typescript/server/src/lib/actions/actions.ts @@ -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(), diff --git a/typescript/server/src/lib/activity/activity.test.ts b/typescript/server/src/lib/activity/activity.test.ts index 3f81fc6a2..73ed935c2 100644 --- a/typescript/server/src/lib/activity/activity.test.ts +++ b/typescript/server/src/lib/activity/activity.test.ts @@ -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}` })]), ); }); }); diff --git a/typescript/server/src/lib/activity/activity.ts b/typescript/server/src/lib/activity/activity.ts index 8357c7b8b..06845d3bf 100644 --- a/typescript/server/src/lib/activity/activity.ts +++ b/typescript/server/src/lib/activity/activity.ts @@ -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) => diff --git a/typescript/server/src/lib/db-formats/chart.ts b/typescript/server/src/lib/db-formats/chart.ts index a43660624..6058e3541 100644 --- a/typescript/server/src/lib/db-formats/chart.ts +++ b/typescript/server/src/lib/db-formats/chart.ts @@ -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; +export type ChartRow = Selection; -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 { 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`(data->>'2dxtraSet') IS NULL`); + q = q.where(sql`(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, + chartIDs: Array, ): Promise> { - 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); } diff --git a/typescript/server/src/lib/db-formats/class-achievement.ts b/typescript/server/src/lib/db-formats/class-achievement.ts index 1cc4fee62..13c8d1f60 100644 --- a/typescript/server/src/lib/db-formats/class-achievement.ts +++ b/typescript/server/src/lib/db-formats/class-achievement.ts @@ -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 { diff --git a/typescript/server/src/lib/db-formats/pb.ts b/typescript/server/src/lib/db-formats/pb.ts index edf329d6e..c6046fc41 100644 --- a/typescript/server/src/lib/db-formats/pb.ts +++ b/typescript/server/src/lib/db-formats/pb.ts @@ -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 { + 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 { + 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[], diff --git a/typescript/server/src/lib/db-formats/questline.ts b/typescript/server/src/lib/db-formats/questline.ts new file mode 100644 index 000000000..2ca92fd1c --- /dev/null +++ b/typescript/server/src/lib/db-formats/questline.ts @@ -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; + +export function ToQuestlineDocument( + row: QuestlineRow, + questIdsOrdered: Array, +): 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, + }; +} diff --git a/typescript/server/src/lib/db-formats/song.ts b/typescript/server/src/lib/db-formats/song.ts index 66f365521..50401a905 100644 --- a/typescript/server/src/lib/db-formats/song.ts +++ b/typescript/server/src/lib/db-formats/song.ts @@ -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 }; } /** diff --git a/typescript/server/src/lib/folders/folders.ts b/typescript/server/src/lib/folders/folders.ts index ab7c53bee..8ee54b7e9 100644 --- a/typescript/server/src/lib/folders/folders.ts +++ b/typescript/server/src/lib/folders/folders.ts @@ -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 }; } diff --git a/typescript/server/src/lib/game-specific/iidx-playlists.ts b/typescript/server/src/lib/game-specific/iidx-playlists.ts index cfe51da95..e0be61449 100644 --- a/typescript/server/src/lib/game-specific/iidx-playlists.ts +++ b/typescript/server/src/lib/game-specific/iidx-playlists.ts @@ -118,13 +118,12 @@ export const CUSTOM_TACHI_IIDX_PLAYLISTS: Array = [ 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`(chart.data->>'kaidenAverage') IS NOT NULL`) .where(sql`(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"> >; diff --git a/typescript/server/src/lib/handlers/sigterm.ts b/typescript/server/src/lib/handlers/sigterm.ts index c85eb1a8b..6cadc47b1 100644 --- a/typescript/server/src/lib/handlers/sigterm.ts +++ b/typescript/server/src/lib/handlers/sigterm.ts @@ -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(); diff --git a/typescript/server/src/lib/jobs/deorphan-scores.ts b/typescript/server/src/lib/jobs/deorphan-scores.ts index 415475715..06eba2343 100644 --- a/typescript/server/src/lib/jobs/deorphan-scores.ts +++ b/typescript/server/src/lib/jobs/deorphan-scores.ts @@ -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.`); diff --git a/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts b/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts index d0d279fa0..9057726a6 100644 --- a/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts +++ b/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts @@ -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, diff --git a/typescript/server/src/lib/orphan-queue/orphan-queue.ts b/typescript/server/src/lib/orphan-queue/orphan-queue.ts index 3d2520054..5aeef591d 100644 --- a/typescript/server/src/lib/orphan-queue/orphan-queue.ts +++ b/typescript/server/src/lib/orphan-queue/orphan-queue.ts @@ -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( 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( v3Game, songDocU as MONGO_SongDocument<"bms" | "pms">, chartDocU, - songPgId, + songNewID, songLegacyId, ); @@ -272,7 +272,7 @@ export async function DeorphanIfInQueue( 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( v3Game, songDocU as MONGO_SongDocument<"bms" | "pms">, chartDocU, - songPgId, + songNewID, songLegacyId, ); diff --git a/typescript/server/src/lib/rivals/rivals.ts b/typescript/server/src/lib/rivals/rivals.ts index b25858d56..b914c82a5 100644 --- a/typescript/server/src/lib/rivals/rivals.ts +++ b/typescript/server/src/lib/rivals/rivals.ts @@ -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>> { - 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> = {}; - 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); + const v = (scoreData as Record)[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, -) { +): Promise { 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, +): Promise { + 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> = []; - - 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) + : ((raw ?? {}) as Record); + delete cd.rank; + delete cd.outOf; + + await DB.updateTable("pb") + .set({ + calculated_data: JSON.stringify({ + ...cd, + rivalRank, + }), + }) + .where("row_id", "=", pb.row_id) + .execute(); + }), + ); } diff --git a/typescript/server/src/lib/score-import/framework/goals/goals.ts b/typescript/server/src/lib/score-import/framework/goals/goals.ts index 5ba19ae00..1d213070e 100644 --- a/typescript/server/src/lib/score-import/framework/goals/goals.ts +++ b/typescript/server/src/lib/score-import/framework/goals/goals.ts @@ -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; - - // 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>(); 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), + ), + ); } diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.test.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.test.ts index f28edaf94..6dbd6a67a 100644 --- a/typescript/server/src/lib/score-import/framework/orphans/orphans.test.ts +++ b/typescript/server/src/lib/score-import/framework/orphans/orphans.test.ts @@ -324,7 +324,9 @@ describe("DeorphanScores beatoraja filter (Postgres)", () => { .where("user_id", "=", userId) .where("import_type", "=", "ir/beatoraja") .where(sql`(orphan_score.context::jsonb->'chart'->>'sha256') = ${sha}`) - .where(sql`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`) + .where( + sql`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`, + ) .execute(); expect(keyboardRows.map((r) => r.orphan_id)).toEqual(["obe-kb"]); diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts index 603311bbc..0ea665d88 100644 --- a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts +++ b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts @@ -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`(orphan_score.context::jsonb->'chart'->>'sha256') = ${filter.chartSha256}`); + .where( + sql`(orphan_score.context::jsonb->'chart'->>'sha256') = ${filter.chartSha256}`, + ); if (filter.pmsPlaytype !== undefined) { if (filter.pmsPlaytype === "Controller") { - q = q.where(sql`(orphan_score.data::jsonb->>'deviceType') = ${"BM_CONTROLLER"}`); + q = q.where( + sql`(orphan_score.data::jsonb->>'deviceType') = ${"BM_CONTROLLER"}`, + ); } else { - q = q.where(sql`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`); + q = q.where( + sql`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`, + ); } } } diff --git a/typescript/server/src/lib/score-import/framework/quests/quests.ts b/typescript/server/src/lib/score-import/framework/quests/quests.ts index 610942368..e5895d7a4 100644 --- a/typescript/server/src/lib/score-import/framework/quests/quests.ts +++ b/typescript/server/src/lib/score-import/framework/quests/quests.ts @@ -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, @@ -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(); for (const um of questSubs) { questSubMap.set(um.questID, um); } - const bwrite: Array> = []; - const importQuestInfo: Array = []; await Promise.all( @@ -62,18 +61,6 @@ export async function UpdateQuestsForUser( return; } - const bwriteOp: BulkWriteUpdateOneOperation = { - 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, 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 }; } diff --git a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts index 13263783c..0de433fe3 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts @@ -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 }; diff --git a/typescript/server/src/lib/search/folders.ts b/typescript/server/src/lib/search/folders.ts index 50c4db671..7f43dc6b6 100644 --- a/typescript/server/src/lib/search/folders.ts +++ b/typescript/server/src/lib/search/folders.ts @@ -70,9 +70,9 @@ async function finalizeFolderSearchRows( async function searchFoldersFtsAndTrgmCore( search: string, opts: { + allowedV3Games: Array | null; limit: integer; onlyActiveFolders: boolean; - allowedV3Games: Array | null; }, ): Promise> { 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> { diff --git a/typescript/server/src/lib/search/search.ts b/typescript/server/src/lib/search/search.ts index cfdd73f6a..b6b3d4a1c 100644 --- a/typescript/server/src/lib/search/search.ts +++ b/typescript/server/src/lib/search/search.ts @@ -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; - 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>; - -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( - collection: ICollection, - search: string, - searchMethod: keyof typeof SEARCH_CONTROLS, - existingMatch: FilterQuery = {}, - limit = 500, -): Promise> { - 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; - - // 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 = []; - - for (const key of controls.keys) { - searchData.push( - ...data - .filter((d) => d[key]) - .flatMap((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 = new Set(); - const scores: Record = {}; - - 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 = 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) { +export async function SearchGamesSongs(search: string, games: Array | null) { const promises = []; - for (const game of games) { + for (const game of games ?? TachiConfig.GAMES) { promises.push(SearchAllGamesSingleGame(game, search)); } diff --git a/typescript/server/src/lib/search/song-charts.ts b/typescript/server/src/lib/search/song-charts.ts index 8c506aa65..ed5579400 100644 --- a/typescript/server/src/lib/search/song-charts.ts +++ b/typescript/server/src/lib/search/song-charts.ts @@ -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); } - 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> { - 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", }); diff --git a/typescript/server/src/lib/search/songs.test.ts b/typescript/server/src/lib/search/songs.test.ts index 9c254c369..4057ba39e 100644 --- a/typescript/server/src/lib/search/songs.test.ts +++ b/typescript/server/src/lib/search/songs.test.ts @@ -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, }); diff --git a/typescript/server/src/lib/targets/goal-pb-queries.ts b/typescript/server/src/lib/targets/goal-pb-queries.ts new file mode 100644 index 000000000..bb63360db --- /dev/null +++ b/typescript/server/src/lib/targets/goal-pb-queries.ts @@ -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>; + +/** + * Loads the user's leaderboard PB row for each chart in `chartPgIds` (Postgres `chart.id`). + */ +export async function LoadPbsForUserOnChartsForGoal( + userId: number, + chartIDs: string[], +): Promise { + 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)[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; +} diff --git a/typescript/server/src/lib/targets/goal-utils.ts b/typescript/server/src/lib/targets/goal-utils.ts index 0d4bbd571..7f1b1a689 100644 --- a/typescript/server/src/lib/targets/goal-utils.ts +++ b/typescript/server/src/lib/targets/goal-utils.ts @@ -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( diff --git a/typescript/server/src/lib/targets/goals.ts b/typescript/server/src/lib/targets/goals.ts index 6c3958620..67775e12a 100644 --- a/typescript/server/src/lib/targets/goals.ts +++ b/typescript/server/src/lib/targets/goals.ts @@ -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 { - // 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 = { - 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 = { - 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 { - // 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> { + const rows = await DB.selectFrom("quest") + .selectAll() + .where( + sql`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> { - 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`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; + 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; goalSubsMap: Map; }> { - const gsQuery: FilterQuery = { - 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 = []; @@ -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 = []; + 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 = 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, chartIDsArr: Array) { - // 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 = 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.`); } } diff --git a/typescript/server/src/lib/targets/quests.ts b/typescript/server/src/lib/targets/quests.ts index 3d9791346..20614f7d1 100644 --- a/typescript/server/src/lib/targets/quests.ts +++ b/typescript/server/src/lib/targets/quests.ts @@ -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) { 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(); 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, ) { - 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 = 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`not exists ( + select 1 from questline_quest qq where qq.quest_id = quest.id + )`, + ) + .execute(); + + return rows.map(ToQuestDocument); } diff --git a/typescript/server/src/main.ts b/typescript/server/src/main.ts index dbcf1c8fc..93aa9cf9b 100644 --- a/typescript/server/src/main.ts +++ b/typescript/server/src/main.ts @@ -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") { diff --git a/typescript/server/src/scripts/migrate-to-postgres.ts b/typescript/server/src/scripts/migrate-to-postgres.ts index 1bf1719c3..c0b9488f8 100644 --- a/typescript/server/src/scripts/migrate-to-postgres.ts +++ b/typescript/server/src/scripts/migrate-to-postgres.ts @@ -1001,9 +1001,15 @@ async function main(): Promise { 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 = []; diff --git a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.test.ts b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.test.ts index 1fa9cf0ef..29a524376 100644 --- a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.test.ts +++ b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.test.ts @@ -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, diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts index 1a09d48c7..8dca90777 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts @@ -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"] === diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.ts index 1362019ab..c8e2c0a24 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/router.ts @@ -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 = { - sort: { - [`ratings.${alg}`]: -1, - }, - limit, - }; + const v3Game = GamePTToV3(game, playtype); + const ratingCol = sql`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)); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/middleware.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/middleware.ts index 08fbe62a3..3615b2428 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/middleware.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/middleware.ts @@ -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(); }; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.test.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.test.ts index 45d48446c..394f42dbf 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.test.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.test.ts @@ -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, diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts index 0c7a36baa..ff2cf0ca7 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/songs/_songID/router.ts @@ -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, diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts index eb6a342e8..f5462fd1e 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts index 6b9701740..d2eada26c 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts @@ -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)); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts new file mode 100644 index 000000000..15bda78ec --- /dev/null +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.test.ts @@ -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 }> + ).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]); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts index b41ea2567..91397ce01 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/questlines/router.ts @@ -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); diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts index abc790bef..511bc0162 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/quests/router.ts @@ -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({ diff --git a/typescript/server/src/server/router/api/v1/import/router.ts b/typescript/server/src/server/router/api/v1/import/router.ts index 4c5d80a8d..ab4081c72 100644 --- a/typescript/server/src/server/router/api/v1/import/router.ts +++ b/typescript/server/src/server/router/api/v1/import/router.ts @@ -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.`); diff --git a/typescript/server/src/server/router/api/v1/imports/router.ts b/typescript/server/src/server/router/api/v1/imports/router.ts index c2caa3e23..59ac857a2 100644 --- a/typescript/server/src/server/router/api/v1/imports/router.ts +++ b/typescript/server/src/server/router/api/v1/imports/router.ts @@ -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) { diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts index 6e29b7cdf..021963201 100644 --- a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts +++ b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts @@ -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; diff --git a/typescript/server/src/server/router/api/v1/search/router.test.ts b/typescript/server/src/server/router/api/v1/search/router.test.ts index 58104c64e..e56326df0 100644 --- a/typescript/server/src/server/router/api/v1/search/router.test.ts +++ b/typescript/server/src/server/router/api/v1/search/router.test.ts @@ -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, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.test.ts index 11cb98e24..7b629106b 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.test.ts @@ -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) { diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts new file mode 100644 index 000000000..7be30cf06 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts @@ -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); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts index dd635bdcd..51dc9f50a 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.ts @@ -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 = { - 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, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts index 949b5b367..8f5240ed6 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts @@ -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)); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index ac7434ac6..de540e4de 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -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.`, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts index f1f7a5049..2a2f72fe2 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts @@ -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`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({ diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts index 6d9829915..88d28e0ee 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts @@ -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> = []; + + 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.`, diff --git a/typescript/server/src/server/router/ir/kshook/sv6c/router.test.ts b/typescript/server/src/server/router/ir/kshook/sv6c/router.test.ts index 66b970fee..019ed996a 100644 --- a/typescript/server/src/server/router/ir/kshook/sv6c/router.test.ts +++ b/typescript/server/src/server/router/ir/kshook/sv6c/router.test.ts @@ -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 }) diff --git a/typescript/server/src/services/mongo/db.oldtest.ts b/typescript/server/src/services/mongo/db.oldtest.ts deleted file mode 100644 index f48ea5e45..000000000 --- a/typescript/server/src/services/mongo/db.oldtest.ts +++ /dev/null @@ -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(); -}); diff --git a/typescript/server/src/services/mongo/db.ts b/typescript/server/src/services/mongo/db.ts deleted file mode 100644 index b0e4efbf7..000000000 --- a/typescript/server/src/services/mongo/db.ts +++ /dev/null @@ -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(`songs-${e}`)]), -) as unknown as { - [G in GameGroup]: ICollection>; -}; - -const charts = Object.fromEntries( - allSupportedGameGroups.map((e) => [e, monkDB.get(`charts-${e}`)]), -) as unknown as { - [G in GameGroup]: ICollection>; -}; - -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>, - anySongs: songs as Record>, - - scores: monkDB.get("scores"), - "personal-bests": monkDB.get("personal-bests"), - folders: monkDB.get("folders"), - "folder-chart-lookup": monkDB.get("folder-chart-lookup"), - goals: monkDB.get("goals"), - "goal-subs": monkDB.get("goal-subs"), - quests: monkDB.get("quests"), - "quest-subs": monkDB.get("quest-subs"), - users: monkDB.get("users"), - imports: monkDB.get("imports"), - "import-timings": monkDB.get("import-timings"), - sessions: monkDB.get("sessions"), - invites: monkDB.get("invites"), - counters: monkDB.get("counters"), - "game-stats": monkDB.get("game-stats"), - "kai-auth-tokens": monkDB.get("kai-auth-tokens"), - "cg-card-info": monkDB.get("cg-card-info"), - "myt-card-info": monkDB.get("myt-card-info"), - - "bms-course-lookup": monkDB.get("bms-course-lookup"), - "api-tokens": monkDB.get("api-tokens"), - "orphan-scores": monkDB.get("orphan-scores"), - "import-locks": monkDB.get<{ locked: boolean; lockedAt: integer | null; userID: integer }>( - "import-locks", - ), - tables: monkDB.get("tables"), - "invite-locks": monkDB.get<{ locked: boolean; userID: integer }>("invite-locks"), - "game-settings": monkDB.get("game-settings"), - "game-stats-snapshots": monkDB.get("game-stats-snapshots"), - "user-settings": monkDB.get("user-settings"), - "user-private-information": monkDB.get("user-private-information"), - "api-clients": monkDB.get("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("fer-settings"), - "kshook-sv6c-settings": monkDB.get("kshook-sv6c-settings"), - "orphan-chart-queue": monkDB.get("orphan-chart-queue"), - "password-reset-codes": monkDB.get<{ - code: string; - createdOn: number; - userID: integer; - }>("password-reset-codes"), - "class-achievements": monkDB.get("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("recent-folder-views"), - questlines: monkDB.get("questlines"), - migrations: monkDB.get("migrations"), - notifications: monkDB.get("notifications"), - "import-trackers": monkDB.get("import-trackers"), - "user-name-changes": monkDB.get("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; diff --git a/typescript/server/src/services/mongo/indexes.ts b/typescript/server/src/services/mongo/indexes.ts deleted file mode 100644 index 31454f34d..000000000 --- a/typescript/server/src/services/mongo/indexes.ts +++ /dev/null @@ -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; - options?: IndexOptions; -} - -function index(fields: Record, 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>> = { - 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>> = 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(); -} diff --git a/typescript/server/src/services/mongo/schemas.ts b/typescript/server/src/services/mongo/schemas.ts deleted file mode 100644 index a3dc0fb94..000000000 --- a/typescript/server/src/services/mongo/schemas.ts +++ /dev/null @@ -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 = { - "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, -}; diff --git a/typescript/server/src/test-utils/asserts.ts b/typescript/server/src/test-utils/asserts.ts deleted file mode 100644 index 1d0337171..000000000 --- a/typescript/server/src/test-utils/asserts.ts +++ /dev/null @@ -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); -} diff --git a/typescript/server/src/test-utils/cleanup.ts b/typescript/server/src/test-utils/cleanup.ts deleted file mode 100644 index 6678ab0fe..000000000 --- a/typescript/server/src/test-utils/cleanup.ts +++ /dev/null @@ -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(); -} diff --git a/typescript/server/src/test-utils/hack-setup.js b/typescript/server/src/test-utils/hack-setup.js deleted file mode 100644 index 5e5e3bd4b..000000000 --- a/typescript/server/src/test-utils/hack-setup.js +++ /dev/null @@ -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" }); diff --git a/typescript/server/src/test-utils/pg-fixtures.ts b/typescript/server/src/test-utils/pg-fixtures.ts index 31186821d..dbea16dda 100644 --- a/typescript/server/src/test-utils/pg-fixtures.ts +++ b/typescript/server/src/test-utils/pg-fixtures.ts @@ -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 { diff --git a/typescript/server/src/test-utils/resets.ts b/typescript/server/src/test-utils/resets.ts deleted file mode 100644 index 60e937cee..000000000 --- a/typescript/server/src/test-utils/resets.ts +++ /dev/null @@ -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; -} diff --git a/typescript/server/src/test-utils/server.ts b/typescript/server/src/test-utils/server.ts deleted file mode 100644 index 7d0b58e74..000000000 --- a/typescript/server/src/test-utils/server.ts +++ /dev/null @@ -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; diff --git a/typescript/server/src/test-utils/setup.ts b/typescript/server/src/test-utils/setup.ts deleted file mode 100644 index b16f7ad0d..000000000 --- a/typescript/server/src/test-utils/setup.ts +++ /dev/null @@ -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); - }); diff --git a/typescript/server/src/test-utils/single-process-snapshot.ts b/typescript/server/src/test-utils/single-process-snapshot.ts deleted file mode 100644 index 0f20bce70..000000000 --- a/typescript/server/src/test-utils/single-process-snapshot.ts +++ /dev/null @@ -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; - -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}`); - } -} diff --git a/typescript/server/src/test-utils/single-process-tap.ts b/typescript/server/src/test-utils/single-process-tap.ts deleted file mode 100644 index 24b8f3747..000000000 --- a/typescript/server/src/test-utils/single-process-tap.ts +++ /dev/null @@ -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); diff --git a/typescript/server/src/utils/crypto.oldtest.ts b/typescript/server/src/utils/crypto.oldtest.ts deleted file mode 100644 index 1f1d5f549..000000000 --- a/typescript/server/src/utils/crypto.oldtest.ts +++ /dev/null @@ -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(); -}); diff --git a/typescript/server/src/utils/crypto.test.ts b/typescript/server/src/utils/crypto.test.ts new file mode 100644 index 000000000..e525bd3cf --- /dev/null +++ b/typescript/server/src/utils/crypto.test.ts @@ -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", + ); + }); +}); diff --git a/typescript/server/src/utils/db-song-legacy-id.test.ts b/typescript/server/src/utils/db-song-legacy-id.test.ts index 5d0fdccd8..acff79aa5 100644 --- a/typescript/server/src/utils/db-song-legacy-id.test.ts +++ b/typescript/server/src/utils/db-song-legacy-id.test.ts @@ -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(); }); }); diff --git a/typescript/server/src/utils/db.ts b/typescript/server/src/utils/db.ts index 4dec126bf..48308ccfe 100644 --- a/typescript/server/src/utils/db.ts +++ b/typescript/server/src/utils/db.ts @@ -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 => q !== undefined) + .map(ToQuestDocument); } diff --git a/typescript/server/src/utils/ea3id.test.ts b/typescript/server/src/utils/ea3id.test.ts new file mode 100644 index 000000000..35a2fb101 --- /dev/null +++ b/typescript/server/src/utils/ea3id.test.ts @@ -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.", + ); + }); +}); diff --git a/typescript/server/src/utils/efficient-db-iterate.ts b/typescript/server/src/utils/efficient-db-iterate.ts deleted file mode 100644 index 34c5e282b..000000000 --- a/typescript/server/src/utils/efficient-db-iterate.ts +++ /dev/null @@ -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( - collection: ICollection, - callbackFn: (c: T) => Promise | R, - saveOp: (c: Array) => Promise, - 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); - } -} diff --git a/typescript/server/src/utils/folder.ts b/typescript/server/src/utils/folder.ts index 62f1a95b8..7bc4acc27 100644 --- a/typescript/server/src/utils/folder.ts +++ b/typescript/server/src/utils/folder.ts @@ -1,3 +1,4 @@ +// TODO(zk): remove this - this is stupid export { BuildFolderQuery, CreateFolderID, diff --git a/typescript/server/src/utils/misc.oldtest.ts b/typescript/server/src/utils/misc.oldtest.ts deleted file mode 100644 index b8a2cc4cc..000000000 --- a/typescript/server/src/utils/misc.oldtest.ts +++ /dev/null @@ -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(); -}); diff --git a/typescript/server/src/utils/misc.test.ts b/typescript/server/src/utils/misc.test.ts new file mode 100644 index 000000000..8b43e41f8 --- /dev/null +++ b/typescript/server/src/utils/misc.test.ts @@ -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); + }); +}); diff --git a/typescript/server/src/utils/queries/charts.ts b/typescript/server/src/utils/queries/charts.ts index 6752df7fd..0aabbf78b 100644 --- a/typescript/server/src/utils/queries/charts.ts +++ b/typescript/server/src/utils/queries/charts.ts @@ -19,24 +19,11 @@ import { type Versions, } from "tachi-common"; -type ChartJoinedRow = { - song_legacy_id: number; -} & Parameters[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`(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> = []; 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`(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`(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`(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`(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`(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`(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`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where(sql`(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`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where(sql`(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`(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`(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`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`) .where(sql`${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`(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`(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`(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`(chart.data::jsonb->>'hashSHA1') = ${hash}`) .execute(); - return rows.map((row) => - chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow), - ); + return rows.map((row) => ToChartDocument(row)); } /** diff --git a/typescript/server/src/utils/queries/questlines.ts b/typescript/server/src/utils/queries/questlines.ts new file mode 100644 index 000000000..cefddc3bc --- /dev/null +++ b/typescript/server/src/utils/queries/questlines.ts @@ -0,0 +1,79 @@ +import { SELECT_QUESTLINE, ToQuestlineDocument } from "#lib/db-formats/questline"; +import DB from "#services/pg/db"; +import { + type GameGroup, + GamePTToV3, + type MONGO_QuestlineDocument, + type Playtype, +} from "tachi-common"; + +/** + * All questlines for a game/playtype, with `quests` ordered by `questline_quest.sort_order`. + */ +export async function GetQuestlinesForGamePlaytype( + game: GameGroup, + playtype: Playtype, +): Promise> { + const v3Game = GamePTToV3(game, playtype); + + const qlRows = await DB.selectFrom("questline") + .select(SELECT_QUESTLINE) + .where("questline.game", "=", v3Game) + .execute(); + + if (qlRows.length === 0) { + return []; + } + + const questlineIds = qlRows.map((r) => r.questline_id); + + const qqRows = await DB.selectFrom("questline_quest") + .select(["questline_quest.questline_id", "questline_quest.quest_id", "questline_quest.sort_order"]) + .where("questline_quest.questline_id", "in", questlineIds) + .orderBy("questline_quest.questline_id") + .orderBy("questline_quest.sort_order") + .execute(); + + const byQuestline = new Map>(); + + for (const row of qqRows) { + const list = byQuestline.get(row.questline_id) ?? []; + + list.push(row.quest_id); + byQuestline.set(row.questline_id, list); + } + + return qlRows.map((row) => ToQuestlineDocument(row, byQuestline.get(row.questline_id) ?? [])); +} + +/** + * One questline by id, scoped to game/playtype. `quests` are ordered by `questline_quest.sort_order`. + */ +export async function GetQuestlineById( + game: GameGroup, + playtype: Playtype, + questlineID: string, +): Promise { + const v3Game = GamePTToV3(game, playtype); + + const row = await DB.selectFrom("questline") + .select(SELECT_QUESTLINE) + .where("questline.id", "=", questlineID) + .where("questline.game", "=", v3Game) + .executeTakeFirst(); + + if (!row) { + return undefined; + } + + const qqRows = await DB.selectFrom("questline_quest") + .select("questline_quest.quest_id") + .where("questline_quest.questline_id", "=", questlineID) + .orderBy("questline_quest.sort_order") + .execute(); + + return ToQuestlineDocument( + row, + qqRows.map((r) => r.quest_id), + ); +} diff --git a/typescript/server/src/utils/queries/scores.ts b/typescript/server/src/utils/queries/scores.ts index 1ed6cb1d4..c555602e1 100644 --- a/typescript/server/src/utils/queries/scores.ts +++ b/typescript/server/src/utils/queries/scores.ts @@ -7,7 +7,15 @@ import { } from "#lib/db-formats/score"; import DB from "#services/pg/db"; import { sql } from "kysely"; -import { type GameGroup, GamePTToV3, type integer, type Playtype } from "tachi-common"; +import { + type GameGroup, + GamePTToV3, + GetGPTConfig, + GetGPTString, + type integer, + type MONGO_ScoreDocument, + type Playtype, +} from "tachi-common"; /** Shared score + chart + song + import select used by activity and UGPT score queries. */ export function scoreDocumentJoin() { @@ -115,3 +123,55 @@ export async function GetRecentUGPTHighlights( return rows.map((row) => ToScoreDocument(row as ScoreDocumentJoinRow)); } + +/** + * For each chart in {@link chartIDs}, the earliest score (by play time; nulls last) + * that satisfies `metric >= criteriaValue`, then ordered by play time with + * null `timeAchieved` sorting as 0 (Mongo `/folders/.../timeline` parity). + */ +export async function GetFolderTimelineScores( + userID: integer, + game: GameGroup, + playtype: Playtype, + chartIDs: string[], + metric: string, + criteriaValue: number, +): Promise> { + if (chartIDs.length === 0) { + return []; + } + + const gpt = GetGPTString(game, playtype); + const gptConfig = GetGPTConfig(gpt); + const v3Game = GamePTToV3(game, playtype) as Game; + + const jsonBlob = + gptConfig.derivedMetrics[metric] !== undefined ? sql`score.derived_data` : sql`score.data`; + + const rows = await scoreDocumentJoin() + .where("score.user_id", "=", userID) + .where("score.game", "=", v3Game) + .where("chart.id", "in", chartIDs) + .where(sql`(${jsonBlob}::jsonb->>${sql.lit(metric)})::numeric >= ${criteriaValue}`) + .orderBy("score.chart_id") + .orderBy(sql`coalesce(score.time_achieved, 'infinity'::timestamptz) asc`) + .execute(); + + const seen = new Set(); + const picked: Array = []; + + for (const row of rows) { + const r = row as ScoreDocumentJoinRow; + + if (!seen.has(r.chart_id)) { + seen.add(r.chart_id); + picked.push(r); + } + } + + const scores = picked.map((r) => ToScoreDocument(r)); + + scores.sort((a, b) => (a.timeAchieved ?? 0) - (b.timeAchieved ?? 0)); + + return scores; +} diff --git a/typescript/server/src/utils/queries/sessions.ts b/typescript/server/src/utils/queries/sessions.ts index 8240790bb..cb21b79a8 100644 --- a/typescript/server/src/utils/queries/sessions.ts +++ b/typescript/server/src/utils/queries/sessions.ts @@ -61,13 +61,13 @@ export async function GetSessionData(session: MONGO_SessionDocument): Promise<{ .where("score.session_id", "=", session.sessionID) .execute(); - let charts = rows.map((row) => ToChartDocument(row, row.song_legacy_id)); + let charts = rows.map(ToChartDocument); charts = _.uniqBy(charts, "chartID"); - let songs = rows.map((row) => ToSongDocument(row)); + let songs = rows.map(ToSongDocument); songs = _.uniqBy(songs, "id"); - let scores = rows.map((row) => ToScoreDocument(row)); + let scores = rows.map(ToScoreDocument); scores = _.uniqBy(scores, "scoreID"); // TODO: Hard to implement efficiently diff --git a/typescript/server/src/utils/types.ts b/typescript/server/src/utils/types.ts index 4b2a82a13..979114620 100644 --- a/typescript/server/src/utils/types.ts +++ b/typescript/server/src/utils/types.ts @@ -80,7 +80,7 @@ export interface TachiRequestData { chartDoc?: MONGO_ChartDocument; songDoc?: MONGO_SongDocument; - songPgId?: string; + songNewID?: string; scoreDoc?: MONGO_ScoreDocument; sessionDoc?: MONGO_SessionDocument; tableDoc?: MONGO_TableDocument; diff --git a/typescript/server/tsconfig.json b/typescript/server/tsconfig.json index c330bb552..6f4a16996 100644 --- a/typescript/server/tsconfig.json +++ b/typescript/server/tsconfig.json @@ -8,6 +8,6 @@ "#*": ["./src/*"] } }, - "include": ["src"], + "include": ["src", "vitest.config.ts", "vitest.globalSetup.ts", "vitest.setup.ts"], "exclude": ["node_modules", "src/**/*.oldtest.ts"] } diff --git a/typescript/sieglinde/tsconfig.json b/typescript/sieglinde/tsconfig.json index e8eb41596..1b89c0e57 100644 --- a/typescript/sieglinde/tsconfig.json +++ b/typescript/sieglinde/tsconfig.json @@ -7,6 +7,6 @@ "#*": ["./src/*"] } }, - "include": ["src"], + "include": ["src", "vitest.config.ts"], "exclude": ["node_modules"] }