mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-07 14:28:09 +03:00
checkpoint
This commit is contained in:
@@ -2,24 +2,21 @@ import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { Random20Hex } from "#utils/misc.js";
|
||||
|
||||
export const ACTION_CreateOAuth2AuthCode = MakeAction(
|
||||
"CREATE_OAUTH2_AUTH_CODE",
|
||||
async (taker) => {
|
||||
const code = Random20Hex();
|
||||
const createdOn = Date.now();
|
||||
export const ACTION_CreateOAuth2AuthCode = MakeAction("CREATE_OAUTH2_AUTH_CODE", async (taker) => {
|
||||
const code = Random20Hex();
|
||||
const createdOn = Date.now();
|
||||
|
||||
await DB.insertInto("priv_oauth2_auth_token")
|
||||
.values({
|
||||
token: code,
|
||||
user_id: taker.acct.id,
|
||||
created_on: new Date(createdOn).toISOString(),
|
||||
})
|
||||
.execute();
|
||||
await DB.insertInto("priv_oauth2_auth_token")
|
||||
.values({
|
||||
token: code,
|
||||
user_id: taker.acct.id,
|
||||
created_on: new Date(createdOn).toISOString(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
return {
|
||||
code,
|
||||
userID: taker.acct.id,
|
||||
createdOn,
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
code,
|
||||
userID: taker.acct.id,
|
||||
createdOn,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { mergeScoreDataFromPg, mongoScoreDataToPg } from "#lib/v3/migration-tools";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { type MongoScoreData, type ScoreDocument } from "tachi-common";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_CustomiseScore } from "./customise-score";
|
||||
|
||||
// ─── mergeScoreDataFromPg roundtrip ───────────────────────────────────────────
|
||||
|
||||
describe("mergeScoreDataFromPg", () => {
|
||||
it("inverts mongoScoreDataToPg for iidx:SP", () => {
|
||||
const original = {
|
||||
grade: "F",
|
||||
lamp: "FAILED",
|
||||
percent: 50,
|
||||
score: 200,
|
||||
optional: {},
|
||||
} as MongoScoreData<"iidx:SP">;
|
||||
|
||||
const { data, derived } = mongoScoreDataToPg("iidx:SP", original);
|
||||
const back = mergeScoreDataFromPg("iidx:SP", data, derived);
|
||||
|
||||
expect(back).toMatchObject({
|
||||
grade: "F",
|
||||
lamp: "FAILED",
|
||||
percent: 50,
|
||||
score: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ACTION_CustomiseScore ────────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_CustomiseScore", () => {
|
||||
let counter = 0;
|
||||
|
||||
async function seedIidxScore(userId: number) {
|
||||
const n = ++counter;
|
||||
const songId = `song-cs-${n}`;
|
||||
const chartId = `chart-cs-${n}`;
|
||||
const scoreId = `score-cs-${n}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songId,
|
||||
legacy_id: n,
|
||||
game_group: "iidx",
|
||||
title: "S",
|
||||
artist: "A",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: chartId,
|
||||
legacy_id: chartId,
|
||||
game: "iidx-sp",
|
||||
song_id: songId,
|
||||
level: "10",
|
||||
level_num: 10,
|
||||
is_primary: true,
|
||||
difficulty: "NORMAL",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const { data, derived } = mongoScoreDataToPg("iidx:SP", {
|
||||
grade: "F",
|
||||
lamp: "FAILED",
|
||||
percent: 0,
|
||||
score: 100,
|
||||
optional: {},
|
||||
} as MongoScoreData<"iidx:SP">);
|
||||
|
||||
await DB.insertInto("score")
|
||||
.values({
|
||||
id: scoreId,
|
||||
user_id: userId,
|
||||
chart_id: chartId,
|
||||
game: "iidx-sp",
|
||||
session_id: null,
|
||||
import_id: null,
|
||||
data: JSON.stringify(data),
|
||||
derived_data: JSON.stringify(derived),
|
||||
calculated_data: JSON.stringify({}),
|
||||
meta: JSON.stringify({}),
|
||||
time_achieved: null,
|
||||
time_added: now,
|
||||
highlight: false,
|
||||
comment: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
return { scoreId, chartId };
|
||||
}
|
||||
|
||||
it("updates comment and returns the score document", async () => {
|
||||
const { id: userId, username } = await seedUser();
|
||||
const { scoreId } = await seedIidxScore(userId);
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_CustomiseScore(taker, {
|
||||
scoreID: scoreId,
|
||||
comment: "nice",
|
||||
});
|
||||
|
||||
expect((result.score as ScoreDocument).comment).toBe("nice");
|
||||
|
||||
const row = await DB.selectFrom("score")
|
||||
.select("comment")
|
||||
.where("id", "=", scoreId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row?.comment).toBe("nice");
|
||||
});
|
||||
|
||||
it("updates highlight and propagates to pb when present", async () => {
|
||||
const { id: userId, username } = await seedUser();
|
||||
const { scoreId, chartId } = await seedIidxScore(userId);
|
||||
|
||||
await DB.insertInto("pb")
|
||||
.values({
|
||||
user_id: userId,
|
||||
chart_id: chartId,
|
||||
lens: null,
|
||||
data: JSON.stringify({}),
|
||||
derived_data: JSON.stringify({}),
|
||||
calculated_data: JSON.stringify({}),
|
||||
ranking_value: 0,
|
||||
ranking_value_tb1: null,
|
||||
ranking_value_tb2: null,
|
||||
ranking_value_tb3: null,
|
||||
ranking_value_tb4: null,
|
||||
ranking_value_tb5: null,
|
||||
highlight: false,
|
||||
time_achieved: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_CustomiseScore(taker, {
|
||||
scoreID: scoreId,
|
||||
highlight: true,
|
||||
});
|
||||
|
||||
const [scoreRow, pbRow] = await Promise.all([
|
||||
DB.selectFrom("score").select("highlight").where("id", "=", scoreId).executeTakeFirst(),
|
||||
DB.selectFrom("pb")
|
||||
.select("highlight")
|
||||
.where("user_id", "=", userId)
|
||||
.where("chart_id", "=", chartId)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
|
||||
expect(scoreRow?.highlight).toBe(true);
|
||||
expect(pbRow?.highlight).toBe(true);
|
||||
});
|
||||
|
||||
it("throws 400 when nothing to change", async () => {
|
||||
const { id: userId, username } = await seedUser();
|
||||
const { scoreId } = await seedIidxScore(userId);
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(ACTION_CustomiseScore(taker, { scoreID: scoreId })).rejects.toMatchObject({
|
||||
code: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
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";
|
||||
|
||||
export const ACTION_CustomiseScore = MakeAction("CUSTOMISE_SCORE", async (_taker, input) => {
|
||||
if (input.comment === undefined && input.highlight === undefined) {
|
||||
throw new ExpectedErr(400, "This request modifies nothing about the score.");
|
||||
}
|
||||
|
||||
await DB.transaction().execute(async (trx) => {
|
||||
const scoreRow = await trx
|
||||
.selectFrom("score")
|
||||
.select(["chart_id", "user_id"])
|
||||
.where("id", "=", input.scoreID)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!scoreRow) {
|
||||
throw new ExpectedErr(404, "This score does not exist.");
|
||||
}
|
||||
|
||||
const setScore: { comment?: string | null; highlight?: boolean } = {};
|
||||
|
||||
if (input.comment !== undefined) {
|
||||
setScore.comment = input.comment;
|
||||
}
|
||||
|
||||
if (input.highlight !== undefined) {
|
||||
setScore.highlight = input.highlight;
|
||||
}
|
||||
|
||||
await trx.updateTable("score").set(setScore).where("id", "=", input.scoreID).execute();
|
||||
|
||||
if (input.highlight === true || input.highlight === false) {
|
||||
await trx
|
||||
.updateTable("pb")
|
||||
.set({ highlight: input.highlight })
|
||||
.where("user_id", "=", scoreRow.user_id)
|
||||
.where("chart_id", "=", scoreRow.chart_id)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
const doc = await LoadScoreDocumentById(input.scoreID);
|
||||
|
||||
if (!doc) {
|
||||
throw new ExpectedErr(500, "Score disappeared after update.");
|
||||
}
|
||||
|
||||
return { score: doc };
|
||||
});
|
||||
@@ -63,7 +63,6 @@ describe("ACTION_FollowUser", () => {
|
||||
const originalMax = ServerConfig.MAX_FOLLOWING_AMOUNT;
|
||||
|
||||
try {
|
||||
// @ts-expect-error deliberate mutation for test isolation
|
||||
ServerConfig.MAX_FOLLOWING_AMOUNT = 1;
|
||||
|
||||
// Seed one real follow so the count equals the new cap.
|
||||
@@ -78,7 +77,6 @@ describe("ACTION_FollowUser", () => {
|
||||
code: 400,
|
||||
});
|
||||
} finally {
|
||||
// @ts-expect-error deliberate mutation for test isolation
|
||||
ServerConfig.MAX_FOLLOWING_AMOUNT = originalMax;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
PBReference,
|
||||
PBScoreDocument,
|
||||
ProfileRatingAlgorithms,
|
||||
ScoreData,
|
||||
MongoScoreData,
|
||||
ScoreDocument,
|
||||
ScoreRatingAlgorithms,
|
||||
SessionRatingAlgorithms,
|
||||
@@ -130,14 +130,14 @@ export type GPTDerivers<GPT extends GPTString> = {
|
||||
// New-style deriver; just f(scoreData, chart) -> derivedMetrics
|
||||
// instead of the overly complex shit above.
|
||||
export type GPTNewDeriver<GPT extends GPTString> = (
|
||||
scoreData: ScoreData<GPT>,
|
||||
scoreData: MongoScoreData<GPT>,
|
||||
chart: ChartDocument<GPT>,
|
||||
) => DerivedMetrics[GPT];
|
||||
|
||||
// New-style score calc; just f(scoreData, derivedData, chart) -> calculatedData
|
||||
// instead of the per-algorithm record above.
|
||||
export type GPTNewCalcs<GPT extends GPTString> = (
|
||||
scoreData: ScoreData<GPT>,
|
||||
scoreData: MongoScoreData<GPT>,
|
||||
derivedData: DerivedMetrics[GPT],
|
||||
chart: ChartDocument<GPT>,
|
||||
) => Record<ScoreRatingAlgorithms[GPT], number | null>;
|
||||
|
||||
@@ -258,6 +258,16 @@ export const ActionSignatures = {
|
||||
consumedBy: z.null(),
|
||||
}),
|
||||
},
|
||||
CUSTOMISE_SCORE: {
|
||||
input: z.object({
|
||||
scoreID: z.string(),
|
||||
comment: z.union([z.string().min(1).max(120), z.null()]).optional(),
|
||||
highlight: z.boolean().optional(),
|
||||
}),
|
||||
output: z.object({
|
||||
score: z.unknown(),
|
||||
}),
|
||||
},
|
||||
UPSERT_KAI_AUTH_TOKEN: {
|
||||
input: z.object({
|
||||
service: z.enum(["EAG", "FLO", "MIN"]),
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import DB from "#services/pg/db";
|
||||
import type { ChartDocument, ChartDocumentData, Difficulties, GPTString, Versions } from "tachi-common";
|
||||
import type {
|
||||
ChartDocument,
|
||||
ChartDocumentData,
|
||||
Difficulties,
|
||||
GPTString,
|
||||
Versions,
|
||||
} from "tachi-common";
|
||||
import { V3ToGamePT } from "tachi-common";
|
||||
import type { Game } from "tachi-db";
|
||||
|
||||
@@ -14,7 +20,16 @@ export async function GetChartsBySongPgId(
|
||||
songLegacyId: number,
|
||||
): Promise<ChartDocument[]> {
|
||||
const rows = await DB.selectFrom("chart")
|
||||
.select(["id", "legacy_id", "game", "level", "level_num", "is_primary", "difficulty", "data"])
|
||||
.select([
|
||||
"id",
|
||||
"legacy_id",
|
||||
"game",
|
||||
"level",
|
||||
"level_num",
|
||||
"is_primary",
|
||||
"difficulty",
|
||||
"data",
|
||||
])
|
||||
.where("song_id", "=", songPgId)
|
||||
.where("game", "=", gamePt)
|
||||
.execute();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LoadImportDocumentById } from "./import-document";
|
||||
|
||||
describe("LoadImportDocumentById", () => {
|
||||
it("returns undefined when import does not exist", async () => {
|
||||
const doc = await LoadImportDocumentById("nonexistent-import-id");
|
||||
|
||||
expect(doc).toBeUndefined();
|
||||
});
|
||||
|
||||
it("composes an ImportDocument from Postgres rows", async () => {
|
||||
const { id: userId } = await seedUser();
|
||||
const importId = `import-fmt-${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await DB.insertInto("import")
|
||||
.values({
|
||||
id: importId,
|
||||
user_id: userId,
|
||||
time_started: now,
|
||||
time_finished: now,
|
||||
game_group: "iidx",
|
||||
import_type: "ir/direct-manual",
|
||||
user_intent: true,
|
||||
service: "test-svc",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("import_game").values({ id: importId, game: "iidx-sp" }).execute();
|
||||
|
||||
await DB.insertInto("import_error")
|
||||
.values({ import_id: importId, type: "T", message: "m" })
|
||||
.execute();
|
||||
|
||||
const doc = await LoadImportDocumentById(importId);
|
||||
|
||||
expect(doc).toMatchObject({
|
||||
importID: importId,
|
||||
userID: userId,
|
||||
game: "iidx",
|
||||
importType: "ir/direct-manual",
|
||||
userIntent: true,
|
||||
errors: [{ type: "T", message: "m" }],
|
||||
gptStrings: expect.arrayContaining(["iidx:SP"]),
|
||||
goalInfo: [],
|
||||
questInfo: [],
|
||||
});
|
||||
|
||||
expect(doc?.playtypes).toContain("SP");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { ISO8601ToUnixMilliseconds } from "#utils/time";
|
||||
import {
|
||||
type ClassDelta,
|
||||
GetGPTString,
|
||||
type GPTString,
|
||||
type ImportDocument,
|
||||
type ImportTypes,
|
||||
V3ToGamePT,
|
||||
} from "tachi-common";
|
||||
|
||||
/**
|
||||
* Build a full {@link ImportDocument} from normalized Postgres import tables.
|
||||
* `goalInfo` / `questInfo` are always empty (historical import_goal / import_quest were not migrated).
|
||||
*/
|
||||
export async function LoadImportDocumentById(
|
||||
importID: string,
|
||||
): Promise<ImportDocument | undefined> {
|
||||
const base = await DB.selectFrom("import")
|
||||
.selectAll()
|
||||
.where("id", "=", importID)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!base) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [games, errors, classes, sessions, scoreIds] = await Promise.all([
|
||||
DB.selectFrom("import_game").select("game").where("id", "=", importID).execute(),
|
||||
DB.selectFrom("import_error")
|
||||
.select(["type", "message"])
|
||||
.where("import_id", "=", importID)
|
||||
.execute(),
|
||||
DB.selectFrom("import_class")
|
||||
.select(["game", "set", "prev", "new"])
|
||||
.where("import_id", "=", importID)
|
||||
.execute(),
|
||||
DB.selectFrom("import_session")
|
||||
.select(["session_id", "type"])
|
||||
.where("import_id", "=", importID)
|
||||
.execute(),
|
||||
DB.selectFrom("score").select("id").where("import_id", "=", importID).execute(),
|
||||
]);
|
||||
|
||||
const gptStringSet = new Set<GPTString>();
|
||||
|
||||
for (const g of games) {
|
||||
const { game, playtype } = V3ToGamePT(g.game);
|
||||
|
||||
gptStringSet.add(GetGPTString(game, playtype));
|
||||
}
|
||||
|
||||
const gptStrings = [...gptStringSet];
|
||||
|
||||
const playtypeSet = new Set(
|
||||
games.map((g) => {
|
||||
const { playtype } = V3ToGamePT(g.game);
|
||||
|
||||
return playtype;
|
||||
}),
|
||||
);
|
||||
|
||||
const classDeltas: Array<ClassDelta> = classes.map((c) => {
|
||||
const { game, playtype } = V3ToGamePT(c.game);
|
||||
|
||||
return {
|
||||
game,
|
||||
playtype,
|
||||
set: c.set as ClassDelta["set"],
|
||||
old: c.prev,
|
||||
new: c.new,
|
||||
};
|
||||
});
|
||||
|
||||
const createdSessions = sessions.map((s) => {
|
||||
const t = s.type;
|
||||
|
||||
const cap = t.length > 0 ? t.charAt(0).toUpperCase() + t.slice(1).toLowerCase() : t;
|
||||
|
||||
return {
|
||||
sessionID: s.session_id,
|
||||
type: cap as SessionInfoType,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
userID: base.user_id,
|
||||
timeStarted: ISO8601ToUnixMilliseconds(base.time_started),
|
||||
timeFinished: ISO8601ToUnixMilliseconds(base.time_finished),
|
||||
gptStrings,
|
||||
importID: base.id,
|
||||
scoreIDs: scoreIds.map((s) => s.id),
|
||||
game: base.game_group,
|
||||
playtypes: [...playtypeSet] as ImportDocument["playtypes"],
|
||||
errors: errors.map((e) => ({ type: e.type, message: e.message })),
|
||||
createdSessions,
|
||||
importType: base.import_type as ImportTypes,
|
||||
classDeltas,
|
||||
goalInfo: [],
|
||||
questInfo: [],
|
||||
userIntent: base.user_intent,
|
||||
};
|
||||
}
|
||||
|
||||
type SessionInfoType = ImportDocument["createdSessions"][number]["type"];
|
||||
@@ -0,0 +1,104 @@
|
||||
import { mergeScoreDataFromPg } from "#lib/v3/migration-tools";
|
||||
import DB from "#services/pg/db";
|
||||
import { ISO8601ToUnixMilliseconds } from "#utils/time";
|
||||
import {
|
||||
GetGPTString,
|
||||
type GPTString,
|
||||
type ImportTypes,
|
||||
type ScoreDocument,
|
||||
V3ToGamePT,
|
||||
} from "tachi-common";
|
||||
import { type Game } from "tachi-db";
|
||||
|
||||
/** Columns from `score` joined with chart/song for a full {@link ScoreDocument}. */
|
||||
export const SELECT_SCORE_DOCUMENT = [
|
||||
"score.id",
|
||||
"score.user_id",
|
||||
"score.game",
|
||||
"score.data",
|
||||
"score.derived_data",
|
||||
"score.calculated_data",
|
||||
"score.meta",
|
||||
"score.time_achieved",
|
||||
"score.time_added",
|
||||
"score.highlight",
|
||||
"score.comment",
|
||||
"chart.legacy_id as chart_legacy_id",
|
||||
"chart.is_primary",
|
||||
"song.legacy_id as song_legacy_id",
|
||||
"import.service as import_service",
|
||||
"import.import_type as import_import_type",
|
||||
] as const;
|
||||
|
||||
/** Row shape from {@link SELECT_SCORE_DOCUMENT} join query. */
|
||||
export interface ScoreDocumentJoinRow {
|
||||
id: string;
|
||||
user_id: number;
|
||||
game: Game;
|
||||
data: unknown;
|
||||
derived_data: unknown;
|
||||
calculated_data: unknown;
|
||||
meta: unknown;
|
||||
time_achieved: string | null;
|
||||
time_added: string;
|
||||
highlight: boolean;
|
||||
comment: string | null;
|
||||
chart_legacy_id: string;
|
||||
is_primary: boolean;
|
||||
song_legacy_id: number;
|
||||
import_service: string | null;
|
||||
import_import_type: string | null;
|
||||
}
|
||||
|
||||
export function ToScoreDocument(row: ScoreDocumentJoinRow): ScoreDocument {
|
||||
const { game, playtype } = V3ToGamePT(row.game);
|
||||
const gpt = GetGPTString(game, playtype) as GPTString;
|
||||
|
||||
const scoreData = mergeScoreDataFromPg(gpt, row.data, row.derived_data);
|
||||
|
||||
const scoreMeta = row.meta as ScoreDocument["scoreMeta"];
|
||||
|
||||
const calculatedData = row.calculated_data as ScoreDocument["calculatedData"];
|
||||
|
||||
const service =
|
||||
row.import_service !== null &&
|
||||
row.import_service !== undefined &&
|
||||
row.import_service.length > 0
|
||||
? row.import_service
|
||||
: "Unknown";
|
||||
|
||||
return {
|
||||
service,
|
||||
game,
|
||||
playtype,
|
||||
userID: row.user_id,
|
||||
scoreData,
|
||||
scoreMeta: scoreMeta ?? {},
|
||||
calculatedData: calculatedData ?? {},
|
||||
timeAchieved: row.time_achieved ? ISO8601ToUnixMilliseconds(row.time_achieved) : null,
|
||||
songID: row.song_legacy_id,
|
||||
chartID: row.chart_legacy_id,
|
||||
isPrimary: row.is_primary,
|
||||
highlight: row.highlight,
|
||||
comment: row.comment,
|
||||
timeAdded: ISO8601ToUnixMilliseconds(row.time_added),
|
||||
scoreID: row.id,
|
||||
importType: row.import_import_type as ImportTypes | null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function LoadScoreDocumentById(scoreID: string): Promise<ScoreDocument | undefined> {
|
||||
const row = await DB.selectFrom("score")
|
||||
.innerJoin("chart", "chart.id", "score.chart_id")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.leftJoin("import", "import.id", "score.import_id")
|
||||
.select(SELECT_SCORE_DOCUMENT)
|
||||
.where("score.id", "=", scoreID)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return ToScoreDocument(row as ScoreDocumentJoinRow);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LoadSessionDocumentById } from "./session";
|
||||
|
||||
describe("LoadSessionDocumentById", () => {
|
||||
it("returns undefined when session is missing", async () => {
|
||||
expect(await LoadSessionDocumentById("no-such-session")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps session row and score IDs", async () => {
|
||||
const { id: userId } = await seedUser();
|
||||
const sessionId = `sess-full-${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await DB.insertInto("session")
|
||||
.values({
|
||||
id: sessionId,
|
||||
user_id: userId,
|
||||
game: "iidx-sp",
|
||||
name: "N",
|
||||
description: null,
|
||||
time_inserted: now,
|
||||
time_started: now,
|
||||
time_ended: now,
|
||||
calculated_data: JSON.stringify({}),
|
||||
highlight: false,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const songId = `s-${sessionId}`;
|
||||
const chartId = `c-${sessionId}`;
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: songId,
|
||||
legacy_id: 1,
|
||||
game_group: "iidx",
|
||||
title: "T",
|
||||
artist: "A",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: chartId,
|
||||
legacy_id: chartId,
|
||||
game: "iidx-sp",
|
||||
song_id: songId,
|
||||
level: "1",
|
||||
level_num: 1,
|
||||
is_primary: true,
|
||||
difficulty: "NORMAL",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const scoreId = `sc-${sessionId}`;
|
||||
|
||||
await DB.insertInto("score")
|
||||
.values({
|
||||
id: scoreId,
|
||||
user_id: userId,
|
||||
chart_id: chartId,
|
||||
game: "iidx-sp",
|
||||
session_id: sessionId,
|
||||
import_id: null,
|
||||
data: JSON.stringify({}),
|
||||
derived_data: JSON.stringify({}),
|
||||
calculated_data: JSON.stringify({}),
|
||||
meta: JSON.stringify({}),
|
||||
time_achieved: null,
|
||||
time_added: now,
|
||||
highlight: false,
|
||||
comment: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const doc = await LoadSessionDocumentById(sessionId);
|
||||
|
||||
expect(doc).toMatchObject({
|
||||
sessionID: sessionId,
|
||||
userID: userId,
|
||||
name: "N",
|
||||
scoreIDs: [scoreId],
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,72 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { ISO8601ToUnixMilliseconds } from "#utils/time";
|
||||
import { type Selection } from "kysely";
|
||||
import { type GPTString, type SessionDocument, V3ToGamePT } from "tachi-common";
|
||||
import { type SessionDocument, V3ToGamePT } from "tachi-common";
|
||||
import { type Database } from "tachi-db";
|
||||
|
||||
export const SELECT_SESSION_DOCUMENT = [
|
||||
"session.id",
|
||||
"session.user_id",
|
||||
"session.game",
|
||||
"session.name",
|
||||
"session.description",
|
||||
"session.time_inserted",
|
||||
"session.time_started",
|
||||
"session.time_ended",
|
||||
"session.calculated_data",
|
||||
"session.highlight",
|
||||
] as const;
|
||||
|
||||
export function ToSessionDocument(
|
||||
row: Selection<Database, "session", (typeof SELECT_SESSION_DOCUMENT)[number]>,
|
||||
scoreIDs: Array<string>,
|
||||
): SessionDocument {
|
||||
const { game, playtype } = V3ToGamePT(row.game);
|
||||
|
||||
const calculatedData =
|
||||
typeof row.calculated_data === "string"
|
||||
? (JSON.parse(row.calculated_data) as SessionDocument["calculatedData"])
|
||||
: (row.calculated_data as SessionDocument["calculatedData"]);
|
||||
|
||||
return {
|
||||
userID: row.user_id,
|
||||
sessionID: row.id,
|
||||
scoreIDs,
|
||||
name: row.name,
|
||||
desc: row.description,
|
||||
game,
|
||||
playtype,
|
||||
timeInserted: ISO8601ToUnixMilliseconds(row.time_inserted),
|
||||
timeStarted: ISO8601ToUnixMilliseconds(row.time_started),
|
||||
timeEnded: ISO8601ToUnixMilliseconds(row.time_ended),
|
||||
calculatedData,
|
||||
highlight: row.highlight,
|
||||
};
|
||||
}
|
||||
|
||||
export async function LoadSessionDocumentById(
|
||||
sessionID: string,
|
||||
): Promise<SessionDocument | undefined> {
|
||||
const row = await DB.selectFrom("session")
|
||||
.select(SELECT_SESSION_DOCUMENT)
|
||||
.where("id", "=", sessionID)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scores = await DB.selectFrom("score")
|
||||
.select("id")
|
||||
.where("session_id", "=", sessionID)
|
||||
.execute();
|
||||
|
||||
return ToSessionDocument(
|
||||
row,
|
||||
scores.map((s) => s.id),
|
||||
);
|
||||
}
|
||||
|
||||
export const SELECT_SESSION_CALENDAR = [
|
||||
"session.id",
|
||||
"session.name",
|
||||
|
||||
@@ -25,10 +25,7 @@ export async function GetSongByLegacyID(
|
||||
.select("search_term")
|
||||
.where("song_id", "=", row.id)
|
||||
.execute(),
|
||||
DB.selectFrom("song_alt_title")
|
||||
.select("alt_title")
|
||||
.where("song_id", "=", row.id)
|
||||
.execute(),
|
||||
DB.selectFrom("song_alt_title").select("alt_title").where("song_id", "=", row.id).execute(),
|
||||
]);
|
||||
|
||||
const doc: SongDocument = {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { toPgGame } from "#services/pg/seeds";
|
||||
import { type Selectable } from "kysely";
|
||||
import { type GameGroup, type Playtype, type TableDocument, V3ToGamePT } from "tachi-common";
|
||||
import { type Database } from "tachi-db";
|
||||
|
||||
export type TableRow = Selectable<Database["table"]>;
|
||||
|
||||
export function ToTableDocument(row: TableRow, folderIds: Array<string>): TableDocument {
|
||||
const { game, playtype } = V3ToGamePT(row.game);
|
||||
|
||||
return {
|
||||
tableID: row.legacy_id,
|
||||
game,
|
||||
playtype,
|
||||
title: row.title,
|
||||
description: "",
|
||||
folders: folderIds,
|
||||
inactive: row.inactive,
|
||||
default: row.default_value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load table documents for a game/playtype (API shape). No folder *documents* — only folder IDs.
|
||||
*/
|
||||
export async function GetTableDocumentsForGamePlaytype(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
includeInactive: boolean,
|
||||
): Promise<Array<TableDocument>> {
|
||||
const pgGame = toPgGame(game, playtype);
|
||||
|
||||
let q = DB.selectFrom("table").selectAll().where("game", "=", pgGame);
|
||||
|
||||
if (!includeInactive) {
|
||||
q = q.where("inactive", "=", false);
|
||||
}
|
||||
|
||||
const rows = await q.execute();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tableIds = rows.map((r) => r.id);
|
||||
|
||||
const tfRows = await DB.selectFrom("table_folder")
|
||||
.select(["table_id", "folder_id"])
|
||||
.where("table_id", "in", tableIds)
|
||||
.execute();
|
||||
|
||||
const foldersByTable = new Map<string, Array<string>>();
|
||||
|
||||
for (const t of tfRows) {
|
||||
const list = foldersByTable.get(t.table_id) ?? [];
|
||||
|
||||
list.push(t.folder_id);
|
||||
foldersByTable.set(t.table_id, list);
|
||||
}
|
||||
|
||||
return rows.map((row) => ToTableDocument(row, foldersByTable.get(row.id) ?? []));
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type GPTString,
|
||||
type integer,
|
||||
type OptionalEnumIndexes,
|
||||
type ScoreData,
|
||||
type MongoScoreData,
|
||||
type ScoreEnumIndexes,
|
||||
} from "tachi-common";
|
||||
|
||||
@@ -129,7 +129,7 @@ export function CreateFullScoreData<GPT extends GPTString>(
|
||||
const scoreData = {
|
||||
...dryScoreData,
|
||||
...derivedMetrics,
|
||||
} as unknown as ScoreData<GPT>;
|
||||
} as unknown as MongoScoreData<GPT>;
|
||||
// ^ hacky force-cast because these types are *really* unstable.
|
||||
|
||||
const { indexes, optionalIndexes } = CreateEnumIndexes(gpt, scoreData, log);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DryScore } from "#lib/score-import/framework/common/types";
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { ScoreData } from "tachi-common";
|
||||
import type { MongoScoreData } from "tachi-common";
|
||||
|
||||
import {
|
||||
InternalFailure,
|
||||
@@ -29,7 +29,7 @@ const DIFFICULTIES = {
|
||||
function getLamp(
|
||||
comboStatus: number,
|
||||
isClear: boolean,
|
||||
): ScoreData<"maimaidx:Single">["lamp"] | undefined {
|
||||
): MongoScoreData<"maimaidx:Single">["lamp"] | undefined {
|
||||
if (comboStatus === MaimaiComboStatus.UNSPECIFIED) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DryScore } from "#lib/score-import/framework/common/types";
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { ScoreData } from "tachi-common";
|
||||
import type { MongoScoreData } from "tachi-common";
|
||||
|
||||
import {
|
||||
InternalFailure,
|
||||
@@ -32,7 +32,7 @@ function getNoteLamp(
|
||||
comboStatus: number,
|
||||
clearStatus: number,
|
||||
techScore: number,
|
||||
): ScoreData<"ongeki:Single">["noteLamp"] | undefined {
|
||||
): MongoScoreData<"ongeki:Single">["noteLamp"] | undefined {
|
||||
if (
|
||||
comboStatus === OngekiComboStatus.UNSPECIFIED ||
|
||||
clearStatus === OngekiClearStatus.UNSPECIFIED
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SDVX_GRADES, SDVX_LAMPS, type MongoScoreData } from "tachi-common";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mergeScoreDataFromPg, mongoScoreDataToPg } from "./migration-tools.js";
|
||||
|
||||
describe("mergeScoreDataFromPg", () => {
|
||||
const scoreData = {
|
||||
score: 9_876_543,
|
||||
lamp: "EXCESSIVE CLEAR",
|
||||
grade: "S",
|
||||
optional: {
|
||||
enumIndexes: {},
|
||||
exScore: 123,
|
||||
fast: 32,
|
||||
gauge: 99,
|
||||
maxCombo: 9,
|
||||
slow: 42,
|
||||
},
|
||||
enumIndexes: {
|
||||
grade: SDVX_GRADES.S,
|
||||
lamp: SDVX_LAMPS.EXCESSIVE_CLEAR,
|
||||
},
|
||||
judgements: {
|
||||
critical: 123,
|
||||
miss: 23,
|
||||
near: 22,
|
||||
},
|
||||
} satisfies MongoScoreData<"sdvx:Single">;
|
||||
|
||||
it("roundtrips as expected (sdvx:Single)", () => {
|
||||
const gpt = "sdvx:Single";
|
||||
|
||||
const { data, derived } = mongoScoreDataToPg(gpt, scoreData);
|
||||
const merged = mergeScoreDataFromPg(gpt, data, derived);
|
||||
|
||||
expect(merged).toStrictEqual(scoreData);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { DeleteUndefinedProps, staticAssertUnreachable } from "#utils/misc.js";
|
||||
import { Mongos } from "mongodb";
|
||||
import {
|
||||
type DerivedMetrics,
|
||||
GetGPTConfig,
|
||||
type GPTString,
|
||||
type OptionalMetrics,
|
||||
type ProvidedMetrics,
|
||||
type ScoreData,
|
||||
type MongoScoreData,
|
||||
PgScoreData,
|
||||
GPTStringToV3Game,
|
||||
integer,
|
||||
} from "tachi-common";
|
||||
|
||||
interface RetVal<GPT extends GPTString> {
|
||||
@@ -36,11 +41,11 @@ function applyOrdinals(
|
||||
return result;
|
||||
}
|
||||
|
||||
function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString> {
|
||||
function splitScoreData(gpt: GPTString, scoreData: MongoScoreData): RetVal<GPTString> {
|
||||
switch (gpt) {
|
||||
case "iidx:SP": {
|
||||
const { grade, lamp, percent, score, optional } =
|
||||
scoreData as unknown as ScoreData<"iidx:SP">;
|
||||
scoreData as unknown as MongoScoreData<"iidx:SP">;
|
||||
|
||||
const ret: RetVal<"iidx:SP"> = {
|
||||
data: {
|
||||
@@ -59,7 +64,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "iidx:DP": {
|
||||
const { grade, lamp, percent, score, optional } =
|
||||
scoreData as unknown as ScoreData<"iidx:DP">;
|
||||
scoreData as unknown as MongoScoreData<"iidx:DP">;
|
||||
|
||||
const ret: RetVal<"iidx:DP"> = {
|
||||
data: {
|
||||
@@ -75,7 +80,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "museca:Single": {
|
||||
const { grade, lamp, score, optional } =
|
||||
scoreData as unknown as ScoreData<"museca:Single">;
|
||||
scoreData as unknown as MongoScoreData<"museca:Single">;
|
||||
|
||||
const ret: RetVal<"museca:Single"> = {
|
||||
data: {
|
||||
@@ -91,7 +96,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "chunithm:Single": {
|
||||
const { grade, score, noteLamp, clearLamp, optional } =
|
||||
scoreData as unknown as ScoreData<"chunithm:Single">;
|
||||
scoreData as unknown as MongoScoreData<"chunithm:Single">;
|
||||
|
||||
const ret: RetVal<"chunithm:Single"> = {
|
||||
data: {
|
||||
@@ -108,7 +113,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "bms:7K": {
|
||||
const { grade, lamp, percent, score, optional } =
|
||||
scoreData as unknown as ScoreData<"bms:7K">;
|
||||
scoreData as unknown as MongoScoreData<"bms:7K">;
|
||||
|
||||
const ret: RetVal<"bms:7K"> = {
|
||||
data: {
|
||||
@@ -124,7 +129,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "bms:14K": {
|
||||
const { grade, lamp, percent, score, optional } =
|
||||
scoreData as unknown as ScoreData<"bms:14K">;
|
||||
scoreData as unknown as MongoScoreData<"bms:14K">;
|
||||
|
||||
const ret: RetVal<"bms:14K"> = {
|
||||
data: {
|
||||
@@ -140,7 +145,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "gitadora:Gita": {
|
||||
const { grade, lamp, percent, optional } =
|
||||
scoreData as unknown as ScoreData<"gitadora:Gita">;
|
||||
scoreData as unknown as MongoScoreData<"gitadora:Gita">;
|
||||
|
||||
const ret: RetVal<"gitadora:Gita"> = {
|
||||
data: {
|
||||
@@ -156,7 +161,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "gitadora:Dora": {
|
||||
const { grade, lamp, percent, optional } =
|
||||
scoreData as unknown as ScoreData<"gitadora:Dora">;
|
||||
scoreData as unknown as MongoScoreData<"gitadora:Dora">;
|
||||
|
||||
const ret: RetVal<"gitadora:Dora"> = {
|
||||
data: {
|
||||
@@ -172,7 +177,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "jubeat:Single": {
|
||||
const { grade, score, musicRate, lamp, optional } =
|
||||
scoreData as unknown as ScoreData<"jubeat:Single">;
|
||||
scoreData as unknown as MongoScoreData<"jubeat:Single">;
|
||||
|
||||
const ret: RetVal<"jubeat:Single"> = {
|
||||
data: {
|
||||
@@ -189,7 +194,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "maimai:Single": {
|
||||
const { grade, lamp, percent, optional } =
|
||||
scoreData as unknown as ScoreData<"maimai:Single">;
|
||||
scoreData as unknown as MongoScoreData<"maimai:Single">;
|
||||
|
||||
const ret: RetVal<"maimai:Single"> = {
|
||||
data: {
|
||||
@@ -205,7 +210,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "maimaidx:Single": {
|
||||
const { grade, lamp, percent, optional } =
|
||||
scoreData as unknown as ScoreData<"maimaidx:Single">;
|
||||
scoreData as unknown as MongoScoreData<"maimaidx:Single">;
|
||||
|
||||
const ret: RetVal<"maimaidx:Single"> = {
|
||||
data: {
|
||||
@@ -221,7 +226,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "popn:9B": {
|
||||
const { grade, lamp, score, clearMedal, optional } =
|
||||
scoreData as unknown as ScoreData<"popn:9B">;
|
||||
scoreData as unknown as MongoScoreData<"popn:9B">;
|
||||
|
||||
const ret: RetVal<"popn:9B"> = {
|
||||
data: {
|
||||
@@ -237,7 +242,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "sdvx:Single": {
|
||||
const { grade, lamp, score, optional } =
|
||||
scoreData as unknown as ScoreData<"sdvx:Single">;
|
||||
scoreData as unknown as MongoScoreData<"sdvx:Single">;
|
||||
|
||||
const ret: RetVal<"sdvx:Single"> = {
|
||||
data: {
|
||||
@@ -253,7 +258,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "usc:Controller": {
|
||||
const { grade, lamp, score, optional } =
|
||||
scoreData as unknown as ScoreData<"usc:Controller">;
|
||||
scoreData as unknown as MongoScoreData<"usc:Controller">;
|
||||
|
||||
const ret: RetVal<"usc:Controller"> = {
|
||||
data: {
|
||||
@@ -269,7 +274,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "usc:Keyboard": {
|
||||
const { grade, lamp, score, optional } =
|
||||
scoreData as unknown as ScoreData<"usc:Keyboard">;
|
||||
scoreData as unknown as MongoScoreData<"usc:Keyboard">;
|
||||
|
||||
const ret: RetVal<"usc:Keyboard"> = {
|
||||
data: {
|
||||
@@ -285,7 +290,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "wacca:Single": {
|
||||
const { grade, lamp, score, optional } =
|
||||
scoreData as unknown as ScoreData<"wacca:Single">;
|
||||
scoreData as unknown as MongoScoreData<"wacca:Single">;
|
||||
|
||||
const ret: RetVal<"wacca:Single"> = {
|
||||
data: {
|
||||
@@ -301,7 +306,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "pms:Controller": {
|
||||
const { grade, lamp, percent, score, optional } =
|
||||
scoreData as unknown as ScoreData<"pms:Controller">;
|
||||
scoreData as unknown as MongoScoreData<"pms:Controller">;
|
||||
|
||||
const ret: RetVal<"pms:Controller"> = {
|
||||
data: {
|
||||
@@ -317,7 +322,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "pms:Keyboard": {
|
||||
const { grade, lamp, percent, score, optional } =
|
||||
scoreData as unknown as ScoreData<"pms:Keyboard">;
|
||||
scoreData as unknown as MongoScoreData<"pms:Keyboard">;
|
||||
|
||||
const ret: RetVal<"pms:Keyboard"> = {
|
||||
data: {
|
||||
@@ -333,7 +338,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "itg:Stamina": {
|
||||
const { grade, lamp, scorePercent, survivedPercent, finalPercent, optional } =
|
||||
scoreData as unknown as ScoreData<"itg:Stamina">;
|
||||
scoreData as unknown as MongoScoreData<"itg:Stamina">;
|
||||
|
||||
const ret: RetVal<"itg:Stamina"> = {
|
||||
data: {
|
||||
@@ -350,7 +355,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "arcaea:Touch": {
|
||||
const { grade, lamp, score, optional } =
|
||||
scoreData as unknown as ScoreData<"arcaea:Touch">;
|
||||
scoreData as unknown as MongoScoreData<"arcaea:Touch">;
|
||||
|
||||
const ret: RetVal<"arcaea:Touch"> = {
|
||||
data: {
|
||||
@@ -366,7 +371,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
case "ongeki:Single": {
|
||||
const { grade, score, noteLamp, bellLamp, platinumScore, platinumStars, optional } =
|
||||
scoreData as unknown as ScoreData<"ongeki:Single">;
|
||||
scoreData as unknown as MongoScoreData<"ongeki:Single">;
|
||||
|
||||
const ret: RetVal<"ongeki:Single"> = {
|
||||
data: {
|
||||
@@ -383,7 +388,8 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
}
|
||||
|
||||
case "ddr:SP": {
|
||||
const { grade, lamp, score, optional } = scoreData as unknown as ScoreData<"ddr:SP">;
|
||||
const { grade, lamp, score, optional } =
|
||||
scoreData as unknown as MongoScoreData<"ddr:SP">;
|
||||
|
||||
const ret: RetVal<"ddr:SP"> = {
|
||||
data: {
|
||||
@@ -398,7 +404,8 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
}
|
||||
|
||||
case "ddr:DP": {
|
||||
const { grade, lamp, score, optional } = scoreData as unknown as ScoreData<"ddr:DP">;
|
||||
const { grade, lamp, score, optional } =
|
||||
scoreData as unknown as MongoScoreData<"ddr:DP">;
|
||||
|
||||
const ret: RetVal<"ddr:DP"> = {
|
||||
data: {
|
||||
@@ -416,7 +423,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString>
|
||||
|
||||
export function mongoScoreDataToPg(
|
||||
gpt: GPTString,
|
||||
scoreData: ScoreData,
|
||||
scoreData: MongoScoreData,
|
||||
): { data: unknown; derived: unknown } {
|
||||
const { data, derived } = splitScoreData(gpt, scoreData);
|
||||
|
||||
@@ -427,3 +434,62 @@ export function mongoScoreDataToPg(
|
||||
derived: applyOrdinals(derived, config.derivedMetrics as any),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct API {@link MongoScoreData} from Postgres `data` / `derived_data` JSON blobs
|
||||
* (the inverse of {@link mongoScoreDataToPg}).
|
||||
*/
|
||||
export function mergeScoreDataFromPg<G extends GPTString = GPTString>(
|
||||
gpt: G,
|
||||
dataRaw: any,
|
||||
derivedRaw: any,
|
||||
): MongoScoreData<G> {
|
||||
const config = GetGPTConfig(gpt);
|
||||
|
||||
const eventualOut: any = {
|
||||
enumIndexes: {},
|
||||
optional: {
|
||||
enumIndexes: {},
|
||||
},
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(config.providedMetrics)) {
|
||||
if (value.type === "ENUM") {
|
||||
const index: integer = dataRaw[key];
|
||||
eventualOut[key] = value.values[index];
|
||||
eventualOut.enumIndexes[key] = index;
|
||||
} else {
|
||||
eventualOut[key] = dataRaw[key];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(dataRaw);
|
||||
console.log(derivedRaw);
|
||||
|
||||
for (const [key, value] of Object.entries(config.derivedMetrics)) {
|
||||
if (value.type === "ENUM") {
|
||||
const index: integer = derivedRaw[key];
|
||||
eventualOut[key] = value.values[index];
|
||||
eventualOut.enumIndexes[key] = index;
|
||||
} else {
|
||||
eventualOut[key] = derivedRaw[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(config.optionalMetrics)) {
|
||||
if (value.type === "ENUM") {
|
||||
const index: integer = dataRaw[key];
|
||||
eventualOut.optional[key] = value.values[index];
|
||||
eventualOut.optional.enumIndexes[key] = index;
|
||||
} else {
|
||||
eventualOut.optional[key] = dataRaw[key];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(eventualOut);
|
||||
|
||||
DeleteUndefinedProps(eventualOut.optional);
|
||||
DeleteUndefinedProps(eventualOut);
|
||||
|
||||
return eventualOut as any;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { FilterQuery } from "mongodb";
|
||||
|
||||
import { log } from "#lib/log/log";
|
||||
import { GetTableDocumentsForGamePlaytype } from "#lib/db-formats/table";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetFoldersFromTable } from "#utils/folder";
|
||||
import { GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
import { FormatGameGroup, type TableDocument } from "tachi-common";
|
||||
import { FormatGameGroup } from "tachi-common";
|
||||
|
||||
import { GetTableFromParam } from "./middleware";
|
||||
|
||||
@@ -21,13 +20,9 @@ const router: Router = Router({ mergeParams: true });
|
||||
router.get("/", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const query: FilterQuery<TableDocument> = { game, playtype };
|
||||
const includeInactive = req.query.showInactive !== undefined;
|
||||
|
||||
if (req.query.showInactive === undefined) {
|
||||
query.inactive = false;
|
||||
}
|
||||
|
||||
const tables = await MONGODB_KILL.tables.find(query);
|
||||
const tables = await GetTableDocumentsForGamePlaytype(game, playtype, includeInactive);
|
||||
|
||||
if (tables.length === 0) {
|
||||
log.error(
|
||||
|
||||
@@ -2,12 +2,12 @@ import type { RequestHandler } from "express";
|
||||
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { LoadImportDocumentById } from "#lib/db-formats/import-document";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { IsRequesterAdmin } from "#utils/user";
|
||||
|
||||
export const GetImportFromParam: RequestHandler = async (req, res, next) => {
|
||||
const importDoc = await MONGODB_KILL.imports.findOne({ importID: req.params.importID });
|
||||
const importDoc = await LoadImportDocumentById(req.params.importID);
|
||||
|
||||
if (!importDoc) {
|
||||
return res.status(404).json({
|
||||
|
||||
@@ -2,12 +2,12 @@ import type { RequestHandler } from "express";
|
||||
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { LoadScoreDocumentById } from "#lib/db-formats/score";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { IsRequesterAdmin } from "#utils/user";
|
||||
|
||||
export const GetScoreFromParam: RequestHandler = async (req, res, next) => {
|
||||
const score = await MONGODB_KILL.scores.findOne({ scoreID: req.params.scoreID });
|
||||
const score = await LoadScoreDocumentById(req.params.scoreID);
|
||||
|
||||
if (!score) {
|
||||
return res.status(404).json({
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ACTION_CustomiseScore } from "#actions/customise-score";
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
|
||||
import { GetSongByLegacyID } from "#lib/db-formats/song";
|
||||
import { log } from "#lib/log/log";
|
||||
import { DeleteScore } from "#lib/score-mutation/delete-scores";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { toPgGame } from "#services/pg/seeds";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
@@ -25,11 +29,21 @@ router.get("/", async (req, res) => {
|
||||
const score = GetTachiData(req, "scoreDoc");
|
||||
|
||||
if (req.query.getRelated !== undefined) {
|
||||
const [user, chart, song] = await Promise.all([
|
||||
GetUserWithID(score.userID),
|
||||
MONGODB_KILL.anyCharts[score.game].findOne({ chartID: score.chartID }),
|
||||
MONGODB_KILL.anySongs[score.game].findOne({ id: score.songID }),
|
||||
]);
|
||||
const user = await GetUserWithID(score.userID);
|
||||
|
||||
const songRes = await GetSongByLegacyID(score.game, score.songID);
|
||||
|
||||
const charts =
|
||||
songRes === undefined
|
||||
? []
|
||||
: await GetChartsBySongPgId(
|
||||
toPgGame(score.game, score.playtype),
|
||||
songRes.pgId,
|
||||
score.songID,
|
||||
);
|
||||
|
||||
const chart = charts.find((c) => c.chartID === score.chartID);
|
||||
const song = songRes?.doc;
|
||||
|
||||
if (!user || !chart || !song) {
|
||||
log.error(
|
||||
@@ -110,29 +124,35 @@ router.patch(
|
||||
});
|
||||
}
|
||||
|
||||
const newScore = await MONGODB_KILL.scores.findOneAndUpdate(
|
||||
{ scoreID: score.scoreID },
|
||||
{ $set: modifyOption },
|
||||
);
|
||||
const auth = req[SYMBOL_TACHI_API_AUTH];
|
||||
|
||||
if (modifyOption.highlight === true || modifyOption.highlight === false) {
|
||||
await MONGODB_KILL["personal-bests"].findOneAndUpdate(
|
||||
{
|
||||
chartID: score.chartID,
|
||||
userID: score.userID,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
highlight: modifyOption.highlight,
|
||||
},
|
||||
},
|
||||
);
|
||||
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 } };
|
||||
|
||||
const result = await ACTION_CustomiseScore(taker, {
|
||||
scoreID: score.scoreID,
|
||||
...modifyOption,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Updated score.`,
|
||||
body: newScore,
|
||||
body: result.score,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,13 +2,11 @@ import type { RequestHandler } from "express";
|
||||
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { LoadSessionDocumentById } from "#lib/db-formats/session";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
export const GetSessionFromParam: RequestHandler = async (req, res, next) => {
|
||||
const session = await MONGODB_KILL.sessions.findOne({
|
||||
sessionID: req.params.sessionID,
|
||||
});
|
||||
const session = await LoadSessionDocumentById(req.params.sessionID);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({
|
||||
|
||||
@@ -10,7 +10,6 @@ import { SELECT_GAME_STATS, ToGameStatsDocument } from "#lib/db-formats/game-sta
|
||||
import { log } from "#lib/log/log";
|
||||
import { GetRivalIDs } from "#lib/rivals/rivals";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { optNullFluffStrField } from "#utils/prudence";
|
||||
import {
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
FormatUserDoc,
|
||||
GetAllRankings,
|
||||
GetNextAvailableUsernameChange,
|
||||
GetSettingsForUser,
|
||||
GetUserWithIDGuaranteed,
|
||||
} from "#utils/user";
|
||||
import { Router } from "express";
|
||||
@@ -567,21 +567,16 @@ router.get(
|
||||
|
||||
const user = GetUser(req);
|
||||
|
||||
const gpts = await MONGODB_KILL["game-stats"].find({
|
||||
userID: user.id,
|
||||
});
|
||||
const gameStatRows = await DB.selectFrom("game_stats")
|
||||
.select(SELECT_GAME_STATS)
|
||||
.where("user_id", "=", user.id)
|
||||
.execute();
|
||||
|
||||
const gpts = gameStatRows.map(ToGameStatsDocument);
|
||||
|
||||
const data: Partial<Record<GPTString, unknown>> = {};
|
||||
|
||||
const settings = await MONGODB_KILL["user-settings"].findOne({ userID: user.id });
|
||||
|
||||
if (!settings) {
|
||||
log.error(`User ${FormatUserDoc(user)} doesn't have any settings?`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `This user has no settings.`,
|
||||
});
|
||||
}
|
||||
const settings = await GetSettingsForUser(user.id);
|
||||
|
||||
await Promise.all(
|
||||
gpts.map(async (e) => {
|
||||
|
||||
@@ -12,10 +12,7 @@ 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 { TestingKsHookSV6CScore, TestingKsHookSV6CStaticScore } from "#test-utils/test-data";
|
||||
import deepmerge from "deepmerge";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -192,7 +189,9 @@ describe("POST /ir/kshook/sv6c/score/export", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.description).toBe("Static importing is disabled. Ignoring static import request.");
|
||||
expect(res.body.description).toBe(
|
||||
"Static importing is disabled. Ignoring static import request.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid software models and missing header", async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
NotificationDocument,
|
||||
PBScoreDocument,
|
||||
Playtype,
|
||||
ScoreData,
|
||||
MongoScoreData,
|
||||
ScoreDocument,
|
||||
UGPTSettingsDocument,
|
||||
UserDocument,
|
||||
@@ -159,7 +159,7 @@ export function mkMockPB<GPT extends GPTString>(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
chart: ChartDocument<GPT>,
|
||||
scoreData: ScoreData<GPT>,
|
||||
scoreData: MongoScoreData<GPT>,
|
||||
): PBScoreDocument<GPT> {
|
||||
return {
|
||||
userID: 1,
|
||||
@@ -181,7 +181,7 @@ export function mkMockScore<GPT extends GPTString>(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
chart: ChartDocument<GPT>,
|
||||
scoreData: ScoreData<GPT>,
|
||||
scoreData: MongoScoreData<GPT>,
|
||||
): ScoreDocument<GPT> {
|
||||
// @ts-expect-error whatever lol
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user